Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/ai-pivot_xbot.json
T

1 line
2.9 MiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "ai-pivot/xbot", "files": {"agent/llm_factory_test.go": {"6c70ebdf5986d314e736eabb69598ce8279a072d": {"t": 1785746066.7006493, "n": 20682, "h": "8e5f4e966e273051", "c": "package agent\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"xbot/config\"\n\t\"xbot/llm\"\n\t\"xbot/storage/sqlite\"\n)\n\nfunc TestGuessProvider(t *testing.T) {\n\ttests := []struct {\n\t\tmodel string\n\t\twant string\n\t}{\n\t\t{\"claude-sonnet-4-20250514\", \"anthropic\"},\n\t\t{\"claude-opus-4-20250115\", \"anthropic\"},\n\t\t{\"gpt-4o\", \"openai\"},\n\t\t{\"gpt-4.1\", \"openai\"},\n\t\t{\"o1-preview\", \"openai\"},\n\t\t{\"o3-mini\", \"openai\"},\n\t\t{\"deepseek-chat\", \"deepseek\"},\n\t\t{\"deepseek-reasoner\", \"deepseek\"},\n\t\t{\"gemini-2.0-flash\", \"google\"},\n\t\t{\"qwen-max\", \"qwen\"},\n\t\t{\"unknown-model\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.model, func(t *testing.T) {\n\t\t\tgot := guessProvider(tt.model)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"guessProvider(%q) = %q, want %q\", tt.model, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestLLMSemAcquireForUser_ReadsMaxConcurrencyFromDB verifies that\n// LLMSemAcquireForUser correctly reads the max_concurrency setting from\n// the user_settings DB via the correct channel, rather than falling back\n// to the hardcoded default. Regression test for the bug where the setting\n// key was misspelled (\"max_concurrent\" vs \"max_concurrency\").\nfunc TestLLMSemAcquireForUser_ReadsMaxConcurrencyFromDB(t *testing.T) {\n\tdir := t.TempDir()\n\tdbPath := filepath.Join(dir, \"test.db\")\n\tdb, err := sqlite.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tstore := sqlite.NewUserSettingsService(db)\n\tsettingsSvc := NewSettingsService(store)\n\t// Write max_concurrency=100 into the DB under channel \"cli\".\n\tif err := settingsSvc.SetSetting(\"cli\", \"test_user\", settingMaxConcurrency, \"100\"); err != nil {\n\t\tt.Fatalf(\"set setting: %v\", err)\n\t}\n\n\t// Create LLMFactory with the settings service.\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.SetSettingsService(settingsSvc)\n\tmgr := llm.NewLLMSemaphoreManager()\n\tf.SetLLMSemaphoreManager(mgr)\n\n\t// Acquire the semaphore and release immediately to verify capacity.\n\tacquire := f.LLMSemAcquireForUser(\"test_user\", \"cli\")\n\tif acquire == nil {\n\t\tt.Fatal(\"LLMSemAcquireForUser returned nil\")\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\t// Acquire N slots to verify capacity is at least 100 (not the default 5).\n\treleases := make([]func(), 0, 100)\n\tfor i := 0; i < 100; i++ {\n\t\trelease := acquire(ctx)\n\t\tif release == nil {\n\t\t\tt.Fatalf(\"failed to acquire slot %d (capacity too low, was it %d?)\", i, llm.DefaultLLMConcurrency)\n\t\t}\n\t\treleases = append(releases, release)\n\t}\n\tfor _, r := range releases {\n\t\tr()\n\t}\n}\n\n// TestSubAgentSemAcquireForUser_ReadsMaxConcurrencyFromDB verifies that\n// SubAgentSemAcquireForUser correctly reads max_concurrency from the DB.\nfunc TestSubAgentSemAcquireForUser_ReadsMaxConcurrencyFromDB(t *testing.T) {\n\tdir := t.TempDir()\n\tdbPath := filepath.Join(dir, \"test.db\")\n\tdb, err := sqlite.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tstore := sqlite.NewUserSettingsService(db)\n\tsettingsSvc := NewSettingsService(store)\n\tif err := settingsSvc.SetSetting(\"cli\", \"test_user\", settingMaxConcurrency, \"50\"); err != nil {\n\t\tt.Fatalf(\"set setting: %v\", err)\n\t}\n\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.SetSettingsService(settingsSvc)\n\tmgr := llm.NewLLMSemaphoreManager()\n\tf.SetLLMSemaphoreManager(mgr)\n\n\tacquire := f.SubAgentSemAcquireForUser(\"test_user\", \"cli\")\n\tif acquire == nil {\n\t\tt.Fatal(\"SubAgentSemAcquireForUser returned nil\")\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\treleases := make([]func(), 0, 50)\n\tfor i := 0; i < 50; i++ {\n\t\trelease := acquire(ctx)\n\t\tif release == nil {\n\t\t\tt.Fatalf(\"failed to acquire subagent slot %d (capacity too low, was it %d?)\", i, llm.DefaultLLMConcurrency)\n\t\t}\n\t\treleases = append(releases, release)\n\t}\n\tfor _, r := range releases {\n\t\tr()\n\t}\n}\n\n// TestSettingKeyConstants_MatchDB verifies that the setting key constants\n// used in LLMFactory match the canonical keys stored in user_settings DB.\nfunc TestSettingKeyConstants_MatchDB(t *testing.T) {\n\t// These constants must match the keys written by settings panel.\n\tif settingMaxConcurrency != \"max_concurrency\" {\n\t\tt.Errorf(\"settingMaxConcurrency = %q, want %q\", settingMaxConcurrency, \"max_concurrency\")\n\t}\n\tif settingSubAgentMaxConcurrency != \"subagent_max_concurrency\" {\n\t\tt.Errorf(\"settingSubAgentMaxConcurrency = %q, want %q\", settingSubAgentMaxConcurrency, \"subagent_max_concurrency\")\n\t}\n}\n\nfunc TestGetLLMForModel_EmptyTarget(t *testing.T) {\n\t// Empty target model → should return default model name without hitting subscription logic\n\tf := NewLLMFactory(nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Verify the early return path: targetModel=\"\" should not try to list subscriptions\n\t// (subscriptionSvc is nil, so if it tried, we'd get a different error)\n\t_, model, _, tm, _, usedCustom := f.GetLLMForModel(\"user1\", \"\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"default-model\")\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false for empty target model\")\n\t}\n\tif tm != \"auto\" {\n\t\tt.Errorf(\"thinkingMode = %q, want %q\", tm, \"auto\")\n\t}\n}\n\nfunc TestGetLLMForModel_NilSubscriptionSvc(t *testing.T) {\n\tf := NewLLMFactory(nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// No subscriptionSvc + explicit model → model not found in any subscription,\n\t// fallback to default client with the RESOLVED model (not the default model).\n\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"claude-opus-4-20250115\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115 (resolved model preserved in fallback)\", model)\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n}\n\nfunc TestNormalizeModelTier(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant string\n\t}{\n\t\t{\"vanguard\", \"vanguard\"},\n\t\t{\"VANGUARD\", \"vanguard\"},\n\t\t{\"Vanguard\", \"vanguard\"},\n\t\t{\"strong\", \"vanguard\"},\n\t\t{\"Strong\", \"vanguard\"},\n\t\t{\"balance\", \"balance\"},\n\t\t{\"medium\", \"balance\"},\n\t\t{\"swift\", \"swift\"},\n\t\t{\"weak\", \"swift\"},\n\t\t{\"gpt-4o\", \"\"},\n\t\t{\"\", \"\"},\n\t\t{\"unknown\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := normalizeModelTier(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"normalizeModelTier(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestHasCustomLLMChecksSubscriptionSvc(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Setenv(\"XBOT_HOME\", dir)\n\tdb, err := sqlite.Open(config.DBFilePath())\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tfactory := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tsubSvc := sqlite.NewLLMSubscriptionService(db)\n\tfactory.SetSubscriptionSvc(subSvc)\n\tif err := subSvc.Add(&sqlite.LLMSubscription{ID: \"sub-1\", SenderID: \"cli_user\", Name: \"s1\", Provider: \"openai\", BaseURL: \"https://example.com/v1\", APIKey: \"sk-test\", Model: \"m1\", IsDefault: true}); err != nil {\n\t\tt.Fatalf(\"add sub: %v\", err)\n\t}\n\tif !factory.HasCustomLLM(\"cli_user\") {\n\t\tt.Fatal(\"expected HasCustomLLM to return true when default subscription exists\")\n\t}\n}\n\n// TestInvalidate_ClearsPerChatCache verifies that Invalidate(senderID) clears\n// both user-level and per-chat (senderID:chatID) cache entries.\n// This is the fix for: switching sub then changing model in settings was stuck\n// on the old model because Invalidate only cleared the user-level key.\nfunc TestInvalidate_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsubA := &sqlite.LLMSubscription{\n\t\tID: \"sub-a\", Provider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tID: \"sub-b\", Provider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"deepseek-v3\", MaxOutputTokens: 4096,\n\t}\n\n\t// Without subscriptionSvc, SwitchSubscription resolves model from\n\t// user_default_model (which is also nil here). The default LLM client\n\t// is still created. Model resolution happens when the user picks a model.\n\tif err := f.SwitchSubscription(senderID, subA, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subA: %v\", err)\n\t}\n\n\t// Verify default LLM was created (model is \"\" without subscription_models)\n\t_, modelA, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelA != \"\" {\n\t\tt.Fatalf(\"initial model = %q, want \\\"\\\" (no subscription_models)\", modelA)\n\t}\n\n\t// Simulate: set_default_subscription calls Invalidate then SwitchSubscription\n\tf.Invalidate(senderID)\n\tif err := f.SwitchSubscription(senderID, subB, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subB: %v\", err)\n\t}\n\n\t_, modelB, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelB != \"\" {\n\t\tt.Errorf(\"after sub switch, model = %q, want \\\"\\\" (no subscription_models)\", modelB)\n\t}\n\n\t// Simulate: update_subscription (settings panel) calls Invalidate + SwitchSubscription\n\t// with chatID=\"\" — per-chat cache was NOT cleared before the fix\n\tf.Invalidate(senderID)\n\tupdatedSubB := *subB\n\tupdatedSubB.Model = \"deepseek-r1\"\n\tupdatedSubB.MaxOutputTokens = 16384\n\tif err := f.SwitchSubscription(senderID, &updatedSubB, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription updatedSubB: %v\", err)\n\t}\n\n\t// GetLLMForChat should NOT return stale per-chat cache\n\t_, modelUpdated, _, thinkingUpdated, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelUpdated != \"\" {\n\t\tt.Errorf(\"after settings update, model = %q, want \\\"\\\" (no subscription_models, stale per-chat cache bug)\", modelUpdated)\n\t}\n\t// Verify thinking mode is also not stale\n\tif thinkingUpdated != \"\" {\n\t\tt.Errorf(\"after settings update, thinkingMode = %q, want empty\", thinkingUpdated)\n\t}\n}\n\n// TestSwitchSubscription_UpdatesDefaultLLM verifies that SwitchSubscription\n// DOES update the global defaultLLM/defaultModel for cli_user. In CLI mode,\n// all sessions share senderID \"cli_user\", so defaultLLM is a user-level\n// preference that should follow the user's last subscription choice.\n// SubAgent fallback, ListModels(), and GetLLM() for sessions without\n// per-session subscriptions should all see the new default.\nfunc TestSwitchSubscription_UpdatesDefaultLLM(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"original-default-model\")\n\n\tsubDeepSeek := &sqlite.LLMSubscription{\n\t\tID: \"sub-ds\", Provider: \"openai\", BaseURL: \"https://api.deepseek.com/v1\", APIKey: \"sk-deep\",\n\t\tModel: \"deepseek-v4-pro\",\n\t}\n\n\t// Global default before switch\n\tif dm := f.GetDefaultModel(); dm != \"original-default-model\" {\n\t\tt.Fatalf(\"initial default model = %q, want original-default-model\", dm)\n\t}\n\n\t// Switch subscription for cli_user (the common CLI senderID)\n\tif err := f.SwitchSubscription(\"cli_user\", subDeepSeek, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription: %v\", err)\n\t}\n\n\t// Global default SHOULD be updated — SwitchSubscription resolves model\n\t// from user_default_model. Without subscriptionSvc, it returns \"\" (no\n\t// model). The default LLM client is still created (credentials work),\n\t// just without a model name.\n\t// The model is resolved later when the user picks one from the panel.\n\tif dm := f.GetDefaultModel(); dm != \"\" {\n\t\tt.Errorf(\"default model after SwitchSubscription = %q, want \\\"\\\" (no subscription_models rows)\", dm)\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubExactMatch verifies the config.json subscription path:\n// when configSubsFn returns a subscription whose Model matches the resolved model,\n// GetLLMForModel should use that subscription (usedCustom=true).\nfunc TestGetLLMForModel_ConfigSubExactMatch(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Set up configSubsFn with a matching subscription\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"test-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub matches resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil when config sub matches\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubNoMatch verifies that when configSubsFn returns\n// subscriptions with different Model fields, it still tries to use them with\n// the model name (OpenAI-compatible endpoints can serve any model).\nfunc TestGetLLMForModel_ConfigSubNoMatch(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Config sub has a different model — but still usable with gpt-4o\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"other-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"other-model\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub can serve the resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q (resolved model)\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubSkipsEmptyCredentials verifies that config\n// subscriptions with matching Model but empty BaseURL or APIKey are skipped,\n// and the function falls through to the default LLM.\nfunc TestGetLLMForModel_ConfigSubSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\ttests := []struct {\n\t\tname string\n\t\tsub config.SubscriptionConfig\n\t}{\n\t\t{\n\t\t\tname: \"empty BaseURL\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"no-url\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"empty APIKey\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"no-key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\t\t\treturn []config.SubscriptionConfig{tt.sub}\n\t\t\t})\n\n\t\t\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\t\t\tif usedCustom {\n\t\t\t\tt.Error(\"usedCustom should be false when config sub has empty credentials\")\n\t\t\t}\n\t\t\tif model != \"gpt-4o\" {\n\t\t\t\tt.Errorf(\"model = %q, want %q (resolved model preserved in fallback)\", model, \"gpt-4o\")\n\t\t\t}\n\t\t})\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Tests for buildModelSubscriptionMap & configSubToLLMSubscription\n// ---------------------------------------------------------------------------\n\n// TestBuildModelSubscriptionMap_ConfigSubs verifies that config subscriptions\n// with different models each produce an entry in the model→subscription map.\nfunc TestBuildModelSubscriptionMap_ConfigSubs(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-a\",\n\t\t\t\tName: \"OpenAI\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"sk-openai\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-b\",\n\t\t\t\tName: \"Anthropic\",\n\t\t\t\tProvider: \"anthropic\",\n\t\t\t\tBaseURL: \"https://api.anthropic.com\",\n\t\t\t\tAPIKey: \"sk-ant-key\",\n\t\t\t\tModel: \"claude-sonnet-4-20250514\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 2 {\n\t\tt.Fatalf(\"map size = %d, want 2\", len(m))\n\t}\n\tif sub, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry\")\n\t} else if sub.ID != \"sub-a\" {\n\t\tt.Errorf(\"gpt-4o mapped to sub %q, want sub-a\", sub.ID)\n\t}\n\tif sub, ok := m[\"claude-sonnet-4-20250514\"]; !ok {\n\t\tt.Error(\"missing claude-sonnet-4-20250514 entry\")\n\t} else if sub.ID != \"sub-b\" {\n\t\tt.Errorf(\"claude-sonnet-4-20250514 mapped to sub %q, want sub-b\", sub.ID)\n\t}\n}\n\n// TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials verifies that\n// config subscriptions with empty BaseURL or APIKey are not added to the map.\nfunc TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\n\t// Sub with matching Model but empty BaseURL — must be skipped.\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"No URL\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"No Key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o-mini\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 0 {\n\t\tt.Fatalf(\"map size = %d, want 0 (both subs have empty credentials)\", len(m))\n\t}\n}\n\n// TestBuildModelSubscriptionMap_EmptySenderID verifies that with an empty\n// senderID and nil subscriptionSvc, only config subs are included.\nfunc TestBuildModelSubscriptionMap_EmptySenderID(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\t// subscriptionSvc is nil by default — no DB path at all.\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"cfg-1\",\n\t\t\t\tName: \"ConfigSub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-cfg\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\t// Empty senderID — DB path is also gated by senderID != \"\"\n\tm := f.buildModelSubscriptionMap(\"\")\n\n\tif len(m) != 1 {\n\t\tt.Fatalf(\"map size = %d, want 1 (only config sub)\", len(m))\n\t}\n\tif _, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry from config sub\")\n\t}\n}\n\n// TestConfigSubToLLMSubscription verifies that configSubToLLMSubscription\n// correctly maps every field from SubscriptionConfig to LLMSubscription.\nfunc TestConfigSubToLLMSubscription(t *testing.T) {\n\tcs := config.SubscriptionConfig{\n\t\tID: \"sub-42\",\n\t\tName: \"My Sub\",\n\t\tProvider: \"deepseek\",\n\t\tBaseURL: \"https://api.deepseek.com/v1\",\n\t\tAPIKey: \"sk-deep\",\n\t\tModel: \"deepseek-chat\",\n\t\tMaxOutputTokens: 4096,\n\t\tThinkingMode: \"enabled\",\n\t}\n\n\tsub := configSubToLLMSubscription(cs)\n\n\tif sub.ID != \"sub-42\" {\n\t\tt.Errorf(\"ID = %q, want %q\", sub.ID, \"sub-42\")\n\t}\n\tif sub.Name != \"My Sub\" {\n\t\tt.Errorf(\"Name = %q, want %q\", sub.Name, \"My Sub\")\n\t}\n\tif sub.Provider != \"deepseek\" {\n\t\tt.Errorf(\"Provider = %q, want %q\", sub.Provider, \"deepseek\")\n\t}\n\tif sub.BaseURL != \"https://api.deepseek.com/v1\" {\n\t\tt.Errorf(\"BaseURL = %q, want %q\", sub.BaseURL, \"https://api.deepseek.com/v1\")\n\t}\n\tif sub.APIKey != \"sk-deep\" {\n\t\tt.Errorf(\"APIKey = %q, want %q\", sub.APIKey, \"sk-deep\")\n\t}\n\tif sub.Model != \"deepseek-chat\" {\n\t\tt.Errorf(\"Model = %q, want %q\", sub.Model, \"deepseek-chat\")\n\t}\n\tif sub.MaxOutputTokens != 4096 {\n\t\tt.Errorf(\"MaxOutputTokens = %d, want 4096\", sub.MaxOutputTokens)\n\t}\n\tif sub.ThinkingMode != \"enabled\" {\n\t\tt.Errorf(\"ThinkingMode = %q, want %q\", sub.ThinkingMode, \"enabled\")\n\t}\n}\n\n// --- chatKey tests ---\n\nfunc TestChatKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tsenderID string\n\t\tchatID string\n\t\twant string\n\t}{\n\t\t{\"normal\", \"user123\", \"chat456\", \"user123:chat456\"},\n\t\t{\"empty senderID\", \"\", \"chat456\", \":chat456\"},\n\t\t{\"empty chatID\", \"user123\", \"\", \"user123:\"},\n\t\t{\"both empty\", \"\", \"\", \":\"},\n\t\t{\"colons in values\", \"user:1\", \"chat:2\", \"user:1:chat:2\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := chatKey(tt.senderID, tt.chatID)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"chatKey(%q, %q) = %q, want %q\", tt.senderID, tt.chatID, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// --- parseOrDefault tests ---\n\nfunc TestParseOrDefault(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tinput string\n\t\tdefaultVal int\n\t\twant int\n\t}{\n\t\t{\"empty string returns default\", \"\", 42, 42},\n\t\t{\"valid positive int\", \"100\", 42, 100},\n\t\t{\"zero returns default\", \"0\", 42, 42},\n\t\t{\"negative returns default\", \"-5\", 42, 42},\n\t\t{\"non-numeric returns default\", \"abc\", 42, 42},\n\t\t{\"whitespace-padded number\", \" 7\", 42, 7},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := parseOrDefault(tt.input, tt.defaultVal)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"parseOrDefault(%q, %d) = %d, want %d\", tt.input, tt.defaultVal, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"}, "a44fb35b4145a09fb7780c8dbd91abdf9b60787e": {"t": 1785759440.575026, "n": 20682, "h": "8e5f4e966e273051", "c": "package agent\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"xbot/config\"\n\t\"xbot/llm\"\n\t\"xbot/storage/sqlite\"\n)\n\nfunc TestGuessProvider(t *testing.T) {\n\ttests := []struct {\n\t\tmodel string\n\t\twant string\n\t}{\n\t\t{\"claude-sonnet-4-20250514\", \"anthropic\"},\n\t\t{\"claude-opus-4-20250115\", \"anthropic\"},\n\t\t{\"gpt-4o\", \"openai\"},\n\t\t{\"gpt-4.1\", \"openai\"},\n\t\t{\"o1-preview\", \"openai\"},\n\t\t{\"o3-mini\", \"openai\"},\n\t\t{\"deepseek-chat\", \"deepseek\"},\n\t\t{\"deepseek-reasoner\", \"deepseek\"},\n\t\t{\"gemini-2.0-flash\", \"google\"},\n\t\t{\"qwen-max\", \"qwen\"},\n\t\t{\"unknown-model\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.model, func(t *testing.T) {\n\t\t\tgot := guessProvider(tt.model)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"guessProvider(%q) = %q, want %q\", tt.model, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestLLMSemAcquireForUser_ReadsMaxConcurrencyFromDB verifies that\n// LLMSemAcquireForUser correctly reads the max_concurrency setting from\n// the user_settings DB via the correct channel, rather than falling back\n// to the hardcoded default. Regression test for the bug where the setting\n// key was misspelled (\"max_concurrent\" vs \"max_concurrency\").\nfunc TestLLMSemAcquireForUser_ReadsMaxConcurrencyFromDB(t *testing.T) {\n\tdir := t.TempDir()\n\tdbPath := filepath.Join(dir, \"test.db\")\n\tdb, err := sqlite.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tstore := sqlite.NewUserSettingsService(db)\n\tsettingsSvc := NewSettingsService(store)\n\t// Write max_concurrency=100 into the DB under channel \"cli\".\n\tif err := settingsSvc.SetSetting(\"cli\", \"test_user\", settingMaxConcurrency, \"100\"); err != nil {\n\t\tt.Fatalf(\"set setting: %v\", err)\n\t}\n\n\t// Create LLMFactory with the settings service.\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.SetSettingsService(settingsSvc)\n\tmgr := llm.NewLLMSemaphoreManager()\n\tf.SetLLMSemaphoreManager(mgr)\n\n\t// Acquire the semaphore and release immediately to verify capacity.\n\tacquire := f.LLMSemAcquireForUser(\"test_user\", \"cli\")\n\tif acquire == nil {\n\t\tt.Fatal(\"LLMSemAcquireForUser returned nil\")\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\t// Acquire N slots to verify capacity is at least 100 (not the default 5).\n\treleases := make([]func(), 0, 100)\n\tfor i := 0; i < 100; i++ {\n\t\trelease := acquire(ctx)\n\t\tif release == nil {\n\t\t\tt.Fatalf(\"failed to acquire slot %d (capacity too low, was it %d?)\", i, llm.DefaultLLMConcurrency)\n\t\t}\n\t\treleases = append(releases, release)\n\t}\n\tfor _, r := range releases {\n\t\tr()\n\t}\n}\n\n// TestSubAgentSemAcquireForUser_ReadsMaxConcurrencyFromDB verifies that\n// SubAgentSemAcquireForUser correctly reads max_concurrency from the DB.\nfunc TestSubAgentSemAcquireForUser_ReadsMaxConcurrencyFromDB(t *testing.T) {\n\tdir := t.TempDir()\n\tdbPath := filepath.Join(dir, \"test.db\")\n\tdb, err := sqlite.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tstore := sqlite.NewUserSettingsService(db)\n\tsettingsSvc := NewSettingsService(store)\n\tif err := settingsSvc.SetSetting(\"cli\", \"test_user\", settingMaxConcurrency, \"50\"); err != nil {\n\t\tt.Fatalf(\"set setting: %v\", err)\n\t}\n\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.SetSettingsService(settingsSvc)\n\tmgr := llm.NewLLMSemaphoreManager()\n\tf.SetLLMSemaphoreManager(mgr)\n\n\tacquire := f.SubAgentSemAcquireForUser(\"test_user\", \"cli\")\n\tif acquire == nil {\n\t\tt.Fatal(\"SubAgentSemAcquireForUser returned nil\")\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\treleases := make([]func(), 0, 50)\n\tfor i := 0; i < 50; i++ {\n\t\trelease := acquire(ctx)\n\t\tif release == nil {\n\t\t\tt.Fatalf(\"failed to acquire subagent slot %d (capacity too low, was it %d?)\", i, llm.DefaultLLMConcurrency)\n\t\t}\n\t\treleases = append(releases, release)\n\t}\n\tfor _, r := range releases {\n\t\tr()\n\t}\n}\n\n// TestSettingKeyConstants_MatchDB verifies that the setting key constants\n// used in LLMFactory match the canonical keys stored in user_settings DB.\nfunc TestSettingKeyConstants_MatchDB(t *testing.T) {\n\t// These constants must match the keys written by settings panel.\n\tif settingMaxConcurrency != \"max_concurrency\" {\n\t\tt.Errorf(\"settingMaxConcurrency = %q, want %q\", settingMaxConcurrency, \"max_concurrency\")\n\t}\n\tif settingSubAgentMaxConcurrency != \"subagent_max_concurrency\" {\n\t\tt.Errorf(\"settingSubAgentMaxConcurrency = %q, want %q\", settingSubAgentMaxConcurrency, \"subagent_max_concurrency\")\n\t}\n}\n\nfunc TestGetLLMForModel_EmptyTarget(t *testing.T) {\n\t// Empty target model → should return default model name without hitting subscription logic\n\tf := NewLLMFactory(nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Verify the early return path: targetModel=\"\" should not try to list subscriptions\n\t// (subscriptionSvc is nil, so if it tried, we'd get a different error)\n\t_, model, _, tm, _, usedCustom := f.GetLLMForModel(\"user1\", \"\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"default-model\")\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false for empty target model\")\n\t}\n\tif tm != \"auto\" {\n\t\tt.Errorf(\"thinkingMode = %q, want %q\", tm, \"auto\")\n\t}\n}\n\nfunc TestGetLLMForModel_NilSubscriptionSvc(t *testing.T) {\n\tf := NewLLMFactory(nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// No subscriptionSvc + explicit model → model not found in any subscription,\n\t// fallback to default client with the RESOLVED model (not the default model).\n\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"claude-opus-4-20250115\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115 (resolved model preserved in fallback)\", model)\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n}\n\nfunc TestNormalizeModelTier(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant string\n\t}{\n\t\t{\"vanguard\", \"vanguard\"},\n\t\t{\"VANGUARD\", \"vanguard\"},\n\t\t{\"Vanguard\", \"vanguard\"},\n\t\t{\"strong\", \"vanguard\"},\n\t\t{\"Strong\", \"vanguard\"},\n\t\t{\"balance\", \"balance\"},\n\t\t{\"medium\", \"balance\"},\n\t\t{\"swift\", \"swift\"},\n\t\t{\"weak\", \"swift\"},\n\t\t{\"gpt-4o\", \"\"},\n\t\t{\"\", \"\"},\n\t\t{\"unknown\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := normalizeModelTier(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"normalizeModelTier(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestHasCustomLLMChecksSubscriptionSvc(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Setenv(\"XBOT_HOME\", dir)\n\tdb, err := sqlite.Open(config.DBFilePath())\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tfactory := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tsubSvc := sqlite.NewLLMSubscriptionService(db)\n\tfactory.SetSubscriptionSvc(subSvc)\n\tif err := subSvc.Add(&sqlite.LLMSubscription{ID: \"sub-1\", SenderID: \"cli_user\", Name: \"s1\", Provider: \"openai\", BaseURL: \"https://example.com/v1\", APIKey: \"sk-test\", Model: \"m1\", IsDefault: true}); err != nil {\n\t\tt.Fatalf(\"add sub: %v\", err)\n\t}\n\tif !factory.HasCustomLLM(\"cli_user\") {\n\t\tt.Fatal(\"expected HasCustomLLM to return true when default subscription exists\")\n\t}\n}\n\n// TestInvalidate_ClearsPerChatCache verifies that Invalidate(senderID) clears\n// both user-level and per-chat (senderID:chatID) cache entries.\n// This is the fix for: switching sub then changing model in settings was stuck\n// on the old model because Invalidate only cleared the user-level key.\nfunc TestInvalidate_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsubA := &sqlite.LLMSubscription{\n\t\tID: \"sub-a\", Provider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tID: \"sub-b\", Provider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"deepseek-v3\", MaxOutputTokens: 4096,\n\t}\n\n\t// Without subscriptionSvc, SwitchSubscription resolves model from\n\t// user_default_model (which is also nil here). The default LLM client\n\t// is still created. Model resolution happens when the user picks a model.\n\tif err := f.SwitchSubscription(senderID, subA, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subA: %v\", err)\n\t}\n\n\t// Verify default LLM was created (model is \"\" without subscription_models)\n\t_, modelA, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelA != \"\" {\n\t\tt.Fatalf(\"initial model = %q, want \\\"\\\" (no subscription_models)\", modelA)\n\t}\n\n\t// Simulate: set_default_subscription calls Invalidate then SwitchSubscription\n\tf.Invalidate(senderID)\n\tif err := f.SwitchSubscription(senderID, subB, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subB: %v\", err)\n\t}\n\n\t_, modelB, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelB != \"\" {\n\t\tt.Errorf(\"after sub switch, model = %q, want \\\"\\\" (no subscription_models)\", modelB)\n\t}\n\n\t// Simulate: update_subscription (settings panel) calls Invalidate + SwitchSubscription\n\t// with chatID=\"\" — per-chat cache was NOT cleared before the fix\n\tf.Invalidate(senderID)\n\tupdatedSubB := *subB\n\tupdatedSubB.Model = \"deepseek-r1\"\n\tupdatedSubB.MaxOutputTokens = 16384\n\tif err := f.SwitchSubscription(senderID, &updatedSubB, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription updatedSubB: %v\", err)\n\t}\n\n\t// GetLLMForChat should NOT return stale per-chat cache\n\t_, modelUpdated, _, thinkingUpdated, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelUpdated != \"\" {\n\t\tt.Errorf(\"after settings update, model = %q, want \\\"\\\" (no subscription_models, stale per-chat cache bug)\", modelUpdated)\n\t}\n\t// Verify thinking mode is also not stale\n\tif thinkingUpdated != \"\" {\n\t\tt.Errorf(\"after settings update, thinkingMode = %q, want empty\", thinkingUpdated)\n\t}\n}\n\n// TestSwitchSubscription_UpdatesDefaultLLM verifies that SwitchSubscription\n// DOES update the global defaultLLM/defaultModel for cli_user. In CLI mode,\n// all sessions share senderID \"cli_user\", so defaultLLM is a user-level\n// preference that should follow the user's last subscription choice.\n// SubAgent fallback, ListModels(), and GetLLM() for sessions without\n// per-session subscriptions should all see the new default.\nfunc TestSwitchSubscription_UpdatesDefaultLLM(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"original-default-model\")\n\n\tsubDeepSeek := &sqlite.LLMSubscription{\n\t\tID: \"sub-ds\", Provider: \"openai\", BaseURL: \"https://api.deepseek.com/v1\", APIKey: \"sk-deep\",\n\t\tModel: \"deepseek-v4-pro\",\n\t}\n\n\t// Global default before switch\n\tif dm := f.GetDefaultModel(); dm != \"original-default-model\" {\n\t\tt.Fatalf(\"initial default model = %q, want original-default-model\", dm)\n\t}\n\n\t// Switch subscription for cli_user (the common CLI senderID)\n\tif err := f.SwitchSubscription(\"cli_user\", subDeepSeek, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription: %v\", err)\n\t}\n\n\t// Global default SHOULD be updated — SwitchSubscription resolves model\n\t// from user_default_model. Without subscriptionSvc, it returns \"\" (no\n\t// model). The default LLM client is still created (credentials work),\n\t// just without a model name.\n\t// The model is resolved later when the user picks one from the panel.\n\tif dm := f.GetDefaultModel(); dm != \"\" {\n\t\tt.Errorf(\"default model after SwitchSubscription = %q, want \\\"\\\" (no subscription_models rows)\", dm)\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubExactMatch verifies the config.json subscription path:\n// when configSubsFn returns a subscription whose Model matches the resolved model,\n// GetLLMForModel should use that subscription (usedCustom=true).\nfunc TestGetLLMForModel_ConfigSubExactMatch(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Set up configSubsFn with a matching subscription\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"test-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub matches resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil when config sub matches\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubNoMatch verifies that when configSubsFn returns\n// subscriptions with different Model fields, it still tries to use them with\n// the model name (OpenAI-compatible endpoints can serve any model).\nfunc TestGetLLMForModel_ConfigSubNoMatch(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Config sub has a different model — but still usable with gpt-4o\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"other-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"other-model\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub can serve the resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q (resolved model)\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubSkipsEmptyCredentials verifies that config\n// subscriptions with matching Model but empty BaseURL or APIKey are skipped,\n// and the function falls through to the default LLM.\nfunc TestGetLLMForModel_ConfigSubSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\ttests := []struct {\n\t\tname string\n\t\tsub config.SubscriptionConfig\n\t}{\n\t\t{\n\t\t\tname: \"empty BaseURL\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"no-url\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"empty APIKey\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"no-key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\t\t\treturn []config.SubscriptionConfig{tt.sub}\n\t\t\t})\n\n\t\t\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\t\t\tif usedCustom {\n\t\t\t\tt.Error(\"usedCustom should be false when config sub has empty credentials\")\n\t\t\t}\n\t\t\tif model != \"gpt-4o\" {\n\t\t\t\tt.Errorf(\"model = %q, want %q (resolved model preserved in fallback)\", model, \"gpt-4o\")\n\t\t\t}\n\t\t})\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Tests for buildModelSubscriptionMap & configSubToLLMSubscription\n// ---------------------------------------------------------------------------\n\n// TestBuildModelSubscriptionMap_ConfigSubs verifies that config subscriptions\n// with different models each produce an entry in the model→subscription map.\nfunc TestBuildModelSubscriptionMap_ConfigSubs(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-a\",\n\t\t\t\tName: \"OpenAI\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"sk-openai\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-b\",\n\t\t\t\tName: \"Anthropic\",\n\t\t\t\tProvider: \"anthropic\",\n\t\t\t\tBaseURL: \"https://api.anthropic.com\",\n\t\t\t\tAPIKey: \"sk-ant-key\",\n\t\t\t\tModel: \"claude-sonnet-4-20250514\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 2 {\n\t\tt.Fatalf(\"map size = %d, want 2\", len(m))\n\t}\n\tif sub, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry\")\n\t} else if sub.ID != \"sub-a\" {\n\t\tt.Errorf(\"gpt-4o mapped to sub %q, want sub-a\", sub.ID)\n\t}\n\tif sub, ok := m[\"claude-sonnet-4-20250514\"]; !ok {\n\t\tt.Error(\"missing claude-sonnet-4-20250514 entry\")\n\t} else if sub.ID != \"sub-b\" {\n\t\tt.Errorf(\"claude-sonnet-4-20250514 mapped to sub %q, want sub-b\", sub.ID)\n\t}\n}\n\n// TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials verifies that\n// config subscriptions with empty BaseURL or APIKey are not added to the map.\nfunc TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\n\t// Sub with matching Model but empty BaseURL — must be skipped.\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"No URL\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"No Key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o-mini\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 0 {\n\t\tt.Fatalf(\"map size = %d, want 0 (both subs have empty credentials)\", len(m))\n\t}\n}\n\n// TestBuildModelSubscriptionMap_EmptySenderID verifies that with an empty\n// senderID and nil subscriptionSvc, only config subs are included.\nfunc TestBuildModelSubscriptionMap_EmptySenderID(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\t// subscriptionSvc is nil by default — no DB path at all.\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"cfg-1\",\n\t\t\t\tName: \"ConfigSub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-cfg\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\t// Empty senderID — DB path is also gated by senderID != \"\"\n\tm := f.buildModelSubscriptionMap(\"\")\n\n\tif len(m) != 1 {\n\t\tt.Fatalf(\"map size = %d, want 1 (only config sub)\", len(m))\n\t}\n\tif _, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry from config sub\")\n\t}\n}\n\n// TestConfigSubToLLMSubscription verifies that configSubToLLMSubscription\n// correctly maps every field from SubscriptionConfig to LLMSubscription.\nfunc TestConfigSubToLLMSubscription(t *testing.T) {\n\tcs := config.SubscriptionConfig{\n\t\tID: \"sub-42\",\n\t\tName: \"My Sub\",\n\t\tProvider: \"deepseek\",\n\t\tBaseURL: \"https://api.deepseek.com/v1\",\n\t\tAPIKey: \"sk-deep\",\n\t\tModel: \"deepseek-chat\",\n\t\tMaxOutputTokens: 4096,\n\t\tThinkingMode: \"enabled\",\n\t}\n\n\tsub := configSubToLLMSubscription(cs)\n\n\tif sub.ID != \"sub-42\" {\n\t\tt.Errorf(\"ID = %q, want %q\", sub.ID, \"sub-42\")\n\t}\n\tif sub.Name != \"My Sub\" {\n\t\tt.Errorf(\"Name = %q, want %q\", sub.Name, \"My Sub\")\n\t}\n\tif sub.Provider != \"deepseek\" {\n\t\tt.Errorf(\"Provider = %q, want %q\", sub.Provider, \"deepseek\")\n\t}\n\tif sub.BaseURL != \"https://api.deepseek.com/v1\" {\n\t\tt.Errorf(\"BaseURL = %q, want %q\", sub.BaseURL, \"https://api.deepseek.com/v1\")\n\t}\n\tif sub.APIKey != \"sk-deep\" {\n\t\tt.Errorf(\"APIKey = %q, want %q\", sub.APIKey, \"sk-deep\")\n\t}\n\tif sub.Model != \"deepseek-chat\" {\n\t\tt.Errorf(\"Model = %q, want %q\", sub.Model, \"deepseek-chat\")\n\t}\n\tif sub.MaxOutputTokens != 4096 {\n\t\tt.Errorf(\"MaxOutputTokens = %d, want 4096\", sub.MaxOutputTokens)\n\t}\n\tif sub.ThinkingMode != \"enabled\" {\n\t\tt.Errorf(\"ThinkingMode = %q, want %q\", sub.ThinkingMode, \"enabled\")\n\t}\n}\n\n// --- chatKey tests ---\n\nfunc TestChatKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tsenderID string\n\t\tchatID string\n\t\twant string\n\t}{\n\t\t{\"normal\", \"user123\", \"chat456\", \"user123:chat456\"},\n\t\t{\"empty senderID\", \"\", \"chat456\", \":chat456\"},\n\t\t{\"empty chatID\", \"user123\", \"\", \"user123:\"},\n\t\t{\"both empty\", \"\", \"\", \":\"},\n\t\t{\"colons in values\", \"user:1\", \"chat:2\", \"user:1:chat:2\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := chatKey(tt.senderID, tt.chatID)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"chatKey(%q, %q) = %q, want %q\", tt.senderID, tt.chatID, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// --- parseOrDefault tests ---\n\nfunc TestParseOrDefault(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tinput string\n\t\tdefaultVal int\n\t\twant int\n\t}{\n\t\t{\"empty string returns default\", \"\", 42, 42},\n\t\t{\"valid positive int\", \"100\", 42, 100},\n\t\t{\"zero returns default\", \"0\", 42, 42},\n\t\t{\"negative returns default\", \"-5\", 42, 42},\n\t\t{\"non-numeric returns default\", \"abc\", 42, 42},\n\t\t{\"whitespace-padded number\", \" 7\", 42, 7},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := parseOrDefault(tt.input, tt.defaultVal)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"parseOrDefault(%q, %d) = %d, want %d\", tt.input, tt.defaultVal, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"}, "71aed6440d942c4a1bdc7e16d6082accef86a754": {"t": 1785759442.3493142, "n": 20880, "h": "67c3634120d99914", "c": "package agent\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"xbot/config\"\n\t\"xbot/llm\"\n\t\"xbot/storage/sqlite\"\n)\n\nfunc TestGuessProvider(t *testing.T) {\n\ttests := []struct {\n\t\tmodel string\n\t\twant string\n\t}{\n\t\t{\"claude-sonnet-4-20250514\", \"anthropic\"},\n\t\t{\"claude-opus-4-20250115\", \"anthropic\"},\n\t\t{\"gpt-4o\", \"openai\"},\n\t\t{\"gpt-4.1\", \"openai\"},\n\t\t{\"o1-preview\", \"openai\"},\n\t\t{\"o3-mini\", \"openai\"},\n\t\t{\"deepseek-chat\", \"deepseek\"},\n\t\t{\"deepseek-reasoner\", \"deepseek\"},\n\t\t{\"gemini-2.0-flash\", \"google\"},\n\t\t{\"qwen-max\", \"qwen\"},\n\t\t{\"unknown-model\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.model, func(t *testing.T) {\n\t\t\tgot := guessProvider(tt.model)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"guessProvider(%q) = %q, want %q\", tt.model, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestLLMSemAcquireForUser_ReadsMaxConcurrencyFromDB verifies that\n// LLMSemAcquireForUser correctly reads the max_concurrency setting from\n// the user_settings DB via the correct channel, rather than falling back\n// to the hardcoded default. Regression test for the bug where the setting\n// key was misspelled (\"max_concurrent\" vs \"max_concurrency\").\nfunc TestLLMSemAcquireForUser_ReadsMaxConcurrencyFromDB(t *testing.T) {\n\tdir := t.TempDir()\n\tdbPath := filepath.Join(dir, \"test.db\")\n\tdb, err := sqlite.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tstore := sqlite.NewUserSettingsService(db)\n\tsettingsSvc := NewSettingsService(store)\n\t// Write max_concurrency=100 into the DB under channel \"cli\".\n\tif err := settingsSvc.SetSetting(\"cli\", \"test_user\", settingMaxConcurrency, \"100\"); err != nil {\n\t\tt.Fatalf(\"set setting: %v\", err)\n\t}\n\n\t// Create LLMFactory with the settings service.\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.SetSettingsService(settingsSvc)\n\tmgr := llm.NewLLMSemaphoreManager()\n\tf.SetLLMSemaphoreManager(mgr)\n\n\t// Acquire the semaphore and release immediately to verify capacity.\n\tacquire := f.LLMSemAcquireForUser(\"test_user\", \"cli\")\n\tif acquire == nil {\n\t\tt.Fatal(\"LLMSemAcquireForUser returned nil\")\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\t// Acquire N slots to verify capacity is at least 100 (not the default 5).\n\treleases := make([]func(), 0, 100)\n\tfor i := 0; i < 100; i++ {\n\t\trelease := acquire(ctx)\n\t\tif release == nil {\n\t\t\tt.Fatalf(\"failed to acquire slot %d (capacity too low, was it %d?)\", i, llm.DefaultLLMConcurrency)\n\t\t}\n\t\treleases = append(releases, release)\n\t}\n\tfor _, r := range releases {\n\t\tr()\n\t}\n}\n\n// TestSubAgentSemAcquireForUser_ReadsMaxConcurrencyFromDB verifies that\n// SubAgentSemAcquireForUser correctly reads max_concurrency from the DB.\nfunc TestSubAgentSemAcquireForUser_ReadsMaxConcurrencyFromDB(t *testing.T) {\n\tdir := t.TempDir()\n\tdbPath := filepath.Join(dir, \"test.db\")\n\tdb, err := sqlite.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tstore := sqlite.NewUserSettingsService(db)\n\tsettingsSvc := NewSettingsService(store)\n\tif err := settingsSvc.SetSetting(\"cli\", \"test_user\", settingMaxConcurrency, \"50\"); err != nil {\n\t\tt.Fatalf(\"set setting: %v\", err)\n\t}\n\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.SetSettingsService(settingsSvc)\n\tmgr := llm.NewLLMSemaphoreManager()\n\tf.SetLLMSemaphoreManager(mgr)\n\n\tacquire := f.SubAgentSemAcquireForUser(\"test_user\", \"cli\")\n\tif acquire == nil {\n\t\tt.Fatal(\"SubAgentSemAcquireForUser returned nil\")\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\treleases := make([]func(), 0, 50)\n\tfor i := 0; i < 50; i++ {\n\t\trelease := acquire(ctx)\n\t\tif release == nil {\n\t\t\tt.Fatalf(\"failed to acquire subagent slot %d (capacity too low, was it %d?)\", i, llm.DefaultLLMConcurrency)\n\t\t}\n\t\treleases = append(releases, release)\n\t}\n\tfor _, r := range releases {\n\t\tr()\n\t}\n}\n\n// TestSettingKeyConstants_MatchDB verifies that the setting key constants\n// used in LLMFactory match the canonical keys stored in user_settings DB.\nfunc TestSettingKeyConstants_MatchDB(t *testing.T) {\n\t// These constants must match the keys written by settings panel.\n\tif settingMaxConcurrency != \"max_concurrency\" {\n\t\tt.Errorf(\"settingMaxConcurrency = %q, want %q\", settingMaxConcurrency, \"max_concurrency\")\n\t}\n\tif settingSubAgentMaxConcurrency != \"subagent_max_concurrency\" {\n\t\tt.Errorf(\"settingSubAgentMaxConcurrency = %q, want %q\", settingSubAgentMaxConcurrency, \"subagent_max_concurrency\")\n\t}\n\tif settingLLMMaxConcurrentPersonal != \"llm_max_concurrent_personal\" {\n\t\tt.Errorf(\"settingLLMMaxConcurrentPersonal = %q, want %q\", settingLLMMaxConcurrentPersonal, \"llm_max_concurrent_personal\")\n\t}\n}\n\nfunc TestGetLLMForModel_EmptyTarget(t *testing.T) {\n\t// Empty target model → should return default model name without hitting subscription logic\n\tf := NewLLMFactory(nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Verify the early return path: targetModel=\"\" should not try to list subscriptions\n\t// (subscriptionSvc is nil, so if it tried, we'd get a different error)\n\t_, model, _, tm, _, usedCustom := f.GetLLMForModel(\"user1\", \"\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"default-model\")\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false for empty target model\")\n\t}\n\tif tm != \"auto\" {\n\t\tt.Errorf(\"thinkingMode = %q, want %q\", tm, \"auto\")\n\t}\n}\n\nfunc TestGetLLMForModel_NilSubscriptionSvc(t *testing.T) {\n\tf := NewLLMFactory(nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// No subscriptionSvc + explicit model → model not found in any subscription,\n\t// fallback to default client with the RESOLVED model (not the default model).\n\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"claude-opus-4-20250115\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115 (resolved model preserved in fallback)\", model)\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n}\n\nfunc TestNormalizeModelTier(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant string\n\t}{\n\t\t{\"vanguard\", \"vanguard\"},\n\t\t{\"VANGUARD\", \"vanguard\"},\n\t\t{\"Vanguard\", \"vanguard\"},\n\t\t{\"strong\", \"vanguard\"},\n\t\t{\"Strong\", \"vanguard\"},\n\t\t{\"balance\", \"balance\"},\n\t\t{\"medium\", \"balance\"},\n\t\t{\"swift\", \"swift\"},\n\t\t{\"weak\", \"swift\"},\n\t\t{\"gpt-4o\", \"\"},\n\t\t{\"\", \"\"},\n\t\t{\"unknown\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := normalizeModelTier(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"normalizeModelTier(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestHasCustomLLMChecksSubscriptionSvc(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Setenv(\"XBOT_HOME\", dir)\n\tdb, err := sqlite.Open(config.DBFilePath())\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tfactory := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tsubSvc := sqlite.NewLLMSubscriptionService(db)\n\tfactory.SetSubscriptionSvc(subSvc)\n\tif err := subSvc.Add(&sqlite.LLMSubscription{ID: \"sub-1\", SenderID: \"cli_user\", Name: \"s1\", Provider: \"openai\", BaseURL: \"https://example.com/v1\", APIKey: \"sk-test\", Model: \"m1\", IsDefault: true}); err != nil {\n\t\tt.Fatalf(\"add sub: %v\", err)\n\t}\n\tif !factory.HasCustomLLM(\"cli_user\") {\n\t\tt.Fatal(\"expected HasCustomLLM to return true when default subscription exists\")\n\t}\n}\n\n// TestInvalidate_ClearsPerChatCache verifies that Invalidate(senderID) clears\n// both user-level and per-chat (senderID:chatID) cache entries.\n// This is the fix for: switching sub then changing model in settings was stuck\n// on the old model because Invalidate only cleared the user-level key.\nfunc TestInvalidate_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsubA := &sqlite.LLMSubscription{\n\t\tID: \"sub-a\", Provider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tID: \"sub-b\", Provider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"deepseek-v3\", MaxOutputTokens: 4096,\n\t}\n\n\t// Without subscriptionSvc, SwitchSubscription resolves model from\n\t// user_default_model (which is also nil here). The default LLM client\n\t// is still created. Model resolution happens when the user picks a model.\n\tif err := f.SwitchSubscription(senderID, subA, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subA: %v\", err)\n\t}\n\n\t// Verify default LLM was created (model is \"\" without subscription_models)\n\t_, modelA, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelA != \"\" {\n\t\tt.Fatalf(\"initial model = %q, want \\\"\\\" (no subscription_models)\", modelA)\n\t}\n\n\t// Simulate: set_default_subscription calls Invalidate then SwitchSubscription\n\tf.Invalidate(senderID)\n\tif err := f.SwitchSubscription(senderID, subB, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subB: %v\", err)\n\t}\n\n\t_, modelB, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelB != \"\" {\n\t\tt.Errorf(\"after sub switch, model = %q, want \\\"\\\" (no subscription_models)\", modelB)\n\t}\n\n\t// Simulate: update_subscription (settings panel) calls Invalidate + SwitchSubscription\n\t// with chatID=\"\" — per-chat cache was NOT cleared before the fix\n\tf.Invalidate(senderID)\n\tupdatedSubB := *subB\n\tupdatedSubB.Model = \"deepseek-r1\"\n\tupdatedSubB.MaxOutputTokens = 16384\n\tif err := f.SwitchSubscription(senderID, &updatedSubB, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription updatedSubB: %v\", err)\n\t}\n\n\t// GetLLMForChat should NOT return stale per-chat cache\n\t_, modelUpdated, _, thinkingUpdated, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelUpdated != \"\" {\n\t\tt.Errorf(\"after settings update, model = %q, want \\\"\\\" (no subscription_models, stale per-chat cache bug)\", modelUpdated)\n\t}\n\t// Verify thinking mode is also not stale\n\tif thinkingUpdated != \"\" {\n\t\tt.Errorf(\"after settings update, thinkingMode = %q, want empty\", thinkingUpdated)\n\t}\n}\n\n// TestSwitchSubscription_UpdatesDefaultLLM verifies that SwitchSubscription\n// DOES update the global defaultLLM/defaultModel for cli_user. In CLI mode,\n// all sessions share senderID \"cli_user\", so defaultLLM is a user-level\n// preference that should follow the user's last subscription choice.\n// SubAgent fallback, ListModels(), and GetLLM() for sessions without\n// per-session subscriptions should all see the new default.\nfunc TestSwitchSubscription_UpdatesDefaultLLM(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"original-default-model\")\n\n\tsubDeepSeek := &sqlite.LLMSubscription{\n\t\tID: \"sub-ds\", Provider: \"openai\", BaseURL: \"https://api.deepseek.com/v1\", APIKey: \"sk-deep\",\n\t\tModel: \"deepseek-v4-pro\",\n\t}\n\n\t// Global default before switch\n\tif dm := f.GetDefaultModel(); dm != \"original-default-model\" {\n\t\tt.Fatalf(\"initial default model = %q, want original-default-model\", dm)\n\t}\n\n\t// Switch subscription for cli_user (the common CLI senderID)\n\tif err := f.SwitchSubscription(\"cli_user\", subDeepSeek, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription: %v\", err)\n\t}\n\n\t// Global default SHOULD be updated — SwitchSubscription resolves model\n\t// from user_default_model. Without subscriptionSvc, it returns \"\" (no\n\t// model). The default LLM client is still created (credentials work),\n\t// just without a model name.\n\t// The model is resolved later when the user picks one from the panel.\n\tif dm := f.GetDefaultModel(); dm != \"\" {\n\t\tt.Errorf(\"default model after SwitchSubscription = %q, want \\\"\\\" (no subscription_models rows)\", dm)\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubExactMatch verifies the config.json subscription path:\n// when configSubsFn returns a subscription whose Model matches the resolved model,\n// GetLLMForModel should use that subscription (usedCustom=true).\nfunc TestGetLLMForModel_ConfigSubExactMatch(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Set up configSubsFn with a matching subscription\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"test-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub matches resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil when config sub matches\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubNoMatch verifies that when configSubsFn returns\n// subscriptions with different Model fields, it still tries to use them with\n// the model name (OpenAI-compatible endpoints can serve any model).\nfunc TestGetLLMForModel_ConfigSubNoMatch(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Config sub has a different model — but still usable with gpt-4o\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"other-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"other-model\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub can serve the resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q (resolved model)\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubSkipsEmptyCredentials verifies that config\n// subscriptions with matching Model but empty BaseURL or APIKey are skipped,\n// and the function falls through to the default LLM.\nfunc TestGetLLMForModel_ConfigSubSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\ttests := []struct {\n\t\tname string\n\t\tsub config.SubscriptionConfig\n\t}{\n\t\t{\n\t\t\tname: \"empty BaseURL\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"no-url\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"empty APIKey\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"no-key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\t\t\treturn []config.SubscriptionConfig{tt.sub}\n\t\t\t})\n\n\t\t\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\t\t\tif usedCustom {\n\t\t\t\tt.Error(\"usedCustom should be false when config sub has empty credentials\")\n\t\t\t}\n\t\t\tif model != \"gpt-4o\" {\n\t\t\t\tt.Errorf(\"model = %q, want %q (resolved model preserved in fallback)\", model, \"gpt-4o\")\n\t\t\t}\n\t\t})\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Tests for buildModelSubscriptionMap & configSubToLLMSubscription\n// ---------------------------------------------------------------------------\n\n// TestBuildModelSubscriptionMap_ConfigSubs verifies that config subscriptions\n// with different models each produce an entry in the model→subscription map.\nfunc TestBuildModelSubscriptionMap_ConfigSubs(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-a\",\n\t\t\t\tName: \"OpenAI\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"sk-openai\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-b\",\n\t\t\t\tName: \"Anthropic\",\n\t\t\t\tProvider: \"anthropic\",\n\t\t\t\tBaseURL: \"https://api.anthropic.com\",\n\t\t\t\tAPIKey: \"sk-ant-key\",\n\t\t\t\tModel: \"claude-sonnet-4-20250514\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 2 {\n\t\tt.Fatalf(\"map size = %d, want 2\", len(m))\n\t}\n\tif sub, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry\")\n\t} else if sub.ID != \"sub-a\" {\n\t\tt.Errorf(\"gpt-4o mapped to sub %q, want sub-a\", sub.ID)\n\t}\n\tif sub, ok := m[\"claude-sonnet-4-20250514\"]; !ok {\n\t\tt.Error(\"missing claude-sonnet-4-20250514 entry\")\n\t} else if sub.ID != \"sub-b\" {\n\t\tt.Errorf(\"claude-sonnet-4-20250514 mapped to sub %q, want sub-b\", sub.ID)\n\t}\n}\n\n// TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials verifies that\n// config subscriptions with empty BaseURL or APIKey are not added to the map.\nfunc TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\n\t// Sub with matching Model but empty BaseURL — must be skipped.\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"No URL\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"No Key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o-mini\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 0 {\n\t\tt.Fatalf(\"map size = %d, want 0 (both subs have empty credentials)\", len(m))\n\t}\n}\n\n// TestBuildModelSubscriptionMap_EmptySenderID verifies that with an empty\n// senderID and nil subscriptionSvc, only config subs are included.\nfunc TestBuildModelSubscriptionMap_EmptySenderID(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\t// subscriptionSvc is nil by default — no DB path at all.\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"cfg-1\",\n\t\t\t\tName: \"ConfigSub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-cfg\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\t// Empty senderID — DB path is also gated by senderID != \"\"\n\tm := f.buildModelSubscriptionMap(\"\")\n\n\tif len(m) != 1 {\n\t\tt.Fatalf(\"map size = %d, want 1 (only config sub)\", len(m))\n\t}\n\tif _, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry from config sub\")\n\t}\n}\n\n// TestConfigSubToLLMSubscription verifies that configSubToLLMSubscription\n// correctly maps every field from SubscriptionConfig to LLMSubscription.\nfunc TestConfigSubToLLMSubscription(t *testing.T) {\n\tcs := config.SubscriptionConfig{\n\t\tID: \"sub-42\",\n\t\tName: \"My Sub\",\n\t\tProvider: \"deepseek\",\n\t\tBaseURL: \"https://api.deepseek.com/v1\",\n\t\tAPIKey: \"sk-deep\",\n\t\tModel: \"deepseek-chat\",\n\t\tMaxOutputTokens: 4096,\n\t\tThinkingMode: \"enabled\",\n\t}\n\n\tsub := configSubToLLMSubscription(cs)\n\n\tif sub.ID != \"sub-42\" {\n\t\tt.Errorf(\"ID = %q, want %q\", sub.ID, \"sub-42\")\n\t}\n\tif sub.Name != \"My Sub\" {\n\t\tt.Errorf(\"Name = %q, want %q\", sub.Name, \"My Sub\")\n\t}\n\tif sub.Provider != \"deepseek\" {\n\t\tt.Errorf(\"Provider = %q, want %q\", sub.Provider, \"deepseek\")\n\t}\n\tif sub.BaseURL != \"https://api.deepseek.com/v1\" {\n\t\tt.Errorf(\"BaseURL = %q, want %q\", sub.BaseURL, \"https://api.deepseek.com/v1\")\n\t}\n\tif sub.APIKey != \"sk-deep\" {\n\t\tt.Errorf(\"APIKey = %q, want %q\", sub.APIKey, \"sk-deep\")\n\t}\n\tif sub.Model != \"deepseek-chat\" {\n\t\tt.Errorf(\"Model = %q, want %q\", sub.Model, \"deepseek-chat\")\n\t}\n\tif sub.MaxOutputTokens != 4096 {\n\t\tt.Errorf(\"MaxOutputTokens = %d, want 4096\", sub.MaxOutputTokens)\n\t}\n\tif sub.ThinkingMode != \"enabled\" {\n\t\tt.Errorf(\"ThinkingMode = %q, want %q\", sub.ThinkingMode, \"enabled\")\n\t}\n}\n\n// --- chatKey tests ---\n\nfunc TestChatKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tsenderID string\n\t\tchatID string\n\t\twant string\n\t}{\n\t\t{\"normal\", \"user123\", \"chat456\", \"user123:chat456\"},\n\t\t{\"empty senderID\", \"\", \"chat456\", \":chat456\"},\n\t\t{\"empty chatID\", \"user123\", \"\", \"user123:\"},\n\t\t{\"both empty\", \"\", \"\", \":\"},\n\t\t{\"colons in values\", \"user:1\", \"chat:2\", \"user:1:chat:2\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := chatKey(tt.senderID, tt.chatID)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"chatKey(%q, %q) = %q, want %q\", tt.senderID, tt.chatID, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// --- parseOrDefault tests ---\n\nfunc TestParseOrDefault(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tinput string\n\t\tdefaultVal int\n\t\twant int\n\t}{\n\t\t{\"empty string returns default\", \"\", 42, 42},\n\t\t{\"valid positive int\", \"100\", 42, 100},\n\t\t{\"zero returns default\", \"0\", 42, 42},\n\t\t{\"negative returns default\", \"-5\", 42, 42},\n\t\t{\"non-numeric returns default\", \"abc\", 42, 42},\n\t\t{\"whitespace-padded number\", \" 7\", 42, 7},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := parseOrDefault(tt.input, tt.defaultVal)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"parseOrDefault(%q, %d) = %d, want %d\", tt.input, tt.defaultVal, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"}, "ae652c88d3b95dfd033ae3a7c27517020452e100": {"t": 1785759443.9710705, "n": 20611, "h": "29660931a8961017", "c": "package agent\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"xbot/config\"\n\t\"xbot/llm\"\n\t\"xbot/storage/sqlite\"\n)\n\nfunc TestGuessProvider(t *testing.T) {\n\ttests := []struct {\n\t\tmodel string\n\t\twant string\n\t}{\n\t\t{\"claude-sonnet-4-20250514\", \"anthropic\"},\n\t\t{\"claude-opus-4-20250115\", \"anthropic\"},\n\t\t{\"gpt-4o\", \"openai\"},\n\t\t{\"gpt-4.1\", \"openai\"},\n\t\t{\"o1-preview\", \"openai\"},\n\t\t{\"o3-mini\", \"openai\"},\n\t\t{\"deepseek-chat\", \"deepseek\"},\n\t\t{\"deepseek-reasoner\", \"deepseek\"},\n\t\t{\"gemini-2.0-flash\", \"google\"},\n\t\t{\"qwen-max\", \"qwen\"},\n\t\t{\"unknown-model\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.model, func(t *testing.T) {\n\t\t\tgot := guessProvider(tt.model)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"guessProvider(%q) = %q, want %q\", tt.model, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestLLMSemAcquireForUser_ReadsMaxConcurrencyFromDB verifies that\n// LLMSemAcquireForUser correctly reads the max_concurrency setting from\n// the user_settings DB via the correct channel, rather than falling back\n// to the hardcoded default. Regression test for the bug where the setting\n// key was misspelled (\"max_concurrent\" vs \"max_concurrency\").\nfunc TestLLMSemAcquireForUser_ReadsMaxConcurrencyFromDB(t *testing.T) {\n\tdir := t.TempDir()\n\tdbPath := filepath.Join(dir, \"test.db\")\n\tdb, err := sqlite.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tstore := sqlite.NewUserSettingsService(db)\n\tsettingsSvc := NewSettingsService(store)\n\t// Write max_concurrency=100 into the DB under channel \"cli\".\n\tif err := settingsSvc.SetSetting(\"cli\", \"test_user\", settingMaxConcurrency, \"100\"); err != nil {\n\t\tt.Fatalf(\"set setting: %v\", err)\n\t}\n\n\t// Create LLMFactory with the settings service.\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.SetSettingsService(settingsSvc)\n\tmgr := llm.NewLLMSemaphoreManager()\n\tf.SetLLMSemaphoreManager(mgr)\n\n\t// Acquire the semaphore and release immediately to verify capacity.\n\tacquire := f.LLMSemAcquireForUser(\"test_user\", \"cli\")\n\tif acquire == nil {\n\t\tt.Fatal(\"LLMSemAcquireForUser returned nil\")\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\t// Acquire N slots to verify capacity is at least 100 (not the default 5).\n\treleases := make([]func(), 0, 100)\n\tfor i := 0; i < 100; i++ {\n\t\trelease := acquire(ctx)\n\t\tif release == nil {\n\t\t\tt.Fatalf(\"failed to acquire slot %d (capacity too low, was it %d?)\", i, llm.DefaultLLMConcurrency)\n\t\t}\n\t\treleases = append(releases, release)\n\t}\n\tfor _, r := range releases {\n\t\tr()\n\t}\n}\n\n// TestSubAgentSemAcquireForUser_ReadsMaxConcurrencyFromDB verifies that\n// SubAgentSemAcquireForUser correctly reads max_concurrency from the DB.\nfunc TestSubAgentSemAcquireForUser_ReadsMaxConcurrencyFromDB(t *testing.T) {\n\tdir := t.TempDir()\n\tdbPath := filepath.Join(dir, \"test.db\")\n\tdb, err := sqlite.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tstore := sqlite.NewUserSettingsService(db)\n\tsettingsSvc := NewSettingsService(store)\n\tif err := settingsSvc.SetSetting(\"cli\", \"test_user\", settingMaxConcurrency, \"50\"); err != nil {\n\t\tt.Fatalf(\"set setting: %v\", err)\n\t}\n\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.SetSettingsService(settingsSvc)\n\tmgr := llm.NewLLMSemaphoreManager()\n\tf.SetLLMSemaphoreManager(mgr)\n\n\tacquire := f.SubAgentSemAcquireForUser(\"test_user\", \"cli\")\n\tif acquire == nil {\n\t\tt.Fatal(\"SubAgentSemAcquireForUser returned nil\")\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\treleases := make([]func(), 0, 50)\n\tfor i := 0; i < 50; i++ {\n\t\trelease := acquire(ctx)\n\t\tif release == nil {\n\t\t\tt.Fatalf(\"failed to acquire subagent slot %d (capacity too low, was it %d?)\", i, llm.DefaultLLMConcurrency)\n\t\t}\n\t\treleases = append(releases, release)\n\t}\n\tfor _, r := range releases {\n\t\tr()\n\t}\n}\n\n// TestSettingKeyConstants_MatchDB verifies that the setting key constants\n// used in LLMFactory match the canonical keys stored in user_settings DB.\nfunc TestSettingKeyConstants_MatchDB(t *testing.T) {\n\t// These constants must match the keys written by settings panel.\n\tif settingMaxConcurrency != \"max_concurrency\" {\n\t\tt.Errorf(\"settingMaxConcurrency = %q, want %q\", settingMaxConcurrency, \"max_concurrency\")\n\t}\n\tif settingSubAgentMaxConcurrency != \"subagent_max_concurrency\" {\n\t\tt.Errorf(\"settingSubAgentMaxConcurrency = %q, want %q\", settingSubAgentMaxConcurrency, \"subagent_max_concurrency\")\n\t}\n\tif settingLLMMaxConcurrentPersonal != \"llm_max_concurrent_personal\" {\n\t\tt.Errorf(\"settingLLMMaxConcurrentPersonal = %q, want %q\", settingLLMMaxConcurrentPersonal, \"llm_max_concurrent_personal\")\n\t}\n}\n\nfunc TestGetLLMForModel_EmptyTarget(t *testing.T) {\n\t// Empty target model → should return default model name without hitting subscription logic\n\tf := NewLLMFactory(nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Verify the early return path: targetModel=\"\" should not try to list subscriptions\n\t// (subscriptionSvc is nil, so if it tried, we'd get a different error)\n\t_, model, _, tm, _, usedCustom := f.GetLLMForModel(\"user1\", \"\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"default-model\")\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false for empty target model\")\n\t}\n\tif tm != \"auto\" {\n\t\tt.Errorf(\"thinkingMode = %q, want %q\", tm, \"auto\")\n\t}\n}\n\nfunc TestGetLLMForModel_NilSubscriptionSvc(t *testing.T) {\n\tf := NewLLMFactory(nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// No subscriptionSvc + explicit model → model not found in any subscription,\n\t// fallback to default client with the RESOLVED model (not the default model).\n\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"claude-opus-4-20250115\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115 (resolved model preserved in fallback)\", model)\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n}\n\nfunc TestNormalizeModelTier(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant string\n\t}{\n\t\t{\"vanguard\", \"vanguard\"},\n\t\t{\"VANGUARD\", \"vanguard\"},\n\t\t{\"Vanguard\", \"vanguard\"},\n\t\t{\"strong\", \"vanguard\"},\n\t\t{\"Strong\", \"vanguard\"},\n\t\t{\"balance\", \"balance\"},\n\t\t{\"medium\", \"balance\"},\n\t\t{\"swift\", \"swift\"},\n\t\t{\"weak\", \"swift\"},\n\t\t{\"gpt-4o\", \"\"},\n\t\t{\"\", \"\"},\n\t\t{\"unknown\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := normalizeModelTier(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"normalizeModelTier(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestHasCustomLLMChecksSubscriptionSvc(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Setenv(\"XBOT_HOME\", dir)\n\tdb, err := sqlite.Open(config.DBFilePath())\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tfactory := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tsubSvc := sqlite.NewLLMSubscriptionService(db)\n\tfactory.SetSubscriptionSvc(subSvc)\n\tif err := subSvc.Add(&sqlite.LLMSubscription{ID: \"sub-1\", SenderID: \"cli_user\", Name: \"s1\", Provider: \"openai\", BaseURL: \"https://example.com/v1\", APIKey: \"sk-test\", Model: \"m1\", IsDefault: true}); err != nil {\n\t\tt.Fatalf(\"add sub: %v\", err)\n\t}\n\tif !factory.HasCustomLLM(\"cli_user\") {\n\t\tt.Fatal(\"expected HasCustomLLM to return true when default subscription exists\")\n\t}\n}\n\n// TestInvalidate_ClearsPerChatCache verifies that Invalidate(senderID) clears\n// both user-level and per-chat (senderID:chatID) cache entries.\n// This is the fix for: switching sub then changing model in settings was stuck\n// on the old model because Invalidate only cleared the user-level key.\nfunc TestInvalidate_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsubA := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"deepseek-v3\", MaxOutputTokens: 4096,\n\t}\n\n\t// Simulate: SwitchSubscription creates both user-level and per-chat caches\n\tif err := f.SwitchSubscription(senderID, subA, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subA: %v\", err)\n\t}\n\n\t// Verify both caches exist\n\t_, modelA, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelA != \"gpt-4o\" {\n\t\tt.Fatalf(\"initial model = %q, want gpt-4o\", modelA)\n\t}\n\n\t// Simulate: set_default_subscription calls Invalidate then SwitchSubscription\n\t// (the actual server handler path for subscription switching)\n\tf.Invalidate(senderID)\n\tif err := f.SwitchSubscription(senderID, subB, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subB: %v\", err)\n\t}\n\n\t_, modelB, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelB != \"deepseek-v3\" {\n\t\tt.Errorf(\"after sub switch, model = %q, want deepseek-v3\", modelB)\n\t}\n\n\t// Simulate: update_subscription (settings panel) calls Invalidate + SwitchSubscription\n\t// with chatID=\"\" — per-chat cache was NOT cleared before the fix\n\tf.Invalidate(senderID)\n\tupdatedSubB := *subB\n\tupdatedSubB.Model = \"deepseek-r1\"\n\tupdatedSubB.MaxOutputTokens = 16384\n\tif err := f.SwitchSubscription(senderID, &updatedSubB, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription updatedSubB: %v\", err)\n\t}\n\n\t// GetLLMForChat should NOT return stale per-chat cache\n\t_, modelUpdated, _, thinkingUpdated, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelUpdated != \"deepseek-r1\" {\n\t\tt.Errorf(\"after settings update, model = %q, want deepseek-r1 (stale per-chat cache bug)\", modelUpdated)\n\t}\n\t// Verify thinking mode is also not stale\n\tif thinkingUpdated != \"\" {\n\t\tt.Errorf(\"after settings update, thinkingMode = %q, want empty\", thinkingUpdated)\n\t}\n}\n\n// TestSwitchSubscription_UpdatesDefaultLLM verifies that SwitchSubscription\n// DOES update the global defaultLLM/defaultModel for cli_user. In CLI mode,\n// all sessions share senderID \"cli_user\", so defaultLLM is a user-level\n// preference that should follow the user's last subscription choice.\n// SubAgent fallback, ListModels(), and GetLLM() for sessions without\n// per-session subscriptions should all see the new default.\nfunc TestSwitchSubscription_UpdatesDefaultLLM(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"original-default-model\")\n\n\tsubDeepSeek := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api.deepseek.com/v1\", APIKey: \"sk-deep\",\n\t\tModel: \"deepseek-v4-pro\",\n\t}\n\n\t// Global default before switch\n\tif dm := f.GetDefaultModel(); dm != \"original-default-model\" {\n\t\tt.Fatalf(\"initial default model = %q, want original-default-model\", dm)\n\t}\n\n\t// Switch subscription for cli_user (the common CLI senderID)\n\tif err := f.SwitchSubscription(\"cli_user\", subDeepSeek, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription: %v\", err)\n\t}\n\n\t// Global default SHOULD be updated (user-level preference)\n\tif dm := f.GetDefaultModel(); dm != \"deepseek-v4-pro\" {\n\t\tt.Errorf(\"default model after SwitchSubscription = %q, want deepseek-v4-pro\", dm)\n\t}\n\n\t// User-level entry should also be updated\n\t_, model, _, _, _ := f.GetLLM(\"cli_user\")\n\tif model != \"deepseek-v4-pro\" {\n\t\tt.Errorf(\"cli_user model = %q, want deepseek-v4-pro\", model)\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubExactMatch verifies the config.json subscription path:\n// when configSubsFn returns a subscription whose Model matches the resolved model,\n// GetLLMForModel should use that subscription (usedCustom=true).\nfunc TestGetLLMForModel_ConfigSubExactMatch(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Set up configSubsFn with a matching subscription\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"test-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub matches resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil when config sub matches\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubNoMatch verifies that when configSubsFn returns\n// subscriptions with different Model fields, it still tries to use them with\n// the model name (OpenAI-compatible endpoints can serve any model).\nfunc TestGetLLMForModel_ConfigSubNoMatch(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Config sub has a different model — but still usable with gpt-4o\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"other-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"other-model\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub can serve the resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q (resolved model)\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubSkipsEmptyCredentials verifies that config\n// subscriptions with matching Model but empty BaseURL or APIKey are skipped,\n// and the function falls through to the default LLM.\nfunc TestGetLLMForModel_ConfigSubSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\ttests := []struct {\n\t\tname string\n\t\tsub config.SubscriptionConfig\n\t}{\n\t\t{\n\t\t\tname: \"empty BaseURL\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"no-url\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"empty APIKey\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"no-key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\t\t\treturn []config.SubscriptionConfig{tt.sub}\n\t\t\t})\n\n\t\t\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\t\t\tif usedCustom {\n\t\t\t\tt.Error(\"usedCustom should be false when config sub has empty credentials\")\n\t\t\t}\n\t\t\tif model != \"gpt-4o\" {\n\t\t\t\tt.Errorf(\"model = %q, want %q (resolved model preserved in fallback)\", model, \"gpt-4o\")\n\t\t\t}\n\t\t})\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Tests for buildModelSubscriptionMap & configSubToLLMSubscription\n// ---------------------------------------------------------------------------\n\n// TestBuildModelSubscriptionMap_ConfigSubs verifies that config subscriptions\n// with different models each produce an entry in the model→subscription map.\nfunc TestBuildModelSubscriptionMap_ConfigSubs(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-a\",\n\t\t\t\tName: \"OpenAI\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"sk-openai\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-b\",\n\t\t\t\tName: \"Anthropic\",\n\t\t\t\tProvider: \"anthropic\",\n\t\t\t\tBaseURL: \"https://api.anthropic.com\",\n\t\t\t\tAPIKey: \"sk-ant-key\",\n\t\t\t\tModel: \"claude-sonnet-4-20250514\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 2 {\n\t\tt.Fatalf(\"map size = %d, want 2\", len(m))\n\t}\n\tif sub, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry\")\n\t} else if sub.ID != \"sub-a\" {\n\t\tt.Errorf(\"gpt-4o mapped to sub %q, want sub-a\", sub.ID)\n\t}\n\tif sub, ok := m[\"claude-sonnet-4-20250514\"]; !ok {\n\t\tt.Error(\"missing claude-sonnet-4-20250514 entry\")\n\t} else if sub.ID != \"sub-b\" {\n\t\tt.Errorf(\"claude-sonnet-4-20250514 mapped to sub %q, want sub-b\", sub.ID)\n\t}\n}\n\n// TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials verifies that\n// config subscriptions with empty BaseURL or APIKey are not added to the map.\nfunc TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\n\t// Sub with matching Model but empty BaseURL — must be skipped.\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"No URL\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"No Key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o-mini\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 0 {\n\t\tt.Fatalf(\"map size = %d, want 0 (both subs have empty credentials)\", len(m))\n\t}\n}\n\n// TestBuildModelSubscriptionMap_EmptySenderID verifies that with an empty\n// senderID and nil subscriptionSvc, only config subs are included.\nfunc TestBuildModelSubscriptionMap_EmptySenderID(t *testing.T) {\n\tf := NewLLMFactory(&llm.MockLLM{}, \"default-model\")\n\t// subscriptionSvc is nil by default — no DB path at all.\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"cfg-1\",\n\t\t\t\tName: \"ConfigSub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-cfg\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\t// Empty senderID — DB path is also gated by senderID != \"\"\n\tm := f.buildModelSubscriptionMap(\"\")\n\n\tif len(m) != 1 {\n\t\tt.Fatalf(\"map size = %d, want 1 (only config sub)\", len(m))\n\t}\n\tif _, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry from config sub\")\n\t}\n}\n\n// TestConfigSubToLLMSubscription verifies that configSubToLLMSubscription\n// correctly maps every field from SubscriptionConfig to LLMSubscription.\nfunc TestConfigSubToLLMSubscription(t *testing.T) {\n\tcs := config.SubscriptionConfig{\n\t\tID: \"sub-42\",\n\t\tName: \"My Sub\",\n\t\tProvider: \"deepseek\",\n\t\tBaseURL: \"https://api.deepseek.com/v1\",\n\t\tAPIKey: \"sk-deep\",\n\t\tModel: \"deepseek-chat\",\n\t\tMaxOutputTokens: 4096,\n\t\tThinkingMode: \"enabled\",\n\t}\n\n\tsub := configSubToLLMSubscription(cs)\n\n\tif sub.ID != \"sub-42\" {\n\t\tt.Errorf(\"ID = %q, want %q\", sub.ID, \"sub-42\")\n\t}\n\tif sub.Name != \"My Sub\" {\n\t\tt.Errorf(\"Name = %q, want %q\", sub.Name, \"My Sub\")\n\t}\n\tif sub.Provider != \"deepseek\" {\n\t\tt.Errorf(\"Provider = %q, want %q\", sub.Provider, \"deepseek\")\n\t}\n\tif sub.BaseURL != \"https://api.deepseek.com/v1\" {\n\t\tt.Errorf(\"BaseURL = %q, want %q\", sub.BaseURL, \"https://api.deepseek.com/v1\")\n\t}\n\tif sub.APIKey != \"sk-deep\" {\n\t\tt.Errorf(\"APIKey = %q, want %q\", sub.APIKey, \"sk-deep\")\n\t}\n\tif sub.Model != \"deepseek-chat\" {\n\t\tt.Errorf(\"Model = %q, want %q\", sub.Model, \"deepseek-chat\")\n\t}\n\tif sub.MaxOutputTokens != 4096 {\n\t\tt.Errorf(\"MaxOutputTokens = %d, want 4096\", sub.MaxOutputTokens)\n\t}\n\tif sub.ThinkingMode != \"enabled\" {\n\t\tt.Errorf(\"ThinkingMode = %q, want %q\", sub.ThinkingMode, \"enabled\")\n\t}\n}\n\n// --- chatKey tests ---\n\nfunc TestChatKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tsenderID string\n\t\tchatID string\n\t\twant string\n\t}{\n\t\t{\"normal\", \"user123\", \"chat456\", \"user123:chat456\"},\n\t\t{\"empty senderID\", \"\", \"chat456\", \":chat456\"},\n\t\t{\"empty chatID\", \"user123\", \"\", \"user123:\"},\n\t\t{\"both empty\", \"\", \"\", \":\"},\n\t\t{\"colons in values\", \"user:1\", \"chat:2\", \"user:1:chat:2\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := chatKey(tt.senderID, tt.chatID)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"chatKey(%q, %q) = %q, want %q\", tt.senderID, tt.chatID, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// --- parseOrDefault tests ---\n\nfunc TestParseOrDefault(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tinput string\n\t\tdefaultVal int\n\t\twant int\n\t}{\n\t\t{\"empty string returns default\", \"\", 42, 42},\n\t\t{\"valid positive int\", \"100\", 42, 100},\n\t\t{\"zero returns default\", \"0\", 42, 42},\n\t\t{\"negative returns default\", \"-5\", 42, 42},\n\t\t{\"non-numeric returns default\", \"abc\", 42, 42},\n\t\t{\"whitespace-padded number\", \" 7\", 42, 7},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := parseOrDefault(tt.input, tt.defaultVal)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"parseOrDefault(%q, %d) = %d, want %d\", tt.input, tt.defaultVal, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"}, "e94c74452a58340e55196e8c6e58c404103f2f35": {"t": 1785759446.2370634, "n": 31746, "h": "9238ad14f653a0c7", "c": "package agent\n\nimport (\n\t\"context\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"xbot/config\"\n\t\"xbot/llm\"\n\t\"xbot/storage/sqlite\"\n)\n\nfunc TestGuessProvider(t *testing.T) {\n\ttests := []struct {\n\t\tmodel string\n\t\twant string\n\t}{\n\t\t{\"claude-sonnet-4-20250514\", \"anthropic\"},\n\t\t{\"claude-opus-4-20250115\", \"anthropic\"},\n\t\t{\"gpt-4o\", \"openai\"},\n\t\t{\"gpt-4.1\", \"openai\"},\n\t\t{\"o1-preview\", \"openai\"},\n\t\t{\"o3-mini\", \"openai\"},\n\t\t{\"deepseek-chat\", \"deepseek\"},\n\t\t{\"deepseek-reasoner\", \"deepseek\"},\n\t\t{\"gemini-2.0-flash\", \"google\"},\n\t\t{\"qwen-max\", \"qwen\"},\n\t\t{\"unknown-model\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.model, func(t *testing.T) {\n\t\t\tgot := guessProvider(tt.model)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"guessProvider(%q) = %q, want %q\", tt.model, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestLLMSemAcquireForUser_ReadsMaxConcurrencyFromDB verifies that\n// LLMSemAcquireForUser correctly reads the max_concurrency setting from\n// the user_settings DB via the correct channel, rather than falling back\n// to the hardcoded default. Regression test for the bug where the setting\n// key was misspelled (\"max_concurrent\" vs \"max_concurrency\").\nfunc TestLLMSemAcquireForUser_ReadsMaxConcurrencyFromDB(t *testing.T) {\n\tdir := t.TempDir()\n\tdbPath := filepath.Join(dir, \"test.db\")\n\tdb, err := sqlite.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tstore := sqlite.NewUserSettingsService(db)\n\tsettingsSvc := NewSettingsService(store)\n\t// Write max_concurrency=100 into the DB under channel \"cli\".\n\tif err := settingsSvc.SetSetting(\"cli\", \"test_user\", settingMaxConcurrency, \"100\"); err != nil {\n\t\tt.Fatalf(\"set setting: %v\", err)\n\t}\n\n\t// Create LLMFactory with the settings service.\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.SetSettingsService(settingsSvc)\n\tmgr := llm.NewLLMSemaphoreManager()\n\tf.SetLLMSemaphoreManager(mgr)\n\n\t// Acquire the semaphore and release immediately to verify capacity.\n\tacquire := f.LLMSemAcquireForUser(\"test_user\", \"cli\")\n\tif acquire == nil {\n\t\tt.Fatal(\"LLMSemAcquireForUser returned nil\")\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\t// Acquire N slots to verify capacity is at least 100 (not the default 5).\n\treleases := make([]func(), 0, 100)\n\tfor i := 0; i < 100; i++ {\n\t\trelease := acquire(ctx)\n\t\tif release == nil {\n\t\t\tt.Fatalf(\"failed to acquire slot %d (capacity too low, was it %d?)\", i, llm.DefaultLLMConcurrency)\n\t\t}\n\t\treleases = append(releases, release)\n\t}\n\tfor _, r := range releases {\n\t\tr()\n\t}\n}\n\n// TestSubAgentSemAcquireForUser_ReadsMaxConcurrencyFromDB verifies that\n// SubAgentSemAcquireForUser correctly reads max_concurrency from the DB.\nfunc TestSubAgentSemAcquireForUser_ReadsMaxConcurrencyFromDB(t *testing.T) {\n\tdir := t.TempDir()\n\tdbPath := filepath.Join(dir, \"test.db\")\n\tdb, err := sqlite.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tstore := sqlite.NewUserSettingsService(db)\n\tsettingsSvc := NewSettingsService(store)\n\tif err := settingsSvc.SetSetting(\"cli\", \"test_user\", settingMaxConcurrency, \"50\"); err != nil {\n\t\tt.Fatalf(\"set setting: %v\", err)\n\t}\n\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.SetSettingsService(settingsSvc)\n\tmgr := llm.NewLLMSemaphoreManager()\n\tf.SetLLMSemaphoreManager(mgr)\n\n\tacquire := f.SubAgentSemAcquireForUser(\"test_user\", \"cli\")\n\tif acquire == nil {\n\t\tt.Fatal(\"SubAgentSemAcquireForUser returned nil\")\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\treleases := make([]func(), 0, 50)\n\tfor i := 0; i < 50; i++ {\n\t\trelease := acquire(ctx)\n\t\tif release == nil {\n\t\t\tt.Fatalf(\"failed to acquire subagent slot %d (capacity too low, was it %d?)\", i, llm.DefaultLLMConcurrency)\n\t\t}\n\t\treleases = append(releases, release)\n\t}\n\tfor _, r := range releases {\n\t\tr()\n\t}\n}\n\n// TestSettingKeyConstants_MatchDB verifies that the setting key constants\n// used in LLMFactory match the canonical keys stored in user_settings DB.\nfunc TestSettingKeyConstants_MatchDB(t *testing.T) {\n\t// These constants must match the keys written by settings panel.\n\tif settingMaxConcurrency != \"max_concurrency\" {\n\t\tt.Errorf(\"settingMaxConcurrency = %q, want %q\", settingMaxConcurrency, \"max_concurrency\")\n\t}\n\tif settingSubAgentMaxConcurrency != \"subagent_max_concurrency\" {\n\t\tt.Errorf(\"settingSubAgentMaxConcurrency = %q, want %q\", settingSubAgentMaxConcurrency, \"subagent_max_concurrency\")\n\t}\n\tif settingLLMMaxConcurrentPersonal != \"llm_max_concurrent_personal\" {\n\t\tt.Errorf(\"settingLLMMaxConcurrentPersonal = %q, want %q\", settingLLMMaxConcurrentPersonal, \"llm_max_concurrent_personal\")\n\t}\n}\n\nfunc TestGetLLMForModel_EmptyTarget(t *testing.T) {\n\t// Empty target model → should return default model name without hitting subscription logic\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Verify the early return path: targetModel=\"\" should not try to list subscriptions\n\t// (subscriptionSvc is nil, so if it tried, we'd get a different error)\n\t_, model, _, tm, _, usedCustom := f.GetLLMForModel(\"user1\", \"\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"default-model\")\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false for empty target model\")\n\t}\n\tif tm != \"auto\" {\n\t\tt.Errorf(\"thinkingMode = %q, want %q\", tm, \"auto\")\n\t}\n}\n\nfunc TestGetLLMForModel_NilSubscriptionSvc(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// No subscriptionSvc + explicit model → model not found in any subscription,\n\t// fallback to default client with the RESOLVED model (not the default model).\n\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"claude-opus-4-20250115\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115 (resolved model preserved in fallback)\", model)\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n}\n\nfunc TestNormalizeModelTier(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant string\n\t}{\n\t\t{\"vanguard\", \"vanguard\"},\n\t\t{\"VANGUARD\", \"vanguard\"},\n\t\t{\"Vanguard\", \"vanguard\"},\n\t\t{\"strong\", \"vanguard\"},\n\t\t{\"Strong\", \"vanguard\"},\n\t\t{\"balance\", \"balance\"},\n\t\t{\"medium\", \"balance\"},\n\t\t{\"swift\", \"swift\"},\n\t\t{\"weak\", \"swift\"},\n\t\t{\"gpt-4o\", \"\"},\n\t\t{\"\", \"\"},\n\t\t{\"unknown\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := normalizeModelTier(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"normalizeModelTier(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestResolveTierModel(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\n\t// No tiers configured → tier keywords are recognized but model is empty\n\tmodel, usedTier := f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty\", model)\n\t}\n\n\t// Non-tier value passes through unchanged\n\tmodel, usedTier = f.resolveTierModel(\"gpt-4o\")\n\tif usedTier {\n\t\tt.Error(\"usedTier should be false for non-tier value\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o\", model)\n\t}\n\n\t// Configure tiers\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t\tBalanceModel: \"claude-sonnet-4-20250514\",\n\t\tSwiftModel: \"gpt-4o-mini\",\n\t})\n\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Aliases: strong/medium/weak\n\tmodel, _ = f.resolveTierModel(\"strong\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"medium\")\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"weak\")\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Partial config: only vanguard set\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"opus\",\n\t})\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true even for unconfigured tier\")\n\t}\n\t// balance unconfigured → fallback to vanguard\n\tif model != \"opus\" {\n\t\tt.Errorf(\"model = %q, want opus (fallback from unconfigured balance to vanguard)\", model)\n\t}\n}\n\nfunc TestGetLLMForModel_TierResolution(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Tier with no subscriptionSvc → model not found, fallback to default client\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t})\n\n\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115 (resolved tier model)\", model)\n\t}\n\n\t// Non-tier model with no subscriptionSvc → returns the model name itself\n\t_, model, _, _, _, usedCustom = f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o (non-tier model, returned as-is)\", model)\n\t}\n}\n\nfunc TestResolveTierModel_UnconfiguredFallback(t *testing.T) {\n\t// When swift/vanguard are not configured, should fallback to balance\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{\n\t\tBalanceModel: \"gpt-4o\",\n\t\t// VanguardModel and SwiftModel intentionally empty\n\t})\n\n\t// swift not configured → fallback to balance\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"swift fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// vanguard not configured → fallback to balance\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"vanguard fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// balance configured → returns balance\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"balance = %q, want gpt-4o\", model)\n\t}\n}\n\nfunc TestResolveTierModel_AllUnconfigured(t *testing.T) {\n\t// All tiers unconfigured → returns empty string (will fall to default client)\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{})\n\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (tier keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty (no tiers configured)\", model)\n\t}\n}\n\nfunc TestHasCustomLLMChecksSubscriptionSvc(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Setenv(\"XBOT_HOME\", dir)\n\tdb, err := sqlite.Open(config.DBFilePath())\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tfactory := NewLLMFactory(sqlite.NewUserLLMConfigService(db), &llm.MockLLM{}, \"default-model\")\n\tsubSvc := sqlite.NewLLMSubscriptionService(db)\n\tfactory.SetSubscriptionSvc(subSvc)\n\tif err := subSvc.Add(&sqlite.LLMSubscription{ID: \"sub-1\", SenderID: \"cli_user\", Name: \"s1\", Provider: \"openai\", BaseURL: \"https://example.com/v1\", APIKey: \"sk-test\", Model: \"m1\", IsDefault: true}); err != nil {\n\t\tt.Fatalf(\"add sub: %v\", err)\n\t}\n\tif !factory.HasCustomLLM(\"cli_user\") {\n\t\tt.Fatal(\"expected HasCustomLLM to return true when default subscription exists\")\n\t}\n}\n\n// TestInvalidate_ClearsPerChatCache verifies that Invalidate(senderID) clears\n// both user-level and per-chat (senderID:chatID) cache entries.\n// This is the fix for: switching sub then changing model in settings was stuck\n// on the old model because Invalidate only cleared the user-level key.\nfunc TestInvalidate_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsubA := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"deepseek-v3\", MaxOutputTokens: 4096,\n\t}\n\n\t// Simulate: SwitchSubscription creates both user-level and per-chat caches\n\tif err := f.SwitchSubscription(senderID, subA, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subA: %v\", err)\n\t}\n\n\t// Verify both caches exist\n\t_, modelA, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelA != \"gpt-4o\" {\n\t\tt.Fatalf(\"initial model = %q, want gpt-4o\", modelA)\n\t}\n\n\t// Simulate: set_default_subscription calls Invalidate then SwitchSubscription\n\t// (the actual server handler path for subscription switching)\n\tf.Invalidate(senderID)\n\tif err := f.SwitchSubscription(senderID, subB, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subB: %v\", err)\n\t}\n\n\t_, modelB, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelB != \"deepseek-v3\" {\n\t\tt.Errorf(\"after sub switch, model = %q, want deepseek-v3\", modelB)\n\t}\n\n\t// Simulate: update_subscription (settings panel) calls Invalidate + SwitchSubscription\n\t// with chatID=\"\" — per-chat cache was NOT cleared before the fix\n\tf.Invalidate(senderID)\n\tupdatedSubB := *subB\n\tupdatedSubB.Model = \"deepseek-r1\"\n\tupdatedSubB.MaxOutputTokens = 16384\n\tif err := f.SwitchSubscription(senderID, &updatedSubB, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription updatedSubB: %v\", err)\n\t}\n\n\t// GetLLMForChat should NOT return stale per-chat cache\n\t_, modelUpdated, _, thinkingUpdated, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelUpdated != \"deepseek-r1\" {\n\t\tt.Errorf(\"after settings update, model = %q, want deepseek-r1 (stale per-chat cache bug)\", modelUpdated)\n\t}\n\t// Verify thinking mode is also not stale\n\tif thinkingUpdated != \"\" {\n\t\tt.Errorf(\"after settings update, thinkingMode = %q, want empty\", thinkingUpdated)\n\t}\n}\n\n// TestSwitchModel_ClearsPerChatCache verifies that SwitchModel clears per-chat\n// model caches so GetLLMForChat returns the new model instead of a stale\n// per-chat entry.\nfunc TestSwitchModel_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsub := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api.example.com/v1\", APIKey: \"sk-test\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\n\t// Create per-chat cache via SwitchSubscription\n\tif err := f.SwitchSubscription(senderID, sub, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription: %v\", err)\n\t}\n\n\t// Now SwitchModel (e.g., from quick panel model switch)\n\tf.SwitchModel(senderID, \"gpt-4o-mini\")\n\n\t// GetLLMForChat should return the new model, not the stale per-chat one\n\t_, model, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"after SwitchModel, per-chat model = %q, want gpt-4o-mini (stale per-chat cache bug)\", model)\n\t}\n}\n\n// TestInvalidate_DoesNotAffectOtherUsers verifies that Invalidate(senderID) only\n// clears entries for that specific sender, not other users.\nfunc TestInvalidate_DoesNotAffectOtherUsers(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsubA := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\",\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"claude-3-opus\",\n\t}\n\n\t// User A gets per-chat cache\n\tif err := f.SwitchSubscription(\"userA\", subA, \"/home/a\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription userA: %v\", err)\n\t}\n\t// User B gets per-chat cache\n\tif err := f.SwitchSubscription(\"userB\", subB, \"/home/b\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription userB: %v\", err)\n\t}\n\n\t// Invalidate user A — should NOT affect user B\n\tf.Invalidate(\"userA\")\n\n\t// User B's per-chat cache should still work\n\t_, modelB, _, _, _ := f.GetLLMForChat(\"userB\", \"/home/b\")\n\tif modelB != \"claude-3-opus\" {\n\t\tt.Errorf(\"userB model after Invalidate(userA) = %q, want claude-3-opus\", modelB)\n\t}\n}\n\n// TestSwitchSubscription_UpdatesDefaultLLM verifies that SwitchSubscription\n// DOES update the global defaultLLM/defaultModel for cli_user. In CLI mode,\n// all sessions share senderID \"cli_user\", so defaultLLM is a user-level\n// preference that should follow the user's last subscription choice.\n// SubAgent fallback, ListModels(), and GetLLM() for sessions without\n// per-session subscriptions should all see the new default.\nfunc TestSwitchSubscription_UpdatesDefaultLLM(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"original-default-model\")\n\n\tsubDeepSeek := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api.deepseek.com/v1\", APIKey: \"sk-deep\",\n\t\tModel: \"deepseek-v4-pro\",\n\t}\n\n\t// Global default before switch\n\tif dm := f.GetDefaultModel(); dm != \"original-default-model\" {\n\t\tt.Fatalf(\"initial default model = %q, want original-default-model\", dm)\n\t}\n\n\t// Switch subscription for cli_user (the common CLI senderID)\n\tif err := f.SwitchSubscription(\"cli_user\", subDeepSeek, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription: %v\", err)\n\t}\n\n\t// Global default SHOULD be updated (user-level preference)\n\tif dm := f.GetDefaultModel(); dm != \"deepseek-v4-pro\" {\n\t\tt.Errorf(\"default model after SwitchSubscription = %q, want deepseek-v4-pro\", dm)\n\t}\n\n\t// User-level entry should also be updated\n\t_, model, _, _, _ := f.GetLLM(\"cli_user\")\n\tif model != \"deepseek-v4-pro\" {\n\t\tt.Errorf(\"cli_user model = %q, want deepseek-v4-pro\", model)\n\t}\n}\n\n// TestGlobalSwitch_PreservesPerSessionEntries verifies that when a global subscription\n// switch happens (via InvalidateSender + SwitchSubscription), per-session entries\n// belonging to other chatIDs are NOT cleared. This prevents cross-session contamination:\n// session A switching to DeepSeek must not destroy session B's per-session GLM override.\nfunc TestGlobalSwitch_PreservesPerSessionEntries(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tsenderID := \"cli_user\" // all CLI sessions share this senderID\n\n\tsubGLM := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api.glm.com/v1\", APIKey: \"sk-glm\",\n\t\tModel: \"glm-5\", PerModelConfigs: map[string]sqlite.PerModelConfig{\n\t\t\t\"glm-5\": {MaxContext: 200000},\n\t\t},\n\t}\n\tsubDeepSeek := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api.deepseek.com/v1\", APIKey: \"sk-deep\",\n\t\tModel: \"deepseek-v4-pro\", PerModelConfigs: map[string]sqlite.PerModelConfig{\n\t\t\t\"deepseek-v4-pro\": {MaxContext: 1000000},\n\t\t},\n\t}\n\n\tchatA := \"/home/user/src/project-a:Agent-001\"\n\tchatB := \"/home/user/src/project-b:Agent-002\"\n\n\t// Session A gets per-session GLM\n\tif err := f.SetSessionLLM(senderID, chatA, subGLM); err != nil {\n\t\tt.Fatalf(\"SetSessionLLM A: %v\", err)\n\t}\n\n\t// Session B has no per-session entry, uses user-level default\n\n\t// User-level default is DeepSeek (e.g. global switch)\n\tf.InvalidateSender(senderID)\n\tif err := f.SwitchSubscription(senderID, subDeepSeek, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription: %v\", err)\n\t}\n\n\t// Session A should STILL use GLM (per-session preserved)\n\t_, modelA, maxCtxA, _, _ := f.GetLLMForChat(senderID, chatA)\n\tif modelA != \"glm-5\" {\n\t\tt.Errorf(\"session A model = %q, want glm-5 (per-session must survive global switch)\", modelA)\n\t}\n\tif maxCtxA != 200000 {\n\t\tt.Errorf(\"session A maxCtx = %d, want 200000 (GLM per-model config)\", maxCtxA)\n\t}\n\n\t// Session B should use DeepSeek (from user-level, no per-session override)\n\t_, modelB, maxCtxB, _, _ := f.GetLLMForChat(senderID, chatB)\n\tif modelB != \"deepseek-v4-pro\" {\n\t\tt.Errorf(\"session B model = %q, want deepseek-v4-pro (user-level default)\", modelB)\n\t}\n\tif maxCtxB != 1000000 {\n\t\tt.Errorf(\"session B maxCtx = %d, want 1000000 (DeepSeek per-model config)\", maxCtxB)\n\t}\n}\n\n// TestInvalidate_ClearsPerSessionEntries verifies that Invalidate (full) DOES clear\n// per-session entries. This is the old behavior that caused cross-session contamination\n// and should NOT be used for global subscription switches.\nfunc TestInvalidate_ClearsPerSessionEntries(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tsenderID := \"cli_user\"\n\n\tsubGLM := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api.glm.com/v1\", APIKey: \"sk-glm\",\n\t\tModel: \"glm-5\",\n\t}\n\tsubDeepSeek := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api.deepseek.com/v1\", APIKey: \"sk-deep\",\n\t\tModel: \"deepseek-v4-pro\",\n\t}\n\n\tchatA := \"/home/user/src/project-a:Agent-001\"\n\n\t// Session A gets per-session GLM\n\tif err := f.SetSessionLLM(senderID, chatA, subGLM); err != nil {\n\t\tt.Fatalf(\"SetSessionLLM: %v\", err)\n\t}\n\n\t// Verify A has GLM\n\t_, modelA, _, _, _ := f.GetLLMForChat(senderID, chatA)\n\tif modelA != \"glm-5\" {\n\t\tt.Fatalf(\"session A model before Invalidate = %q, want glm-5\", modelA)\n\t}\n\n\t// Full Invalidate (old behavior) destroys per-session entry\n\tf.Invalidate(senderID)\n\tif err := f.SwitchSubscription(senderID, subDeepSeek, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription: %v\", err)\n\t}\n\n\t// Session A now falls back to user-level DeepSeek (per-session was wiped)\n\t_, modelA2, _, _, _ := f.GetLLMForChat(senderID, chatA)\n\tif modelA2 != \"deepseek-v4-pro\" {\n\t\tt.Errorf(\"session A model after Invalidate = %q, want deepseek-v4-pro (per-session was cleared)\", modelA2)\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubExactMatch verifies the config.json subscription path:\n// when configSubsFn returns a subscription whose Model matches the resolved tier model,\n// GetLLMForModel should use that subscription (usedCustom=true).\nfunc TestGetLLMForModel_ConfigSubExactMatch(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Configure tier so \"vanguard\" resolves to \"gpt-4o\"\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"gpt-4o\",\n\t})\n\n\t// Set up configSubsFn with a matching subscription\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"test-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub matches resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil when config sub matches\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubNoMatch verifies that when configSubsFn returns\n// subscriptions with different Model fields, it still tries to use them with\n// the resolved model name (OpenAI-compatible endpoints can serve any model).\nfunc TestGetLLMForModel_ConfigSubNoMatch(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Configure tier so \"vanguard\" resolves to \"gpt-4o\"\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"gpt-4o\",\n\t})\n\n\t// Config sub has a different model — but still usable with gpt-4o\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"other-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"other-model\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub can serve the resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q (resolved tier model)\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubSkipsEmptyCredentials verifies that config\n// subscriptions with matching Model but empty BaseURL or APIKey are skipped,\n// and the function falls through to the default LLM.\nfunc TestGetLLMForModel_ConfigSubSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Configure tier so \"vanguard\" resolves to \"gpt-4o\"\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"gpt-4o\",\n\t})\n\n\t// Sub-tests for empty BaseURL and empty APIKey\n\ttests := []struct {\n\t\tname string\n\t\tsub config.SubscriptionConfig\n\t}{\n\t\t{\n\t\t\tname: \"empty BaseURL\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"no-url\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"empty APIKey\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"no-key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\t\t\treturn []config.SubscriptionConfig{tt.sub}\n\t\t\t})\n\n\t\t\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\t\t\tif usedCustom {\n\t\t\t\tt.Error(\"usedCustom should be false when config sub has empty credentials\")\n\t\t\t}\n\t\t\tif model != \"gpt-4o\" {\n\t\t\t\tt.Errorf(\"model = %q, want %q (resolved tier model preserved in fallback)\", model, \"gpt-4o\")\n\t\t\t}\n\t\t})\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Tests for buildModelSubscriptionMap & configSubToLLMSubscription\n// ---------------------------------------------------------------------------\n\n// TestBuildModelSubscriptionMap_ConfigSubs verifies that config subscriptions\n// with different models each produce an entry in the model→subscription map.\nfunc TestBuildModelSubscriptionMap_ConfigSubs(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-a\",\n\t\t\t\tName: \"OpenAI\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"sk-openai\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-b\",\n\t\t\t\tName: \"Anthropic\",\n\t\t\t\tProvider: \"anthropic\",\n\t\t\t\tBaseURL: \"https://api.anthropic.com\",\n\t\t\t\tAPIKey: \"sk-ant-key\",\n\t\t\t\tModel: \"claude-sonnet-4-20250514\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 2 {\n\t\tt.Fatalf(\"map size = %d, want 2\", len(m))\n\t}\n\tif sub, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry\")\n\t} else if sub.ID != \"sub-a\" {\n\t\tt.Errorf(\"gpt-4o mapped to sub %q, want sub-a\", sub.ID)\n\t}\n\tif sub, ok := m[\"claude-sonnet-4-20250514\"]; !ok {\n\t\tt.Error(\"missing claude-sonnet-4-20250514 entry\")\n\t} else if sub.ID != \"sub-b\" {\n\t\tt.Errorf(\"claude-sonnet-4-20250514 mapped to sub %q, want sub-b\", sub.ID)\n\t}\n}\n\n// TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials verifies that\n// config subscriptions with empty BaseURL or APIKey are not added to the map.\nfunc TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\t// Sub with matching Model but empty BaseURL — must be skipped.\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"No URL\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"No Key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o-mini\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 0 {\n\t\tt.Fatalf(\"map size = %d, want 0 (both subs have empty credentials)\", len(m))\n\t}\n}\n\n// TestBuildModelSubscriptionMap_EmptySenderID verifies that with an empty\n// senderID and nil subscriptionSvc, only config subs are included.\nfunc TestBuildModelSubscriptionMap_EmptySenderID(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\t// subscriptionSvc is nil by default — no DB path at all.\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"cfg-1\",\n\t\t\t\tName: \"ConfigSub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-cfg\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\t// Empty senderID — DB path is also gated by senderID != \"\"\n\tm := f.buildModelSubscriptionMap(\"\")\n\n\tif len(m) != 1 {\n\t\tt.Fatalf(\"map size = %d, want 1 (only config sub)\", len(m))\n\t}\n\tif _, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry from config sub\")\n\t}\n}\n\n// TestConfigSubToLLMSubscription verifies that configSubToLLMSubscription\n// correctly maps every field from SubscriptionConfig to LLMSubscription.\nfunc TestConfigSubToLLMSubscription(t *testing.T) {\n\tcs := config.SubscriptionConfig{\n\t\tID: \"sub-42\",\n\t\tName: \"My Sub\",\n\t\tProvider: \"deepseek\",\n\t\tBaseURL: \"https://api.deepseek.com/v1\",\n\t\tAPIKey: \"sk-deep\",\n\t\tModel: \"deepseek-chat\",\n\t\tMaxOutputTokens: 4096,\n\t\tThinkingMode: \"enabled\",\n\t}\n\n\tsub := configSubToLLMSubscription(cs)\n\n\tif sub.ID != \"sub-42\" {\n\t\tt.Errorf(\"ID = %q, want %q\", sub.ID, \"sub-42\")\n\t}\n\tif sub.Name != \"My Sub\" {\n\t\tt.Errorf(\"Name = %q, want %q\", sub.Name, \"My Sub\")\n\t}\n\tif sub.Provider != \"deepseek\" {\n\t\tt.Errorf(\"Provider = %q, want %q\", sub.Provider, \"deepseek\")\n\t}\n\tif sub.BaseURL != \"https://api.deepseek.com/v1\" {\n\t\tt.Errorf(\"BaseURL = %q, want %q\", sub.BaseURL, \"https://api.deepseek.com/v1\")\n\t}\n\tif sub.APIKey != \"sk-deep\" {\n\t\tt.Errorf(\"APIKey = %q, want %q\", sub.APIKey, \"sk-deep\")\n\t}\n\tif sub.Model != \"deepseek-chat\" {\n\t\tt.Errorf(\"Model = %q, want %q\", sub.Model, \"deepseek-chat\")\n\t}\n\tif sub.MaxOutputTokens != 4096 {\n\t\tt.Errorf(\"MaxOutputTokens = %d, want 4096\", sub.MaxOutputTokens)\n\t}\n\tif sub.ThinkingMode != \"enabled\" {\n\t\tt.Errorf(\"ThinkingMode = %q, want %q\", sub.ThinkingMode, \"enabled\")\n\t}\n}\n\n// --- chatKey tests ---\n\nfunc TestChatKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tsenderID string\n\t\tchatID string\n\t\twant string\n\t}{\n\t\t{\"normal\", \"user123\", \"chat456\", \"user123:chat456\"},\n\t\t{\"empty senderID\", \"\", \"chat456\", \":chat456\"},\n\t\t{\"empty chatID\", \"user123\", \"\", \"user123:\"},\n\t\t{\"both empty\", \"\", \"\", \":\"},\n\t\t{\"colons in values\", \"user:1\", \"chat:2\", \"user:1:chat:2\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := chatKey(tt.senderID, tt.chatID)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"chatKey(%q, %q) = %q, want %q\", tt.senderID, tt.chatID, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// --- parseOrDefault tests ---\n\nfunc TestParseOrDefault(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tinput string\n\t\tdefaultVal int\n\t\twant int\n\t}{\n\t\t{\"empty string returns default\", \"\", 42, 42},\n\t\t{\"valid positive int\", \"100\", 42, 100},\n\t\t{\"zero returns default\", \"0\", 42, 42},\n\t\t{\"negative returns default\", \"-5\", 42, 42},\n\t\t{\"non-numeric returns default\", \"abc\", 42, 42},\n\t\t{\"whitespace-padded number\", \" 7\", 42, 7},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := parseOrDefault(tt.input, tt.defaultVal)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"parseOrDefault(%q, %d) = %d, want %d\", tt.input, tt.defaultVal, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"}, "79f05d4e3bf8fce90abdf571415f136c0682071b": {"t": 1785759471.2071605, "n": 22609, "h": "9a4c623d7a9076f4", "c": "package agent\n\nimport (\n\t\"testing\"\n\n\t\"xbot/config\"\n\t\"xbot/llm\"\n\t\"xbot/storage/sqlite\"\n)\n\nfunc TestGuessProvider(t *testing.T) {\n\ttests := []struct {\n\t\tmodel string\n\t\twant string\n\t}{\n\t\t{\"claude-sonnet-4-20250514\", \"anthropic\"},\n\t\t{\"claude-opus-4-20250115\", \"anthropic\"},\n\t\t{\"gpt-4o\", \"openai\"},\n\t\t{\"gpt-4.1\", \"openai\"},\n\t\t{\"o1-preview\", \"openai\"},\n\t\t{\"o3-mini\", \"openai\"},\n\t\t{\"deepseek-chat\", \"deepseek\"},\n\t\t{\"deepseek-reasoner\", \"deepseek\"},\n\t\t{\"gemini-2.0-flash\", \"google\"},\n\t\t{\"qwen-max\", \"qwen\"},\n\t\t{\"unknown-model\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.model, func(t *testing.T) {\n\t\t\tgot := guessProvider(tt.model)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"guessProvider(%q) = %q, want %q\", tt.model, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetLLMForModel_EmptyTarget(t *testing.T) {\n\t// Empty target model → should return default model name without hitting subscription logic\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Verify the early return path: targetModel=\"\" should not try to list subscriptions\n\t// (subscriptionSvc is nil, so if it tried, we'd get a different error)\n\t_, model, _, tm, _, usedCustom := f.GetLLMForModel(\"user1\", \"\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"default-model\")\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false for empty target model\")\n\t}\n\tif tm != \"auto\" {\n\t\tt.Errorf(\"thinkingMode = %q, want %q\", tm, \"auto\")\n\t}\n}\n\nfunc TestGetLLMForModel_NilSubscriptionSvc(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// No subscriptionSvc + explicit model → model not found in any subscription,\n\t// fallback to default client with the RESOLVED model (not the default model).\n\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"claude-opus-4-20250115\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115 (resolved model preserved in fallback)\", model)\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n}\n\nfunc TestNormalizeModelTier(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant string\n\t}{\n\t\t{\"vanguard\", \"vanguard\"},\n\t\t{\"VANGUARD\", \"vanguard\"},\n\t\t{\"Vanguard\", \"vanguard\"},\n\t\t{\"strong\", \"vanguard\"},\n\t\t{\"Strong\", \"vanguard\"},\n\t\t{\"balance\", \"balance\"},\n\t\t{\"medium\", \"balance\"},\n\t\t{\"swift\", \"swift\"},\n\t\t{\"weak\", \"swift\"},\n\t\t{\"gpt-4o\", \"\"},\n\t\t{\"\", \"\"},\n\t\t{\"unknown\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := normalizeModelTier(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"normalizeModelTier(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestResolveTierModel(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\n\t// No tiers configured → tier keywords are recognized but model is empty\n\tmodel, usedTier := f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty\", model)\n\t}\n\n\t// Non-tier value passes through unchanged\n\tmodel, usedTier = f.resolveTierModel(\"gpt-4o\")\n\tif usedTier {\n\t\tt.Error(\"usedTier should be false for non-tier value\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o\", model)\n\t}\n\n\t// Configure tiers\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t\tBalanceModel: \"claude-sonnet-4-20250514\",\n\t\tSwiftModel: \"gpt-4o-mini\",\n\t})\n\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Aliases: strong/medium/weak\n\tmodel, _ = f.resolveTierModel(\"strong\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"medium\")\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"weak\")\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Partial config: only vanguard set\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"opus\",\n\t})\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true even for unconfigured tier\")\n\t}\n\t// balance unconfigured → fallback to vanguard\n\tif model != \"opus\" {\n\t\tt.Errorf(\"model = %q, want opus (fallback from unconfigured balance to vanguard)\", model)\n\t}\n}\n\nfunc TestGetLLMForModel_TierResolution(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Tier with no subscriptionSvc → model not found, fallback to default client\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t})\n\n\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115 (resolved tier model)\", model)\n\t}\n\n\t// Non-tier model with no subscriptionSvc → returns the model name itself\n\t_, model, _, _, _, usedCustom = f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o (non-tier model, returned as-is)\", model)\n\t}\n}\n\nfunc TestResolveTierModel_UnconfiguredFallback(t *testing.T) {\n\t// When swift/vanguard are not configured, should fallback to balance\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{\n\t\tBalanceModel: \"gpt-4o\",\n\t\t// VanguardModel and SwiftModel intentionally empty\n\t})\n\n\t// swift not configured → fallback to balance\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"swift fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// vanguard not configured → fallback to balance\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"vanguard fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// balance configured → returns balance\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"balance = %q, want gpt-4o\", model)\n\t}\n}\n\nfunc TestResolveTierModel_AllUnconfigured(t *testing.T) {\n\t// All tiers unconfigured → returns empty string (will fall to default client)\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{})\n\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (tier keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty (no tiers configured)\", model)\n\t}\n}\n\nfunc TestHasCustomLLMChecksSubscriptionSvc(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Setenv(\"XBOT_HOME\", dir)\n\tdb, err := sqlite.Open(config.DBFilePath())\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tfactory := NewLLMFactory(sqlite.NewUserLLMConfigService(db), &llm.MockLLM{}, \"default-model\")\n\tsubSvc := sqlite.NewLLMSubscriptionService(db)\n\tfactory.SetSubscriptionSvc(subSvc)\n\tif err := subSvc.Add(&sqlite.LLMSubscription{ID: \"sub-1\", SenderID: \"cli_user\", Name: \"s1\", Provider: \"openai\", BaseURL: \"https://example.com/v1\", APIKey: \"sk-test\", Model: \"m1\", IsDefault: true}); err != nil {\n\t\tt.Fatalf(\"add sub: %v\", err)\n\t}\n\tif !factory.HasCustomLLM(\"cli_user\") {\n\t\tt.Fatal(\"expected HasCustomLLM to return true when default subscription exists\")\n\t}\n}\n\n// TestInvalidate_ClearsPerChatCache verifies that Invalidate(senderID) clears\n// both user-level and per-chat (senderID:chatID) cache entries.\n// This is the fix for: switching sub then changing model in settings was stuck\n// on the old model because Invalidate only cleared the user-level key.\nfunc TestInvalidate_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsubA := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"deepseek-v3\", MaxOutputTokens: 4096,\n\t}\n\n\t// Simulate: SwitchSubscription creates both user-level and per-chat caches\n\tif err := f.SwitchSubscription(senderID, subA, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subA: %v\", err)\n\t}\n\n\t// Verify both caches exist\n\t_, modelA, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelA != \"gpt-4o\" {\n\t\tt.Fatalf(\"initial model = %q, want gpt-4o\", modelA)\n\t}\n\n\t// Simulate: set_default_subscription calls Invalidate then SwitchSubscription\n\t// (the actual server handler path for subscription switching)\n\tf.Invalidate(senderID)\n\tif err := f.SwitchSubscription(senderID, subB, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subB: %v\", err)\n\t}\n\n\t_, modelB, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelB != \"deepseek-v3\" {\n\t\tt.Errorf(\"after sub switch, model = %q, want deepseek-v3\", modelB)\n\t}\n\n\t// Simulate: update_subscription (settings panel) calls Invalidate + SwitchSubscription\n\t// with chatID=\"\" — per-chat cache was NOT cleared before the fix\n\tf.Invalidate(senderID)\n\tupdatedSubB := *subB\n\tupdatedSubB.Model = \"deepseek-r1\"\n\tupdatedSubB.MaxOutputTokens = 16384\n\tif err := f.SwitchSubscription(senderID, &updatedSubB, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription updatedSubB: %v\", err)\n\t}\n\n\t// GetLLMForChat should NOT return stale per-chat cache\n\t_, modelUpdated, _, thinkingUpdated, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelUpdated != \"deepseek-r1\" {\n\t\tt.Errorf(\"after settings update, model = %q, want deepseek-r1 (stale per-chat cache bug)\", modelUpdated)\n\t}\n\t// Verify thinking mode is also not stale\n\tif thinkingUpdated != \"\" {\n\t\tt.Errorf(\"after settings update, thinkingMode = %q, want empty\", thinkingUpdated)\n\t}\n}\n\n// TestSwitchModel_ClearsPerChatCache verifies that SwitchModel clears per-chat\n// model caches so GetLLMForChat returns the new model instead of a stale\n// per-chat entry.\nfunc TestSwitchModel_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsub := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api.example.com/v1\", APIKey: \"sk-test\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\n\t// Create per-chat cache via SwitchSubscription\n\tif err := f.SwitchSubscription(senderID, sub, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription: %v\", err)\n\t}\n\n\t// Now SwitchModel (e.g., from quick panel model switch)\n\tf.SwitchModel(senderID, \"gpt-4o-mini\")\n\n\t// GetLLMForChat should return the new model, not the stale per-chat one\n\t_, model, _, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"after SwitchModel, per-chat model = %q, want gpt-4o-mini (stale per-chat cache bug)\", model)\n\t}\n}\n\n// TestInvalidate_DoesNotAffectOtherUsers verifies that Invalidate(senderID) only\n// clears entries for that specific sender, not other users.\nfunc TestInvalidate_DoesNotAffectOtherUsers(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsubA := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\",\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"claude-3-opus\",\n\t}\n\n\t// User A gets per-chat cache\n\tif err := f.SwitchSubscription(\"userA\", subA, \"/home/a\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription userA: %v\", err)\n\t}\n\t// User B gets per-chat cache\n\tif err := f.SwitchSubscription(\"userB\", subB, \"/home/b\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription userB: %v\", err)\n\t}\n\n\t// Invalidate user A — should NOT affect user B\n\tf.Invalidate(\"userA\")\n\n\t// User B's per-chat cache should still work\n\t_, modelB, _, _, _ := f.GetLLMForChat(\"userB\", \"/home/b\")\n\tif modelB != \"claude-3-opus\" {\n\t\tt.Errorf(\"userB model after Invalidate(userA) = %q, want claude-3-opus\", modelB)\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubExactMatch verifies the config.json subscription path:\n// when configSubsFn returns a subscription whose Model matches the resolved tier model,\n// GetLLMForModel should use that subscription (usedCustom=true).\nfunc TestGetLLMForModel_ConfigSubExactMatch(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Configure tier so \"vanguard\" resolves to \"gpt-4o\"\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"gpt-4o\",\n\t})\n\n\t// Set up configSubsFn with a matching subscription\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"test-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub matches resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil when config sub matches\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubNoMatch verifies that when configSubsFn returns\n// subscriptions with different Model fields, it still tries to use them with\n// the resolved model name (OpenAI-compatible endpoints can serve any model).\nfunc TestGetLLMForModel_ConfigSubNoMatch(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Configure tier so \"vanguard\" resolves to \"gpt-4o\"\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"gpt-4o\",\n\t})\n\n\t// Config sub has a different model — but still usable with gpt-4o\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"other-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"other-model\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub can serve the resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q (resolved tier model)\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubSkipsEmptyCredentials verifies that config\n// subscriptions with matching Model but empty BaseURL or APIKey are skipped,\n// and the function falls through to the default LLM.\nfunc TestGetLLMForModel_ConfigSubSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Configure tier so \"vanguard\" resolves to \"gpt-4o\"\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"gpt-4o\",\n\t})\n\n\t// Sub-tests for empty BaseURL and empty APIKey\n\ttests := []struct {\n\t\tname string\n\t\tsub config.SubscriptionConfig\n\t}{\n\t\t{\n\t\t\tname: \"empty BaseURL\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"no-url\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"empty APIKey\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"no-key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\t\t\treturn []config.SubscriptionConfig{tt.sub}\n\t\t\t})\n\n\t\t\t_, model, _, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\t\t\tif usedCustom {\n\t\t\t\tt.Error(\"usedCustom should be false when config sub has empty credentials\")\n\t\t\t}\n\t\t\tif model != \"gpt-4o\" {\n\t\t\t\tt.Errorf(\"model = %q, want %q (resolved tier model preserved in fallback)\", model, \"gpt-4o\")\n\t\t\t}\n\t\t})\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Tests for buildModelSubscriptionMap & configSubToLLMSubscription\n// ---------------------------------------------------------------------------\n\n// TestBuildModelSubscriptionMap_ConfigSubs verifies that config subscriptions\n// with different models each produce an entry in the model→subscription map.\nfunc TestBuildModelSubscriptionMap_ConfigSubs(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-a\",\n\t\t\t\tName: \"OpenAI\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"sk-openai\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-b\",\n\t\t\t\tName: \"Anthropic\",\n\t\t\t\tProvider: \"anthropic\",\n\t\t\t\tBaseURL: \"https://api.anthropic.com\",\n\t\t\t\tAPIKey: \"sk-ant-key\",\n\t\t\t\tModel: \"claude-sonnet-4-20250514\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 2 {\n\t\tt.Fatalf(\"map size = %d, want 2\", len(m))\n\t}\n\tif sub, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry\")\n\t} else if sub.ID != \"sub-a\" {\n\t\tt.Errorf(\"gpt-4o mapped to sub %q, want sub-a\", sub.ID)\n\t}\n\tif sub, ok := m[\"claude-sonnet-4-20250514\"]; !ok {\n\t\tt.Error(\"missing claude-sonnet-4-20250514 entry\")\n\t} else if sub.ID != \"sub-b\" {\n\t\tt.Errorf(\"claude-sonnet-4-20250514 mapped to sub %q, want sub-b\", sub.ID)\n\t}\n}\n\n// TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials verifies that\n// config subscriptions with empty BaseURL or APIKey are not added to the map.\nfunc TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\t// Sub with matching Model but empty BaseURL — must be skipped.\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"No URL\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"No Key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o-mini\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 0 {\n\t\tt.Fatalf(\"map size = %d, want 0 (both subs have empty credentials)\", len(m))\n\t}\n}\n\n// TestBuildModelSubscriptionMap_EmptySenderID verifies that with an empty\n// senderID and nil subscriptionSvc, only config subs are included.\nfunc TestBuildModelSubscriptionMap_EmptySenderID(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\t// subscriptionSvc is nil by default — no DB path at all.\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"cfg-1\",\n\t\t\t\tName: \"ConfigSub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-cfg\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\t// Empty senderID — DB path is also gated by senderID != \"\"\n\tm := f.buildModelSubscriptionMap(\"\")\n\n\tif len(m) != 1 {\n\t\tt.Fatalf(\"map size = %d, want 1 (only config sub)\", len(m))\n\t}\n\tif _, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry from config sub\")\n\t}\n}\n\n// TestConfigSubToLLMSubscription verifies that configSubToLLMSubscription\n// correctly maps every field from SubscriptionConfig to LLMSubscription.\nfunc TestConfigSubToLLMSubscription(t *testing.T) {\n\tcs := config.SubscriptionConfig{\n\t\tID: \"sub-42\",\n\t\tName: \"My Sub\",\n\t\tProvider: \"deepseek\",\n\t\tBaseURL: \"https://api.deepseek.com/v1\",\n\t\tAPIKey: \"sk-deep\",\n\t\tModel: \"deepseek-chat\",\n\t\tMaxOutputTokens: 4096,\n\t\tThinkingMode: \"enabled\",\n\t}\n\n\tsub := configSubToLLMSubscription(cs)\n\n\tif sub.ID != \"sub-42\" {\n\t\tt.Errorf(\"ID = %q, want %q\", sub.ID, \"sub-42\")\n\t}\n\tif sub.Name != \"My Sub\" {\n\t\tt.Errorf(\"Name = %q, want %q\", sub.Name, \"My Sub\")\n\t}\n\tif sub.Provider != \"deepseek\" {\n\t\tt.Errorf(\"Provider = %q, want %q\", sub.Provider, \"deepseek\")\n\t}\n\tif sub.BaseURL != \"https://api.deepseek.com/v1\" {\n\t\tt.Errorf(\"BaseURL = %q, want %q\", sub.BaseURL, \"https://api.deepseek.com/v1\")\n\t}\n\tif sub.APIKey != \"sk-deep\" {\n\t\tt.Errorf(\"APIKey = %q, want %q\", sub.APIKey, \"sk-deep\")\n\t}\n\tif sub.Model != \"deepseek-chat\" {\n\t\tt.Errorf(\"Model = %q, want %q\", sub.Model, \"deepseek-chat\")\n\t}\n\tif sub.MaxOutputTokens != 4096 {\n\t\tt.Errorf(\"MaxOutputTokens = %d, want 4096\", sub.MaxOutputTokens)\n\t}\n\tif sub.ThinkingMode != \"enabled\" {\n\t\tt.Errorf(\"ThinkingMode = %q, want %q\", sub.ThinkingMode, \"enabled\")\n\t}\n}\n\n// --- chatKey tests ---\n\nfunc TestChatKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tsenderID string\n\t\tchatID string\n\t\twant string\n\t}{\n\t\t{\"normal\", \"user123\", \"chat456\", \"user123:chat456\"},\n\t\t{\"empty senderID\", \"\", \"chat456\", \":chat456\"},\n\t\t{\"empty chatID\", \"user123\", \"\", \"user123:\"},\n\t\t{\"both empty\", \"\", \"\", \":\"},\n\t\t{\"colons in values\", \"user:1\", \"chat:2\", \"user:1:chat:2\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := chatKey(tt.senderID, tt.chatID)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"chatKey(%q, %q) = %q, want %q\", tt.senderID, tt.chatID, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// --- parseOrDefault tests ---\n\nfunc TestParseOrDefault(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tinput string\n\t\tdefaultVal int\n\t\twant int\n\t}{\n\t\t{\"empty string returns default\", \"\", 42, 42},\n\t\t{\"valid positive int\", \"100\", 42, 100},\n\t\t{\"zero returns default\", \"0\", 42, 42},\n\t\t{\"negative returns default\", \"-5\", 42, 42},\n\t\t{\"non-numeric returns default\", \"abc\", 42, 42},\n\t\t{\"whitespace-padded number\", \" 7\", 42, 7},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := parseOrDefault(tt.input, tt.defaultVal)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"parseOrDefault(%q, %d) = %d, want %d\", tt.input, tt.defaultVal, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"}, "42c87d7d916403d4723a2544e2d4ef92a56805ea": {"t": 1785759473.0045197, "n": 22573, "h": "4ec2c383a9d4db63", "c": "package agent\n\nimport (\n\t\"testing\"\n\n\t\"xbot/config\"\n\t\"xbot/llm\"\n\t\"xbot/storage/sqlite\"\n)\n\nfunc TestGuessProvider(t *testing.T) {\n\ttests := []struct {\n\t\tmodel string\n\t\twant string\n\t}{\n\t\t{\"claude-sonnet-4-20250514\", \"anthropic\"},\n\t\t{\"claude-opus-4-20250115\", \"anthropic\"},\n\t\t{\"gpt-4o\", \"openai\"},\n\t\t{\"gpt-4.1\", \"openai\"},\n\t\t{\"o1-preview\", \"openai\"},\n\t\t{\"o3-mini\", \"openai\"},\n\t\t{\"deepseek-chat\", \"deepseek\"},\n\t\t{\"deepseek-reasoner\", \"deepseek\"},\n\t\t{\"gemini-2.0-flash\", \"google\"},\n\t\t{\"qwen-max\", \"qwen\"},\n\t\t{\"unknown-model\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.model, func(t *testing.T) {\n\t\t\tgot := guessProvider(tt.model)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"guessProvider(%q) = %q, want %q\", tt.model, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetLLMForModel_EmptyTarget(t *testing.T) {\n\t// Empty target model → should return default model name without hitting subscription logic\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Verify the early return path: targetModel=\"\" should not try to list subscriptions\n\t// (subscriptionSvc is nil, so if it tried, we'd get a different error)\n\t_, model, _, tm, usedCustom := f.GetLLMForModel(\"user1\", \"\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"default-model\")\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false for empty target model\")\n\t}\n\tif tm != \"auto\" {\n\t\tt.Errorf(\"thinkingMode = %q, want %q\", tm, \"auto\")\n\t}\n}\n\nfunc TestGetLLMForModel_NilSubscriptionSvc(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// No subscriptionSvc + explicit model → model not found in any subscription,\n\t// fallback to default client with the RESOLVED model (not the default model).\n\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"claude-opus-4-20250115\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115 (resolved model preserved in fallback)\", model)\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n}\n\nfunc TestNormalizeModelTier(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant string\n\t}{\n\t\t{\"vanguard\", \"vanguard\"},\n\t\t{\"VANGUARD\", \"vanguard\"},\n\t\t{\"Vanguard\", \"vanguard\"},\n\t\t{\"strong\", \"vanguard\"},\n\t\t{\"Strong\", \"vanguard\"},\n\t\t{\"balance\", \"balance\"},\n\t\t{\"medium\", \"balance\"},\n\t\t{\"swift\", \"swift\"},\n\t\t{\"weak\", \"swift\"},\n\t\t{\"gpt-4o\", \"\"},\n\t\t{\"\", \"\"},\n\t\t{\"unknown\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := normalizeModelTier(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"normalizeModelTier(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestResolveTierModel(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\n\t// No tiers configured → tier keywords are recognized but model is empty\n\tmodel, usedTier := f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty\", model)\n\t}\n\n\t// Non-tier value passes through unchanged\n\tmodel, usedTier = f.resolveTierModel(\"gpt-4o\")\n\tif usedTier {\n\t\tt.Error(\"usedTier should be false for non-tier value\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o\", model)\n\t}\n\n\t// Configure tiers\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t\tBalanceModel: \"claude-sonnet-4-20250514\",\n\t\tSwiftModel: \"gpt-4o-mini\",\n\t})\n\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Aliases: strong/medium/weak\n\tmodel, _ = f.resolveTierModel(\"strong\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"medium\")\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"weak\")\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Partial config: only vanguard set\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"opus\",\n\t})\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true even for unconfigured tier\")\n\t}\n\t// balance unconfigured → fallback to vanguard\n\tif model != \"opus\" {\n\t\tt.Errorf(\"model = %q, want opus (fallback from unconfigured balance to vanguard)\", model)\n\t}\n}\n\nfunc TestGetLLMForModel_TierResolution(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Tier with no subscriptionSvc → model not found, fallback to default client\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t})\n\n\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115 (resolved tier model)\", model)\n\t}\n\n\t// Non-tier model with no subscriptionSvc → returns the model name itself\n\t_, model, _, _, usedCustom = f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o (non-tier model, returned as-is)\", model)\n\t}\n}\n\nfunc TestResolveTierModel_UnconfiguredFallback(t *testing.T) {\n\t// When swift/vanguard are not configured, should fallback to balance\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{\n\t\tBalanceModel: \"gpt-4o\",\n\t\t// VanguardModel and SwiftModel intentionally empty\n\t})\n\n\t// swift not configured → fallback to balance\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"swift fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// vanguard not configured → fallback to balance\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"vanguard fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// balance configured → returns balance\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"balance = %q, want gpt-4o\", model)\n\t}\n}\n\nfunc TestResolveTierModel_AllUnconfigured(t *testing.T) {\n\t// All tiers unconfigured → returns empty string (will fall to default client)\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{})\n\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (tier keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty (no tiers configured)\", model)\n\t}\n}\n\nfunc TestHasCustomLLMChecksSubscriptionSvc(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Setenv(\"XBOT_HOME\", dir)\n\tdb, err := sqlite.Open(config.DBFilePath())\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tfactory := NewLLMFactory(sqlite.NewUserLLMConfigService(db), &llm.MockLLM{}, \"default-model\")\n\tsubSvc := sqlite.NewLLMSubscriptionService(db)\n\tfactory.SetSubscriptionSvc(subSvc)\n\tif err := subSvc.Add(&sqlite.LLMSubscription{ID: \"sub-1\", SenderID: \"cli_user\", Name: \"s1\", Provider: \"openai\", BaseURL: \"https://example.com/v1\", APIKey: \"sk-test\", Model: \"m1\", IsDefault: true}); err != nil {\n\t\tt.Fatalf(\"add sub: %v\", err)\n\t}\n\tif !factory.HasCustomLLM(\"cli_user\") {\n\t\tt.Fatal(\"expected HasCustomLLM to return true when default subscription exists\")\n\t}\n}\n\n// TestInvalidate_ClearsPerChatCache verifies that Invalidate(senderID) clears\n// both user-level and per-chat (senderID:chatID) cache entries.\n// This is the fix for: switching sub then changing model in settings was stuck\n// on the old model because Invalidate only cleared the user-level key.\nfunc TestInvalidate_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsubA := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"deepseek-v3\", MaxOutputTokens: 4096,\n\t}\n\n\t// Simulate: SwitchSubscription creates both user-level and per-chat caches\n\tif err := f.SwitchSubscription(senderID, subA, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subA: %v\", err)\n\t}\n\n\t// Verify both caches exist\n\t_, modelA, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelA != \"gpt-4o\" {\n\t\tt.Fatalf(\"initial model = %q, want gpt-4o\", modelA)\n\t}\n\n\t// Simulate: set_default_subscription calls Invalidate then SwitchSubscription\n\t// (the actual server handler path for subscription switching)\n\tf.Invalidate(senderID)\n\tif err := f.SwitchSubscription(senderID, subB, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subB: %v\", err)\n\t}\n\n\t_, modelB, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelB != \"deepseek-v3\" {\n\t\tt.Errorf(\"after sub switch, model = %q, want deepseek-v3\", modelB)\n\t}\n\n\t// Simulate: update_subscription (settings panel) calls Invalidate + SwitchSubscription\n\t// with chatID=\"\" — per-chat cache was NOT cleared before the fix\n\tf.Invalidate(senderID)\n\tupdatedSubB := *subB\n\tupdatedSubB.Model = \"deepseek-r1\"\n\tupdatedSubB.MaxOutputTokens = 16384\n\tif err := f.SwitchSubscription(senderID, &updatedSubB, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription updatedSubB: %v\", err)\n\t}\n\n\t// GetLLMForChat should NOT return stale per-chat cache\n\t_, modelUpdated, _, thinkingUpdated := f.GetLLMForChat(senderID, chatID)\n\tif modelUpdated != \"deepseek-r1\" {\n\t\tt.Errorf(\"after settings update, model = %q, want deepseek-r1 (stale per-chat cache bug)\", modelUpdated)\n\t}\n\t// Verify thinking mode is also not stale\n\tif thinkingUpdated != \"\" {\n\t\tt.Errorf(\"after settings update, thinkingMode = %q, want empty\", thinkingUpdated)\n\t}\n}\n\n// TestSwitchModel_ClearsPerChatCache verifies that SwitchModel clears per-chat\n// model caches so GetLLMForChat returns the new model instead of a stale\n// per-chat entry.\nfunc TestSwitchModel_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsub := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api.example.com/v1\", APIKey: \"sk-test\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\n\t// Create per-chat cache via SwitchSubscription\n\tif err := f.SwitchSubscription(senderID, sub, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription: %v\", err)\n\t}\n\n\t// Now SwitchModel (e.g., from quick panel model switch)\n\tf.SwitchModel(senderID, \"gpt-4o-mini\")\n\n\t// GetLLMForChat should return the new model, not the stale per-chat one\n\t_, model, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"after SwitchModel, per-chat model = %q, want gpt-4o-mini (stale per-chat cache bug)\", model)\n\t}\n}\n\n// TestInvalidate_DoesNotAffectOtherUsers verifies that Invalidate(senderID) only\n// clears entries for that specific sender, not other users.\nfunc TestInvalidate_DoesNotAffectOtherUsers(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsubA := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\",\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"claude-3-opus\",\n\t}\n\n\t// User A gets per-chat cache\n\tif err := f.SwitchSubscription(\"userA\", subA, \"/home/a\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription userA: %v\", err)\n\t}\n\t// User B gets per-chat cache\n\tif err := f.SwitchSubscription(\"userB\", subB, \"/home/b\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription userB: %v\", err)\n\t}\n\n\t// Invalidate user A — should NOT affect user B\n\tf.Invalidate(\"userA\")\n\n\t// User B's per-chat cache should still work\n\t_, modelB, _, _ := f.GetLLMForChat(\"userB\", \"/home/b\")\n\tif modelB != \"claude-3-opus\" {\n\t\tt.Errorf(\"userB model after Invalidate(userA) = %q, want claude-3-opus\", modelB)\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubExactMatch verifies the config.json subscription path:\n// when configSubsFn returns a subscription whose Model matches the resolved tier model,\n// GetLLMForModel should use that subscription (usedCustom=true).\nfunc TestGetLLMForModel_ConfigSubExactMatch(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Configure tier so \"vanguard\" resolves to \"gpt-4o\"\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"gpt-4o\",\n\t})\n\n\t// Set up configSubsFn with a matching subscription\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"test-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub matches resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil when config sub matches\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubNoMatch verifies that when configSubsFn returns\n// subscriptions with different Model fields, it still tries to use them with\n// the resolved model name (OpenAI-compatible endpoints can serve any model).\nfunc TestGetLLMForModel_ConfigSubNoMatch(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Configure tier so \"vanguard\" resolves to \"gpt-4o\"\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"gpt-4o\",\n\t})\n\n\t// Config sub has a different model — but still usable with gpt-4o\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"other-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"other-model\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub can serve the resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q (resolved tier model)\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubSkipsEmptyCredentials verifies that config\n// subscriptions with matching Model but empty BaseURL or APIKey are skipped,\n// and the function falls through to the default LLM.\nfunc TestGetLLMForModel_ConfigSubSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Configure tier so \"vanguard\" resolves to \"gpt-4o\"\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"gpt-4o\",\n\t})\n\n\t// Sub-tests for empty BaseURL and empty APIKey\n\ttests := []struct {\n\t\tname string\n\t\tsub config.SubscriptionConfig\n\t}{\n\t\t{\n\t\t\tname: \"empty BaseURL\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"no-url\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"empty APIKey\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"no-key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\t\t\treturn []config.SubscriptionConfig{tt.sub}\n\t\t\t})\n\n\t\t\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\t\t\tif usedCustom {\n\t\t\t\tt.Error(\"usedCustom should be false when config sub has empty credentials\")\n\t\t\t}\n\t\t\tif model != \"gpt-4o\" {\n\t\t\t\tt.Errorf(\"model = %q, want %q (resolved tier model preserved in fallback)\", model, \"gpt-4o\")\n\t\t\t}\n\t\t})\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Tests for buildModelSubscriptionMap & configSubToLLMSubscription\n// ---------------------------------------------------------------------------\n\n// TestBuildModelSubscriptionMap_ConfigSubs verifies that config subscriptions\n// with different models each produce an entry in the model→subscription map.\nfunc TestBuildModelSubscriptionMap_ConfigSubs(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-a\",\n\t\t\t\tName: \"OpenAI\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"sk-openai\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-b\",\n\t\t\t\tName: \"Anthropic\",\n\t\t\t\tProvider: \"anthropic\",\n\t\t\t\tBaseURL: \"https://api.anthropic.com\",\n\t\t\t\tAPIKey: \"sk-ant-key\",\n\t\t\t\tModel: \"claude-sonnet-4-20250514\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 2 {\n\t\tt.Fatalf(\"map size = %d, want 2\", len(m))\n\t}\n\tif sub, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry\")\n\t} else if sub.ID != \"sub-a\" {\n\t\tt.Errorf(\"gpt-4o mapped to sub %q, want sub-a\", sub.ID)\n\t}\n\tif sub, ok := m[\"claude-sonnet-4-20250514\"]; !ok {\n\t\tt.Error(\"missing claude-sonnet-4-20250514 entry\")\n\t} else if sub.ID != \"sub-b\" {\n\t\tt.Errorf(\"claude-sonnet-4-20250514 mapped to sub %q, want sub-b\", sub.ID)\n\t}\n}\n\n// TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials verifies that\n// config subscriptions with empty BaseURL or APIKey are not added to the map.\nfunc TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\t// Sub with matching Model but empty BaseURL — must be skipped.\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"No URL\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"No Key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o-mini\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 0 {\n\t\tt.Fatalf(\"map size = %d, want 0 (both subs have empty credentials)\", len(m))\n\t}\n}\n\n// TestBuildModelSubscriptionMap_EmptySenderID verifies that with an empty\n// senderID and nil subscriptionSvc, only config subs are included.\nfunc TestBuildModelSubscriptionMap_EmptySenderID(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\t// subscriptionSvc is nil by default — no DB path at all.\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"cfg-1\",\n\t\t\t\tName: \"ConfigSub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-cfg\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\t// Empty senderID — DB path is also gated by senderID != \"\"\n\tm := f.buildModelSubscriptionMap(\"\")\n\n\tif len(m) != 1 {\n\t\tt.Fatalf(\"map size = %d, want 1 (only config sub)\", len(m))\n\t}\n\tif _, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry from config sub\")\n\t}\n}\n\n// TestConfigSubToLLMSubscription verifies that configSubToLLMSubscription\n// correctly maps every field from SubscriptionConfig to LLMSubscription.\nfunc TestConfigSubToLLMSubscription(t *testing.T) {\n\tcs := config.SubscriptionConfig{\n\t\tID: \"sub-42\",\n\t\tName: \"My Sub\",\n\t\tProvider: \"deepseek\",\n\t\tBaseURL: \"https://api.deepseek.com/v1\",\n\t\tAPIKey: \"sk-deep\",\n\t\tModel: \"deepseek-chat\",\n\t\tMaxOutputTokens: 4096,\n\t\tThinkingMode: \"enabled\",\n\t}\n\n\tsub := configSubToLLMSubscription(cs)\n\n\tif sub.ID != \"sub-42\" {\n\t\tt.Errorf(\"ID = %q, want %q\", sub.ID, \"sub-42\")\n\t}\n\tif sub.Name != \"My Sub\" {\n\t\tt.Errorf(\"Name = %q, want %q\", sub.Name, \"My Sub\")\n\t}\n\tif sub.Provider != \"deepseek\" {\n\t\tt.Errorf(\"Provider = %q, want %q\", sub.Provider, \"deepseek\")\n\t}\n\tif sub.BaseURL != \"https://api.deepseek.com/v1\" {\n\t\tt.Errorf(\"BaseURL = %q, want %q\", sub.BaseURL, \"https://api.deepseek.com/v1\")\n\t}\n\tif sub.APIKey != \"sk-deep\" {\n\t\tt.Errorf(\"APIKey = %q, want %q\", sub.APIKey, \"sk-deep\")\n\t}\n\tif sub.Model != \"deepseek-chat\" {\n\t\tt.Errorf(\"Model = %q, want %q\", sub.Model, \"deepseek-chat\")\n\t}\n\tif sub.MaxOutputTokens != 4096 {\n\t\tt.Errorf(\"MaxOutputTokens = %d, want 4096\", sub.MaxOutputTokens)\n\t}\n\tif sub.ThinkingMode != \"enabled\" {\n\t\tt.Errorf(\"ThinkingMode = %q, want %q\", sub.ThinkingMode, \"enabled\")\n\t}\n}\n\n// --- chatKey tests ---\n\nfunc TestChatKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tsenderID string\n\t\tchatID string\n\t\twant string\n\t}{\n\t\t{\"normal\", \"user123\", \"chat456\", \"user123:chat456\"},\n\t\t{\"empty senderID\", \"\", \"chat456\", \":chat456\"},\n\t\t{\"empty chatID\", \"user123\", \"\", \"user123:\"},\n\t\t{\"both empty\", \"\", \"\", \":\"},\n\t\t{\"colons in values\", \"user:1\", \"chat:2\", \"user:1:chat:2\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := chatKey(tt.senderID, tt.chatID)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"chatKey(%q, %q) = %q, want %q\", tt.senderID, tt.chatID, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// --- parseOrDefault tests ---\n\nfunc TestParseOrDefault(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tinput string\n\t\tdefaultVal int\n\t\twant int\n\t}{\n\t\t{\"empty string returns default\", \"\", 42, 42},\n\t\t{\"valid positive int\", \"100\", 42, 100},\n\t\t{\"zero returns default\", \"0\", 42, 42},\n\t\t{\"negative returns default\", \"-5\", 42, 42},\n\t\t{\"non-numeric returns default\", \"abc\", 42, 42},\n\t\t{\"whitespace-padded number\", \" 7\", 42, 7},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := parseOrDefault(tt.input, tt.defaultVal)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"parseOrDefault(%q, %d) = %d, want %d\", tt.input, tt.defaultVal, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"}, "7840cb7a24fe997e04656d3584efac565b31b0bd": {"t": 1785759474.4831667, "n": 22483, "h": "b8bf087bacc9ed3f", "c": "package agent\n\nimport (\n\t\"testing\"\n\n\t\"xbot/config\"\n\t\"xbot/llm\"\n\t\"xbot/storage/sqlite\"\n)\n\nfunc TestGuessProvider(t *testing.T) {\n\ttests := []struct {\n\t\tmodel string\n\t\twant string\n\t}{\n\t\t{\"claude-sonnet-4-20250514\", \"anthropic\"},\n\t\t{\"claude-opus-4-20250115\", \"anthropic\"},\n\t\t{\"gpt-4o\", \"openai\"},\n\t\t{\"gpt-4.1\", \"openai\"},\n\t\t{\"o1-preview\", \"openai\"},\n\t\t{\"o3-mini\", \"openai\"},\n\t\t{\"deepseek-chat\", \"deepseek\"},\n\t\t{\"deepseek-reasoner\", \"deepseek\"},\n\t\t{\"gemini-2.0-flash\", \"google\"},\n\t\t{\"qwen-max\", \"qwen\"},\n\t\t{\"unknown-model\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.model, func(t *testing.T) {\n\t\t\tgot := guessProvider(tt.model)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"guessProvider(%q) = %q, want %q\", tt.model, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetLLMForModel_EmptyTarget(t *testing.T) {\n\t// Empty target model → should return default model name without hitting subscription logic\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Verify the early return path: targetModel=\"\" should not try to list subscriptions\n\t// (subscriptionSvc is nil, so if it tried, we'd get a different error)\n\t_, model, _, tm, usedCustom := f.GetLLMForModel(\"user1\", \"\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"default-model\")\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false for empty target model\")\n\t}\n\tif tm != \"auto\" {\n\t\tt.Errorf(\"thinkingMode = %q, want %q\", tm, \"auto\")\n\t}\n}\n\nfunc TestGetLLMForModel_NilSubscriptionSvc(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// No subscriptionSvc + explicit model → model not found in any subscription,\n\t// fallback to default client with its OWN model (not the target model).\n\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"claude-opus-4-20250115\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want default-model (fallback uses default client's model)\", model)\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n}\n\nfunc TestNormalizeModelTier(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant string\n\t}{\n\t\t{\"vanguard\", \"vanguard\"},\n\t\t{\"VANGUARD\", \"vanguard\"},\n\t\t{\"Vanguard\", \"vanguard\"},\n\t\t{\"strong\", \"vanguard\"},\n\t\t{\"Strong\", \"vanguard\"},\n\t\t{\"balance\", \"balance\"},\n\t\t{\"medium\", \"balance\"},\n\t\t{\"swift\", \"swift\"},\n\t\t{\"weak\", \"swift\"},\n\t\t{\"gpt-4o\", \"\"},\n\t\t{\"\", \"\"},\n\t\t{\"unknown\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := normalizeModelTier(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"normalizeModelTier(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestResolveTierModel(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\n\t// No tiers configured → tier keywords are recognized but model is empty\n\tmodel, usedTier := f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty\", model)\n\t}\n\n\t// Non-tier value passes through unchanged\n\tmodel, usedTier = f.resolveTierModel(\"gpt-4o\")\n\tif usedTier {\n\t\tt.Error(\"usedTier should be false for non-tier value\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o\", model)\n\t}\n\n\t// Configure tiers\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t\tBalanceModel: \"claude-sonnet-4-20250514\",\n\t\tSwiftModel: \"gpt-4o-mini\",\n\t})\n\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Aliases: strong/medium/weak\n\tmodel, _ = f.resolveTierModel(\"strong\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"medium\")\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"weak\")\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Partial config: only vanguard set\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"opus\",\n\t})\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true even for unconfigured tier\")\n\t}\n\t// balance unconfigured → fallback to vanguard\n\tif model != \"opus\" {\n\t\tt.Errorf(\"model = %q, want opus (fallback from unconfigured balance to vanguard)\", model)\n\t}\n}\n\nfunc TestGetLLMForModel_TierResolution(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Tier with no subscriptionSvc → model not found, fallback to default client\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t})\n\n\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want default-model (fallback)\", model)\n\t}\n\n\t// Non-tier model with no subscriptionSvc → same fallback\n\t_, model, _, _, usedCustom = f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want default-model (fallback)\", model)\n\t}\n}\n\nfunc TestResolveTierModel_UnconfiguredFallback(t *testing.T) {\n\t// When swift/vanguard are not configured, should fallback to balance\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{\n\t\tBalanceModel: \"gpt-4o\",\n\t\t// VanguardModel and SwiftModel intentionally empty\n\t})\n\n\t// swift not configured → fallback to balance\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"swift fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// vanguard not configured → fallback to balance\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"vanguard fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// balance configured → returns balance\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"balance = %q, want gpt-4o\", model)\n\t}\n}\n\nfunc TestResolveTierModel_AllUnconfigured(t *testing.T) {\n\t// All tiers unconfigured → returns empty string (will fall to default client)\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{})\n\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (tier keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty (no tiers configured)\", model)\n\t}\n}\n\nfunc TestHasCustomLLMChecksSubscriptionSvc(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Setenv(\"XBOT_HOME\", dir)\n\tdb, err := sqlite.Open(config.DBFilePath())\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tfactory := NewLLMFactory(sqlite.NewUserLLMConfigService(db), &llm.MockLLM{}, \"default-model\")\n\tsubSvc := sqlite.NewLLMSubscriptionService(db)\n\tfactory.SetSubscriptionSvc(subSvc)\n\tif err := subSvc.Add(&sqlite.LLMSubscription{ID: \"sub-1\", SenderID: \"cli_user\", Name: \"s1\", Provider: \"openai\", BaseURL: \"https://example.com/v1\", APIKey: \"sk-test\", Model: \"m1\", IsDefault: true}); err != nil {\n\t\tt.Fatalf(\"add sub: %v\", err)\n\t}\n\tif !factory.HasCustomLLM(\"cli_user\") {\n\t\tt.Fatal(\"expected HasCustomLLM to return true when default subscription exists\")\n\t}\n}\n\n// TestInvalidate_ClearsPerChatCache verifies that Invalidate(senderID) clears\n// both user-level and per-chat (senderID:chatID) cache entries.\n// This is the fix for: switching sub then changing model in settings was stuck\n// on the old model because Invalidate only cleared the user-level key.\nfunc TestInvalidate_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsubA := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"deepseek-v3\", MaxOutputTokens: 4096,\n\t}\n\n\t// Simulate: SwitchSubscription creates both user-level and per-chat caches\n\tif err := f.SwitchSubscription(senderID, subA, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subA: %v\", err)\n\t}\n\n\t// Verify both caches exist\n\t_, modelA, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelA != \"gpt-4o\" {\n\t\tt.Fatalf(\"initial model = %q, want gpt-4o\", modelA)\n\t}\n\n\t// Simulate: set_default_subscription calls Invalidate then SwitchSubscription\n\t// (the actual server handler path for subscription switching)\n\tf.Invalidate(senderID)\n\tif err := f.SwitchSubscription(senderID, subB, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subB: %v\", err)\n\t}\n\n\t_, modelB, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelB != \"deepseek-v3\" {\n\t\tt.Errorf(\"after sub switch, model = %q, want deepseek-v3\", modelB)\n\t}\n\n\t// Simulate: update_subscription (settings panel) calls Invalidate + SwitchSubscription\n\t// with chatID=\"\" — per-chat cache was NOT cleared before the fix\n\tf.Invalidate(senderID)\n\tupdatedSubB := *subB\n\tupdatedSubB.Model = \"deepseek-r1\"\n\tupdatedSubB.MaxOutputTokens = 16384\n\tif err := f.SwitchSubscription(senderID, &updatedSubB, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription updatedSubB: %v\", err)\n\t}\n\n\t// GetLLMForChat should NOT return stale per-chat cache\n\t_, modelUpdated, _, thinkingUpdated := f.GetLLMForChat(senderID, chatID)\n\tif modelUpdated != \"deepseek-r1\" {\n\t\tt.Errorf(\"after settings update, model = %q, want deepseek-r1 (stale per-chat cache bug)\", modelUpdated)\n\t}\n\t// Verify thinking mode is also not stale\n\tif thinkingUpdated != \"\" {\n\t\tt.Errorf(\"after settings update, thinkingMode = %q, want empty\", thinkingUpdated)\n\t}\n}\n\n// TestSwitchModel_ClearsPerChatCache verifies that SwitchModel clears per-chat\n// model caches so GetLLMForChat returns the new model instead of a stale\n// per-chat entry.\nfunc TestSwitchModel_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsub := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api.example.com/v1\", APIKey: \"sk-test\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\n\t// Create per-chat cache via SwitchSubscription\n\tif err := f.SwitchSubscription(senderID, sub, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription: %v\", err)\n\t}\n\n\t// Now SwitchModel (e.g., from quick panel model switch)\n\tf.SwitchModel(senderID, \"gpt-4o-mini\")\n\n\t// GetLLMForChat should return the new model, not the stale per-chat one\n\t_, model, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"after SwitchModel, per-chat model = %q, want gpt-4o-mini (stale per-chat cache bug)\", model)\n\t}\n}\n\n// TestInvalidate_DoesNotAffectOtherUsers verifies that Invalidate(senderID) only\n// clears entries for that specific sender, not other users.\nfunc TestInvalidate_DoesNotAffectOtherUsers(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsubA := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\",\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"claude-3-opus\",\n\t}\n\n\t// User A gets per-chat cache\n\tif err := f.SwitchSubscription(\"userA\", subA, \"/home/a\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription userA: %v\", err)\n\t}\n\t// User B gets per-chat cache\n\tif err := f.SwitchSubscription(\"userB\", subB, \"/home/b\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription userB: %v\", err)\n\t}\n\n\t// Invalidate user A — should NOT affect user B\n\tf.Invalidate(\"userA\")\n\n\t// User B's per-chat cache should still work\n\t_, modelB, _, _ := f.GetLLMForChat(\"userB\", \"/home/b\")\n\tif modelB != \"claude-3-opus\" {\n\t\tt.Errorf(\"userB model after Invalidate(userA) = %q, want claude-3-opus\", modelB)\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubExactMatch verifies the config.json subscription path:\n// when configSubsFn returns a subscription whose Model matches the resolved tier model,\n// GetLLMForModel should use that subscription (usedCustom=true).\nfunc TestGetLLMForModel_ConfigSubExactMatch(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Configure tier so \"vanguard\" resolves to \"gpt-4o\"\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"gpt-4o\",\n\t})\n\n\t// Set up configSubsFn with a matching subscription\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"test-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when config sub matches resolved model\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"gpt-4o\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil when config sub matches\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubNoMatch verifies that when configSubsFn returns\n// subscriptions but none match the resolved tier model, it falls back to the\n// default LLM (usedCustom=false, model=default-model).\nfunc TestGetLLMForModel_ConfigSubNoMatch(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Configure tier so \"vanguard\" resolves to \"gpt-4o\"\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"gpt-4o\",\n\t})\n\n\t// Config sub has a different model — no match\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-1\",\n\t\t\t\tName: \"other-sub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"other-model\",\n\t\t\t},\n\t\t}\n\t})\n\n\tclient, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when no config sub matches resolved model\")\n\t}\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q (fallback to default)\", model, \"default-model\")\n\t}\n\tif client == nil {\n\t\tt.Error(\"client should not be nil (fallback default client)\")\n\t}\n}\n\n// TestGetLLMForModel_ConfigSubSkipsEmptyCredentials verifies that config\n// subscriptions with matching Model but empty BaseURL or APIKey are skipped,\n// and the function falls through to the default LLM.\nfunc TestGetLLMForModel_ConfigSubSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Configure tier so \"vanguard\" resolves to \"gpt-4o\"\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"gpt-4o\",\n\t})\n\n\t// Sub-tests for empty BaseURL and empty APIKey\n\ttests := []struct {\n\t\tname string\n\t\tsub config.SubscriptionConfig\n\t}{\n\t\t{\n\t\t\tname: \"empty BaseURL\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"no-url\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"empty APIKey\",\n\t\t\tsub: config.SubscriptionConfig{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"no-key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\t\t\treturn []config.SubscriptionConfig{tt.sub}\n\t\t\t})\n\n\t\t\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\t\t\tif usedCustom {\n\t\t\t\tt.Error(\"usedCustom should be false when config sub has empty credentials\")\n\t\t\t}\n\t\t\tif model != \"default-model\" {\n\t\t\t\tt.Errorf(\"model = %q, want %q (fallback to default)\", model, \"default-model\")\n\t\t\t}\n\t\t})\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Tests for buildModelSubscriptionMap & configSubToLLMSubscription\n// ---------------------------------------------------------------------------\n\n// TestBuildModelSubscriptionMap_ConfigSubs verifies that config subscriptions\n// with different models each produce an entry in the model→subscription map.\nfunc TestBuildModelSubscriptionMap_ConfigSubs(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-a\",\n\t\t\t\tName: \"OpenAI\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"sk-openai\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-b\",\n\t\t\t\tName: \"Anthropic\",\n\t\t\t\tProvider: \"anthropic\",\n\t\t\t\tBaseURL: \"https://api.anthropic.com\",\n\t\t\t\tAPIKey: \"sk-ant-key\",\n\t\t\t\tModel: \"claude-sonnet-4-20250514\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 2 {\n\t\tt.Fatalf(\"map size = %d, want 2\", len(m))\n\t}\n\tif sub, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry\")\n\t} else if sub.ID != \"sub-a\" {\n\t\tt.Errorf(\"gpt-4o mapped to sub %q, want sub-a\", sub.ID)\n\t}\n\tif sub, ok := m[\"claude-sonnet-4-20250514\"]; !ok {\n\t\tt.Error(\"missing claude-sonnet-4-20250514 entry\")\n\t} else if sub.ID != \"sub-b\" {\n\t\tt.Errorf(\"claude-sonnet-4-20250514 mapped to sub %q, want sub-b\", sub.ID)\n\t}\n}\n\n// TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials verifies that\n// config subscriptions with empty BaseURL or APIKey are not added to the map.\nfunc TestBuildModelSubscriptionMap_ConfigSubsSkipsEmptyCredentials(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\t// Sub with matching Model but empty BaseURL — must be skipped.\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"sub-empty-url\",\n\t\t\t\tName: \"No URL\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"\",\n\t\t\t\tAPIKey: \"sk-test\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tID: \"sub-empty-key\",\n\t\t\t\tName: \"No Key\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\t\t\tAPIKey: \"\",\n\t\t\t\tModel: \"gpt-4o-mini\",\n\t\t\t},\n\t\t}\n\t})\n\n\tm := f.buildModelSubscriptionMap(\"user1\")\n\n\tif len(m) != 0 {\n\t\tt.Fatalf(\"map size = %d, want 0 (both subs have empty credentials)\", len(m))\n\t}\n}\n\n// TestBuildModelSubscriptionMap_EmptySenderID verifies that with an empty\n// senderID and nil subscriptionSvc, only config subs are included.\nfunc TestBuildModelSubscriptionMap_EmptySenderID(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\t// subscriptionSvc is nil by default — no DB path at all.\n\n\tf.SetConfigSubs(func() []config.SubscriptionConfig {\n\t\treturn []config.SubscriptionConfig{\n\t\t\t{\n\t\t\t\tID: \"cfg-1\",\n\t\t\t\tName: \"ConfigSub\",\n\t\t\t\tProvider: \"openai\",\n\t\t\t\tBaseURL: \"https://api.test/v1\",\n\t\t\t\tAPIKey: \"sk-cfg\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t})\n\n\t// Empty senderID — DB path is also gated by senderID != \"\"\n\tm := f.buildModelSubscriptionMap(\"\")\n\n\tif len(m) != 1 {\n\t\tt.Fatalf(\"map size = %d, want 1 (only config sub)\", len(m))\n\t}\n\tif _, ok := m[\"gpt-4o\"]; !ok {\n\t\tt.Error(\"missing gpt-4o entry from config sub\")\n\t}\n}\n\n// TestConfigSubToLLMSubscription verifies that configSubToLLMSubscription\n// correctly maps every field from SubscriptionConfig to LLMSubscription.\nfunc TestConfigSubToLLMSubscription(t *testing.T) {\n\tcs := config.SubscriptionConfig{\n\t\tID: \"sub-42\",\n\t\tName: \"My Sub\",\n\t\tProvider: \"deepseek\",\n\t\tBaseURL: \"https://api.deepseek.com/v1\",\n\t\tAPIKey: \"sk-deep\",\n\t\tModel: \"deepseek-chat\",\n\t\tMaxOutputTokens: 4096,\n\t\tThinkingMode: \"enabled\",\n\t}\n\n\tsub := configSubToLLMSubscription(cs)\n\n\tif sub.ID != \"sub-42\" {\n\t\tt.Errorf(\"ID = %q, want %q\", sub.ID, \"sub-42\")\n\t}\n\tif sub.Name != \"My Sub\" {\n\t\tt.Errorf(\"Name = %q, want %q\", sub.Name, \"My Sub\")\n\t}\n\tif sub.Provider != \"deepseek\" {\n\t\tt.Errorf(\"Provider = %q, want %q\", sub.Provider, \"deepseek\")\n\t}\n\tif sub.BaseURL != \"https://api.deepseek.com/v1\" {\n\t\tt.Errorf(\"BaseURL = %q, want %q\", sub.BaseURL, \"https://api.deepseek.com/v1\")\n\t}\n\tif sub.APIKey != \"sk-deep\" {\n\t\tt.Errorf(\"APIKey = %q, want %q\", sub.APIKey, \"sk-deep\")\n\t}\n\tif sub.Model != \"deepseek-chat\" {\n\t\tt.Errorf(\"Model = %q, want %q\", sub.Model, \"deepseek-chat\")\n\t}\n\tif sub.MaxOutputTokens != 4096 {\n\t\tt.Errorf(\"MaxOutputTokens = %d, want 4096\", sub.MaxOutputTokens)\n\t}\n\tif sub.ThinkingMode != \"enabled\" {\n\t\tt.Errorf(\"ThinkingMode = %q, want %q\", sub.ThinkingMode, \"enabled\")\n\t}\n}\n\n// --- chatKey tests ---\n\nfunc TestChatKey(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tsenderID string\n\t\tchatID string\n\t\twant string\n\t}{\n\t\t{\"normal\", \"user123\", \"chat456\", \"user123:chat456\"},\n\t\t{\"empty senderID\", \"\", \"chat456\", \":chat456\"},\n\t\t{\"empty chatID\", \"user123\", \"\", \"user123:\"},\n\t\t{\"both empty\", \"\", \"\", \":\"},\n\t\t{\"colons in values\", \"user:1\", \"chat:2\", \"user:1:chat:2\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := chatKey(tt.senderID, tt.chatID)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"chatKey(%q, %q) = %q, want %q\", tt.senderID, tt.chatID, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// --- parseOrDefault tests ---\n\nfunc TestParseOrDefault(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tinput string\n\t\tdefaultVal int\n\t\twant int\n\t}{\n\t\t{\"empty string returns default\", \"\", 42, 42},\n\t\t{\"valid positive int\", \"100\", 42, 100},\n\t\t{\"zero returns default\", \"0\", 42, 42},\n\t\t{\"negative returns default\", \"-5\", 42, 42},\n\t\t{\"non-numeric returns default\", \"abc\", 42, 42},\n\t\t{\"whitespace-padded number\", \" 7\", 42, 7},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := parseOrDefault(tt.input, tt.defaultVal)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"parseOrDefault(%q, %d) = %d, want %d\", tt.input, tt.defaultVal, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"}, "5fcbd896d1dbda0f17554c1a20196629ff5fd73b": {"t": 1785759478.068053, "n": 12615, "h": "a551c3fe672b70b1", "c": "package agent\n\nimport (\n\t\"testing\"\n\n\t\"xbot/config\"\n\t\"xbot/llm\"\n\t\"xbot/storage/sqlite\"\n)\n\nfunc TestGuessProvider(t *testing.T) {\n\ttests := []struct {\n\t\tmodel string\n\t\twant string\n\t}{\n\t\t{\"claude-sonnet-4-20250514\", \"anthropic\"},\n\t\t{\"claude-opus-4-20250115\", \"anthropic\"},\n\t\t{\"gpt-4o\", \"openai\"},\n\t\t{\"gpt-4.1\", \"openai\"},\n\t\t{\"o1-preview\", \"openai\"},\n\t\t{\"o3-mini\", \"openai\"},\n\t\t{\"deepseek-chat\", \"deepseek\"},\n\t\t{\"deepseek-reasoner\", \"deepseek\"},\n\t\t{\"gemini-2.0-flash\", \"google\"},\n\t\t{\"qwen-max\", \"qwen\"},\n\t\t{\"unknown-model\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.model, func(t *testing.T) {\n\t\t\tgot := guessProvider(tt.model)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"guessProvider(%q) = %q, want %q\", tt.model, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetLLMForModel_EmptyTarget(t *testing.T) {\n\t// Empty target model → should return default model name without hitting subscription logic\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Verify the early return path: targetModel=\"\" should not try to list subscriptions\n\t// (subscriptionSvc is nil, so if it tried, we'd get a different error)\n\t_, model, _, tm, usedCustom := f.GetLLMForModel(\"user1\", \"\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"default-model\")\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false for empty target model\")\n\t}\n\tif tm != \"auto\" {\n\t\tt.Errorf(\"thinkingMode = %q, want %q\", tm, \"auto\")\n\t}\n}\n\nfunc TestGetLLMForModel_NilSubscriptionSvc(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// No subscriptionSvc + explicit model → model not found in any subscription,\n\t// fallback to default client with its OWN model (not the target model).\n\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"claude-opus-4-20250115\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want default-model (fallback uses default client's model)\", model)\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n}\n\nfunc TestNormalizeModelTier(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant string\n\t}{\n\t\t{\"vanguard\", \"vanguard\"},\n\t\t{\"VANGUARD\", \"vanguard\"},\n\t\t{\"Vanguard\", \"vanguard\"},\n\t\t{\"strong\", \"vanguard\"},\n\t\t{\"Strong\", \"vanguard\"},\n\t\t{\"balance\", \"balance\"},\n\t\t{\"medium\", \"balance\"},\n\t\t{\"swift\", \"swift\"},\n\t\t{\"weak\", \"swift\"},\n\t\t{\"gpt-4o\", \"\"},\n\t\t{\"\", \"\"},\n\t\t{\"unknown\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := normalizeModelTier(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"normalizeModelTier(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestResolveTierModel(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\n\t// No tiers configured → tier keywords are recognized but model is empty\n\tmodel, usedTier := f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty\", model)\n\t}\n\n\t// Non-tier value passes through unchanged\n\tmodel, usedTier = f.resolveTierModel(\"gpt-4o\")\n\tif usedTier {\n\t\tt.Error(\"usedTier should be false for non-tier value\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o\", model)\n\t}\n\n\t// Configure tiers\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t\tBalanceModel: \"claude-sonnet-4-20250514\",\n\t\tSwiftModel: \"gpt-4o-mini\",\n\t})\n\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Aliases: strong/medium/weak\n\tmodel, _ = f.resolveTierModel(\"strong\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"medium\")\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"weak\")\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Partial config: only vanguard set\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"opus\",\n\t})\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true even for unconfigured tier\")\n\t}\n\t// balance unconfigured → fallback to vanguard\n\tif model != \"opus\" {\n\t\tt.Errorf(\"model = %q, want opus (fallback from unconfigured balance to vanguard)\", model)\n\t}\n}\n\nfunc TestGetLLMForModel_TierResolution(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Tier with no subscriptionSvc → model not found, fallback to default client\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t})\n\n\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want default-model (fallback)\", model)\n\t}\n\n\t// Non-tier model with no subscriptionSvc → same fallback\n\t_, model, _, _, usedCustom = f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want default-model (fallback)\", model)\n\t}\n}\n\nfunc TestResolveTierModel_UnconfiguredFallback(t *testing.T) {\n\t// When swift/vanguard are not configured, should fallback to balance\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{\n\t\tBalanceModel: \"gpt-4o\",\n\t\t// VanguardModel and SwiftModel intentionally empty\n\t})\n\n\t// swift not configured → fallback to balance\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"swift fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// vanguard not configured → fallback to balance\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"vanguard fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// balance configured → returns balance\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"balance = %q, want gpt-4o\", model)\n\t}\n}\n\nfunc TestResolveTierModel_AllUnconfigured(t *testing.T) {\n\t// All tiers unconfigured → returns empty string (will fall to default client)\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{})\n\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (tier keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty (no tiers configured)\", model)\n\t}\n}\n\nfunc TestHasCustomLLMChecksSubscriptionSvc(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Setenv(\"XBOT_HOME\", dir)\n\tdb, err := sqlite.Open(config.DBFilePath())\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tfactory := NewLLMFactory(sqlite.NewUserLLMConfigService(db), &llm.MockLLM{}, \"default-model\")\n\tsubSvc := sqlite.NewLLMSubscriptionService(db)\n\tfactory.SetSubscriptionSvc(subSvc)\n\tif err := subSvc.Add(&sqlite.LLMSubscription{ID: \"sub-1\", SenderID: \"cli_user\", Name: \"s1\", Provider: \"openai\", BaseURL: \"https://example.com/v1\", APIKey: \"sk-test\", Model: \"m1\", IsDefault: true}); err != nil {\n\t\tt.Fatalf(\"add sub: %v\", err)\n\t}\n\tif !factory.HasCustomLLM(\"cli_user\") {\n\t\tt.Fatal(\"expected HasCustomLLM to return true when default subscription exists\")\n\t}\n}\n\n// TestInvalidate_ClearsPerChatCache verifies that Invalidate(senderID) clears\n// both user-level and per-chat (senderID:chatID) cache entries.\n// This is the fix for: switching sub then changing model in settings was stuck\n// on the old model because Invalidate only cleared the user-level key.\nfunc TestInvalidate_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsubA := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"deepseek-v3\", MaxOutputTokens: 4096,\n\t}\n\n\t// Simulate: SwitchSubscription creates both user-level and per-chat caches\n\tif err := f.SwitchSubscription(senderID, subA, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subA: %v\", err)\n\t}\n\n\t// Verify both caches exist\n\t_, modelA, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelA != \"gpt-4o\" {\n\t\tt.Fatalf(\"initial model = %q, want gpt-4o\", modelA)\n\t}\n\n\t// Simulate: set_default_subscription calls Invalidate then SwitchSubscription\n\t// (the actual server handler path for subscription switching)\n\tf.Invalidate(senderID)\n\tif err := f.SwitchSubscription(senderID, subB, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription subB: %v\", err)\n\t}\n\n\t_, modelB, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif modelB != \"deepseek-v3\" {\n\t\tt.Errorf(\"after sub switch, model = %q, want deepseek-v3\", modelB)\n\t}\n\n\t// Simulate: update_subscription (settings panel) calls Invalidate + SwitchSubscription\n\t// with chatID=\"\" — per-chat cache was NOT cleared before the fix\n\tf.Invalidate(senderID)\n\tupdatedSubB := *subB\n\tupdatedSubB.Model = \"deepseek-r1\"\n\tupdatedSubB.MaxOutputTokens = 16384\n\tif err := f.SwitchSubscription(senderID, &updatedSubB, \"\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription updatedSubB: %v\", err)\n\t}\n\n\t// GetLLMForChat should NOT return stale per-chat cache\n\t_, modelUpdated, _, thinkingUpdated := f.GetLLMForChat(senderID, chatID)\n\tif modelUpdated != \"deepseek-r1\" {\n\t\tt.Errorf(\"after settings update, model = %q, want deepseek-r1 (stale per-chat cache bug)\", modelUpdated)\n\t}\n\t// Verify thinking mode is also not stale\n\tif thinkingUpdated != \"\" {\n\t\tt.Errorf(\"after settings update, thinkingMode = %q, want empty\", thinkingUpdated)\n\t}\n}\n\n// TestSwitchModel_ClearsPerChatCache verifies that SwitchModel clears per-chat\n// model caches so GetLLMForChat returns the new model instead of a stale\n// per-chat entry.\nfunc TestSwitchModel_ClearsPerChatCache(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsenderID := \"cli_user\"\n\tchatID := \"/home/user/project\"\n\tsub := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api.example.com/v1\", APIKey: \"sk-test\",\n\t\tModel: \"gpt-4o\", MaxOutputTokens: 8192,\n\t}\n\n\t// Create per-chat cache via SwitchSubscription\n\tif err := f.SwitchSubscription(senderID, sub, chatID); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription: %v\", err)\n\t}\n\n\t// Now SwitchModel (e.g., from quick panel model switch)\n\tf.SwitchModel(senderID, \"gpt-4o-mini\")\n\n\t// GetLLMForChat should return the new model, not the stale per-chat one\n\t_, model, _, _ := f.GetLLMForChat(senderID, chatID)\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"after SwitchModel, per-chat model = %q, want gpt-4o-mini (stale per-chat cache bug)\", model)\n\t}\n}\n\n// TestInvalidate_DoesNotAffectOtherUsers verifies that Invalidate(senderID) only\n// clears entries for that specific sender, not other users.\nfunc TestInvalidate_DoesNotAffectOtherUsers(t *testing.T) {\n\tf := NewLLMFactory(nil, &llm.MockLLM{}, \"default-model\")\n\n\tsubA := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-a.com/v1\", APIKey: \"sk-a\",\n\t\tModel: \"gpt-4o\",\n\t}\n\tsubB := &sqlite.LLMSubscription{\n\t\tProvider: \"openai\", BaseURL: \"https://api-b.com/v1\", APIKey: \"sk-b\",\n\t\tModel: \"claude-3-opus\",\n\t}\n\n\t// User A gets per-chat cache\n\tif err := f.SwitchSubscription(\"userA\", subA, \"/home/a\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription userA: %v\", err)\n\t}\n\t// User B gets per-chat cache\n\tif err := f.SwitchSubscription(\"userB\", subB, \"/home/b\"); err != nil {\n\t\tt.Fatalf(\"SwitchSubscription userB: %v\", err)\n\t}\n\n\t// Invalidate user A — should NOT affect user B\n\tf.Invalidate(\"userA\")\n\n\t// User B's per-chat cache should still work\n\t_, modelB, _, _ := f.GetLLMForChat(\"userB\", \"/home/b\")\n\tif modelB != \"claude-3-opus\" {\n\t\tt.Errorf(\"userB model after Invalidate(userA) = %q, want claude-3-opus\", modelB)\n\t}\n}\n"}, "4ab559c118bd49d543291bebb5c290cd988c9bee": {"t": 1785759479.5184605, "n": 8027, "h": "a938b7d4861523d2", "c": "package agent\n\nimport (\n\t\"testing\"\n\n\t\"xbot/config\"\n\t\"xbot/llm\"\n\t\"xbot/storage/sqlite\"\n)\n\nfunc TestGuessProvider(t *testing.T) {\n\ttests := []struct {\n\t\tmodel string\n\t\twant string\n\t}{\n\t\t{\"claude-sonnet-4-20250514\", \"anthropic\"},\n\t\t{\"claude-opus-4-20250115\", \"anthropic\"},\n\t\t{\"gpt-4o\", \"openai\"},\n\t\t{\"gpt-4.1\", \"openai\"},\n\t\t{\"o1-preview\", \"openai\"},\n\t\t{\"o3-mini\", \"openai\"},\n\t\t{\"deepseek-chat\", \"deepseek\"},\n\t\t{\"deepseek-reasoner\", \"deepseek\"},\n\t\t{\"gemini-2.0-flash\", \"google\"},\n\t\t{\"qwen-max\", \"qwen\"},\n\t\t{\"unknown-model\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.model, func(t *testing.T) {\n\t\t\tgot := guessProvider(tt.model)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"guessProvider(%q) = %q, want %q\", tt.model, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetLLMForModel_EmptyTarget(t *testing.T) {\n\t// Empty target model → should return default model name without hitting subscription logic\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Verify the early return path: targetModel=\"\" should not try to list subscriptions\n\t// (subscriptionSvc is nil, so if it tried, we'd get a different error)\n\t_, model, _, tm, usedCustom := f.GetLLMForModel(\"user1\", \"\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"default-model\")\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false for empty target model\")\n\t}\n\tif tm != \"auto\" {\n\t\tt.Errorf(\"thinkingMode = %q, want %q\", tm, \"auto\")\n\t}\n}\n\nfunc TestGetLLMForModel_NilSubscriptionSvc(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// No subscriptionSvc + explicit model → model not found in any subscription,\n\t// fallback to default client with its OWN model (not the target model).\n\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"claude-opus-4-20250115\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want default-model (fallback uses default client's model)\", model)\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n}\n\nfunc TestNormalizeModelTier(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant string\n\t}{\n\t\t{\"vanguard\", \"vanguard\"},\n\t\t{\"VANGUARD\", \"vanguard\"},\n\t\t{\"Vanguard\", \"vanguard\"},\n\t\t{\"strong\", \"vanguard\"},\n\t\t{\"Strong\", \"vanguard\"},\n\t\t{\"balance\", \"balance\"},\n\t\t{\"medium\", \"balance\"},\n\t\t{\"swift\", \"swift\"},\n\t\t{\"weak\", \"swift\"},\n\t\t{\"gpt-4o\", \"\"},\n\t\t{\"\", \"\"},\n\t\t{\"unknown\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := normalizeModelTier(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"normalizeModelTier(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestResolveTierModel(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\n\t// No tiers configured → tier keywords are recognized but model is empty\n\tmodel, usedTier := f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty\", model)\n\t}\n\n\t// Non-tier value passes through unchanged\n\tmodel, usedTier = f.resolveTierModel(\"gpt-4o\")\n\tif usedTier {\n\t\tt.Error(\"usedTier should be false for non-tier value\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o\", model)\n\t}\n\n\t// Configure tiers\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t\tBalanceModel: \"claude-sonnet-4-20250514\",\n\t\tSwiftModel: \"gpt-4o-mini\",\n\t})\n\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Aliases: strong/medium/weak\n\tmodel, _ = f.resolveTierModel(\"strong\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"medium\")\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"weak\")\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Partial config: only vanguard set\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"opus\",\n\t})\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true even for unconfigured tier\")\n\t}\n\t// balance unconfigured → fallback to vanguard\n\tif model != \"opus\" {\n\t\tt.Errorf(\"model = %q, want opus (fallback from unconfigured balance to vanguard)\", model)\n\t}\n}\n\nfunc TestGetLLMForModel_TierResolution(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Tier with no subscriptionSvc → model not found, fallback to default client\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t})\n\n\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want default-model (fallback)\", model)\n\t}\n\n\t// Non-tier model with no subscriptionSvc → same fallback\n\t_, model, _, _, usedCustom = f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want default-model (fallback)\", model)\n\t}\n}\n\nfunc TestResolveTierModel_UnconfiguredFallback(t *testing.T) {\n\t// When swift/vanguard are not configured, should fallback to balance\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{\n\t\tBalanceModel: \"gpt-4o\",\n\t\t// VanguardModel and SwiftModel intentionally empty\n\t})\n\n\t// swift not configured → fallback to balance\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"swift fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// vanguard not configured → fallback to balance\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"vanguard fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// balance configured → returns balance\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"balance = %q, want gpt-4o\", model)\n\t}\n}\n\nfunc TestResolveTierModel_AllUnconfigured(t *testing.T) {\n\t// All tiers unconfigured → returns empty string (will fall to default client)\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{})\n\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (tier keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty (no tiers configured)\", model)\n\t}\n}\n\nfunc TestHasCustomLLMChecksSubscriptionSvc(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Setenv(\"XBOT_HOME\", dir)\n\tdb, err := sqlite.Open(config.DBFilePath())\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer db.Close()\n\n\tfactory := NewLLMFactory(sqlite.NewUserLLMConfigService(db), &llm.MockLLM{}, \"default-model\")\n\tsubSvc := sqlite.NewLLMSubscriptionService(db)\n\tfactory.SetSubscriptionSvc(subSvc)\n\tif err := subSvc.Add(&sqlite.LLMSubscription{ID: \"sub-1\", SenderID: \"cli_user\", Name: \"s1\", Provider: \"openai\", BaseURL: \"https://example.com/v1\", APIKey: \"sk-test\", Model: \"m1\", IsDefault: true}); err != nil {\n\t\tt.Fatalf(\"add sub: %v\", err)\n\t}\n\tif !factory.HasCustomLLM(\"cli_user\") {\n\t\tt.Fatal(\"expected HasCustomLLM to return true when default subscription exists\")\n\t}\n}\n"}, "ad2da464b06a2f2c251a1d533f31747c7216bc9e": {"t": 1785759480.8777385, "n": 7216, "h": "d0bf054897ce3ae3", "c": "package agent\n\nimport (\n\t\"testing\"\n\n\t\"xbot/config\"\n)\n\nfunc TestGuessProvider(t *testing.T) {\n\ttests := []struct {\n\t\tmodel string\n\t\twant string\n\t}{\n\t\t{\"claude-sonnet-4-20250514\", \"anthropic\"},\n\t\t{\"claude-opus-4-20250115\", \"anthropic\"},\n\t\t{\"gpt-4o\", \"openai\"},\n\t\t{\"gpt-4.1\", \"openai\"},\n\t\t{\"o1-preview\", \"openai\"},\n\t\t{\"o3-mini\", \"openai\"},\n\t\t{\"deepseek-chat\", \"deepseek\"},\n\t\t{\"deepseek-reasoner\", \"deepseek\"},\n\t\t{\"gemini-2.0-flash\", \"google\"},\n\t\t{\"qwen-max\", \"qwen\"},\n\t\t{\"unknown-model\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.model, func(t *testing.T) {\n\t\t\tgot := guessProvider(tt.model)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"guessProvider(%q) = %q, want %q\", tt.model, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetLLMForModel_EmptyTarget(t *testing.T) {\n\t// Empty target model → should return default model name without hitting subscription logic\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Verify the early return path: targetModel=\"\" should not try to list subscriptions\n\t// (subscriptionSvc is nil, so if it tried, we'd get a different error)\n\t_, model, _, tm, usedCustom := f.GetLLMForModel(\"user1\", \"\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"default-model\")\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false for empty target model\")\n\t}\n\tif tm != \"auto\" {\n\t\tt.Errorf(\"thinkingMode = %q, want %q\", tm, \"auto\")\n\t}\n}\n\nfunc TestGetLLMForModel_NilSubscriptionSvc(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// No subscriptionSvc + explicit model → model not found in any subscription,\n\t// fallback to default client with its OWN model (not the target model).\n\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"claude-opus-4-20250115\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want default-model (fallback uses default client's model)\", model)\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n}\n\nfunc TestNormalizeModelTier(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant string\n\t}{\n\t\t{\"vanguard\", \"vanguard\"},\n\t\t{\"VANGUARD\", \"vanguard\"},\n\t\t{\"Vanguard\", \"vanguard\"},\n\t\t{\"strong\", \"vanguard\"},\n\t\t{\"Strong\", \"vanguard\"},\n\t\t{\"balance\", \"balance\"},\n\t\t{\"medium\", \"balance\"},\n\t\t{\"swift\", \"swift\"},\n\t\t{\"weak\", \"swift\"},\n\t\t{\"gpt-4o\", \"\"},\n\t\t{\"\", \"\"},\n\t\t{\"unknown\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := normalizeModelTier(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"normalizeModelTier(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestResolveTierModel(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\n\t// No tiers configured → tier keywords are recognized but model is empty\n\tmodel, usedTier := f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty\", model)\n\t}\n\n\t// Non-tier value passes through unchanged\n\tmodel, usedTier = f.resolveTierModel(\"gpt-4o\")\n\tif usedTier {\n\t\tt.Error(\"usedTier should be false for non-tier value\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o\", model)\n\t}\n\n\t// Configure tiers\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t\tBalanceModel: \"claude-sonnet-4-20250514\",\n\t\tSwiftModel: \"gpt-4o-mini\",\n\t})\n\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Aliases: strong/medium/weak\n\tmodel, _ = f.resolveTierModel(\"strong\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"medium\")\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"weak\")\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Partial config: only vanguard set\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"opus\",\n\t})\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true even for unconfigured tier\")\n\t}\n\t// balance unconfigured → fallback to vanguard\n\tif model != \"opus\" {\n\t\tt.Errorf(\"model = %q, want opus (fallback from unconfigured balance to vanguard)\", model)\n\t}\n}\n\nfunc TestGetLLMForModel_TierResolution(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Tier with no subscriptionSvc → model not found, fallback to default client\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t})\n\n\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want default-model (fallback)\", model)\n\t}\n\n\t// Non-tier model with no subscriptionSvc → same fallback\n\t_, model, _, _, usedCustom = f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when model not found in any subscription\")\n\t}\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want default-model (fallback)\", model)\n\t}\n}\n\nfunc TestResolveTierModel_UnconfiguredFallback(t *testing.T) {\n\t// When swift/vanguard are not configured, should fallback to balance\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{\n\t\tBalanceModel: \"gpt-4o\",\n\t\t// VanguardModel and SwiftModel intentionally empty\n\t})\n\n\t// swift not configured → fallback to balance\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"swift fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// vanguard not configured → fallback to balance\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"vanguard fallback = %q, want gpt-4o (balance)\", model)\n\t}\n\n\t// balance configured → returns balance\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"balance = %q, want gpt-4o\", model)\n\t}\n}\n\nfunc TestResolveTierModel_AllUnconfigured(t *testing.T) {\n\t// All tiers unconfigured → returns empty string (will fall to default client)\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.SetModelTiers(config.LLMConfig{})\n\n\tmodel, usedTier := f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (tier keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty (no tiers configured)\", model)\n\t}\n}\n"}, "5fca7fd88c94049efacb3d03d09ed2b2e943fcfa": {"t": 1785759482.1893764, "n": 5697, "h": "ad6572eb62bf05ad", "c": "package agent\n\nimport (\n\t\"testing\"\n\n\t\"xbot/config\"\n)\n\nfunc TestGuessProvider(t *testing.T) {\n\ttests := []struct {\n\t\tmodel string\n\t\twant string\n\t}{\n\t\t{\"claude-sonnet-4-20250514\", \"anthropic\"},\n\t\t{\"claude-opus-4-20250115\", \"anthropic\"},\n\t\t{\"gpt-4o\", \"openai\"},\n\t\t{\"gpt-4.1\", \"openai\"},\n\t\t{\"o1-preview\", \"openai\"},\n\t\t{\"o3-mini\", \"openai\"},\n\t\t{\"deepseek-chat\", \"deepseek\"},\n\t\t{\"deepseek-reasoner\", \"deepseek\"},\n\t\t{\"gemini-2.0-flash\", \"google\"},\n\t\t{\"qwen-max\", \"qwen\"},\n\t\t{\"unknown-model\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.model, func(t *testing.T) {\n\t\t\tgot := guessProvider(tt.model)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"guessProvider(%q) = %q, want %q\", tt.model, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetLLMForModel_EmptyTarget(t *testing.T) {\n\t// Empty target model → should return default model name without hitting subscription logic\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Verify the early return path: targetModel=\"\" should not try to list subscriptions\n\t// (subscriptionSvc is nil, so if it tried, we'd get a different error)\n\t_, model, _, tm, usedCustom := f.GetLLMForModel(\"user1\", \"\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"default-model\")\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false for empty target model\")\n\t}\n\tif tm != \"auto\" {\n\t\tt.Errorf(\"thinkingMode = %q, want %q\", tm, \"auto\")\n\t}\n}\n\nfunc TestGetLLMForModel_NilSubscriptionSvc(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// No subscriptionSvc but explicit model → fallback to default client with target model name\n\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"claude-opus-4-20250115\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115 (fallback uses target model name)\", model)\n\t}\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when target model is specified\")\n\t}\n}\n\nfunc TestNormalizeModelTier(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant string\n\t}{\n\t\t{\"vanguard\", \"vanguard\"},\n\t\t{\"VANGUARD\", \"vanguard\"},\n\t\t{\"Vanguard\", \"vanguard\"},\n\t\t{\"strong\", \"vanguard\"},\n\t\t{\"Strong\", \"vanguard\"},\n\t\t{\"balance\", \"balance\"},\n\t\t{\"medium\", \"balance\"},\n\t\t{\"swift\", \"swift\"},\n\t\t{\"weak\", \"swift\"},\n\t\t{\"gpt-4o\", \"\"},\n\t\t{\"\", \"\"},\n\t\t{\"unknown\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := normalizeModelTier(tt.input)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"normalizeModelTier(%q) = %q, want %q\", tt.input, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestResolveTierModel(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\n\t// No tiers configured → tier keywords are recognized but model is empty\n\tmodel, usedTier := f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true (keyword recognized)\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty\", model)\n\t}\n\n\t// Non-tier value passes through unchanged\n\tmodel, usedTier = f.resolveTierModel(\"gpt-4o\")\n\tif usedTier {\n\t\tt.Error(\"usedTier should be false for non-tier value\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o\", model)\n\t}\n\n\t// Configure tiers\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t\tBalanceModel: \"claude-sonnet-4-20250514\",\n\t\tSwiftModel: \"gpt-4o-mini\",\n\t})\n\n\tmodel, usedTier = f.resolveTierModel(\"vanguard\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, usedTier = f.resolveTierModel(\"swift\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true\")\n\t}\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Aliases: strong/medium/weak\n\tmodel, _ = f.resolveTierModel(\"strong\")\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"medium\")\n\tif model != \"claude-sonnet-4-20250514\" {\n\t\tt.Errorf(\"model = %q, want claude-sonnet-4-20250514\", model)\n\t}\n\n\tmodel, _ = f.resolveTierModel(\"weak\")\n\tif model != \"gpt-4o-mini\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o-mini\", model)\n\t}\n\n\t// Partial config: only vanguard set\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"opus\",\n\t})\n\tmodel, usedTier = f.resolveTierModel(\"balance\")\n\tif !usedTier {\n\t\tt.Error(\"usedTier should be true even for unconfigured tier\")\n\t}\n\tif model != \"\" {\n\t\tt.Errorf(\"model = %q, want empty for unconfigured tier\", model)\n\t}\n}\n\nfunc TestGetLLMForModel_TierResolution(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Tier with no subscriptionSvc → fallback to default client with resolved model name\n\tf.SetModelTiers(config.LLMConfig{\n\t\tVanguardModel: \"claude-opus-4-20250115\",\n\t})\n\n\t// Without subscriptionSvc, tier resolves and uses default client with resolved name\n\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"vanguard\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true for tier resolution\")\n\t}\n\tif model != \"claude-opus-4-20250115\" {\n\t\tt.Errorf(\"model = %q, want claude-opus-4-20250115\", model)\n\t}\n\n\t// Non-tier model with no subscriptionSvc → fallback to default client with target model\n\t_, model, _, _, usedCustom = f.GetLLMForModel(\"user1\", \"gpt-4o\")\n\tif !usedCustom {\n\t\tt.Error(\"usedCustom should be true when target model is specified\")\n\t}\n\tif model != \"gpt-4o\" {\n\t\tt.Errorf(\"model = %q, want gpt-4o\", model)\n\t}\n}\n"}, "4ae4c6b5462f54765f2bc690c7c2c9d10a7a16e0": {"t": 1785759483.4244177, "n": 1889, "h": "d08717fbf7b6df31", "c": "package agent\n\nimport \"testing\"\n\nfunc TestGuessProvider(t *testing.T) {\n\ttests := []struct {\n\t\tmodel string\n\t\twant string\n\t}{\n\t\t{\"claude-sonnet-4-20250514\", \"anthropic\"},\n\t\t{\"claude-opus-4-20250115\", \"anthropic\"},\n\t\t{\"gpt-4o\", \"openai\"},\n\t\t{\"gpt-4.1\", \"openai\"},\n\t\t{\"o1-preview\", \"openai\"},\n\t\t{\"o3-mini\", \"openai\"},\n\t\t{\"deepseek-chat\", \"deepseek\"},\n\t\t{\"deepseek-reasoner\", \"deepseek\"},\n\t\t{\"gemini-2.0-flash\", \"google\"},\n\t\t{\"qwen-max\", \"qwen\"},\n\t\t{\"unknown-model\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.model, func(t *testing.T) {\n\t\t\tgot := guessProvider(tt.model)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"guessProvider(%q) = %q, want %q\", tt.model, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetLLMForModel_EmptyTarget(t *testing.T) {\n\t// Empty target model → should return default model name without hitting subscription logic\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// Verify the early return path: targetModel=\"\" should not try to list subscriptions\n\t// (subscriptionSvc is nil, so if it tried, we'd get a different error)\n\t_, model, _, tm, usedCustom := f.GetLLMForModel(\"user1\", \"\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want %q\", model, \"default-model\")\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false for empty target model\")\n\t}\n\tif tm != \"auto\" {\n\t\tt.Errorf(\"thinkingMode = %q, want %q\", tm, \"auto\")\n\t}\n}\n\nfunc TestGetLLMForModel_NilSubscriptionSvc(t *testing.T) {\n\tf := NewLLMFactory(nil, nil, \"default-model\")\n\tf.defaultThinkingMode = \"auto\"\n\n\t// No subscriptionSvc → should fallback to default (not panic)\n\t_, model, _, _, usedCustom := f.GetLLMForModel(\"user1\", \"claude-opus-4-20250115\")\n\tif model != \"default-model\" {\n\t\tt.Errorf(\"model = %q, want default-model (fallback when no subscriptionSvc)\", model)\n\t}\n\tif usedCustom {\n\t\tt.Error(\"usedCustom should be false when no subscriptionSvc\")\n\t}\n}\n"}}, "AGENTS.md": {"6c70ebdf5986d314e736eabb69598ce8279a072d": {"t": 1785747183.0629098, "n": 127927, "h": "332b14020a1bfed0", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- **禁止 AI agent 自行重启 xbot server**:`kill`/`pkill` xbot 进程、`nohup` 重新启动 server 等操作会中断用户正在进行的会话。除非用户明确要求重启,否则只 build 二进制 + 前端部署,由用户自行重启。前端改动 `vite build` 后 `cp dist/*` 部署即可,无需重启 server。\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步、**UserContext 统一解析(ResolveUserContext)**、**单用户模式(single_user)**\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **Subscriptions, settings, and tier config are bound to the canonical user (`user_id`), NOT to `sender_id`.** The `user_llm_subscriptions`, `user_settings`, and `user_default_model` tables all have a `user_id` column (added in v45 migration, backfilled by v46). All identities linked to the same canonical user (e.g. `web-1`, `cli_user`, `ou_xxx`) MUST see the same subscriptions, settings, and tier config. RPC handlers that query subscriptions/settings MUST use `rpcUserID(ctx)` and call `*ByUserID` storage methods (`ListByUserID`, `GetByUserID`, `SetByUserID`, etc.). The old `sender_id`-keyed methods (`List(senderID)`, `Get(channel, senderID)`) are for the agent loop path only (`ResolveUserContext` → `ResolveLLM`), where `senderID` is already resolved to the canonical identity.\n- **`rpcBizID` is for per-session operations only** (tenants table, session switching, history). It must NOT be used for user-level queries (subscriptions, settings, tier, concurrency, model lists). Those use `rpcUserID`. If you see `rpcBizID` in a user-level RPC handler, it's a bug.\n- **All old/historical logic MUST be deleted.** Superseded functions, migration helpers, and workaround hacks must be removed when their replacements are in place. Leaving dead code or old patterns misleads future developers and causes divergence. Examples of deleted patterns: `senderIDFromParams` (replaced by `rpcUserID`), `migrateCLIUserSettingsFromGlobalIfNeeded` (dead after `get_settings` switched to `GetByUserID`), `userScopedSettingsFromGlobalCLI` (only caller was the deleted function), `ResolvePrimarySenderID` (hack replaced by proper `user_id` queries).\n- **Only `user_context_middleware.go` (`ResolveUserContext`) and the RPC layer (`rpc_table.go`) may access user-related DB tables directly.** Agent loop code (`engine.go`, `engine_run.go`, `llm_config_handler.go`, etc.) MUST go through `UserContext` closures. RPC handlers MUST use `rpcUserID(ctx)` / `rpcBizID(ctx)` from context — never call `IdentityResolver` or `LLMFactory` directly to resolve user identity. This is the architectural boundary: user identity resolution happens once at entry, results carried via context.\n- **`Agent.ResolveUserContext` is the SINGLE boundary between the user system and the agent loop.** It resolves ALL user-related components (LLM, identity, settings, sandbox, concurrency, subscription management) once at `processMessage` entry, carrying the result via `context.Context` (`WithUserContext`/`UserContextFromContext`). **NEVER access `a.llmFactory`/`a.settingsSvc`/`a.identityResolver` directly in agent loop code** (`engine.go`, `engine_run.go`, `engine_wire.go`, `agent_process.go`, `compress.go`, `context_handler.go`, `prompt_handler.go`, `card_handler.go`, `middleware_builtin.go`, `perm_control_helpers.go`, `command_builtin.go`, `llm_config_handler.go` loop paths). Read from `UserContext` instead. Adding new user-related logic in middleware-downstream code is **forbidden** — put it in `ResolveUserContext` or `UserContext` methods.\n- **`UserContext` carries management closures for command handlers.** `uc.SubSvc`, `uc.SettingsSvc`, `uc.ResolveLLM(chatID)`, `uc.ResolveActiveSub(chatID)`, `uc.SelectModel(chatID, subID, model)`, `uc.RefreshModels()`, `uc.InvalidateLLM()`, `uc.HasCustomLLM()` — command handlers (`/set-llm`, `/settings`, `/models`, `/set-model`) must use these closures, not `a.llmFactory` directly. External RPC API methods (no `ctx` parameter, e.g. `GetUserMaxContext`) are exempt — they're outside the loop.\n- **Single-user mode: `experimental.single_user: true` in config.json.** When enabled, `ResolveUserContext` maps all senderIDs to `\"cli_user\"` — sharing subscriptions, settings, identity, sandbox. The `a.singleUser` flag (set from `cfg.SingleUser`) controls this. Only `ResolveUserContext` changes; downstream code is completely unaware.\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39/v40 migration notes.** `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **`handleRunOutput`/`handleCancelledRun` must use `out.IterationHistory` directly, NOT `mergeIterationHistory`.** `mergeIterationHistory` was designed to fill pre-restart iterations from `a.iterationHistories` (in-memory). But `a.iterationHistories` is NOT restored from DB on restart — it's always empty after restart — so the merge was always a no-op in the restart case. The ONLY case where it triggered was the AskUser continuation: `a.iterationHistories` persists across Runs in the same turn (WaitingUser does NOT delete it), so `len(global) > len(local)` → the merge returns ALL iterations (first Run + second Run). But the first Run's iterations are ALREADY persisted to a histMsg at WaitingUser time (`buildWaitingUserOutbound`). Including them again in the final reply's Detail → `ConvertMessagesToHistory` renders them twice (once in histMsg, once in final reply). Fix: use `out.IterationHistory` (current Run's snapshots only) directly in both `handleRunOutput` and `handleCancelledRun`. The restart case is handled by the histMsg (persisted to DB, restored via `ConvertMessagesToHistory`'s Detail parsing).\n- **`reconcileHistoryWithLiveRows` must dedup by `turnID:role`, not `eventSeq`/`content`.** After cancel, `appendAssistant` creates a live message (`seq-N`, `turnID=3`, `persisted=false`, `content=\"streaming text\"`). `markDestructiveMutation` → next reload uses `reconcileHistoryWithLiveRows`. DB returns `[interrupted]` (`hist-N`, `turnID=3`, `content=\"\"`). The old code checked `historyHasSeq(cancel_seq)` (false — DB seq differs) and `historyHasContent('assistant', \"streaming text\")` (false — DB content is `\"\"` for `[interrupted]`). Both checks failed → the live row survived → **duplicate rendering**. Fix: build a `Set<turnID:role>` from history. If the live message's `turnID:role` is already in the set, drop it. Keep `content:role` fallback for `turnID=0` messages (user_echo). Test: `cancel: appendAssistant with turnID does NOT duplicate after reload`.\n- **Iteration numbers are 1-based across the entire stack.** The engine's Run loop uses `for i := 1; i <= s.maxIter; i++` (not `0..maxIter-1`). `beginIteration(1)` sets `structuredProgress.Iteration = 1`. `snapshotCompletedIteration(1)` writes `Iteration: 1` to Detail JSON. `ConvertMessagesToHistory`'s `curIterIdx` starts at 0, `++` gives 1 (aligned). `reconstructIterationsFromMessages` uses `iterIdx++` starting from 0 (gives 1). `attachIterationDelta`'s guard is `prev.Iteration >= 1` (0 = uninitialized). Frontend `progressStore.lastIter` starts at 0 (uninitialized), `>= 1` triggers the \"had previous iteration\" clear. DB migration v51 rewrites old Detail JSON from 0-based to 1-based. **0 is always \"uninitialized/untracked\", never a valid iteration number.**\n- **Turn ID is restored from DB on server restart.** `chatProcessLoop` calls `tenantSession.GetMaxTurnID()` at startup and stores it into `ss.turnIDSeq` via `ss.turnIDSeq.Store(maxTurnID)`. This ensures the per-session turn counter stays globally monotonic across restarts — without it, the counter resets to 0 and the next turn gets `turn_id=1`, colliding with a pre-restart turn. `SessionService.GetMaxTurnID(tenantID)` runs `SELECT MAX(turn_id) FROM session_messages WHERE tenant_id = ?`.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **Cancel interception must NOT send premature cancel ack.** The `/cancel` handler in the agent loop only sends the cancel signal to `cancelCh`. The cancel ack (with `cancelled=true` metadata) is sent exclusively by `chatProcessLoop`'s `wasCancelled` path AFTER Run returns. Sending it in the interception caused TUI to enter idle while the Run was still active — the cancel signal is asynchronous (consumed by `cancelListener` goroutine), so there was a window where TUI was idle but the agent was still working.\n- **Cancel records pending bg notifications for the cancelled session.** `handleCancelledRun` takes same-session notifications still pending in `bgRunPending` and records them into the interrupted turn as synthetic tool observations; it also records a `user_cancelled` synthetic tool. Both the synthetic assistant and tool messages are marked `DisplayOnly=true` — they are persisted to DB for UI display but excluded from LLM context (`GetAllMessages` filters `display_only=0`). This ensures the next model call does NOT see the cancel marker, which is correct for graceful shutdown resume (the LLM should continue as if the cancel never happened). The model still sees partial tool calls/results (persisted by `IncrementalPersist` without `DisplayOnly`). Notifications already drained into the Run were already persisted by the normal synthetic-tool injection path, so cancel clears only their `drainedThisRun` tracking metadata. Other-session pending notifications remain in their own session bucket.\n- **DrainBgNotifications is cancel-aware.** `engine_run.go` post-iteration drain checks `ctx.Err() == nil` before draining. If ctx is already cancelled (cancel signal arrived during the LLM call), drain is skipped so `handleCancelledRun` can record this session's pending notifications in the interrupted turn before the UI receives the cancel ack.\n- **`wireBgNotificationDrain` tracks drained notifications.** Drain copies consumed notifications into `bgSessionState.drainedThisRun` (guarded by `drainedThisRunMu`). On normal/error turn completion, `clearDrainedThisRun()` discards the tracking metadata after notifications were processed. On cancel, `handleCancelledRun` clears the tracking metadata without re-delivering those already-persisted notifications as a fresh user message.\n- **`drainAndProcessNotifications` batches all notifications into ONE user message.** All drained notifications are formatted and joined with `\\n\\n---\\n\\n` separators, then injected as a single `injectBgUserMessage` call. This avoids spamming the TUI with N separate messages and triggering N separate agent turns when multiple bg tasks complete simultaneously.\n- **Turn-ID + `turn_started` event: atomic turn association (replaces InjectUserMessage side-channel).** `chatProcessLoop` generates a per-session monotonic `TurnID` (`bgSessionState.turnIDSeq`) for every dequeued message, stores it in `bgSessionState.activeTurnID` (atomic — `sendMessage` reads it from the Run goroutine), and emits a `turn_started` progress event (`Phase:\"turn_started\"`, `TurnID`, `TurnStart{Trigger,Content,RequestID}`) BEFORE `processMessage`. The TurnID propagates through `RunConfig.TurnID` → `StructuredProgress.TurnID` → every `ProgressEvent` (via `buildProgressPayload`) + stream callbacks + `emitBuiltinProgress`/`emitBuiltinProgressDone` + the reply `OutboundMsg.TurnID` (stamped by `sendMessage`). `injectBgUserMessage` NO LONGER calls `injectCLIUserMessage` — the notification user message is delivered atomically with the TurnID via the unified progress stream, eliminating the cross-goroutine race between `InjectUserMessage` (caller goroutine) and the turn's reply (`handleOutbound` goroutine). TUI `handleTurnStarted` (`cli_update_progress.go`) intercepts `turn_started` BEFORE the cancel guard; uses `turnStartedProcessed` flag to distinguish \"first turn_started for this turn (adopt TurnID)\" from \"turn_started for N+1 before N's reply (finalize N, start N+1)\". `handleAgentMessage` uses `msg.TurnID` (fallback `m.agentTurnID` for SubAgent/legacy) and only clears `streamingMsgIdx` when `turnID == m.agentTurnID` — a late reply for a previous turn must NOT clear the current turn's streaming slot. Web `useProgressStream` handles `turn_started` in the `progress_structured` case → `onInjectUserMessage` callback; the `text` event carries `turn_id` (from `WSMessage.TurnID`). Notification user messages render with a 🔔 badge + muted style (`isNotification` flag on `cliMessage` / `ChatMessage`).\n- **Turn-ID / Iteration-ID continuity assertions (3-layer diagnostics).** Every layer asserts that TurnID is strictly monotonic (no regression, no gaps) and IterationID advances by exactly +1 within a turn. **Backend**: `chatProcessLoop` checks `turnID` against `bgSessionState.lastTurnID` (regression → `Error` log `TURN_ID_INVARIANT_VIOLATION`; gap → `Warn` log `TURN_ID_GAP`); `runState.beginIteration` checks `i` against `structuredProgress.Iteration` (regression → `Error` `ITER_ID_INVARIANT_VIOLATION`; gap → `Warn` `ITER_ID_GAP`). **TUI**: `handleTurnStarted` checks `ev.TurnID` against `m.lastReceivedTurnID`; `applyProgressSnapshot` checks `snapshot.Iteration` against `progressState.lastIter`. **Web**: `useProgressStream` checks in the `progress_structured` case — `store.lastTurnID` for TurnID (also updated from non-turn_started structured events for SSE reconnect recovery), `store.lastIter` for iteration. All violations log detailed context (prev/next/delta/gap/turnID/chatID/phase) via `log.Error`/`log.Warn` (Go) or `console.error`/`console.warn` (Web). Assertions are **diagnostic, not blocking** — the turn lifecycle proceeds normally; the logs enable debugging of lost/disordered events (e.g. asyncCh full, SSE disconnect gap, progressSlot coalescing).\n- **SSE reconnect gap-based reload (NOT on every reconnect).** `useChatMessages` listens for `replay_gap` messages (dispatched by `restoreActiveProgress` in `sseConnection.ts`). SSE event gaps are normal (stateless coalescing, buffer drops) — the existing seq-gap detection in `handleEvent` triggers `restoreActiveProgress` which fetches the active progress snapshot. Only when that snapshot shows a **TurnID change** or **turn ended during gap** (real data loss — committed messages like the reply or notification were lost) does `restoreActiveProgress` dispatch `replay_gap` → `useChatMessages` calls `reload()`. A simple reconnect without data loss does NOT reload. `reload` uses `reconcileHistoryWithLiveRows` to merge DB rows with in-flight live rows. Initial connect does NOT trigger reload.\n- **Streaming scroll jitter fix (removed RAF scrollTop restore).** `MessageList` previously had a `useEffect` that captured `scrollTop` before render and restored it via `requestAnimationFrame` when `stickToBottomRef=false`. This fought TanStack Virtual's own scroll correction: the virtualizer's `resizeItem` corrects `scrollTop` via its internal `ResizeObserver` (fires after `useEffect`, before paint, before our RAF). Our RAF restore ran AFTER the virtualizer's correction, **undoing** it — causing the viewport to jump back to the pre-correction position every frame. The fix: remove the capture+restore entirely. The virtualizer's scroll correction is authoritative — it keeps visible items stable when sizes change. Content below the viewport grows without affecting `scrollTop`; content partially in the viewport is corrected to keep visible items fixed. The ResizeObserver at line 346 handles `stick=true` (scroll to bottom). The `useEffect` at line 307 serves as a safety net for `stick=true` (force scroll to bottom if > 2px off) and sets `hasNewContent` for `stick=false` (shows the \"new content\" bubble).\n- **`processMessage` early-exits on cancelled ctx.** Before calling `Run`, `processMessage` checks `ctx.Err()`. If cancelled during setup (DB tenant creation, MCP config, workspace init — especially heavy on the first message), it returns immediately with `cancelled=true` metadata instead of entering Run.\n- **Ctrl+C in TUI immediately cancels + clears queue.** `sendCancel` shows feedback BEFORE `sendInbound` (WS write can block). Ctrl+C while typing always clears `messageQueue` and sends cancel in one keystroke — no more N+1 presses to delete queued messages one by one before cancel takes effect.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **`dedupConsecutiveAssistants()` is the rendering-level guard** against two adjacent Assistant blocks. Called by both `appendNewMessagesToCache` and `fullRebuild`. Two cases: (1) SAME turnID → merge content+iterations; (2) DIFFERENT turnID + first is `isPartial=true` → remove the stale placeholder. Case 2 covers ALL `isPartial` messages (empty or with baked content/iterations from `finalizeTurnFromSnapshot`), not just empty placeholders. The old `upsertMessageByTurn`/`dedupMessagesGuard` O(N) per-frame scans were deleted — `dedupConsecutiveAssistants` is O(n) but only runs on cache rebuild, not every tick.\n- **`startAgentTurn()` calls `finalizeStaleStreamingBeforeNewUserMessage()` internally.** This covers all paths that bypass `sendMessage`/`sendToAgent` (auto-start, bg task injection, `/compress`, `cliProcessingMsg`, AskUser callback). Without this, `endAgentTurn`'s preserved `streamingMsgIdx` leaves the old assistant as `isPartial=true`, and `startAgentTurn` appends a new one right after it → two adjacent Assistant blocks. Callers that already called `finalizeStaleStreamingBeforeNewUserMessage` hit the early return (`streamingMsgIdx=-1`), no side effects.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`liveIterationBlocks` MUST suppress pulse when any tools are present.** `hasSpinner` was only set for `running`/`active` tools. When a tool transitions to `done`/`error`/`pending` (between sequential tool execution), `hasSpinner` goes `false` → pulse spinner appears (+1 line) → next tool starts running → pulse disappears (-1 line). This H→H+1→H oscillation is the visible jitter. Fix: `if len(tools) > 0 { hasSpinner = true }` — pulse is an idle indicator for the thinking phase only, not for tool execution phases.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`updateStreamingOnly` MUST skip liveLines when `!m.typing`.** `endAgentTurn` preserves `progressState.current` and `progressState.iterations` for flicker-free rendering between PhaseDone and `handleAgentMessage`. But `updateStreamingOnly` renders BOTH `completedLines` (from `progressState.iterations`, which includes the final iteration with Content + Reasoning) AND `liveLines` (from `progressState.current`, which has the SAME Content + Reasoning). This causes the final reasoning + response to appear twice. For CLI sessions this is momentary (`handleAgentMessage` clears `streamingMsgIdx` shortly after), but for non-CLI sessions (`/su` to feishu/web/qq), `handleAgentMessage` never arrives → `streamingMsgIdx` stays valid → `updateStreamingOnly` runs every tick → persistent duplication. Fix: the `liveLines` rendering block guards with `m.typing` — when the turn has ended, only completed iterations are rendered (they already contain the final content).\n- **`postRestoreSessionSetup` MUST call `refreshCachedModelName()` for LLM state resolution, NOT inline `LoadSessionLLMState` + `GetDefault` fallback.** The old code had its own LLM resolution path (local JSON → GetDefault) that bypassed the DB tenants table. In remote mode, `SaveSessionLLMState` passes `skipBackendFields=true` (local JSON never writes subID/model), so `LoadSessionLLMState` always returned zero → `GetDefault(\"\")` returned the subscription's default `Model` (often `\"\"`), overwriting the correct per-session model that `restoreSession()` had just restored from `savedSessions`. This only affected the default session (non-default sessions write local JSON via `SaveSessionLLMState` without skip at creation time). Fix: `postRestoreSessionSetup` now calls `refreshCachedModelName()` which checks DB tenants table (`GetSessionSubscription` RPC) first, then local JSON, then savedSessions, then GetDefault — the same 4-level priority chain used at startup. Context limits are then resolved from the now-correct `activeSubID` + `cachedModelName` via `ResolveEffectiveMaxContext`/`ResolveEffectiveMaxOutputTokens`. This makes default sessions and regular sessions follow the exact same restoration path.\n- **`ensureSessionModel` auto-binds a model to ALL new sessions on first use.** Both `ResolveLLM` (agent side, covers all channels including Web/Feishu) and `postRestoreSessionSetup` (CLI side, for immediate status bar display) call auto-bind when a session has no per-session binding in the tenants table. Priority: Balance tier config (`user_settings[\"tier_balance\"]` → `\"subID|model\"`) → last-used model (`user_default_model` table). The binding is persisted via `SelectModel` (writes to both tenants table and `user_default_model`), so subsequent calls hit the tenants table directly and skip auto-bind. If neither Balance tier nor last-used model exists, no binding is created (the caller falls through to system default). This ensures ALL sessions — CLI-created, `/chat new`, `/su` switch, Web/Feishu — get an explicit model binding on first use, not just those created via the CLI session panel. CLI's `ensureSessionModelBinding` is idempotent: it checks `GetSessionSubscription` first and returns immediately if the session already has a binding.\n- **Delta Push + Watermark Pull protocol replaces full-history push.** Structured progress has ONE channel-agnostic producer (`buildProgressEventHandler`): build one `protocol.ProgressEvent`, attach the iteration delta once, store the snapshot once, then broadcast an isolated clone of that semantic event to every `channel.ProgressSender` (CLI/Web/plugin). Channels are transports only — never derive their own history or add channel-specific reducers. `ProgressEvent.Seq` is the semantic log watermark used by reconnecting clients; SSE/WS envelope seq is transport-only. Clients install active snapshots, ignore events with `seq <= snapshot.seq`, and NEVER synthesize completed iterations locally. Push events carry ONLY the newly completed iteration as `IterationHistory` (0 or 1 entries) — not the full cumulative history. `attachIterationDelta` (formerly `recordIterationAdvanceAndAttachHistory`) calls `recordIterationSnapshot` which returns the delta (the previous iteration snapshot) or nil if no advance. `GetActiveProgress` gains a `fromIter` watermark parameter: it filters `iterationHistories` to only return iterations with `Iteration > fromIter`. The CLI sends `m.progressState.lastIter` as the watermark. Tick pull frequency reduced from 100ms to 2s (every 20th tick) — it's now a safety net for recovering iterations lost to progressSlot coalescing or WS disconnect, not the primary delivery path. Push events are the primary path. `restoreIterationsFromSnapshot` changed from count-based replace to per-iteration append-with-dedup (`slices.ContainsFunc` by iteration number). `coalesceProgress` merges delta slices via `mergeIterationDeltas` (concatenate + dedup by iteration number). `/su` switch and initial restore pass `fromIter=0` to get full history (one-time operation). Web callback passes `fromIter=0` (web doesn't use delta protocol). `recordIterationSnapshot` now returns `*protocol.ProgressEvent` (the delta, or nil) instead of being void.\n- **`startAgentTurn` MUST set `progressState.current` AFTER `resetProgressState`, not before.** `resetProgressState` clears `progressState.current` to nil. If the \"thinking\" initial state is set before `resetProgressState`, it gets wiped — the first tick sees `hasIterData=false` in `updateStreamingOnly`, falls back to `renderMessage` (empty content, no pulse spinner), and the user sees no visual feedback until the first structured progress event arrives (seconds later). Setting `progressState.current = {Phase:\"thinking\"}` after `resetProgressState` ensures `liveIterationBlocks` renders a pulse spinner on the very first tick — immediate visual feedback.\n- **`mergeSubAgentTrees` MUST be called in `applyProgressSnapshot` before replacing `progressState.current`.** Structured events don't always carry SubAgents (engine only includes them when `resolveSubAgents` returns non-empty). Without merging, the tree flickers — appears when SubAgents are present, vanishes when the next structured event omits them. The merge preserves prev's running agents and marks dropped ones as done. `mergeSubAgentTrees` also preserves children when `new.Children` is empty but `prev.Children` exists (marks all prev children as done via `markAllDone()`). Without this call, `mergeSubAgentTrees` is dead code (only called in tests).\n- **`finalizeTurnFromSnapshot` MUST create synthetic assistant message for ALL non-CLI channels.** The old code only did this for `m.channelName == \"agent\"` (SubAgent viewer). But when CLI `/su` switches to any non-CLI session (feishu, web, qq, etc.), the agent's outbound reply goes to the originating channel via `sendMessage(chName, ...)`, NOT to the CLI channel. The CLI receives progress events (including Content via structured progress) and PhaseDone, but never receives `cliOutboundMsg`. Without the synthetic message, the CLI stays stuck showing \"...\" after PhaseDone. The fix: `m.channelName != \"cli\"` (covers agent, feishu, web, etc.). Content comes from `cur.Content` (set by `structuredProgress.Content` via `notifyProgress` before PhaseDone). PhaseDone payload itself has no Content — must fall back to `cur` (captured before `endAgentTurn`).\n- **`ProgressEventHandler` MUST be created for ALL channels, not just cli/web/ProgressSender.** `buildMainRunConfig` previously only created `ProgressEventHandler` for `\"cli\"`, `\"web\"`, and plugin `ProgressSender` channels. When a CLI user `/su` switches to a feishu (or other non-cli/web) session and sends a message, the message's `Channel == \"feishu\"` → no `ProgressEventHandler` → `progressFinalizer`'s PhaseDone event is silently dropped → `finalizeTurnFromSnapshot` never runs → `m.typing` stays `true` forever (TUI stuck in busy). Fix: the `switch channel` in `buildMainRunConfig` now has a `default` branch that creates a `buildCLIProgressEventHandler(chatID, channel)` for non-cli/web/non-ProgressSender channels. This ensures PhaseDone, structured progress, and `lastProgressSnapshot` storage work for ALL channels when viewed via `/su`. Additionally, the `else` branch of the `ProgressNotifier` setup now sets a no-op notifier for non-cli/web channels without `autoNotify`, enabling `progressFinalizer` to generate PhaseDone events.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n- **`buildStreamCallbacks` only broadcasts to the ORIGINATING channel.** Each channel's `SendProgress` already broadcasts to ALL Hub subscribers (including other channels' clients via the shared Hub). Broadcasting to multiple channels causes duplicate delivery: each subscriber receives the same event N times (N = number of ProgressSender channels). This was the root cause of the \"two consecutive Assistant messages\" bug that only appeared when Web and TUI were both open on the same session — duplicate stream events arrived after state transitions, triggering `startAgentTurn` to create a duplicate streaming assistant.\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n\n### Web Frontend Rendering\n- **`hasVisibleProgress` must NOT count `snap.streaming` alone.** The `streaming` flag indicates the agent is actively streaming, but streaming alone (no content, no tools, no iterations) means the agent is \"thinking\" — which should show the \"思考中…\" busy placeholder, NOT an empty `liveMessage`. When `streaming=true` was the only field set (e.g. `historyProgressToLive` hydrating from a stale server snapshot with `phase=running` but no `active_tools`), `hasVisibleProgress` returned true → `liveMessage` was non-null but empty → suppressed the busy placeholder → user saw nothing (no tool, no \"思考中…\"). Fix: `hasVisibleProgress` only returns true when there's actual content (`streamContent`, `activeTools`, `iterationHistory`, etc.), not just `streaming`.\n- **Busy placeholder must show during loading when rows exist.** `MessageList.tsx`'s busy placeholder condition was `busy && !liveMessage && !loading`. During history reload on a busy session, `visibleLiveMessage` is null (hidden by the `chat.loading` gate in AgentPanel) AND `!loading` suppressed the placeholder. Result: neither the live progress nor \"思考中…\" showed — the session looked idle even though the backend was executing a tool. Fix: changed to `busy && !liveMessage && !(loading && rows.length === 0)` — the loading spinner (`Loader2`) only handles the empty-state (`rows.length === 0`), so the placeholder shows during loading when there are existing messages.\n- **Stale PhaseDone from SSE replay must NOT reset the store.** `setStructuredTools` in `progressStore.ts` checked `phase === 'done'` BEFORE the `eventSeq` stale check. When switching to a busy session: (1) `initialProgress` from the history API hydrates the store (e.g. seq=8, activeTools=[Shell running]), (2) SSE replay delivers a stale PhaseDone (seq=5) from the PREVIOUS turn. The `phase === 'done'` check fired first → `this.reset()` → store cleared → activeTools vanished. The tool never reappeared because the current turn's events (seq=6,7,8) may not be in the SSE buffer. Fix: moved the `eventSeq` stale check BEFORE the `phase === 'done'` check. A stale PhaseDone (seq <= store's eventSeq) only updates todos (PhaseDone may carry TodoWrite updates) and does NOT reset the store. A non-stale PhaseDone (seq > store's eventSeq or no seq) resets as before. Events without `eventSeq` (e.g. `restoreActiveProgress` dispatching `{ phase: 'done' }` without seq) still pass through to the PhaseDone check.\n- **LiveIteration must NOT filter activeTools by name+label against iterationHistory.** `LiveIteration.tsx` filtered ALL tools (including `activeTools`) by `completedIterToolKeys` (name+label) to prevent stale tools from completed iterations from rendering. But this also filtered out running tools from the CURRENT iteration that happened to share the same name+label as a tool in a completed iteration (e.g. `Shell` called in both iteration 1 and 2). The running tool disappeared from the UI — \"generating会渲染,但executing之后tool就消失\". Fix: (1) filter `activeTools` by iteration number (like `completedTools` — `t.iteration > maxCompletedIter`), (2) only apply the `completedIterToolKeys` name+label filter to `completedTools`, NOT to `activeTools` or `streamingTools`. This also fixes duplicate iteration rendering: stale `activeTools` from completed iterations are now filtered by iteration number, preventing them from rendering alongside `iterationHistory`.\n- **`historyProgressToLive` must handle the iteration-boundary snapshot.** When `GetActiveProgress` is called after `snapshotCompletedIteration` but before `prepareForIteration`, the snapshot carries the PREVIOUS iteration's `Content` and `CompletedTools` (not yet cleared). The delta for the completed iteration hasn't been recorded in `iterationHistories` yet (it's only recorded when the NEXT iteration's first event arrives via `attachIterationDelta`). `historyProgressToLive` hydrated the store with this stale `content` and `completedTools`, and `LiveIteration` rendered them as if they belong to the current (in-flight) iteration — duplicating content and tools. Fix: in `historyProgressToLive`, when `activeTools` is empty and `completedTools` is non-empty, the snapshot is at the iteration boundary. Clear `content` and `completedTools` from the live snapshot so `LiveIteration` doesn't duplicate them. Do NOT add a synthetic entry to `iterationHistory` — it would be appended at the END of the array (which is the BEGINNING if the store is empty), causing the content to appear BEFORE earlier iterations' tools (\"content跑到别的迭代里了\"). The delta protocol will deliver the correct data in order when the next iteration starts.\n- **Mermaid diagrams must NOT render during streaming — integrate via `StreamingContext`.** `mermaid.render()` is async + CPU-intensive; during streaming the source is incomplete (typewriter-clipped), so rendering would fail repeatedly and waste CPU. The `MarkdownRenderer` passes `streaming` state through a React Context (`StreamingContext`). `CodeBlock` consumes it: when `streaming=true` + `lang==='mermaid'` → shows `MermaidSourceBlock` (plain source, synchronous, cheap); when `streaming=false` → mounts `MermaidDiagram` which renders the SVG exactly once. The transition is seamless — both use the same `CodeBlock` visual style. `MermaidDiagram` lazy-loads the ~1MB mermaid package via dynamic `import('mermaid')` (module-scope singleton + `useSyncExternalStore` for ready state, mirroring the highlight.js pattern). `MarkdownPreview.tsx` (file preview, non-streaming) always renders `MermaidDiagram` directly. Error fallback shows raw source in a styled code block. **Responsive width**: mermaid injects an inline `style=\"max-width: <px>\"` that clamps small diagrams to their natural width — `MermaidDiagram` strips this from the rendered SVG so small diagrams expand to fill the container (`w-full h-auto max-w-none`), while large diagrams still scroll horizontally via `overflow-x-auto`. **Fullscreen view**: a maximize button (`Maximize2` icon) opens a fullscreen overlay covering the entire viewport via `createPortal` (imported from `react-dom`, NOT `react` — React 19 removed `createPortal` from the `react` package; importing from `react` throws \"createPortal is not a function\"). Click backdrop or Esc to close; body scroll locked while open.\n\n### Web Terminal (PTY)\n- **PTY follows the user's runner, same as Shell tool.** `channel/web/web_pty.go:ptyManager.selectBackend(senderID)` resolves the user's sandbox via `tools.GetSandbox()` → `SandboxRouter.SandboxForUser(senderID)`. `none` sandbox → `localPtyBackend` (PTY on web server via `creack/pty`). `remote` sandbox → `remotePtyBackend` (PTY on runner via WS protocol). Docker mode on runner uses `docker exec -it`.\n- **PTY protocol mirrors the existing stdio streaming protocol.** `runnerproto` defines 6 message types: `pty_create` (req/resp), `pty_stdin` (fire-and-forget), `pty_resize` (fire-and-forget), `pty_close` (req/resp), `pty_data` (runner→server push), `pty_exit` (runner→server push). Runner-side `internal/runnerclient/pty.go:ptyManager` uses `pty.StartWithSize` + read loop; server-side `tools/pty_remote.go` routes pushes via `handlePtyPush` (called in `remote_sandbox.go` readLoop after `handleStdioPush`).\n- **`PtyCreate` registers the stream before sending the request.** `RemoteSandbox.PtyCreate` calls `registerPtyStream` BEFORE `sendRequest`, so `handlePtyPush` can route `pty_data`/`pty_exit` pushes immediately. On failure, `removePtyStream` cleans up. `SetPtyHandlers` updates callbacks after `PtyCreate` returns (the web channel's `ptyManager.Create` calls `backend.Create` then `backend.SetHandlers`).\n- **Multi-client broadcast + history replay.** `ptySession` holds `clients map[*ptyWSClient]struct{}` and a 64KB scrollback `history []byte`. New WS connections replay history, then receive live `broadcast` calls. WS disconnect removes the client but keeps the PTY alive (idle reaper kills after 5 min). Explicit `close` or PTY exit destroys the session.\n- **`handleTerminalWS` uses cookie auth via `authMiddleware`.** The route `mux.HandleFunc(\"/ws/terminal\", wc.authMiddleware(wc.handleTerminalWS))` — `authMiddleware` validates the cookie, then `handleTerminalWS` upgrades to WebSocket. The WS upgrade is GET, so `authMiddleware`'s body-size limit (only applied to non-GET) doesn't interfere.\n- **Terminal API routes.** `POST /api/terminal/create` (body `{ chatID, cwd }` → `{ tid }`), `POST /api/terminal/{tid}/delete` (→ `{ ok: true }`), `POST /api/terminal/list` (body `{ chat_id }` → `{ terminals: [...] }`), `WS /ws/terminal?tid=<tid>`. All terminal routes use `authenticatedPOST`/`authMiddleware` (cookie auth). Chat delete calls `ptyMgr.CleanupChat(chatID)` to destroy all PTY sessions for the deleted chat.\n- **`BackgroundPanel.tsx` uses xterm.js with incremental write.** Background task output (which contains `\\r`, ANSI codes, progress bars) is rendered via `Terminal.write(output.slice(lastLen))` — only the delta since last poll is written, so `\\r` (carriage return) correctly overwrites the current line instead of stacking as separate lines. The xterm instance is created once per panel mount and disposed on unmount.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Reproduce Before Fixing\n\n**修复任何 bug 之前,必须先写测试复现问题。** 没有复现测试的修复是盲修——你不知道自己是否真的修了根因,也不知道未来是否会回归。\n\n#### 流程\n\n1. **写测试复现** — 单元测试(Go `test` 或前端 `vitest`)或 E2E 测试(Playwright)。测试必须 **先失败**(红灯),证明 bug 确实存在。\n2. **分析根因** — 理解为什么会产生 bug,不要只看症状。\n3. **最小修复** — 只改必要的代码,修复后测试变绿。\n4. **验证无回归** — 运行完整测试套件,确保修复没有引入新问题。\n\n#### 为什么重要\n\n- 没有复现测试 = 没有证据证明 bug 存在,也没有证据证明修复有效\n- 偶发性 bug(竞态、时序、网络)尤其需要测试——手动验证不可靠\n- 测试是活文档——未来开发者能从测试理解 bug 的触发条件\n- 防止\"修复\"引入回归——测试在 CI 中持续守护\n\n#### 对前端 UI bug\n\n- 优先用 Playwright E2E 测试(`web/e2e/`),用 `page.route()` mock 后端,不需要真实服务器\n- 滚动、渲染、状态等视觉行为必须用 E2E 验证,单元测试无法覆盖 DOM 层面\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressSlot coalescing**(原 progressCh buffer=1 已替换):`SendProgress` 使用 mutex-protected `progressSlot` + buffer-1 `progressSignal` 替代旧 buffer-1 channel。旧设计中 eviction+re-insert 有 race window,结构化的 \"done\" 事件可被静默丢弃,导致工具永久卡在 \"running\"(● 不变 ✓)。新设计:`progressSlot` 始终持有最新合并后的事件,`handleProgressDrain` 读取并清空 slot。结构化事件永远不会丢失——stream-only 不能驱逐 structured,structured 替换时保留 stream fields/TokenUsage/CWD。`coalesceProgress`(asyncCh 层)仍保留用于合并连续 progress 消息。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **新用户消息开始前必须固化旧 streaming turn。** `PhaseDone` 后 `endAgentTurn` 会刻意保留 `streamingMsgIdx` 和 `progressState`,等待最终 `handleAgentMessage` 做无闪烁收尾。但 `sendMessage`/`sendToAgent` 会在 `startAgentTurn()` 前先 append 用户消息并刷新 viewport;如果不先调用 `finalizeStaleStreamingBeforeNewUserMessage()`,这次刷新会把上一个 turn 的 live stream 渲染到新用户消息下面。真实新消息入口必须在 append user message 前固化旧 assistant partial、清 `streamingMsgIdx`/旧 progress stream cache。**`startAgentTurn()` 内部也调用 `finalizeStaleStreamingBeforeNewUserMessage()`**,覆盖所有不经过 `sendMessage`/`sendToAgent` 的路径(auto-start、bg task 注入、`/compress`、`cliProcessingMsg`、AskUser 回调)。已有调用的路径因 `streamingMsgIdx=-1` 走 early return,无副作用。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "4e4b834bf085bd24094ce83725c962195572d6ec": {"t": 1785759438.8483498, "n": 126268, "h": "2bc3f4dc9ccd805b", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- **禁止 AI agent 自行重启 xbot server**:`kill`/`pkill` xbot 进程、`nohup` 重新启动 server 等操作会中断用户正在进行的会话。除非用户明确要求重启,否则只 build 二进制 + 前端部署,由用户自行重启。前端改动 `vite build` 后 `cp dist/*` 部署即可,无需重启 server。\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步、**UserContext 统一解析(ResolveUserContext)**、**单用户模式(single_user)**\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **Subscriptions, settings, and tier config are bound to the canonical user (`user_id`), NOT to `sender_id`.** The `user_llm_subscriptions`, `user_settings`, and `user_default_model` tables all have a `user_id` column (added in v45 migration, backfilled by v46). All identities linked to the same canonical user (e.g. `web-1`, `cli_user`, `ou_xxx`) MUST see the same subscriptions, settings, and tier config. RPC handlers that query subscriptions/settings MUST use `rpcUserID(ctx)` and call `*ByUserID` storage methods (`ListByUserID`, `GetByUserID`, `SetByUserID`, etc.). The old `sender_id`-keyed methods (`List(senderID)`, `Get(channel, senderID)`) are for the agent loop path only (`ResolveUserContext` → `ResolveLLM`), where `senderID` is already resolved to the canonical identity.\n- **`rpcBizID` is for per-session operations only** (tenants table, session switching, history). It must NOT be used for user-level queries (subscriptions, settings, tier, concurrency, model lists). Those use `rpcUserID`. If you see `rpcBizID` in a user-level RPC handler, it's a bug.\n- **All old/historical logic MUST be deleted.** Superseded functions, migration helpers, and workaround hacks must be removed when their replacements are in place. Leaving dead code or old patterns misleads future developers and causes divergence. Examples of deleted patterns: `senderIDFromParams` (replaced by `rpcUserID`), `migrateCLIUserSettingsFromGlobalIfNeeded` (dead after `get_settings` switched to `GetByUserID`), `userScopedSettingsFromGlobalCLI` (only caller was the deleted function), `ResolvePrimarySenderID` (hack replaced by proper `user_id` queries).\n- **Only `user_context_middleware.go` (`ResolveUserContext`) and the RPC layer (`rpc_table.go`) may access user-related DB tables directly.** Agent loop code (`engine.go`, `engine_run.go`, `llm_config_handler.go`, etc.) MUST go through `UserContext` closures. RPC handlers MUST use `rpcUserID(ctx)` / `rpcBizID(ctx)` from context — never call `IdentityResolver` or `LLMFactory` directly to resolve user identity. This is the architectural boundary: user identity resolution happens once at entry, results carried via context.\n- **`Agent.ResolveUserContext` is the SINGLE boundary between the user system and the agent loop.** It resolves ALL user-related components (LLM, identity, settings, sandbox, concurrency, subscription management) once at `processMessage` entry, carrying the result via `context.Context` (`WithUserContext`/`UserContextFromContext`). **NEVER access `a.llmFactory`/`a.settingsSvc`/`a.identityResolver` directly in agent loop code** (`engine.go`, `engine_run.go`, `engine_wire.go`, `agent_process.go`, `compress.go`, `context_handler.go`, `prompt_handler.go`, `card_handler.go`, `middleware_builtin.go`, `perm_control_helpers.go`, `command_builtin.go`, `llm_config_handler.go` loop paths). Read from `UserContext` instead. Adding new user-related logic in middleware-downstream code is **forbidden** — put it in `ResolveUserContext` or `UserContext` methods.\n- **`UserContext` carries management closures for command handlers.** `uc.SubSvc`, `uc.SettingsSvc`, `uc.ResolveLLM(chatID)`, `uc.ResolveActiveSub(chatID)`, `uc.SelectModel(chatID, subID, model)`, `uc.RefreshModels()`, `uc.InvalidateLLM()`, `uc.HasCustomLLM()` — command handlers (`/set-llm`, `/settings`, `/models`, `/set-model`) must use these closures, not `a.llmFactory` directly. External RPC API methods (no `ctx` parameter, e.g. `GetUserMaxContext`) are exempt — they're outside the loop.\n- **Single-user mode: `experimental.single_user: true` in config.json.** When enabled, `ResolveUserContext` maps all senderIDs to `\"cli_user\"` — sharing subscriptions, settings, identity, sandbox. The `a.singleUser` flag (set from `cfg.SingleUser`) controls this. Only `ResolveUserContext` changes; downstream code is completely unaware.\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39/v40 migration notes.** `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **`handleRunOutput`/`handleCancelledRun` must use `out.IterationHistory` directly, NOT `mergeIterationHistory`.** `mergeIterationHistory` was designed to fill pre-restart iterations from `a.iterationHistories` (in-memory). But `a.iterationHistories` is NOT restored from DB on restart — it's always empty after restart — so the merge was always a no-op in the restart case. The ONLY case where it triggered was the AskUser continuation: `a.iterationHistories` persists across Runs in the same turn (WaitingUser does NOT delete it), so `len(global) > len(local)` → the merge returns ALL iterations (first Run + second Run). But the first Run's iterations are ALREADY persisted to a histMsg at WaitingUser time (`buildWaitingUserOutbound`). Including them again in the final reply's Detail → `ConvertMessagesToHistory` renders them twice (once in histMsg, once in final reply). Fix: use `out.IterationHistory` (current Run's snapshots only) directly in both `handleRunOutput` and `handleCancelledRun`. The restart case is handled by the histMsg (persisted to DB, restored via `ConvertMessagesToHistory`'s Detail parsing).\n- **`reconcileHistoryWithLiveRows` must dedup by `turnID:role`, not `eventSeq`/`content`.** After cancel, `appendAssistant` creates a live message (`seq-N`, `turnID=3`, `persisted=false`, `content=\"streaming text\"`). `markDestructiveMutation` → next reload uses `reconcileHistoryWithLiveRows`. DB returns `[interrupted]` (`hist-N`, `turnID=3`, `content=\"\"`). The old code checked `historyHasSeq(cancel_seq)` (false — DB seq differs) and `historyHasContent('assistant', \"streaming text\")` (false — DB content is `\"\"` for `[interrupted]`). Both checks failed → the live row survived → **duplicate rendering**. Fix: build a `Set<turnID:role>` from history. If the live message's `turnID:role` is already in the set, drop it. Keep `content:role` fallback for `turnID=0` messages (user_echo). Test: `cancel: appendAssistant with turnID does NOT duplicate after reload`.\n- **Iteration numbers are 1-based across the entire stack.** The engine's Run loop uses `for i := 1; i <= s.maxIter; i++` (not `0..maxIter-1`). `beginIteration(1)` sets `structuredProgress.Iteration = 1`. `snapshotCompletedIteration(1)` writes `Iteration: 1` to Detail JSON. `ConvertMessagesToHistory`'s `curIterIdx` starts at 0, `++` gives 1 (aligned). `reconstructIterationsFromMessages` uses `iterIdx++` starting from 0 (gives 1). `attachIterationDelta`'s guard is `prev.Iteration >= 1` (0 = uninitialized). Frontend `progressStore.lastIter` starts at 0 (uninitialized), `>= 1` triggers the \"had previous iteration\" clear. DB migration v51 rewrites old Detail JSON from 0-based to 1-based. **0 is always \"uninitialized/untracked\", never a valid iteration number.**\n- **Turn ID is restored from DB on server restart.** `chatProcessLoop` calls `tenantSession.GetMaxTurnID()` at startup and stores it into `ss.turnIDSeq` via `ss.turnIDSeq.Store(maxTurnID)`. This ensures the per-session turn counter stays globally monotonic across restarts — without it, the counter resets to 0 and the next turn gets `turn_id=1`, colliding with a pre-restart turn. `SessionService.GetMaxTurnID(tenantID)` runs `SELECT MAX(turn_id) FROM session_messages WHERE tenant_id = ?`.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **Cancel interception must NOT send premature cancel ack.** The `/cancel` handler in the agent loop only sends the cancel signal to `cancelCh`. The cancel ack (with `cancelled=true` metadata) is sent exclusively by `chatProcessLoop`'s `wasCancelled` path AFTER Run returns. Sending it in the interception caused TUI to enter idle while the Run was still active — the cancel signal is asynchronous (consumed by `cancelListener` goroutine), so there was a window where TUI was idle but the agent was still working.\n- **Cancel records pending bg notifications for the cancelled session.** `handleCancelledRun` takes same-session notifications still pending in `bgRunPending` and records them into the interrupted turn as synthetic tool observations; it also records a `user_cancelled` synthetic tool. Both the synthetic assistant and tool messages are marked `DisplayOnly=true` — they are persisted to DB for UI display but excluded from LLM context (`GetAllMessages` filters `display_only=0`). This ensures the next model call does NOT see the cancel marker, which is correct for graceful shutdown resume (the LLM should continue as if the cancel never happened). The model still sees partial tool calls/results (persisted by `IncrementalPersist` without `DisplayOnly`). Notifications already drained into the Run were already persisted by the normal synthetic-tool injection path, so cancel clears only their `drainedThisRun` tracking metadata. Other-session pending notifications remain in their own session bucket.\n- **DrainBgNotifications is cancel-aware.** `engine_run.go` post-iteration drain checks `ctx.Err() == nil` before draining. If ctx is already cancelled (cancel signal arrived during the LLM call), drain is skipped so `handleCancelledRun` can record this session's pending notifications in the interrupted turn before the UI receives the cancel ack.\n- **`wireBgNotificationDrain` tracks drained notifications.** Drain copies consumed notifications into `bgSessionState.drainedThisRun` (guarded by `drainedThisRunMu`). On normal/error turn completion, `clearDrainedThisRun()` discards the tracking metadata after notifications were processed. On cancel, `handleCancelledRun` clears the tracking metadata without re-delivering those already-persisted notifications as a fresh user message.\n- **`drainAndProcessNotifications` batches all notifications into ONE user message.** All drained notifications are formatted and joined with `\\n\\n---\\n\\n` separators, then injected as a single `injectBgUserMessage` call. This avoids spamming the TUI with N separate messages and triggering N separate agent turns when multiple bg tasks complete simultaneously.\n- **Turn-ID + `turn_started` event: atomic turn association (replaces InjectUserMessage side-channel).** `chatProcessLoop` generates a per-session monotonic `TurnID` (`bgSessionState.turnIDSeq`) for every dequeued message, stores it in `bgSessionState.activeTurnID` (atomic — `sendMessage` reads it from the Run goroutine), and emits a `turn_started` progress event (`Phase:\"turn_started\"`, `TurnID`, `TurnStart{Trigger,Content,RequestID}`) BEFORE `processMessage`. The TurnID propagates through `RunConfig.TurnID` → `StructuredProgress.TurnID` → every `ProgressEvent` (via `buildProgressPayload`) + stream callbacks + `emitBuiltinProgress`/`emitBuiltinProgressDone` + the reply `OutboundMsg.TurnID` (stamped by `sendMessage`). `injectBgUserMessage` NO LONGER calls `injectCLIUserMessage` — the notification user message is delivered atomically with the TurnID via the unified progress stream, eliminating the cross-goroutine race between `InjectUserMessage` (caller goroutine) and the turn's reply (`handleOutbound` goroutine). TUI `handleTurnStarted` (`cli_update_progress.go`) intercepts `turn_started` BEFORE the cancel guard; uses `turnStartedProcessed` flag to distinguish \"first turn_started for this turn (adopt TurnID)\" from \"turn_started for N+1 before N's reply (finalize N, start N+1)\". `handleAgentMessage` uses `msg.TurnID` (fallback `m.agentTurnID` for SubAgent/legacy) and only clears `streamingMsgIdx` when `turnID == m.agentTurnID` — a late reply for a previous turn must NOT clear the current turn's streaming slot. Web `useProgressStream` handles `turn_started` in the `progress_structured` case → `onInjectUserMessage` callback; the `text` event carries `turn_id` (from `WSMessage.TurnID`). Notification user messages render with a 🔔 badge + muted style (`isNotification` flag on `cliMessage` / `ChatMessage`).\n- **Turn-ID / Iteration-ID continuity assertions (3-layer diagnostics).** Every layer asserts that TurnID is strictly monotonic (no regression, no gaps) and IterationID advances by exactly +1 within a turn. **Backend**: `chatProcessLoop` checks `turnID` against `bgSessionState.lastTurnID` (regression → `Error` log `TURN_ID_INVARIANT_VIOLATION`; gap → `Warn` log `TURN_ID_GAP`); `runState.beginIteration` checks `i` against `structuredProgress.Iteration` (regression → `Error` `ITER_ID_INVARIANT_VIOLATION`; gap → `Warn` `ITER_ID_GAP`). **TUI**: `handleTurnStarted` checks `ev.TurnID` against `m.lastReceivedTurnID`; `applyProgressSnapshot` checks `snapshot.Iteration` against `progressState.lastIter`. **Web**: `useProgressStream` checks in the `progress_structured` case — `store.lastTurnID` for TurnID (also updated from non-turn_started structured events for SSE reconnect recovery), `store.lastIter` for iteration. All violations log detailed context (prev/next/delta/gap/turnID/chatID/phase) via `log.Error`/`log.Warn` (Go) or `console.error`/`console.warn` (Web). Assertions are **diagnostic, not blocking** — the turn lifecycle proceeds normally; the logs enable debugging of lost/disordered events (e.g. asyncCh full, SSE disconnect gap, progressSlot coalescing).\n- **SSE reconnect gap-based reload (NOT on every reconnect).** `useChatMessages` listens for `replay_gap` messages (dispatched by `restoreActiveProgress` in `sseConnection.ts`). SSE event gaps are normal (stateless coalescing, buffer drops) — the existing seq-gap detection in `handleEvent` triggers `restoreActiveProgress` which fetches the active progress snapshot. Only when that snapshot shows a **TurnID change** or **turn ended during gap** (real data loss — committed messages like the reply or notification were lost) does `restoreActiveProgress` dispatch `replay_gap` → `useChatMessages` calls `reload()`. A simple reconnect without data loss does NOT reload. `reload` uses `reconcileHistoryWithLiveRows` to merge DB rows with in-flight live rows. Initial connect does NOT trigger reload.\n- **Streaming scroll jitter fix (removed RAF scrollTop restore).** `MessageList` previously had a `useEffect` that captured `scrollTop` before render and restored it via `requestAnimationFrame` when `stickToBottomRef=false`. This fought TanStack Virtual's own scroll correction: the virtualizer's `resizeItem` corrects `scrollTop` via its internal `ResizeObserver` (fires after `useEffect`, before paint, before our RAF). Our RAF restore ran AFTER the virtualizer's correction, **undoing** it — causing the viewport to jump back to the pre-correction position every frame. The fix: remove the capture+restore entirely. The virtualizer's scroll correction is authoritative — it keeps visible items stable when sizes change. Content below the viewport grows without affecting `scrollTop`; content partially in the viewport is corrected to keep visible items fixed. The ResizeObserver at line 346 handles `stick=true` (scroll to bottom). The `useEffect` at line 307 serves as a safety net for `stick=true` (force scroll to bottom if > 2px off) and sets `hasNewContent` for `stick=false` (shows the \"new content\" bubble).\n- **`processMessage` early-exits on cancelled ctx.** Before calling `Run`, `processMessage` checks `ctx.Err()`. If cancelled during setup (DB tenant creation, MCP config, workspace init — especially heavy on the first message), it returns immediately with `cancelled=true` metadata instead of entering Run.\n- **Ctrl+C in TUI immediately cancels + clears queue.** `sendCancel` shows feedback BEFORE `sendInbound` (WS write can block). Ctrl+C while typing always clears `messageQueue` and sends cancel in one keystroke — no more N+1 presses to delete queued messages one by one before cancel takes effect.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **`dedupConsecutiveAssistants()` is the rendering-level guard** against two adjacent Assistant blocks. Called by both `appendNewMessagesToCache` and `fullRebuild`. Two cases: (1) SAME turnID → merge content+iterations; (2) DIFFERENT turnID + first is `isPartial=true` → remove the stale placeholder. Case 2 covers ALL `isPartial` messages (empty or with baked content/iterations from `finalizeTurnFromSnapshot`), not just empty placeholders. The old `upsertMessageByTurn`/`dedupMessagesGuard` O(N) per-frame scans were deleted — `dedupConsecutiveAssistants` is O(n) but only runs on cache rebuild, not every tick.\n- **`startAgentTurn()` calls `finalizeStaleStreamingBeforeNewUserMessage()` internally.** This covers all paths that bypass `sendMessage`/`sendToAgent` (auto-start, bg task injection, `/compress`, `cliProcessingMsg`, AskUser callback). Without this, `endAgentTurn`'s preserved `streamingMsgIdx` leaves the old assistant as `isPartial=true`, and `startAgentTurn` appends a new one right after it → two adjacent Assistant blocks. Callers that already called `finalizeStaleStreamingBeforeNewUserMessage` hit the early return (`streamingMsgIdx=-1`), no side effects.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`liveIterationBlocks` MUST suppress pulse when any tools are present.** `hasSpinner` was only set for `running`/`active` tools. When a tool transitions to `done`/`error`/`pending` (between sequential tool execution), `hasSpinner` goes `false` → pulse spinner appears (+1 line) → next tool starts running → pulse disappears (-1 line). This H→H+1→H oscillation is the visible jitter. Fix: `if len(tools) > 0 { hasSpinner = true }` — pulse is an idle indicator for the thinking phase only, not for tool execution phases.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`updateStreamingOnly` MUST skip liveLines when `!m.typing`.** `endAgentTurn` preserves `progressState.current` and `progressState.iterations` for flicker-free rendering between PhaseDone and `handleAgentMessage`. But `updateStreamingOnly` renders BOTH `completedLines` (from `progressState.iterations`, which includes the final iteration with Content + Reasoning) AND `liveLines` (from `progressState.current`, which has the SAME Content + Reasoning). This causes the final reasoning + response to appear twice. For CLI sessions this is momentary (`handleAgentMessage` clears `streamingMsgIdx` shortly after), but for non-CLI sessions (`/su` to feishu/web/qq), `handleAgentMessage` never arrives → `streamingMsgIdx` stays valid → `updateStreamingOnly` runs every tick → persistent duplication. Fix: the `liveLines` rendering block guards with `m.typing` — when the turn has ended, only completed iterations are rendered (they already contain the final content).\n- **`postRestoreSessionSetup` MUST call `refreshCachedModelName()` for LLM state resolution, NOT inline `LoadSessionLLMState` + `GetDefault` fallback.** The old code had its own LLM resolution path (local JSON → GetDefault) that bypassed the DB tenants table. In remote mode, `SaveSessionLLMState` passes `skipBackendFields=true` (local JSON never writes subID/model), so `LoadSessionLLMState` always returned zero → `GetDefault(\"\")` returned the subscription's default `Model` (often `\"\"`), overwriting the correct per-session model that `restoreSession()` had just restored from `savedSessions`. This only affected the default session (non-default sessions write local JSON via `SaveSessionLLMState` without skip at creation time). Fix: `postRestoreSessionSetup` now calls `refreshCachedModelName()` which checks DB tenants table (`GetSessionSubscription` RPC) first, then local JSON, then savedSessions, then GetDefault — the same 4-level priority chain used at startup. Context limits are then resolved from the now-correct `activeSubID` + `cachedModelName` via `ResolveEffectiveMaxContext`/`ResolveEffectiveMaxOutputTokens`. This makes default sessions and regular sessions follow the exact same restoration path.\n- **`ensureSessionModel` auto-binds a model to ALL new sessions on first use.** Both `ResolveLLM` (agent side, covers all channels including Web/Feishu) and `postRestoreSessionSetup` (CLI side, for immediate status bar display) call auto-bind when a session has no per-session binding in the tenants table. Priority: Balance tier config (`user_settings[\"tier_balance\"]` → `\"subID|model\"`) → last-used model (`user_default_model` table). The binding is persisted via `SelectModel` (writes to both tenants table and `user_default_model`), so subsequent calls hit the tenants table directly and skip auto-bind. If neither Balance tier nor last-used model exists, no binding is created (the caller falls through to system default). This ensures ALL sessions — CLI-created, `/chat new`, `/su` switch, Web/Feishu — get an explicit model binding on first use, not just those created via the CLI session panel. CLI's `ensureSessionModelBinding` is idempotent: it checks `GetSessionSubscription` first and returns immediately if the session already has a binding.\n- **Delta Push + Watermark Pull protocol replaces full-history push.** Structured progress has ONE channel-agnostic producer (`buildProgressEventHandler`): build one `protocol.ProgressEvent`, attach the iteration delta once, store the snapshot once, then broadcast an isolated clone of that semantic event to every `channel.ProgressSender` (CLI/Web/plugin). Channels are transports only — never derive their own history or add channel-specific reducers. `ProgressEvent.Seq` is the semantic log watermark used by reconnecting clients; SSE/WS envelope seq is transport-only. Clients install active snapshots, ignore events with `seq <= snapshot.seq`, and NEVER synthesize completed iterations locally. Push events carry ONLY the newly completed iteration as `IterationHistory` (0 or 1 entries) — not the full cumulative history. `attachIterationDelta` (formerly `recordIterationAdvanceAndAttachHistory`) calls `recordIterationSnapshot` which returns the delta (the previous iteration snapshot) or nil if no advance. `GetActiveProgress` gains a `fromIter` watermark parameter: it filters `iterationHistories` to only return iterations with `Iteration > fromIter`. The CLI sends `m.progressState.lastIter` as the watermark. Tick pull frequency reduced from 100ms to 2s (every 20th tick) — it's now a safety net for recovering iterations lost to progressSlot coalescing or WS disconnect, not the primary delivery path. Push events are the primary path. `restoreIterationsFromSnapshot` changed from count-based replace to per-iteration append-with-dedup (`slices.ContainsFunc` by iteration number). `coalesceProgress` merges delta slices via `mergeIterationDeltas` (concatenate + dedup by iteration number). `/su` switch and initial restore pass `fromIter=0` to get full history (one-time operation). Web callback passes `fromIter=0` (web doesn't use delta protocol). `recordIterationSnapshot` now returns `*protocol.ProgressEvent` (the delta, or nil) instead of being void.\n- **`startAgentTurn` MUST set `progressState.current` AFTER `resetProgressState`, not before.** `resetProgressState` clears `progressState.current` to nil. If the \"thinking\" initial state is set before `resetProgressState`, it gets wiped — the first tick sees `hasIterData=false` in `updateStreamingOnly`, falls back to `renderMessage` (empty content, no pulse spinner), and the user sees no visual feedback until the first structured progress event arrives (seconds later). Setting `progressState.current = {Phase:\"thinking\"}` after `resetProgressState` ensures `liveIterationBlocks` renders a pulse spinner on the very first tick — immediate visual feedback.\n- **`mergeSubAgentTrees` MUST be called in `applyProgressSnapshot` before replacing `progressState.current`.** Structured events don't always carry SubAgents (engine only includes them when `resolveSubAgents` returns non-empty). Without merging, the tree flickers — appears when SubAgents are present, vanishes when the next structured event omits them. The merge preserves prev's running agents and marks dropped ones as done. `mergeSubAgentTrees` also preserves children when `new.Children` is empty but `prev.Children` exists (marks all prev children as done via `markAllDone()`). Without this call, `mergeSubAgentTrees` is dead code (only called in tests).\n- **`finalizeTurnFromSnapshot` MUST create synthetic assistant message for ALL non-CLI channels.** The old code only did this for `m.channelName == \"agent\"` (SubAgent viewer). But when CLI `/su` switches to any non-CLI session (feishu, web, qq, etc.), the agent's outbound reply goes to the originating channel via `sendMessage(chName, ...)`, NOT to the CLI channel. The CLI receives progress events (including Content via structured progress) and PhaseDone, but never receives `cliOutboundMsg`. Without the synthetic message, the CLI stays stuck showing \"...\" after PhaseDone. The fix: `m.channelName != \"cli\"` (covers agent, feishu, web, etc.). Content comes from `cur.Content` (set by `structuredProgress.Content` via `notifyProgress` before PhaseDone). PhaseDone payload itself has no Content — must fall back to `cur` (captured before `endAgentTurn`).\n- **`ProgressEventHandler` MUST be created for ALL channels, not just cli/web/ProgressSender.** `buildMainRunConfig` previously only created `ProgressEventHandler` for `\"cli\"`, `\"web\"`, and plugin `ProgressSender` channels. When a CLI user `/su` switches to a feishu (or other non-cli/web) session and sends a message, the message's `Channel == \"feishu\"` → no `ProgressEventHandler` → `progressFinalizer`'s PhaseDone event is silently dropped → `finalizeTurnFromSnapshot` never runs → `m.typing` stays `true` forever (TUI stuck in busy). Fix: the `switch channel` in `buildMainRunConfig` now has a `default` branch that creates a `buildCLIProgressEventHandler(chatID, channel)` for non-cli/web/non-ProgressSender channels. This ensures PhaseDone, structured progress, and `lastProgressSnapshot` storage work for ALL channels when viewed via `/su`. Additionally, the `else` branch of the `ProgressNotifier` setup now sets a no-op notifier for non-cli/web channels without `autoNotify`, enabling `progressFinalizer` to generate PhaseDone events.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n- **`buildStreamCallbacks` only broadcasts to the ORIGINATING channel.** Each channel's `SendProgress` already broadcasts to ALL Hub subscribers (including other channels' clients via the shared Hub). Broadcasting to multiple channels causes duplicate delivery: each subscriber receives the same event N times (N = number of ProgressSender channels). This was the root cause of the \"two consecutive Assistant messages\" bug that only appeared when Web and TUI were both open on the same session — duplicate stream events arrived after state transitions, triggering `startAgentTurn` to create a duplicate streaming assistant.\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n\n### Web Frontend Rendering\n- **`hasVisibleProgress` must NOT count `snap.streaming` alone.** The `streaming` flag indicates the agent is actively streaming, but streaming alone (no content, no tools, no iterations) means the agent is \"thinking\" — which should show the \"思考中…\" busy placeholder, NOT an empty `liveMessage`. When `streaming=true` was the only field set (e.g. `historyProgressToLive` hydrating from a stale server snapshot with `phase=running` but no `active_tools`), `hasVisibleProgress` returned true → `liveMessage` was non-null but empty → suppressed the busy placeholder → user saw nothing (no tool, no \"思考中…\"). Fix: `hasVisibleProgress` only returns true when there's actual content (`streamContent`, `activeTools`, `iterationHistory`, etc.), not just `streaming`.\n- **Busy placeholder must show during loading when rows exist.** `MessageList.tsx`'s busy placeholder condition was `busy && !liveMessage && !loading`. During history reload on a busy session, `visibleLiveMessage` is null (hidden by the `chat.loading` gate in AgentPanel) AND `!loading` suppressed the placeholder. Result: neither the live progress nor \"思考中…\" showed — the session looked idle even though the backend was executing a tool. Fix: changed to `busy && !liveMessage && !(loading && rows.length === 0)` — the loading spinner (`Loader2`) only handles the empty-state (`rows.length === 0`), so the placeholder shows during loading when there are existing messages.\n- **Stale PhaseDone from SSE replay must NOT reset the store.** `setStructuredTools` in `progressStore.ts` checked `phase === 'done'` BEFORE the `eventSeq` stale check. When switching to a busy session: (1) `initialProgress` from the history API hydrates the store (e.g. seq=8, activeTools=[Shell running]), (2) SSE replay delivers a stale PhaseDone (seq=5) from the PREVIOUS turn. The `phase === 'done'` check fired first → `this.reset()` → store cleared → activeTools vanished. The tool never reappeared because the current turn's events (seq=6,7,8) may not be in the SSE buffer. Fix: moved the `eventSeq` stale check BEFORE the `phase === 'done'` check. A stale PhaseDone (seq <= store's eventSeq) only updates todos (PhaseDone may carry TodoWrite updates) and does NOT reset the store. A non-stale PhaseDone (seq > store's eventSeq or no seq) resets as before. Events without `eventSeq` (e.g. `restoreActiveProgress` dispatching `{ phase: 'done' }` without seq) still pass through to the PhaseDone check.\n- **LiveIteration must NOT filter activeTools by name+label against iterationHistory.** `LiveIteration.tsx` filtered ALL tools (including `activeTools`) by `completedIterToolKeys` (name+label) to prevent stale tools from completed iterations from rendering. But this also filtered out running tools from the CURRENT iteration that happened to share the same name+label as a tool in a completed iteration (e.g. `Shell` called in both iteration 1 and 2). The running tool disappeared from the UI — \"generating会渲染,但executing之后tool就消失\". Fix: (1) filter `activeTools` by iteration number (like `completedTools` — `t.iteration > maxCompletedIter`), (2) only apply the `completedIterToolKeys` name+label filter to `completedTools`, NOT to `activeTools` or `streamingTools`. This also fixes duplicate iteration rendering: stale `activeTools` from completed iterations are now filtered by iteration number, preventing them from rendering alongside `iterationHistory`.\n- **`historyProgressToLive` must handle the iteration-boundary snapshot.** When `GetActiveProgress` is called after `snapshotCompletedIteration` but before `prepareForIteration`, the snapshot carries the PREVIOUS iteration's `Content` and `CompletedTools` (not yet cleared). The delta for the completed iteration hasn't been recorded in `iterationHistories` yet (it's only recorded when the NEXT iteration's first event arrives via `attachIterationDelta`). `historyProgressToLive` hydrated the store with this stale `content` and `completedTools`, and `LiveIteration` rendered them as if they belong to the current (in-flight) iteration — duplicating content and tools. Fix: in `historyProgressToLive`, when `activeTools` is empty and `completedTools` is non-empty, the snapshot is at the iteration boundary. Clear `content` and `completedTools` from the live snapshot so `LiveIteration` doesn't duplicate them. Do NOT add a synthetic entry to `iterationHistory` — it would be appended at the END of the array (which is the BEGINNING if the store is empty), causing the content to appear BEFORE earlier iterations' tools (\"content跑到别的迭代里了\"). The delta protocol will deliver the correct data in order when the next iteration starts.\n\n### Web Terminal (PTY)\n- **PTY follows the user's runner, same as Shell tool.** `channel/web/web_pty.go:ptyManager.selectBackend(senderID)` resolves the user's sandbox via `tools.GetSandbox()` → `SandboxRouter.SandboxForUser(senderID)`. `none` sandbox → `localPtyBackend` (PTY on web server via `creack/pty`). `remote` sandbox → `remotePtyBackend` (PTY on runner via WS protocol). Docker mode on runner uses `docker exec -it`.\n- **PTY protocol mirrors the existing stdio streaming protocol.** `runnerproto` defines 6 message types: `pty_create` (req/resp), `pty_stdin` (fire-and-forget), `pty_resize` (fire-and-forget), `pty_close` (req/resp), `pty_data` (runner→server push), `pty_exit` (runner→server push). Runner-side `internal/runnerclient/pty.go:ptyManager` uses `pty.StartWithSize` + read loop; server-side `tools/pty_remote.go` routes pushes via `handlePtyPush` (called in `remote_sandbox.go` readLoop after `handleStdioPush`).\n- **`PtyCreate` registers the stream before sending the request.** `RemoteSandbox.PtyCreate` calls `registerPtyStream` BEFORE `sendRequest`, so `handlePtyPush` can route `pty_data`/`pty_exit` pushes immediately. On failure, `removePtyStream` cleans up. `SetPtyHandlers` updates callbacks after `PtyCreate` returns (the web channel's `ptyManager.Create` calls `backend.Create` then `backend.SetHandlers`).\n- **Multi-client broadcast + history replay.** `ptySession` holds `clients map[*ptyWSClient]struct{}` and a 64KB scrollback `history []byte`. New WS connections replay history, then receive live `broadcast` calls. WS disconnect removes the client but keeps the PTY alive (idle reaper kills after 5 min). Explicit `close` or PTY exit destroys the session.\n- **`handleTerminalWS` uses cookie auth via `authMiddleware`.** The route `mux.HandleFunc(\"/ws/terminal\", wc.authMiddleware(wc.handleTerminalWS))` — `authMiddleware` validates the cookie, then `handleTerminalWS` upgrades to WebSocket. The WS upgrade is GET, so `authMiddleware`'s body-size limit (only applied to non-GET) doesn't interfere.\n- **Terminal API routes.** `POST /api/terminal/create` (body `{ chatID, cwd }` → `{ tid }`), `POST /api/terminal/{tid}/delete` (→ `{ ok: true }`), `POST /api/terminal/list` (body `{ chat_id }` → `{ terminals: [...] }`), `WS /ws/terminal?tid=<tid>`. All terminal routes use `authenticatedPOST`/`authMiddleware` (cookie auth). Chat delete calls `ptyMgr.CleanupChat(chatID)` to destroy all PTY sessions for the deleted chat.\n- **`BackgroundPanel.tsx` uses xterm.js with incremental write.** Background task output (which contains `\\r`, ANSI codes, progress bars) is rendered via `Terminal.write(output.slice(lastLen))` — only the delta since last poll is written, so `\\r` (carriage return) correctly overwrites the current line instead of stacking as separate lines. The xterm instance is created once per panel mount and disposed on unmount.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Reproduce Before Fixing\n\n**修复任何 bug 之前,必须先写测试复现问题。** 没有复现测试的修复是盲修——你不知道自己是否真的修了根因,也不知道未来是否会回归。\n\n#### 流程\n\n1. **写测试复现** — 单元测试(Go `test` 或前端 `vitest`)或 E2E 测试(Playwright)。测试必须 **先失败**(红灯),证明 bug 确实存在。\n2. **分析根因** — 理解为什么会产生 bug,不要只看症状。\n3. **最小修复** — 只改必要的代码,修复后测试变绿。\n4. **验证无回归** — 运行完整测试套件,确保修复没有引入新问题。\n\n#### 为什么重要\n\n- 没有复现测试 = 没有证据证明 bug 存在,也没有证据证明修复有效\n- 偶发性 bug(竞态、时序、网络)尤其需要测试——手动验证不可靠\n- 测试是活文档——未来开发者能从测试理解 bug 的触发条件\n- 防止\"修复\"引入回归——测试在 CI 中持续守护\n\n#### 对前端 UI bug\n\n- 优先用 Playwright E2E 测试(`web/e2e/`),用 `page.route()` mock 后端,不需要真实服务器\n- 滚动、渲染、状态等视觉行为必须用 E2E 验证,单元测试无法覆盖 DOM 层面\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressSlot coalescing**(原 progressCh buffer=1 已替换):`SendProgress` 使用 mutex-protected `progressSlot` + buffer-1 `progressSignal` 替代旧 buffer-1 channel。旧设计中 eviction+re-insert 有 race window,结构化的 \"done\" 事件可被静默丢弃,导致工具永久卡在 \"running\"(● 不变 ✓)。新设计:`progressSlot` 始终持有最新合并后的事件,`handleProgressDrain` 读取并清空 slot。结构化事件永远不会丢失——stream-only 不能驱逐 structured,structured 替换时保留 stream fields/TokenUsage/CWD。`coalesceProgress`(asyncCh 层)仍保留用于合并连续 progress 消息。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **新用户消息开始前必须固化旧 streaming turn。** `PhaseDone` 后 `endAgentTurn` 会刻意保留 `streamingMsgIdx` 和 `progressState`,等待最终 `handleAgentMessage` 做无闪烁收尾。但 `sendMessage`/`sendToAgent` 会在 `startAgentTurn()` 前先 append 用户消息并刷新 viewport;如果不先调用 `finalizeStaleStreamingBeforeNewUserMessage()`,这次刷新会把上一个 turn 的 live stream 渲染到新用户消息下面。真实新消息入口必须在 append user message 前固化旧 assistant partial、清 `streamingMsgIdx`/旧 progress stream cache。**`startAgentTurn()` 内部也调用 `finalizeStaleStreamingBeforeNewUserMessage()`**,覆盖所有不经过 `sendMessage`/`sendToAgent` 的路径(auto-start、bg task 注入、`/compress`、`cliProcessingMsg`、AskUser 回调)。已有调用的路径因 `streamingMsgIdx=-1` 走 early return,无副作用。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "caf8bcde6f250a2e33a873d36613fc98cb22dc24": {"t": 1785759443.224784, "n": 118363, "h": "06ba2fb30717d3f1", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- **禁止 AI agent 自行重启 xbot server**:`kill`/`pkill` xbot 进程、`nohup` 重新启动 server 等操作会中断用户正在进行的会话。除非用户明确要求重启,否则只 build 二进制 + 前端部署,由用户自行重启。前端改动 `vite build` 后 `cp dist/*` 部署即可,无需重启 server。\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步、**UserContext 统一解析(ResolveUserContext)**、**单用户模式(single_user)**\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **Subscriptions, settings, and tier config are bound to the canonical user (`user_id`), NOT to `sender_id`.** The `user_llm_subscriptions`, `user_settings`, and `user_default_model` tables all have a `user_id` column (added in v45 migration, backfilled by v46). All identities linked to the same canonical user (e.g. `web-1`, `cli_user`, `ou_xxx`) MUST see the same subscriptions, settings, and tier config. RPC handlers that query subscriptions/settings MUST use `rpcUserID(ctx)` and call `*ByUserID` storage methods (`ListByUserID`, `GetByUserID`, `SetByUserID`, etc.). The old `sender_id`-keyed methods (`List(senderID)`, `Get(channel, senderID)`) are for the agent loop path only (`ResolveUserContext` → `ResolveLLM`), where `senderID` is already resolved to the canonical identity.\n- **`rpcBizID` is for per-session operations only** (tenants table, session switching, history). It must NOT be used for user-level queries (subscriptions, settings, tier, concurrency, model lists). Those use `rpcUserID`. If you see `rpcBizID` in a user-level RPC handler, it's a bug.\n- **All old/historical logic MUST be deleted.** Superseded functions, migration helpers, and workaround hacks must be removed when their replacements are in place. Leaving dead code or old patterns misleads future developers and causes divergence. Examples of deleted patterns: `senderIDFromParams` (replaced by `rpcUserID`), `migrateCLIUserSettingsFromGlobalIfNeeded` (dead after `get_settings` switched to `GetByUserID`), `userScopedSettingsFromGlobalCLI` (only caller was the deleted function), `ResolvePrimarySenderID` (hack replaced by proper `user_id` queries).\n- **Only `user_context_middleware.go` (`ResolveUserContext`) and the RPC layer (`rpc_table.go`) may access user-related DB tables directly.** Agent loop code (`engine.go`, `engine_run.go`, `llm_config_handler.go`, etc.) MUST go through `UserContext` closures. RPC handlers MUST use `rpcUserID(ctx)` / `rpcBizID(ctx)` from context — never call `IdentityResolver` or `LLMFactory` directly to resolve user identity. This is the architectural boundary: user identity resolution happens once at entry, results carried via context.\n- **`Agent.ResolveUserContext` is the SINGLE boundary between the user system and the agent loop.** It resolves ALL user-related components (LLM, identity, settings, sandbox, concurrency, subscription management) once at `processMessage` entry, carrying the result via `context.Context` (`WithUserContext`/`UserContextFromContext`). **NEVER access `a.llmFactory`/`a.settingsSvc`/`a.identityResolver` directly in agent loop code** (`engine.go`, `engine_run.go`, `engine_wire.go`, `agent_process.go`, `compress.go`, `context_handler.go`, `prompt_handler.go`, `card_handler.go`, `middleware_builtin.go`, `perm_control_helpers.go`, `command_builtin.go`, `llm_config_handler.go` loop paths). Read from `UserContext` instead. Adding new user-related logic in middleware-downstream code is **forbidden** — put it in `ResolveUserContext` or `UserContext` methods.\n- **`UserContext` carries management closures for command handlers.** `uc.SubSvc`, `uc.SettingsSvc`, `uc.ResolveLLM(chatID)`, `uc.ResolveActiveSub(chatID)`, `uc.SelectModel(chatID, subID, model)`, `uc.RefreshModels()`, `uc.InvalidateLLM()`, `uc.HasCustomLLM()` — command handlers (`/set-llm`, `/settings`, `/models`, `/set-model`) must use these closures, not `a.llmFactory` directly. External RPC API methods (no `ctx` parameter, e.g. `GetUserMaxContext`) are exempt — they're outside the loop.\n- **Single-user mode: `experimental.single_user: true` in config.json.** When enabled, `ResolveUserContext` maps all senderIDs to `\"cli_user\"` — sharing subscriptions, settings, identity, sandbox. The `a.singleUser` flag (set from `cfg.SingleUser`) controls this. Only `ResolveUserContext` changes; downstream code is completely unaware.\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39/v40 migration notes.** `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **Cancel interception must NOT send premature cancel ack.** The `/cancel` handler in the agent loop only sends the cancel signal to `cancelCh`. The cancel ack (with `cancelled=true` metadata) is sent exclusively by `chatProcessLoop`'s `wasCancelled` path AFTER Run returns. Sending it in the interception caused TUI to enter idle while the Run was still active — the cancel signal is asynchronous (consumed by `cancelListener` goroutine), so there was a window where TUI was idle but the agent was still working.\n- **Cancel records pending bg notifications for the cancelled session.** `handleCancelledRun` takes same-session notifications still pending in `bgRunPending` and records them into the interrupted turn as synthetic tool observations; it also records a `user_cancelled` synthetic tool. Both the synthetic assistant and tool messages are marked `DisplayOnly=true` — they are persisted to DB for UI display but excluded from LLM context (`GetAllMessages` filters `display_only=0`). This ensures the next model call does NOT see the cancel marker, which is correct for graceful shutdown resume (the LLM should continue as if the cancel never happened). The model still sees partial tool calls/results (persisted by `IncrementalPersist` without `DisplayOnly`). Notifications already drained into the Run were already persisted by the normal synthetic-tool injection path, so cancel clears only their `drainedThisRun` tracking metadata. Other-session pending notifications remain in their own session bucket.\n- **DrainBgNotifications is cancel-aware.** `engine_run.go` post-iteration drain checks `ctx.Err() == nil` before draining. If ctx is already cancelled (cancel signal arrived during the LLM call), drain is skipped so `handleCancelledRun` can record this session's pending notifications in the interrupted turn before the UI receives the cancel ack.\n- **`wireBgNotificationDrain` tracks drained notifications.** Drain copies consumed notifications into `bgSessionState.drainedThisRun` (guarded by `drainedThisRunMu`). On normal/error turn completion, `clearDrainedThisRun()` discards the tracking metadata after notifications were processed. On cancel, `handleCancelledRun` clears the tracking metadata without re-delivering those already-persisted notifications as a fresh user message.\n- **`drainAndProcessNotifications` batches all notifications into ONE user message.** All drained notifications are formatted and joined with `\\n\\n---\\n\\n` separators, then injected as a single `injectBgUserMessage` call. This avoids spamming the TUI with N separate messages and triggering N separate agent turns when multiple bg tasks complete simultaneously.\n- **Turn-ID + `turn_started` event: atomic turn association (replaces InjectUserMessage side-channel).** `chatProcessLoop` generates a per-session monotonic `TurnID` (`bgSessionState.turnIDSeq`) for every dequeued message, stores it in `bgSessionState.activeTurnID` (atomic — `sendMessage` reads it from the Run goroutine), and emits a `turn_started` progress event (`Phase:\"turn_started\"`, `TurnID`, `TurnStart{Trigger,Content,RequestID}`) BEFORE `processMessage`. The TurnID propagates through `RunConfig.TurnID` → `StructuredProgress.TurnID` → every `ProgressEvent` (via `buildProgressPayload`) + stream callbacks + `emitBuiltinProgress`/`emitBuiltinProgressDone` + the reply `OutboundMsg.TurnID` (stamped by `sendMessage`). `injectBgUserMessage` NO LONGER calls `injectCLIUserMessage` — the notification user message is delivered atomically with the TurnID via the unified progress stream, eliminating the cross-goroutine race between `InjectUserMessage` (caller goroutine) and the turn's reply (`handleOutbound` goroutine). TUI `handleTurnStarted` (`cli_update_progress.go`) intercepts `turn_started` BEFORE the cancel guard; uses `turnStartedProcessed` flag to distinguish \"first turn_started for this turn (adopt TurnID)\" from \"turn_started for N+1 before N's reply (finalize N, start N+1)\". `handleAgentMessage` uses `msg.TurnID` (fallback `m.agentTurnID` for SubAgent/legacy) and only clears `streamingMsgIdx` when `turnID == m.agentTurnID` — a late reply for a previous turn must NOT clear the current turn's streaming slot. Web `useProgressStream` handles `turn_started` in the `progress_structured` case → `onInjectUserMessage` callback; the `text` event carries `turn_id` (from `WSMessage.TurnID`). Notification user messages render with a 🔔 badge + muted style (`isNotification` flag on `cliMessage` / `ChatMessage`).\n- **Turn-ID / Iteration-ID continuity assertions (3-layer diagnostics).** Every layer asserts that TurnID is strictly monotonic (no regression, no gaps) and IterationID advances by exactly +1 within a turn. **Backend**: `chatProcessLoop` checks `turnID` against `bgSessionState.lastTurnID` (regression → `Error` log `TURN_ID_INVARIANT_VIOLATION`; gap → `Warn` log `TURN_ID_GAP`); `runState.beginIteration` checks `i` against `structuredProgress.Iteration` (regression → `Error` `ITER_ID_INVARIANT_VIOLATION`; gap → `Warn` `ITER_ID_GAP`). **TUI**: `handleTurnStarted` checks `ev.TurnID` against `m.lastReceivedTurnID`; `applyProgressSnapshot` checks `snapshot.Iteration` against `progressState.lastIter`. **Web**: `useProgressStream` checks in the `progress_structured` case — `store.lastTurnID` for TurnID (also updated from non-turn_started structured events for SSE reconnect recovery), `store.lastIter` for iteration. All violations log detailed context (prev/next/delta/gap/turnID/chatID/phase) via `log.Error`/`log.Warn` (Go) or `console.error`/`console.warn` (Web). Assertions are **diagnostic, not blocking** — the turn lifecycle proceeds normally; the logs enable debugging of lost/disordered events (e.g. asyncCh full, SSE disconnect gap, progressSlot coalescing).\n- **SSE reconnect gap-based reload (NOT on every reconnect).** `useChatMessages` listens for `replay_gap` messages (dispatched by `restoreActiveProgress` in `sseConnection.ts`). SSE event gaps are normal (stateless coalescing, buffer drops) — the existing seq-gap detection in `handleEvent` triggers `restoreActiveProgress` which fetches the active progress snapshot. Only when that snapshot shows a **TurnID change** or **turn ended during gap** (real data loss — committed messages like the reply or notification were lost) does `restoreActiveProgress` dispatch `replay_gap` → `useChatMessages` calls `reload()`. A simple reconnect without data loss does NOT reload. `reload` uses `reconcileHistoryWithLiveRows` to merge DB rows with in-flight live rows. Initial connect does NOT trigger reload.\n- **Streaming scroll jitter fix (removed RAF scrollTop restore).** `MessageList` previously had a `useEffect` that captured `scrollTop` before render and restored it via `requestAnimationFrame` when `stickToBottomRef=false`. This fought TanStack Virtual's own scroll correction: the virtualizer's `resizeItem` corrects `scrollTop` via its internal `ResizeObserver` (fires after `useEffect`, before paint, before our RAF). Our RAF restore ran AFTER the virtualizer's correction, **undoing** it — causing the viewport to jump back to the pre-correction position every frame. The fix: remove the capture+restore entirely. The virtualizer's scroll correction is authoritative — it keeps visible items stable when sizes change. Content below the viewport grows without affecting `scrollTop`; content partially in the viewport is corrected to keep visible items fixed. The ResizeObserver at line 346 handles `stick=true` (scroll to bottom). The `useEffect` at line 307 serves as a safety net for `stick=true` (force scroll to bottom if > 2px off) and sets `hasNewContent` for `stick=false` (shows the \"new content\" bubble).\n- **`processMessage` early-exits on cancelled ctx.** Before calling `Run`, `processMessage` checks `ctx.Err()`. If cancelled during setup (DB tenant creation, MCP config, workspace init — especially heavy on the first message), it returns immediately with `cancelled=true` metadata instead of entering Run.\n- **Ctrl+C in TUI immediately cancels + clears queue.** `sendCancel` shows feedback BEFORE `sendInbound` (WS write can block). Ctrl+C while typing always clears `messageQueue` and sends cancel in one keystroke — no more N+1 presses to delete queued messages one by one before cancel takes effect.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **`dedupConsecutiveAssistants()` is the rendering-level guard** against two adjacent Assistant blocks. Called by both `appendNewMessagesToCache` and `fullRebuild`. Two cases: (1) SAME turnID → merge content+iterations; (2) DIFFERENT turnID + first is `isPartial=true` → remove the stale placeholder. Case 2 covers ALL `isPartial` messages (empty or with baked content/iterations from `finalizeTurnFromSnapshot`), not just empty placeholders. The old `upsertMessageByTurn`/`dedupMessagesGuard` O(N) per-frame scans were deleted — `dedupConsecutiveAssistants` is O(n) but only runs on cache rebuild, not every tick.\n- **`startAgentTurn()` calls `finalizeStaleStreamingBeforeNewUserMessage()` internally.** This covers all paths that bypass `sendMessage`/`sendToAgent` (auto-start, bg task injection, `/compress`, `cliProcessingMsg`, AskUser callback). Without this, `endAgentTurn`'s preserved `streamingMsgIdx` leaves the old assistant as `isPartial=true`, and `startAgentTurn` appends a new one right after it → two adjacent Assistant blocks. Callers that already called `finalizeStaleStreamingBeforeNewUserMessage` hit the early return (`streamingMsgIdx=-1`), no side effects.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`liveIterationBlocks` MUST suppress pulse when any tools are present.** `hasSpinner` was only set for `running`/`active` tools. When a tool transitions to `done`/`error`/`pending` (between sequential tool execution), `hasSpinner` goes `false` → pulse spinner appears (+1 line) → next tool starts running → pulse disappears (-1 line). This H→H+1→H oscillation is the visible jitter. Fix: `if len(tools) > 0 { hasSpinner = true }` — pulse is an idle indicator for the thinking phase only, not for tool execution phases.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`updateStreamingOnly` MUST skip liveLines when `!m.typing`.** `endAgentTurn` preserves `progressState.current` and `progressState.iterations` for flicker-free rendering between PhaseDone and `handleAgentMessage`. But `updateStreamingOnly` renders BOTH `completedLines` (from `progressState.iterations`, which includes the final iteration with Content + Reasoning) AND `liveLines` (from `progressState.current`, which has the SAME Content + Reasoning). This causes the final reasoning + response to appear twice. For CLI sessions this is momentary (`handleAgentMessage` clears `streamingMsgIdx` shortly after), but for non-CLI sessions (`/su` to feishu/web/qq), `handleAgentMessage` never arrives → `streamingMsgIdx` stays valid → `updateStreamingOnly` runs every tick → persistent duplication. Fix: the `liveLines` rendering block guards with `m.typing` — when the turn has ended, only completed iterations are rendered (they already contain the final content).\n- **`postRestoreSessionSetup` MUST call `refreshCachedModelName()` for LLM state resolution, NOT inline `LoadSessionLLMState` + `GetDefault` fallback.** The old code had its own LLM resolution path (local JSON → GetDefault) that bypassed the DB tenants table. In remote mode, `SaveSessionLLMState` passes `skipBackendFields=true` (local JSON never writes subID/model), so `LoadSessionLLMState` always returned zero → `GetDefault(\"\")` returned the subscription's default `Model` (often `\"\"`), overwriting the correct per-session model that `restoreSession()` had just restored from `savedSessions`. This only affected the default session (non-default sessions write local JSON via `SaveSessionLLMState` without skip at creation time). Fix: `postRestoreSessionSetup` now calls `refreshCachedModelName()` which checks DB tenants table (`GetSessionSubscription` RPC) first, then local JSON, then savedSessions, then GetDefault — the same 4-level priority chain used at startup. Context limits are then resolved from the now-correct `activeSubID` + `cachedModelName` via `ResolveEffectiveMaxContext`/`ResolveEffectiveMaxOutputTokens`. This makes default sessions and regular sessions follow the exact same restoration path.\n- **`ensureSessionModel` auto-binds a model to ALL new sessions on first use.** Both `ResolveLLM` (agent side, covers all channels including Web/Feishu) and `postRestoreSessionSetup` (CLI side, for immediate status bar display) call auto-bind when a session has no per-session binding in the tenants table. Priority: Balance tier config (`user_settings[\"tier_balance\"]` → `\"subID|model\"`) → last-used model (`user_default_model` table). The binding is persisted via `SelectModel` (writes to both tenants table and `user_default_model`), so subsequent calls hit the tenants table directly and skip auto-bind. If neither Balance tier nor last-used model exists, no binding is created (the caller falls through to system default). This ensures ALL sessions — CLI-created, `/chat new`, `/su` switch, Web/Feishu — get an explicit model binding on first use, not just those created via the CLI session panel. CLI's `ensureSessionModelBinding` is idempotent: it checks `GetSessionSubscription` first and returns immediately if the session already has a binding.\n- **Delta Push + Watermark Pull protocol replaces full-history push.** Structured progress has ONE channel-agnostic producer (`buildProgressEventHandler`): build one `protocol.ProgressEvent`, attach the iteration delta once, store the snapshot once, then broadcast an isolated clone of that semantic event to every `channel.ProgressSender` (CLI/Web/plugin). Channels are transports only — never derive their own history or add channel-specific reducers. `ProgressEvent.Seq` is the semantic log watermark used by reconnecting clients; SSE/WS envelope seq is transport-only. Clients install active snapshots, ignore events with `seq <= snapshot.seq`, and NEVER synthesize completed iterations locally. Push events carry ONLY the newly completed iteration as `IterationHistory` (0 or 1 entries) — not the full cumulative history. `attachIterationDelta` (formerly `recordIterationAdvanceAndAttachHistory`) calls `recordIterationSnapshot` which returns the delta (the previous iteration snapshot) or nil if no advance. `GetActiveProgress` gains a `fromIter` watermark parameter: it filters `iterationHistories` to only return iterations with `Iteration > fromIter`. The CLI sends `m.progressState.lastIter` as the watermark. Tick pull frequency reduced from 100ms to 2s (every 20th tick) — it's now a safety net for recovering iterations lost to progressSlot coalescing or WS disconnect, not the primary delivery path. Push events are the primary path. `restoreIterationsFromSnapshot` changed from count-based replace to per-iteration append-with-dedup (`slices.ContainsFunc` by iteration number). `coalesceProgress` merges delta slices via `mergeIterationDeltas` (concatenate + dedup by iteration number). `/su` switch and initial restore pass `fromIter=0` to get full history (one-time operation). Web callback passes `fromIter=0` (web doesn't use delta protocol). `recordIterationSnapshot` now returns `*protocol.ProgressEvent` (the delta, or nil) instead of being void.\n- **`startAgentTurn` MUST set `progressState.current` AFTER `resetProgressState`, not before.** `resetProgressState` clears `progressState.current` to nil. If the \"thinking\" initial state is set before `resetProgressState`, it gets wiped — the first tick sees `hasIterData=false` in `updateStreamingOnly`, falls back to `renderMessage` (empty content, no pulse spinner), and the user sees no visual feedback until the first structured progress event arrives (seconds later). Setting `progressState.current = {Phase:\"thinking\"}` after `resetProgressState` ensures `liveIterationBlocks` renders a pulse spinner on the very first tick — immediate visual feedback.\n- **`mergeSubAgentTrees` MUST be called in `applyProgressSnapshot` before replacing `progressState.current`.** Structured events don't always carry SubAgents (engine only includes them when `resolveSubAgents` returns non-empty). Without merging, the tree flickers — appears when SubAgents are present, vanishes when the next structured event omits them. The merge preserves prev's running agents and marks dropped ones as done. `mergeSubAgentTrees` also preserves children when `new.Children` is empty but `prev.Children` exists (marks all prev children as done via `markAllDone()`). Without this call, `mergeSubAgentTrees` is dead code (only called in tests).\n- **`finalizeTurnFromSnapshot` MUST create synthetic assistant message for ALL non-CLI channels.** The old code only did this for `m.channelName == \"agent\"` (SubAgent viewer). But when CLI `/su` switches to any non-CLI session (feishu, web, qq, etc.), the agent's outbound reply goes to the originating channel via `sendMessage(chName, ...)`, NOT to the CLI channel. The CLI receives progress events (including Content via structured progress) and PhaseDone, but never receives `cliOutboundMsg`. Without the synthetic message, the CLI stays stuck showing \"...\" after PhaseDone. The fix: `m.channelName != \"cli\"` (covers agent, feishu, web, etc.). Content comes from `cur.Content` (set by `structuredProgress.Content` via `notifyProgress` before PhaseDone). PhaseDone payload itself has no Content — must fall back to `cur` (captured before `endAgentTurn`).\n- **`ProgressEventHandler` MUST be created for ALL channels, not just cli/web/ProgressSender.** `buildMainRunConfig` previously only created `ProgressEventHandler` for `\"cli\"`, `\"web\"`, and plugin `ProgressSender` channels. When a CLI user `/su` switches to a feishu (or other non-cli/web) session and sends a message, the message's `Channel == \"feishu\"` → no `ProgressEventHandler` → `progressFinalizer`'s PhaseDone event is silently dropped → `finalizeTurnFromSnapshot` never runs → `m.typing` stays `true` forever (TUI stuck in busy). Fix: the `switch channel` in `buildMainRunConfig` now has a `default` branch that creates a `buildCLIProgressEventHandler(chatID, channel)` for non-cli/web/non-ProgressSender channels. This ensures PhaseDone, structured progress, and `lastProgressSnapshot` storage work for ALL channels when viewed via `/su`. Additionally, the `else` branch of the `ProgressNotifier` setup now sets a no-op notifier for non-cli/web channels without `autoNotify`, enabling `progressFinalizer` to generate PhaseDone events.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n- **`buildStreamCallbacks` only broadcasts to the ORIGINATING channel.** Each channel's `SendProgress` already broadcasts to ALL Hub subscribers (including other channels' clients via the shared Hub). Broadcasting to multiple channels causes duplicate delivery: each subscriber receives the same event N times (N = number of ProgressSender channels). This was the root cause of the \"two consecutive Assistant messages\" bug that only appeared when Web and TUI were both open on the same session — duplicate stream events arrived after state transitions, triggering `startAgentTurn` to create a duplicate streaming assistant.\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n\n### Web Terminal (PTY)\n- **PTY follows the user's runner, same as Shell tool.** `channel/web/web_pty.go:ptyManager.selectBackend(senderID)` resolves the user's sandbox via `tools.GetSandbox()` → `SandboxRouter.SandboxForUser(senderID)`. `none` sandbox → `localPtyBackend` (PTY on web server via `creack/pty`). `remote` sandbox → `remotePtyBackend` (PTY on runner via WS protocol). Docker mode on runner uses `docker exec -it`.\n- **PTY protocol mirrors the existing stdio streaming protocol.** `runnerproto` defines 6 message types: `pty_create` (req/resp), `pty_stdin` (fire-and-forget), `pty_resize` (fire-and-forget), `pty_close` (req/resp), `pty_data` (runner→server push), `pty_exit` (runner→server push). Runner-side `internal/runnerclient/pty.go:ptyManager` uses `pty.StartWithSize` + read loop; server-side `tools/pty_remote.go` routes pushes via `handlePtyPush` (called in `remote_sandbox.go` readLoop after `handleStdioPush`).\n- **`PtyCreate` registers the stream before sending the request.** `RemoteSandbox.PtyCreate` calls `registerPtyStream` BEFORE `sendRequest`, so `handlePtyPush` can route `pty_data`/`pty_exit` pushes immediately. On failure, `removePtyStream` cleans up. `SetPtyHandlers` updates callbacks after `PtyCreate` returns (the web channel's `ptyManager.Create` calls `backend.Create` then `backend.SetHandlers`).\n- **Multi-client broadcast + history replay.** `ptySession` holds `clients map[*ptyWSClient]struct{}` and a 64KB scrollback `history []byte`. New WS connections replay history, then receive live `broadcast` calls. WS disconnect removes the client but keeps the PTY alive (idle reaper kills after 5 min). Explicit `close` or PTY exit destroys the session.\n- **`handleTerminalWS` uses cookie auth via `authMiddleware`.** The route `mux.HandleFunc(\"/ws/terminal\", wc.authMiddleware(wc.handleTerminalWS))` — `authMiddleware` validates the cookie, then `handleTerminalWS` upgrades to WebSocket. The WS upgrade is GET, so `authMiddleware`'s body-size limit (only applied to non-GET) doesn't interfere.\n- **Terminal API routes.** `POST /api/terminal/create` (body `{ chatID, cwd }` → `{ tid }`), `POST /api/terminal/{tid}/delete` (→ `{ ok: true }`), `POST /api/terminal/list` (body `{ chat_id }` → `{ terminals: [...] }`), `WS /ws/terminal?tid=<tid>`. All terminal routes use `authenticatedPOST`/`authMiddleware` (cookie auth). Chat delete calls `ptyMgr.CleanupChat(chatID)` to destroy all PTY sessions for the deleted chat.\n- **`BackgroundPanel.tsx` uses xterm.js with incremental write.** Background task output (which contains `\\r`, ANSI codes, progress bars) is rendered via `Terminal.write(output.slice(lastLen))` — only the delta since last poll is written, so `\\r` (carriage return) correctly overwrites the current line instead of stacking as separate lines. The xterm instance is created once per panel mount and disposed on unmount.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Reproduce Before Fixing\n\n**修复任何 bug 之前,必须先写测试复现问题。** 没有复现测试的修复是盲修——你不知道自己是否真的修了根因,也不知道未来是否会回归。\n\n#### 流程\n\n1. **写测试复现** — 单元测试(Go `test` 或前端 `vitest`)或 E2E 测试(Playwright)。测试必须 **先失败**(红灯),证明 bug 确实存在。\n2. **分析根因** — 理解为什么会产生 bug,不要只看症状。\n3. **最小修复** — 只改必要的代码,修复后测试变绿。\n4. **验证无回归** — 运行完整测试套件,确保修复没有引入新问题。\n\n#### 为什么重要\n\n- 没有复现测试 = 没有证据证明 bug 存在,也没有证据证明修复有效\n- 偶发性 bug(竞态、时序、网络)尤其需要测试——手动验证不可靠\n- 测试是活文档——未来开发者能从测试理解 bug 的触发条件\n- 防止\"修复\"引入回归——测试在 CI 中持续守护\n\n#### 对前端 UI bug\n\n- 优先用 Playwright E2E 测试(`web/e2e/`),用 `page.route()` mock 后端,不需要真实服务器\n- 滚动、渲染、状态等视觉行为必须用 E2E 验证,单元测试无法覆盖 DOM 层面\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressSlot coalescing**(原 progressCh buffer=1 已替换):`SendProgress` 使用 mutex-protected `progressSlot` + buffer-1 `progressSignal` 替代旧 buffer-1 channel。旧设计中 eviction+re-insert 有 race window,结构化的 \"done\" 事件可被静默丢弃,导致工具永久卡在 \"running\"(● 不变 ✓)。新设计:`progressSlot` 始终持有最新合并后的事件,`handleProgressDrain` 读取并清空 slot。结构化事件永远不会丢失——stream-only 不能驱逐 structured,structured 替换时保留 stream fields/TokenUsage/CWD。`coalesceProgress`(asyncCh 层)仍保留用于合并连续 progress 消息。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **新用户消息开始前必须固化旧 streaming turn。** `PhaseDone` 后 `endAgentTurn` 会刻意保留 `streamingMsgIdx` 和 `progressState`,等待最终 `handleAgentMessage` 做无闪烁收尾。但 `sendMessage`/`sendToAgent` 会在 `startAgentTurn()` 前先 append 用户消息并刷新 viewport;如果不先调用 `finalizeStaleStreamingBeforeNewUserMessage()`,这次刷新会把上一个 turn 的 live stream 渲染到新用户消息下面。真实新消息入口必须在 append user message 前固化旧 assistant partial、清 `streamingMsgIdx`/旧 progress stream cache。**`startAgentTurn()` 内部也调用 `finalizeStaleStreamingBeforeNewUserMessage()`**,覆盖所有不经过 `sendMessage`/`sendToAgent` 的路径(auto-start、bg task 注入、`/compress`、`cliProcessingMsg`、AskUser 回调)。已有调用的路径因 `streamingMsgIdx=-1` 走 early return,无副作用。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "027619bc4a7e424eeddae98f5263ec5b59b0b48d": {"t": 1785759445.9391627, "n": 113157, "h": "1a7dc1a81c9c3e88", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步、**UserContext 统一解析(ResolveUserContext)**、**单用户模式(single_user)**\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **Subscriptions, settings, and tier config are bound to the canonical user (`user_id`), NOT to `sender_id`.** The `user_llm_subscriptions`, `user_settings`, and `user_default_model` tables all have a `user_id` column (added in v45 migration, backfilled by v46). All identities linked to the same canonical user (e.g. `web-1`, `cli_user`, `ou_xxx`) MUST see the same subscriptions, settings, and tier config. RPC handlers that query subscriptions/settings MUST use `rpcUserID(ctx)` and call `*ByUserID` storage methods (`ListByUserID`, `GetByUserID`, `SetByUserID`, etc.). The old `sender_id`-keyed methods (`List(senderID)`, `Get(channel, senderID)`) are for the agent loop path only (`ResolveUserContext` → `ResolveLLM`), where `senderID` is already resolved to the canonical identity.\n- **`rpcBizID` is for per-session operations only** (tenants table, session switching, history). It must NOT be used for user-level queries (subscriptions, settings, tier, concurrency, model lists). Those use `rpcUserID`. If you see `rpcBizID` in a user-level RPC handler, it's a bug.\n- **All old/historical logic MUST be deleted.** Superseded functions, migration helpers, and workaround hacks must be removed when their replacements are in place. Leaving dead code or old patterns misleads future developers and causes divergence. Examples of deleted patterns: `senderIDFromParams` (replaced by `rpcUserID`), `migrateCLIUserSettingsFromGlobalIfNeeded` (dead after `get_settings` switched to `GetByUserID`), `userScopedSettingsFromGlobalCLI` (only caller was the deleted function), `ResolvePrimarySenderID` (hack replaced by proper `user_id` queries).\n- **Only `user_context_middleware.go` (`ResolveUserContext`) and the RPC layer (`rpc_table.go`) may access user-related DB tables directly.** Agent loop code (`engine.go`, `engine_run.go`, `llm_config_handler.go`, etc.) MUST go through `UserContext` closures. RPC handlers MUST use `rpcUserID(ctx)` / `rpcBizID(ctx)` from context — never call `IdentityResolver` or `LLMFactory` directly to resolve user identity. This is the architectural boundary: user identity resolution happens once at entry, results carried via context.\n- **`Agent.ResolveUserContext` is the SINGLE boundary between the user system and the agent loop.** It resolves ALL user-related components (LLM, identity, settings, sandbox, concurrency, subscription management) once at `processMessage` entry, carrying the result via `context.Context` (`WithUserContext`/`UserContextFromContext`). **NEVER access `a.llmFactory`/`a.settingsSvc`/`a.identityResolver` directly in agent loop code** (`engine.go`, `engine_run.go`, `engine_wire.go`, `agent_process.go`, `compress.go`, `context_handler.go`, `prompt_handler.go`, `card_handler.go`, `middleware_builtin.go`, `perm_control_helpers.go`, `command_builtin.go`, `llm_config_handler.go` loop paths). Read from `UserContext` instead. Adding new user-related logic in middleware-downstream code is **forbidden** — put it in `ResolveUserContext` or `UserContext` methods.\n- **`UserContext` carries management closures for command handlers.** `uc.SubSvc`, `uc.SettingsSvc`, `uc.ResolveLLM(chatID)`, `uc.ResolveActiveSub(chatID)`, `uc.SelectModel(chatID, subID, model)`, `uc.RefreshModels()`, `uc.InvalidateLLM()`, `uc.HasCustomLLM()` — command handlers (`/set-llm`, `/settings`, `/models`, `/set-model`) must use these closures, not `a.llmFactory` directly. External RPC API methods (no `ctx` parameter, e.g. `GetUserMaxContext`) are exempt — they're outside the loop.\n- **Single-user mode: `experimental.single_user: true` in config.json.** When enabled, `ResolveUserContext` maps all senderIDs to `\"cli_user\"` — sharing subscriptions, settings, identity, sandbox. The `a.singleUser` flag (set from `cfg.SingleUser`) controls this. Only `ResolveUserContext` changes; downstream code is completely unaware.\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39/v40 migration notes.** `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **Cancel interception must NOT send premature cancel ack.** The `/cancel` handler in the agent loop only sends the cancel signal to `cancelCh`. The cancel ack (with `cancelled=true` metadata) is sent exclusively by `chatProcessLoop`'s `wasCancelled` path AFTER Run returns. Sending it in the interception caused TUI to enter idle while the Run was still active — the cancel signal is asynchronous (consumed by `cancelListener` goroutine), so there was a window where TUI was idle but the agent was still working.\n- **Cancel records pending bg notifications for the cancelled session.** `handleCancelledRun` takes same-session notifications still pending in `bgRunPending` and records them into the interrupted turn as synthetic tool observations; it also records a `user_cancelled` synthetic tool. Both the synthetic assistant and tool messages are marked `DisplayOnly=true` — they are persisted to DB for UI display but excluded from LLM context (`GetAllMessages` filters `display_only=0`). This ensures the next model call does NOT see the cancel marker, which is correct for graceful shutdown resume (the LLM should continue as if the cancel never happened). The model still sees partial tool calls/results (persisted by `IncrementalPersist` without `DisplayOnly`). Notifications already drained into the Run were already persisted by the normal synthetic-tool injection path, so cancel clears only their `drainedThisRun` tracking metadata. Other-session pending notifications remain in their own session bucket.\n- **DrainBgNotifications is cancel-aware.** `engine_run.go` post-iteration drain checks `ctx.Err() == nil` before draining. If ctx is already cancelled (cancel signal arrived during the LLM call), drain is skipped so `handleCancelledRun` can record this session's pending notifications in the interrupted turn before the UI receives the cancel ack.\n- **`wireBgNotificationDrain` tracks drained notifications.** Drain copies consumed notifications into `bgSessionState.drainedThisRun` (guarded by `drainedThisRunMu`). On normal/error turn completion, `clearDrainedThisRun()` discards the tracking metadata after notifications were processed. On cancel, `handleCancelledRun` clears the tracking metadata without re-delivering those already-persisted notifications as a fresh user message.\n- **`drainAndProcessNotifications` batches all notifications into ONE user message.** All drained notifications are formatted and joined with `\\n\\n---\\n\\n` separators, then injected as a single `injectBgUserMessage` call. This avoids spamming the TUI with N separate messages and triggering N separate agent turns when multiple bg tasks complete simultaneously.\n- **`processMessage` early-exits on cancelled ctx.** Before calling `Run`, `processMessage` checks `ctx.Err()`. If cancelled during setup (DB tenant creation, MCP config, workspace init — especially heavy on the first message), it returns immediately with `cancelled=true` metadata instead of entering Run.\n- **Ctrl+C in TUI immediately cancels + clears queue.** `sendCancel` shows feedback BEFORE `sendInbound` (WS write can block). Ctrl+C while typing always clears `messageQueue` and sends cancel in one keystroke — no more N+1 presses to delete queued messages one by one before cancel takes effect.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **`dedupConsecutiveAssistants()` is the rendering-level guard** against two adjacent Assistant blocks. Called by both `appendNewMessagesToCache` and `fullRebuild`. Two cases: (1) SAME turnID → merge content+iterations; (2) DIFFERENT turnID + first is `isPartial=true` → remove the stale placeholder. Case 2 covers ALL `isPartial` messages (empty or with baked content/iterations from `finalizeTurnFromSnapshot`), not just empty placeholders. The old `upsertMessageByTurn`/`dedupMessagesGuard` O(N) per-frame scans were deleted — `dedupConsecutiveAssistants` is O(n) but only runs on cache rebuild, not every tick.\n- **`startAgentTurn()` calls `finalizeStaleStreamingBeforeNewUserMessage()` internally.** This covers all paths that bypass `sendMessage`/`sendToAgent` (auto-start, bg task injection, `/compress`, `cliProcessingMsg`, AskUser callback). Without this, `endAgentTurn`'s preserved `streamingMsgIdx` leaves the old assistant as `isPartial=true`, and `startAgentTurn` appends a new one right after it → two adjacent Assistant blocks. Callers that already called `finalizeStaleStreamingBeforeNewUserMessage` hit the early return (`streamingMsgIdx=-1`), no side effects.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`liveIterationBlocks` MUST suppress pulse when any tools are present.** `hasSpinner` was only set for `running`/`active` tools. When a tool transitions to `done`/`error`/`pending` (between sequential tool execution), `hasSpinner` goes `false` → pulse spinner appears (+1 line) → next tool starts running → pulse disappears (-1 line). This H→H+1→H oscillation is the visible jitter. Fix: `if len(tools) > 0 { hasSpinner = true }` — pulse is an idle indicator for the thinking phase only, not for tool execution phases.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`updateStreamingOnly` MUST skip liveLines when `!m.typing`.** `endAgentTurn` preserves `progressState.current` and `progressState.iterations` for flicker-free rendering between PhaseDone and `handleAgentMessage`. But `updateStreamingOnly` renders BOTH `completedLines` (from `progressState.iterations`, which includes the final iteration with Content + Reasoning) AND `liveLines` (from `progressState.current`, which has the SAME Content + Reasoning). This causes the final reasoning + response to appear twice. For CLI sessions this is momentary (`handleAgentMessage` clears `streamingMsgIdx` shortly after), but for non-CLI sessions (`/su` to feishu/web/qq), `handleAgentMessage` never arrives → `streamingMsgIdx` stays valid → `updateStreamingOnly` runs every tick → persistent duplication. Fix: the `liveLines` rendering block guards with `m.typing` — when the turn has ended, only completed iterations are rendered (they already contain the final content).\n- **`postRestoreSessionSetup` MUST call `refreshCachedModelName()` for LLM state resolution, NOT inline `LoadSessionLLMState` + `GetDefault` fallback.** The old code had its own LLM resolution path (local JSON → GetDefault) that bypassed the DB tenants table. In remote mode, `SaveSessionLLMState` passes `skipBackendFields=true` (local JSON never writes subID/model), so `LoadSessionLLMState` always returned zero → `GetDefault(\"\")` returned the subscription's default `Model` (often `\"\"`), overwriting the correct per-session model that `restoreSession()` had just restored from `savedSessions`. This only affected the default session (non-default sessions write local JSON via `SaveSessionLLMState` without skip at creation time). Fix: `postRestoreSessionSetup` now calls `refreshCachedModelName()` which checks DB tenants table (`GetSessionSubscription` RPC) first, then local JSON, then savedSessions, then GetDefault — the same 4-level priority chain used at startup. Context limits are then resolved from the now-correct `activeSubID` + `cachedModelName` via `ResolveEffectiveMaxContext`/`ResolveEffectiveMaxOutputTokens`. This makes default sessions and regular sessions follow the exact same restoration path.\n- **`ensureSessionModel` auto-binds a model to ALL new sessions on first use.** Both `ResolveLLM` (agent side, covers all channels including Web/Feishu) and `postRestoreSessionSetup` (CLI side, for immediate status bar display) call auto-bind when a session has no per-session binding in the tenants table. Priority: Balance tier config (`user_settings[\"tier_balance\"]` → `\"subID|model\"`) → last-used model (`user_default_model` table). The binding is persisted via `SelectModel` (writes to both tenants table and `user_default_model`), so subsequent calls hit the tenants table directly and skip auto-bind. If neither Balance tier nor last-used model exists, no binding is created (the caller falls through to system default). This ensures ALL sessions — CLI-created, `/chat new`, `/su` switch, Web/Feishu — get an explicit model binding on first use, not just those created via the CLI session panel. CLI's `ensureSessionModelBinding` is idempotent: it checks `GetSessionSubscription` first and returns immediately if the session already has a binding.\n- **Delta Push + Watermark Pull protocol replaces full-history push.** Structured progress has ONE channel-agnostic producer (`buildProgressEventHandler`): build one `protocol.ProgressEvent`, attach the iteration delta once, store the snapshot once, then broadcast an isolated clone of that semantic event to every `channel.ProgressSender` (CLI/Web/plugin). Channels are transports only — never derive their own history or add channel-specific reducers. `ProgressEvent.Seq` is the semantic log watermark used by reconnecting clients; SSE/WS envelope seq is transport-only. Clients install active snapshots, ignore events with `seq <= snapshot.seq`, and NEVER synthesize completed iterations locally. Push events carry ONLY the newly completed iteration as `IterationHistory` (0 or 1 entries) — not the full cumulative history. `attachIterationDelta` (formerly `recordIterationAdvanceAndAttachHistory`) calls `recordIterationSnapshot` which returns the delta (the previous iteration snapshot) or nil if no advance. `GetActiveProgress` gains a `fromIter` watermark parameter: it filters `iterationHistories` to only return iterations with `Iteration > fromIter`. The CLI sends `m.progressState.lastIter` as the watermark. Tick pull frequency reduced from 100ms to 2s (every 20th tick) — it's now a safety net for recovering iterations lost to progressSlot coalescing or WS disconnect, not the primary delivery path. Push events are the primary path. `restoreIterationsFromSnapshot` changed from count-based replace to per-iteration append-with-dedup (`slices.ContainsFunc` by iteration number). `coalesceProgress` merges delta slices via `mergeIterationDeltas` (concatenate + dedup by iteration number). `/su` switch and initial restore pass `fromIter=0` to get full history (one-time operation). Web callback passes `fromIter=0` (web doesn't use delta protocol). `recordIterationSnapshot` now returns `*protocol.ProgressEvent` (the delta, or nil) instead of being void.\n- **`startAgentTurn` MUST set `progressState.current` AFTER `resetProgressState`, not before.** `resetProgressState` clears `progressState.current` to nil. If the \"thinking\" initial state is set before `resetProgressState`, it gets wiped — the first tick sees `hasIterData=false` in `updateStreamingOnly`, falls back to `renderMessage` (empty content, no pulse spinner), and the user sees no visual feedback until the first structured progress event arrives (seconds later). Setting `progressState.current = {Phase:\"thinking\"}` after `resetProgressState` ensures `liveIterationBlocks` renders a pulse spinner on the very first tick — immediate visual feedback.\n- **`mergeSubAgentTrees` MUST be called in `applyProgressSnapshot` before replacing `progressState.current`.** Structured events don't always carry SubAgents (engine only includes them when `resolveSubAgents` returns non-empty). Without merging, the tree flickers — appears when SubAgents are present, vanishes when the next structured event omits them. The merge preserves prev's running agents and marks dropped ones as done. `mergeSubAgentTrees` also preserves children when `new.Children` is empty but `prev.Children` exists (marks all prev children as done via `markAllDone()`). Without this call, `mergeSubAgentTrees` is dead code (only called in tests).\n- **`finalizeTurnFromSnapshot` MUST create synthetic assistant message for ALL non-CLI channels.** The old code only did this for `m.channelName == \"agent\"` (SubAgent viewer). But when CLI `/su` switches to any non-CLI session (feishu, web, qq, etc.), the agent's outbound reply goes to the originating channel via `sendMessage(chName, ...)`, NOT to the CLI channel. The CLI receives progress events (including Content via structured progress) and PhaseDone, but never receives `cliOutboundMsg`. Without the synthetic message, the CLI stays stuck showing \"...\" after PhaseDone. The fix: `m.channelName != \"cli\"` (covers agent, feishu, web, etc.). Content comes from `cur.Content` (set by `structuredProgress.Content` via `notifyProgress` before PhaseDone). PhaseDone payload itself has no Content — must fall back to `cur` (captured before `endAgentTurn`).\n- **`ProgressEventHandler` MUST be created for ALL channels, not just cli/web/ProgressSender.** `buildMainRunConfig` previously only created `ProgressEventHandler` for `\"cli\"`, `\"web\"`, and plugin `ProgressSender` channels. When a CLI user `/su` switches to a feishu (or other non-cli/web) session and sends a message, the message's `Channel == \"feishu\"` → no `ProgressEventHandler` → `progressFinalizer`'s PhaseDone event is silently dropped → `finalizeTurnFromSnapshot` never runs → `m.typing` stays `true` forever (TUI stuck in busy). Fix: the `switch channel` in `buildMainRunConfig` now has a `default` branch that creates a `buildCLIProgressEventHandler(chatID, channel)` for non-cli/web/non-ProgressSender channels. This ensures PhaseDone, structured progress, and `lastProgressSnapshot` storage work for ALL channels when viewed via `/su`. Additionally, the `else` branch of the `ProgressNotifier` setup now sets a no-op notifier for non-cli/web channels without `autoNotify`, enabling `progressFinalizer` to generate PhaseDone events.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n- **`buildStreamCallbacks` only broadcasts to the ORIGINATING channel.** Each channel's `SendProgress` already broadcasts to ALL Hub subscribers (including other channels' clients via the shared Hub). Broadcasting to multiple channels causes duplicate delivery: each subscriber receives the same event N times (N = number of ProgressSender channels). This was the root cause of the \"two consecutive Assistant messages\" bug that only appeared when Web and TUI were both open on the same session — duplicate stream events arrived after state transitions, triggering `startAgentTurn` to create a duplicate streaming assistant.\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n\n### Web Terminal (PTY)\n- **PTY follows the user's runner, same as Shell tool.** `channel/web/web_pty.go:ptyManager.selectBackend(senderID)` resolves the user's sandbox via `tools.GetSandbox()` → `SandboxRouter.SandboxForUser(senderID)`. `none` sandbox → `localPtyBackend` (PTY on web server via `creack/pty`). `remote` sandbox → `remotePtyBackend` (PTY on runner via WS protocol). Docker mode on runner uses `docker exec -it`.\n- **PTY protocol mirrors the existing stdio streaming protocol.** `runnerproto` defines 6 message types: `pty_create` (req/resp), `pty_stdin` (fire-and-forget), `pty_resize` (fire-and-forget), `pty_close` (req/resp), `pty_data` (runner→server push), `pty_exit` (runner→server push). Runner-side `internal/runnerclient/pty.go:ptyManager` uses `pty.StartWithSize` + read loop; server-side `tools/pty_remote.go` routes pushes via `handlePtyPush` (called in `remote_sandbox.go` readLoop after `handleStdioPush`).\n- **`PtyCreate` registers the stream before sending the request.** `RemoteSandbox.PtyCreate` calls `registerPtyStream` BEFORE `sendRequest`, so `handlePtyPush` can route `pty_data`/`pty_exit` pushes immediately. On failure, `removePtyStream` cleans up. `SetPtyHandlers` updates callbacks after `PtyCreate` returns (the web channel's `ptyManager.Create` calls `backend.Create` then `backend.SetHandlers`).\n- **Multi-client broadcast + history replay.** `ptySession` holds `clients map[*ptyWSClient]struct{}` and a 64KB scrollback `history []byte`. New WS connections replay history, then receive live `broadcast` calls. WS disconnect removes the client but keeps the PTY alive (idle reaper kills after 5 min). Explicit `close` or PTY exit destroys the session.\n- **`handleTerminalWS` uses cookie auth via `authMiddleware`.** The route `mux.HandleFunc(\"/ws/terminal\", wc.authMiddleware(wc.handleTerminalWS))` — `authMiddleware` validates the cookie, then `handleTerminalWS` upgrades to WebSocket. The WS upgrade is GET, so `authMiddleware`'s body-size limit (only applied to non-GET) doesn't interfere.\n- **Terminal API routes.** `POST /api/terminal/create` (body `{ chatID, cwd }` → `{ tid }`), `POST /api/terminal/{tid}/delete` (→ `{ ok: true }`), `POST /api/terminal/list` (body `{ chat_id }` → `{ terminals: [...] }`), `WS /ws/terminal?tid=<tid>`. All terminal routes use `authenticatedPOST`/`authMiddleware` (cookie auth). Chat delete calls `ptyMgr.CleanupChat(chatID)` to destroy all PTY sessions for the deleted chat.\n- **`BackgroundPanel.tsx` uses xterm.js with incremental write.** Background task output (which contains `\\r`, ANSI codes, progress bars) is rendered via `Terminal.write(output.slice(lastLen))` — only the delta since last poll is written, so `\\r` (carriage return) correctly overwrites the current line instead of stacking as separate lines. The xterm instance is created once per panel mount and disposed on unmount.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Reproduce Before Fixing\n\n**修复任何 bug 之前,必须先写测试复现问题。** 没有复现测试的修复是盲修——你不知道自己是否真的修了根因,也不知道未来是否会回归。\n\n#### 流程\n\n1. **写测试复现** — 单元测试(Go `test` 或前端 `vitest`)或 E2E 测试(Playwright)。测试必须 **先失败**(红灯),证明 bug 确实存在。\n2. **分析根因** — 理解为什么会产生 bug,不要只看症状。\n3. **最小修复** — 只改必要的代码,修复后测试变绿。\n4. **验证无回归** — 运行完整测试套件,确保修复没有引入新问题。\n\n#### 为什么重要\n\n- 没有复现测试 = 没有证据证明 bug 存在,也没有证据证明修复有效\n- 偶发性 bug(竞态、时序、网络)尤其需要测试——手动验证不可靠\n- 测试是活文档——未来开发者能从测试理解 bug 的触发条件\n- 防止\"修复\"引入回归——测试在 CI 中持续守护\n\n#### 对前端 UI bug\n\n- 优先用 Playwright E2E 测试(`web/e2e/`),用 `page.route()` mock 后端,不需要真实服务器\n- 滚动、渲染、状态等视觉行为必须用 E2E 验证,单元测试无法覆盖 DOM 层面\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressSlot coalescing**(原 progressCh buffer=1 已替换):`SendProgress` 使用 mutex-protected `progressSlot` + buffer-1 `progressSignal` 替代旧 buffer-1 channel。旧设计中 eviction+re-insert 有 race window,结构化的 \"done\" 事件可被静默丢弃,导致工具永久卡在 \"running\"(● 不变 ✓)。新设计:`progressSlot` 始终持有最新合并后的事件,`handleProgressDrain` 读取并清空 slot。结构化事件永远不会丢失——stream-only 不能驱逐 structured,structured 替换时保留 stream fields/TokenUsage/CWD。`coalesceProgress`(asyncCh 层)仍保留用于合并连续 progress 消息。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **新用户消息开始前必须固化旧 streaming turn。** `PhaseDone` 后 `endAgentTurn` 会刻意保留 `streamingMsgIdx` 和 `progressState`,等待最终 `handleAgentMessage` 做无闪烁收尾。但 `sendMessage`/`sendToAgent` 会在 `startAgentTurn()` 前先 append 用户消息并刷新 viewport;如果不先调用 `finalizeStaleStreamingBeforeNewUserMessage()`,这次刷新会把上一个 turn 的 live stream 渲染到新用户消息下面。真实新消息入口必须在 append user message 前固化旧 assistant partial、清 `streamingMsgIdx`/旧 progress stream cache。**`startAgentTurn()` 内部也调用 `finalizeStaleStreamingBeforeNewUserMessage()`**,覆盖所有不经过 `sendMessage`/`sendToAgent` 的路径(auto-start、bg task 注入、`/compress`、`cliProcessingMsg`、AskUser 回调)。已有调用的路径因 `streamingMsgIdx=-1` 走 early return,无副作用。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "c1ab251316249314806214df439e66251aec8a78": {"t": 1785759447.9092112, "n": 112569, "h": "04d3e1aed308af0a", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步、**UserContext 统一解析(ResolveUserContext)**、**单用户模式(single_user)**\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **Subscriptions, settings, and tier config are bound to the canonical user (`user_id`), NOT to `sender_id`.** The `user_llm_subscriptions`, `user_settings`, and `user_default_model` tables all have a `user_id` column (added in v45 migration, backfilled by v46). All identities linked to the same canonical user (e.g. `web-1`, `cli_user`, `ou_xxx`) MUST see the same subscriptions, settings, and tier config. RPC handlers that query subscriptions/settings MUST use `rpcUserID(ctx)` and call `*ByUserID` storage methods (`ListByUserID`, `GetByUserID`, `SetByUserID`, etc.). The old `sender_id`-keyed methods (`List(senderID)`, `Get(channel, senderID)`) are for the agent loop path only (`ResolveUserContext` → `ResolveLLM`), where `senderID` is already resolved to the canonical identity.\n- **`rpcBizID` is for per-session operations only** (tenants table, session switching, history). It must NOT be used for user-level queries (subscriptions, settings, tier, concurrency, model lists). Those use `rpcUserID`. If you see `rpcBizID` in a user-level RPC handler, it's a bug.\n- **All old/historical logic MUST be deleted.** Superseded functions, migration helpers, and workaround hacks must be removed when their replacements are in place. Leaving dead code or old patterns misleads future developers and causes divergence. Examples of deleted patterns: `senderIDFromParams` (replaced by `rpcUserID`), `migrateCLIUserSettingsFromGlobalIfNeeded` (dead after `get_settings` switched to `GetByUserID`), `userScopedSettingsFromGlobalCLI` (only caller was the deleted function), `ResolvePrimarySenderID` (hack replaced by proper `user_id` queries).\n- **Only `user_context_middleware.go` (`ResolveUserContext`) and the RPC layer (`rpc_table.go`) may access user-related DB tables directly.** Agent loop code (`engine.go`, `engine_run.go`, `llm_config_handler.go`, etc.) MUST go through `UserContext` closures. RPC handlers MUST use `rpcUserID(ctx)` / `rpcBizID(ctx)` from context — never call `IdentityResolver` or `LLMFactory` directly to resolve user identity. This is the architectural boundary: user identity resolution happens once at entry, results carried via context.\n- **`Agent.ResolveUserContext` is the SINGLE boundary between the user system and the agent loop.** It resolves ALL user-related components (LLM, identity, settings, sandbox, concurrency, subscription management) once at `processMessage` entry, carrying the result via `context.Context` (`WithUserContext`/`UserContextFromContext`). **NEVER access `a.llmFactory`/`a.settingsSvc`/`a.identityResolver` directly in agent loop code** (`engine.go`, `engine_run.go`, `engine_wire.go`, `agent_process.go`, `compress.go`, `context_handler.go`, `prompt_handler.go`, `card_handler.go`, `middleware_builtin.go`, `perm_control_helpers.go`, `command_builtin.go`, `llm_config_handler.go` loop paths). Read from `UserContext` instead. Adding new user-related logic in middleware-downstream code is **forbidden** — put it in `ResolveUserContext` or `UserContext` methods.\n- **`UserContext` carries management closures for command handlers.** `uc.SubSvc`, `uc.SettingsSvc`, `uc.ResolveLLM(chatID)`, `uc.ResolveActiveSub(chatID)`, `uc.SelectModel(chatID, subID, model)`, `uc.RefreshModels()`, `uc.InvalidateLLM()`, `uc.HasCustomLLM()` — command handlers (`/set-llm`, `/settings`, `/models`, `/set-model`) must use these closures, not `a.llmFactory` directly. External RPC API methods (no `ctx` parameter, e.g. `GetUserMaxContext`) are exempt — they're outside the loop.\n- **Single-user mode: `experimental.single_user: true` in config.json.** When enabled, `ResolveUserContext` maps all senderIDs to `\"cli_user\"` — sharing subscriptions, settings, identity, sandbox. The `a.singleUser` flag (set from `cfg.SingleUser`) controls this. Only `ResolveUserContext` changes; downstream code is completely unaware.\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39/v40 migration notes.** `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **Cancel interception must NOT send premature cancel ack.** The `/cancel` handler in the agent loop only sends the cancel signal to `cancelCh`. The cancel ack (with `cancelled=true` metadata) is sent exclusively by `chatProcessLoop`'s `wasCancelled` path AFTER Run returns. Sending it in the interception caused TUI to enter idle while the Run was still active — the cancel signal is asynchronous (consumed by `cancelListener` goroutine), so there was a window where TUI was idle but the agent was still working.\n- **Cancel records pending bg notifications for the cancelled session.** `handleCancelledRun` takes same-session notifications still pending in `bgRunPending` and records them into the interrupted turn as synthetic tool observations; it also records a `user_cancelled` synthetic tool. Both the synthetic assistant and tool messages are marked `DisplayOnly=true` — they are persisted to DB for UI display but excluded from LLM context (`GetAllMessages` filters `display_only=0`). This ensures the next model call does NOT see the cancel marker, which is correct for graceful shutdown resume (the LLM should continue as if the cancel never happened). The model still sees partial tool calls/results (persisted by `IncrementalPersist` without `DisplayOnly`). Notifications already drained into the Run were already persisted by the normal synthetic-tool injection path, so cancel clears only their `drainedThisRun` tracking metadata. Other-session pending notifications remain in their own session bucket.\n- **DrainBgNotifications is cancel-aware.** `engine_run.go` post-iteration drain checks `ctx.Err() == nil` before draining. If ctx is already cancelled (cancel signal arrived during the LLM call), drain is skipped so `handleCancelledRun` can record this session's pending notifications in the interrupted turn before the UI receives the cancel ack.\n- **`wireBgNotificationDrain` tracks drained notifications.** Drain copies consumed notifications into `bgSessionState.drainedThisRun` (guarded by `drainedThisRunMu`). On normal/error turn completion, `clearDrainedThisRun()` discards the tracking metadata after notifications were processed. On cancel, `handleCancelledRun` clears the tracking metadata without re-delivering those already-persisted notifications as a fresh user message.\n- **`drainAndProcessNotifications` batches all notifications into ONE user message.** All drained notifications are formatted and joined with `\\n\\n---\\n\\n` separators, then injected as a single `injectBgUserMessage` call. This avoids spamming the TUI with N separate messages and triggering N separate agent turns when multiple bg tasks complete simultaneously.\n- **`processMessage` early-exits on cancelled ctx.** Before calling `Run`, `processMessage` checks `ctx.Err()`. If cancelled during setup (DB tenant creation, MCP config, workspace init — especially heavy on the first message), it returns immediately with `cancelled=true` metadata instead of entering Run.\n- **Ctrl+C in TUI immediately cancels + clears queue.** `sendCancel` shows feedback BEFORE `sendInbound` (WS write can block). Ctrl+C while typing always clears `messageQueue` and sends cancel in one keystroke — no more N+1 presses to delete queued messages one by one before cancel takes effect.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **`dedupConsecutiveAssistants()` is the rendering-level guard** against two adjacent Assistant blocks. Called by both `appendNewMessagesToCache` and `fullRebuild`. Two cases: (1) SAME turnID → merge content+iterations; (2) DIFFERENT turnID + first is `isPartial=true` → remove the stale placeholder. Case 2 covers ALL `isPartial` messages (empty or with baked content/iterations from `finalizeTurnFromSnapshot`), not just empty placeholders. The old `upsertMessageByTurn`/`dedupMessagesGuard` O(N) per-frame scans were deleted — `dedupConsecutiveAssistants` is O(n) but only runs on cache rebuild, not every tick.\n- **`startAgentTurn()` calls `finalizeStaleStreamingBeforeNewUserMessage()` internally.** This covers all paths that bypass `sendMessage`/`sendToAgent` (auto-start, bg task injection, `/compress`, `cliProcessingMsg`, AskUser callback). Without this, `endAgentTurn`'s preserved `streamingMsgIdx` leaves the old assistant as `isPartial=true`, and `startAgentTurn` appends a new one right after it → two adjacent Assistant blocks. Callers that already called `finalizeStaleStreamingBeforeNewUserMessage` hit the early return (`streamingMsgIdx=-1`), no side effects.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`liveIterationBlocks` MUST suppress pulse when any tools are present.** `hasSpinner` was only set for `running`/`active` tools. When a tool transitions to `done`/`error`/`pending` (between sequential tool execution), `hasSpinner` goes `false` → pulse spinner appears (+1 line) → next tool starts running → pulse disappears (-1 line). This H→H+1→H oscillation is the visible jitter. Fix: `if len(tools) > 0 { hasSpinner = true }` — pulse is an idle indicator for the thinking phase only, not for tool execution phases.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`updateStreamingOnly` MUST skip liveLines when `!m.typing`.** `endAgentTurn` preserves `progressState.current` and `progressState.iterations` for flicker-free rendering between PhaseDone and `handleAgentMessage`. But `updateStreamingOnly` renders BOTH `completedLines` (from `progressState.iterations`, which includes the final iteration with Content + Reasoning) AND `liveLines` (from `progressState.current`, which has the SAME Content + Reasoning). This causes the final reasoning + response to appear twice. For CLI sessions this is momentary (`handleAgentMessage` clears `streamingMsgIdx` shortly after), but for non-CLI sessions (`/su` to feishu/web/qq), `handleAgentMessage` never arrives → `streamingMsgIdx` stays valid → `updateStreamingOnly` runs every tick → persistent duplication. Fix: the `liveLines` rendering block guards with `m.typing` — when the turn has ended, only completed iterations are rendered (they already contain the final content).\n- **`postRestoreSessionSetup` MUST call `refreshCachedModelName()` for LLM state resolution, NOT inline `LoadSessionLLMState` + `GetDefault` fallback.** The old code had its own LLM resolution path (local JSON → GetDefault) that bypassed the DB tenants table. In remote mode, `SaveSessionLLMState` passes `skipBackendFields=true` (local JSON never writes subID/model), so `LoadSessionLLMState` always returned zero → `GetDefault(\"\")` returned the subscription's default `Model` (often `\"\"`), overwriting the correct per-session model that `restoreSession()` had just restored from `savedSessions`. This only affected the default session (non-default sessions write local JSON via `SaveSessionLLMState` without skip at creation time). Fix: `postRestoreSessionSetup` now calls `refreshCachedModelName()` which checks DB tenants table (`GetSessionSubscription` RPC) first, then local JSON, then savedSessions, then GetDefault — the same 4-level priority chain used at startup. Context limits are then resolved from the now-correct `activeSubID` + `cachedModelName` via `ResolveEffectiveMaxContext`/`ResolveEffectiveMaxOutputTokens`. This makes default sessions and regular sessions follow the exact same restoration path.\n- **`ensureSessionModel` auto-binds a model to ALL new sessions on first use.** Both `ResolveLLM` (agent side, covers all channels including Web/Feishu) and `postRestoreSessionSetup` (CLI side, for immediate status bar display) call auto-bind when a session has no per-session binding in the tenants table. Priority: Balance tier config (`user_settings[\"tier_balance\"]` → `\"subID|model\"`) → last-used model (`user_default_model` table). The binding is persisted via `SelectModel` (writes to both tenants table and `user_default_model`), so subsequent calls hit the tenants table directly and skip auto-bind. If neither Balance tier nor last-used model exists, no binding is created (the caller falls through to system default). This ensures ALL sessions — CLI-created, `/chat new`, `/su` switch, Web/Feishu — get an explicit model binding on first use, not just those created via the CLI session panel. CLI's `ensureSessionModelBinding` is idempotent: it checks `GetSessionSubscription` first and returns immediately if the session already has a binding.\n- **Delta Push + Watermark Pull protocol replaces full-history push.** Structured progress has ONE channel-agnostic producer (`buildProgressEventHandler`): build one `protocol.ProgressEvent`, attach the iteration delta once, store the snapshot once, then broadcast an isolated clone of that semantic event to every `channel.ProgressSender` (CLI/Web/plugin). Channels are transports only — never derive their own history or add channel-specific reducers. `ProgressEvent.Seq` is the semantic log watermark used by reconnecting clients; SSE/WS envelope seq is transport-only. Clients install active snapshots, ignore events with `seq <= snapshot.seq`, and NEVER synthesize completed iterations locally. Push events carry ONLY the newly completed iteration as `IterationHistory` (0 or 1 entries) — not the full cumulative history. `attachIterationDelta` (formerly `recordIterationAdvanceAndAttachHistory`) calls `recordIterationSnapshot` which returns the delta (the previous iteration snapshot) or nil if no advance. `GetActiveProgress` gains a `fromIter` watermark parameter: it filters `iterationHistories` to only return iterations with `Iteration > fromIter`. The CLI sends `m.progressState.lastIter` as the watermark. Tick pull frequency reduced from 100ms to 2s (every 20th tick) — it's now a safety net for recovering iterations lost to progressSlot coalescing or WS disconnect, not the primary delivery path. Push events are the primary path. `restoreIterationsFromSnapshot` changed from count-based replace to per-iteration append-with-dedup (`slices.ContainsFunc` by iteration number). `coalesceProgress` merges delta slices via `mergeIterationDeltas` (concatenate + dedup by iteration number). `/su` switch and initial restore pass `fromIter=0` to get full history (one-time operation). Web callback passes `fromIter=0` (web doesn't use delta protocol). `recordIterationSnapshot` now returns `*protocol.ProgressEvent` (the delta, or nil) instead of being void.\n- **`startAgentTurn` MUST set `progressState.current` AFTER `resetProgressState`, not before.** `resetProgressState` clears `progressState.current` to nil. If the \"thinking\" initial state is set before `resetProgressState`, it gets wiped — the first tick sees `hasIterData=false` in `updateStreamingOnly`, falls back to `renderMessage` (empty content, no pulse spinner), and the user sees no visual feedback until the first structured progress event arrives (seconds later). Setting `progressState.current = {Phase:\"thinking\"}` after `resetProgressState` ensures `liveIterationBlocks` renders a pulse spinner on the very first tick — immediate visual feedback.\n- **`mergeSubAgentTrees` MUST be called in `applyProgressSnapshot` before replacing `progressState.current`.** Structured events don't always carry SubAgents (engine only includes them when `resolveSubAgents` returns non-empty). Without merging, the tree flickers — appears when SubAgents are present, vanishes when the next structured event omits them. The merge preserves prev's running agents and marks dropped ones as done. `mergeSubAgentTrees` also preserves children when `new.Children` is empty but `prev.Children` exists (marks all prev children as done via `markAllDone()`). Without this call, `mergeSubAgentTrees` is dead code (only called in tests).\n- **`finalizeTurnFromSnapshot` MUST create synthetic assistant message for ALL non-CLI channels.** The old code only did this for `m.channelName == \"agent\"` (SubAgent viewer). But when CLI `/su` switches to any non-CLI session (feishu, web, qq, etc.), the agent's outbound reply goes to the originating channel via `sendMessage(chName, ...)`, NOT to the CLI channel. The CLI receives progress events (including Content via structured progress) and PhaseDone, but never receives `cliOutboundMsg`. Without the synthetic message, the CLI stays stuck showing \"...\" after PhaseDone. The fix: `m.channelName != \"cli\"` (covers agent, feishu, web, etc.). Content comes from `cur.Content` (set by `structuredProgress.Content` via `notifyProgress` before PhaseDone). PhaseDone payload itself has no Content — must fall back to `cur` (captured before `endAgentTurn`).\n- **`ProgressEventHandler` MUST be created for ALL channels, not just cli/web/ProgressSender.** `buildMainRunConfig` previously only created `ProgressEventHandler` for `\"cli\"`, `\"web\"`, and plugin `ProgressSender` channels. When a CLI user `/su` switches to a feishu (or other non-cli/web) session and sends a message, the message's `Channel == \"feishu\"` → no `ProgressEventHandler` → `progressFinalizer`'s PhaseDone event is silently dropped → `finalizeTurnFromSnapshot` never runs → `m.typing` stays `true` forever (TUI stuck in busy). Fix: the `switch channel` in `buildMainRunConfig` now has a `default` branch that creates a `buildCLIProgressEventHandler(chatID, channel)` for non-cli/web/non-ProgressSender channels. This ensures PhaseDone, structured progress, and `lastProgressSnapshot` storage work for ALL channels when viewed via `/su`. Additionally, the `else` branch of the `ProgressNotifier` setup now sets a no-op notifier for non-cli/web channels without `autoNotify`, enabling `progressFinalizer` to generate PhaseDone events.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n- **`buildStreamCallbacks` only broadcasts to the ORIGINATING channel.** Each channel's `SendProgress` already broadcasts to ALL Hub subscribers (including other channels' clients via the shared Hub). Broadcasting to multiple channels causes duplicate delivery: each subscriber receives the same event N times (N = number of ProgressSender channels). This was the root cause of the \"two consecutive Assistant messages\" bug that only appeared when Web and TUI were both open on the same session — duplicate stream events arrived after state transitions, triggering `startAgentTurn` to create a duplicate streaming assistant.\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n\n### Web Terminal (PTY)\n- **PTY follows the user's runner, same as Shell tool.** `channel/web/web_pty.go:ptyManager.selectBackend(senderID)` resolves the user's sandbox via `tools.GetSandbox()` → `SandboxRouter.SandboxForUser(senderID)`. `none` sandbox → `localPtyBackend` (PTY on web server via `creack/pty`). `remote` sandbox → `remotePtyBackend` (PTY on runner via WS protocol). Docker mode on runner uses `docker exec -it`.\n- **PTY protocol mirrors the existing stdio streaming protocol.** `runnerproto` defines 6 message types: `pty_create` (req/resp), `pty_stdin` (fire-and-forget), `pty_resize` (fire-and-forget), `pty_close` (req/resp), `pty_data` (runner→server push), `pty_exit` (runner→server push). Runner-side `internal/runnerclient/pty.go:ptyManager` uses `pty.StartWithSize` + read loop; server-side `tools/pty_remote.go` routes pushes via `handlePtyPush` (called in `remote_sandbox.go` readLoop after `handleStdioPush`).\n- **`PtyCreate` registers the stream before sending the request.** `RemoteSandbox.PtyCreate` calls `registerPtyStream` BEFORE `sendRequest`, so `handlePtyPush` can route `pty_data`/`pty_exit` pushes immediately. On failure, `removePtyStream` cleans up. `SetPtyHandlers` updates callbacks after `PtyCreate` returns (the web channel's `ptyManager.Create` calls `backend.Create` then `backend.SetHandlers`).\n- **Multi-client broadcast + history replay.** `ptySession` holds `clients map[*ptyWSClient]struct{}` and a 64KB scrollback `history []byte`. New WS connections replay history, then receive live `broadcast` calls. WS disconnect removes the client but keeps the PTY alive (idle reaper kills after 5 min). Explicit `close` or PTY exit destroys the session.\n- **`handleTerminalWS` uses cookie auth via `authMiddleware`.** The route `mux.HandleFunc(\"/ws/terminal\", wc.authMiddleware(wc.handleTerminalWS))` — `authMiddleware` validates the cookie, then `handleTerminalWS` upgrades to WebSocket. The WS upgrade is GET, so `authMiddleware`'s body-size limit (only applied to non-GET) doesn't interfere.\n- **Terminal API routes.** `POST /api/terminal/create` (body `{ chatID, cwd }` → `{ tid }`), `POST /api/terminal/{tid}/delete` (→ `{ ok: true }`), `POST /api/terminal/list` (body `{ chat_id }` → `{ terminals: [...] }`), `WS /ws/terminal?tid=<tid>`. All terminal routes use `authenticatedPOST`/`authMiddleware` (cookie auth). Chat delete calls `ptyMgr.CleanupChat(chatID)` to destroy all PTY sessions for the deleted chat.\n- **`BackgroundPanel.tsx` uses xterm.js with incremental write.** Background task output (which contains `\\r`, ANSI codes, progress bars) is rendered via `Terminal.write(output.slice(lastLen))` — only the delta since last poll is written, so `\\r` (carriage return) correctly overwrites the current line instead of stacking as separate lines. The xterm instance is created once per panel mount and disposed on unmount.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressSlot coalescing**(原 progressCh buffer=1 已替换):`SendProgress` 使用 mutex-protected `progressSlot` + buffer-1 `progressSignal` 替代旧 buffer-1 channel。旧设计中 eviction+re-insert 有 race window,结构化的 \"done\" 事件可被静默丢弃,导致工具永久卡在 \"running\"(● 不变 ✓)。新设计:`progressSlot` 始终持有最新合并后的事件,`handleProgressDrain` 读取并清空 slot。结构化事件永远不会丢失——stream-only 不能驱逐 structured,structured 替换时保留 stream fields/TokenUsage/CWD。`coalesceProgress`(asyncCh 层)仍保留用于合并连续 progress 消息。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **新用户消息开始前必须固化旧 streaming turn。** `PhaseDone` 后 `endAgentTurn` 会刻意保留 `streamingMsgIdx` 和 `progressState`,等待最终 `handleAgentMessage` 做无闪烁收尾。但 `sendMessage`/`sendToAgent` 会在 `startAgentTurn()` 前先 append 用户消息并刷新 viewport;如果不先调用 `finalizeStaleStreamingBeforeNewUserMessage()`,这次刷新会把上一个 turn 的 live stream 渲染到新用户消息下面。真实新消息入口必须在 append user message 前固化旧 assistant partial、清 `streamingMsgIdx`/旧 progress stream cache。**`startAgentTurn()` 内部也调用 `finalizeStaleStreamingBeforeNewUserMessage()`**,覆盖所有不经过 `sendMessage`/`sendToAgent` 的路径(auto-start、bg task 注入、`/compress`、`cliProcessingMsg`、AskUser 回调)。已有调用的路径因 `streamingMsgIdx=-1` 走 early return,无副作用。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "9aaaa1871d90ca9527ced129bc2c4bef27f377bb": {"t": 1785759453.6707609, "n": 109691, "h": "56a9197740f04d12", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步、**UserContext 统一解析(ResolveUserContext)**、**单用户模式(single_user)**\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **Subscriptions, settings, and tier config are bound to the canonical user (`user_id`), NOT to `sender_id`.** The `user_llm_subscriptions`, `user_settings`, and `user_default_model` tables all have a `user_id` column (added in v45 migration, backfilled by v46). All identities linked to the same canonical user (e.g. `web-1`, `cli_user`, `ou_xxx`) MUST see the same subscriptions, settings, and tier config. RPC handlers that query subscriptions/settings MUST use `rpcUserID(ctx)` and call `*ByUserID` storage methods (`ListByUserID`, `GetByUserID`, `SetByUserID`, etc.). The old `sender_id`-keyed methods (`List(senderID)`, `Get(channel, senderID)`) are for the agent loop path only (`ResolveUserContext` → `ResolveLLM`), where `senderID` is already resolved to the canonical identity.\n- **`rpcBizID` is for per-session operations only** (tenants table, session switching, history). It must NOT be used for user-level queries (subscriptions, settings, tier, concurrency, model lists). Those use `rpcUserID`. If you see `rpcBizID` in a user-level RPC handler, it's a bug.\n- **All old/historical logic MUST be deleted.** Superseded functions, migration helpers, and workaround hacks must be removed when their replacements are in place. Leaving dead code or old patterns misleads future developers and causes divergence. Examples of deleted patterns: `senderIDFromParams` (replaced by `rpcUserID`), `migrateCLIUserSettingsFromGlobalIfNeeded` (dead after `get_settings` switched to `GetByUserID`), `userScopedSettingsFromGlobalCLI` (only caller was the deleted function), `ResolvePrimarySenderID` (hack replaced by proper `user_id` queries).\n- **Only `user_context_middleware.go` (`ResolveUserContext`) and the RPC layer (`rpc_table.go`) may access user-related DB tables directly.** Agent loop code (`engine.go`, `engine_run.go`, `llm_config_handler.go`, etc.) MUST go through `UserContext` closures. RPC handlers MUST use `rpcUserID(ctx)` / `rpcBizID(ctx)` from context — never call `IdentityResolver` or `LLMFactory` directly to resolve user identity. This is the architectural boundary: user identity resolution happens once at entry, results carried via context.\n- **`Agent.ResolveUserContext` is the SINGLE boundary between the user system and the agent loop.** It resolves ALL user-related components (LLM, identity, settings, sandbox, concurrency, subscription management) once at `processMessage` entry, carrying the result via `context.Context` (`WithUserContext`/`UserContextFromContext`). **NEVER access `a.llmFactory`/`a.settingsSvc`/`a.identityResolver` directly in agent loop code** (`engine.go`, `engine_run.go`, `engine_wire.go`, `agent_process.go`, `compress.go`, `context_handler.go`, `prompt_handler.go`, `card_handler.go`, `middleware_builtin.go`, `perm_control_helpers.go`, `command_builtin.go`, `llm_config_handler.go` loop paths). Read from `UserContext` instead. Adding new user-related logic in middleware-downstream code is **forbidden** — put it in `ResolveUserContext` or `UserContext` methods.\n- **`UserContext` carries management closures for command handlers.** `uc.SubSvc`, `uc.SettingsSvc`, `uc.ResolveLLM(chatID)`, `uc.ResolveActiveSub(chatID)`, `uc.SelectModel(chatID, subID, model)`, `uc.RefreshModels()`, `uc.InvalidateLLM()`, `uc.HasCustomLLM()` — command handlers (`/set-llm`, `/settings`, `/models`, `/set-model`) must use these closures, not `a.llmFactory` directly. External RPC API methods (no `ctx` parameter, e.g. `GetUserMaxContext`) are exempt — they're outside the loop.\n- **Single-user mode: `experimental.single_user: true` in config.json.** When enabled, `ResolveUserContext` maps all senderIDs to `\"cli_user\"` — sharing subscriptions, settings, identity, sandbox. The `a.singleUser` flag (set from `cfg.SingleUser`) controls this. Only `ResolveUserContext` changes; downstream code is completely unaware.\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39/v40 migration notes.** `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **Cancel interception must NOT send premature cancel ack.** The `/cancel` handler in the agent loop only sends the cancel signal to `cancelCh`. The cancel ack (with `cancelled=true` metadata) is sent exclusively by `chatProcessLoop`'s `wasCancelled` path AFTER Run returns. Sending it in the interception caused TUI to enter idle while the Run was still active — the cancel signal is asynchronous (consumed by `cancelListener` goroutine), so there was a window where TUI was idle but the agent was still working.\n- **Cancel records pending bg notifications for the cancelled session.** `handleCancelledRun` takes same-session notifications still pending in `bgRunPending` and records them into the interrupted turn as synthetic tool observations; it also records a `user_cancelled` synthetic tool. Both the synthetic assistant and tool messages are marked `DisplayOnly=true` — they are persisted to DB for UI display but excluded from LLM context (`GetAllMessages` filters `display_only=0`). This ensures the next model call does NOT see the cancel marker, which is correct for graceful shutdown resume (the LLM should continue as if the cancel never happened). The model still sees partial tool calls/results (persisted by `IncrementalPersist` without `DisplayOnly`). Notifications already drained into the Run were already persisted by the normal synthetic-tool injection path, so cancel clears only their `drainedThisRun` tracking metadata. Other-session pending notifications remain in their own session bucket.\n- **DrainBgNotifications is cancel-aware.** `engine_run.go` post-iteration drain checks `ctx.Err() == nil` before draining. If ctx is already cancelled (cancel signal arrived during the LLM call), drain is skipped so `handleCancelledRun` can record this session's pending notifications in the interrupted turn before the UI receives the cancel ack.\n- **`wireBgNotificationDrain` tracks drained notifications.** Drain copies consumed notifications into `bgSessionState.drainedThisRun` (guarded by `drainedThisRunMu`). On normal/error turn completion, `clearDrainedThisRun()` discards the tracking metadata after notifications were processed. On cancel, `handleCancelledRun` clears the tracking metadata without re-delivering those already-persisted notifications as a fresh user message.\n- **`drainAndProcessNotifications` batches all notifications into ONE user message.** All drained notifications are formatted and joined with `\\n\\n---\\n\\n` separators, then injected as a single `injectBgUserMessage` call. This avoids spamming the TUI with N separate messages and triggering N separate agent turns when multiple bg tasks complete simultaneously.\n- **`processMessage` early-exits on cancelled ctx.** Before calling `Run`, `processMessage` checks `ctx.Err()`. If cancelled during setup (DB tenant creation, MCP config, workspace init — especially heavy on the first message), it returns immediately with `cancelled=true` metadata instead of entering Run.\n- **Ctrl+C in TUI immediately cancels + clears queue.** `sendCancel` shows feedback BEFORE `sendInbound` (WS write can block). Ctrl+C while typing always clears `messageQueue` and sends cancel in one keystroke — no more N+1 presses to delete queued messages one by one before cancel takes effect.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **`dedupConsecutiveAssistants()` is the rendering-level guard** against two adjacent Assistant blocks. Called by both `appendNewMessagesToCache` and `fullRebuild`. Two cases: (1) SAME turnID → merge content+iterations; (2) DIFFERENT turnID + first is `isPartial=true` → remove the stale placeholder. Case 2 covers ALL `isPartial` messages (empty or with baked content/iterations from `finalizeTurnFromSnapshot`), not just empty placeholders. The old `upsertMessageByTurn`/`dedupMessagesGuard` O(N) per-frame scans were deleted — `dedupConsecutiveAssistants` is O(n) but only runs on cache rebuild, not every tick.\n- **`startAgentTurn()` calls `finalizeStaleStreamingBeforeNewUserMessage()` internally.** This covers all paths that bypass `sendMessage`/`sendToAgent` (auto-start, bg task injection, `/compress`, `cliProcessingMsg`, AskUser callback). Without this, `endAgentTurn`'s preserved `streamingMsgIdx` leaves the old assistant as `isPartial=true`, and `startAgentTurn` appends a new one right after it → two adjacent Assistant blocks. Callers that already called `finalizeStaleStreamingBeforeNewUserMessage` hit the early return (`streamingMsgIdx=-1`), no side effects.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`liveIterationBlocks` MUST suppress pulse when any tools are present.** `hasSpinner` was only set for `running`/`active` tools. When a tool transitions to `done`/`error`/`pending` (between sequential tool execution), `hasSpinner` goes `false` → pulse spinner appears (+1 line) → next tool starts running → pulse disappears (-1 line). This H→H+1→H oscillation is the visible jitter. Fix: `if len(tools) > 0 { hasSpinner = true }` — pulse is an idle indicator for the thinking phase only, not for tool execution phases.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`updateStreamingOnly` MUST skip liveLines when `!m.typing`.** `endAgentTurn` preserves `progressState.current` and `progressState.iterations` for flicker-free rendering between PhaseDone and `handleAgentMessage`. But `updateStreamingOnly` renders BOTH `completedLines` (from `progressState.iterations`, which includes the final iteration with Content + Reasoning) AND `liveLines` (from `progressState.current`, which has the SAME Content + Reasoning). This causes the final reasoning + response to appear twice. For CLI sessions this is momentary (`handleAgentMessage` clears `streamingMsgIdx` shortly after), but for non-CLI sessions (`/su` to feishu/web/qq), `handleAgentMessage` never arrives → `streamingMsgIdx` stays valid → `updateStreamingOnly` runs every tick → persistent duplication. Fix: the `liveLines` rendering block guards with `m.typing` — when the turn has ended, only completed iterations are rendered (they already contain the final content).\n- **`postRestoreSessionSetup` MUST call `refreshCachedModelName()` for LLM state resolution, NOT inline `LoadSessionLLMState` + `GetDefault` fallback.** The old code had its own LLM resolution path (local JSON → GetDefault) that bypassed the DB tenants table. In remote mode, `SaveSessionLLMState` passes `skipBackendFields=true` (local JSON never writes subID/model), so `LoadSessionLLMState` always returned zero → `GetDefault(\"\")` returned the subscription's default `Model` (often `\"\"`), overwriting the correct per-session model that `restoreSession()` had just restored from `savedSessions`. This only affected the default session (non-default sessions write local JSON via `SaveSessionLLMState` without skip at creation time). Fix: `postRestoreSessionSetup` now calls `refreshCachedModelName()` which checks DB tenants table (`GetSessionSubscription` RPC) first, then local JSON, then savedSessions, then GetDefault — the same 4-level priority chain used at startup. Context limits are then resolved from the now-correct `activeSubID` + `cachedModelName` via `ResolveEffectiveMaxContext`/`ResolveEffectiveMaxOutputTokens`. This makes default sessions and regular sessions follow the exact same restoration path.\n- **`ensureSessionModel` auto-binds a model to ALL new sessions on first use.** Both `ResolveLLM` (agent side, covers all channels including Web/Feishu) and `postRestoreSessionSetup` (CLI side, for immediate status bar display) call auto-bind when a session has no per-session binding in the tenants table. Priority: Balance tier config (`user_settings[\"tier_balance\"]` → `\"subID|model\"`) → last-used model (`user_default_model` table). The binding is persisted via `SelectModel` (writes to both tenants table and `user_default_model`), so subsequent calls hit the tenants table directly and skip auto-bind. If neither Balance tier nor last-used model exists, no binding is created (the caller falls through to system default). This ensures ALL sessions — CLI-created, `/chat new`, `/su` switch, Web/Feishu — get an explicit model binding on first use, not just those created via the CLI session panel. CLI's `ensureSessionModelBinding` is idempotent: it checks `GetSessionSubscription` first and returns immediately if the session already has a binding.\n- **Delta Push + Watermark Pull protocol replaces full-history push.** Structured progress has ONE channel-agnostic producer (`buildProgressEventHandler`): build one `protocol.ProgressEvent`, attach the iteration delta once, store the snapshot once, then broadcast an isolated clone of that semantic event to every `channel.ProgressSender` (CLI/Web/plugin). Channels are transports only — never derive their own history or add channel-specific reducers. `ProgressEvent.Seq` is the semantic log watermark used by reconnecting clients; SSE/WS envelope seq is transport-only. Clients install active snapshots, ignore events with `seq <= snapshot.seq`, and NEVER synthesize completed iterations locally. Push events carry ONLY the newly completed iteration as `IterationHistory` (0 or 1 entries) — not the full cumulative history. `attachIterationDelta` (formerly `recordIterationAdvanceAndAttachHistory`) calls `recordIterationSnapshot` which returns the delta (the previous iteration snapshot) or nil if no advance. `GetActiveProgress` gains a `fromIter` watermark parameter: it filters `iterationHistories` to only return iterations with `Iteration > fromIter`. The CLI sends `m.progressState.lastIter` as the watermark. Tick pull frequency reduced from 100ms to 2s (every 20th tick) — it's now a safety net for recovering iterations lost to progressSlot coalescing or WS disconnect, not the primary delivery path. Push events are the primary path. `restoreIterationsFromSnapshot` changed from count-based replace to per-iteration append-with-dedup (`slices.ContainsFunc` by iteration number). `coalesceProgress` merges delta slices via `mergeIterationDeltas` (concatenate + dedup by iteration number). `/su` switch and initial restore pass `fromIter=0` to get full history (one-time operation). Web callback passes `fromIter=0` (web doesn't use delta protocol). `recordIterationSnapshot` now returns `*protocol.ProgressEvent` (the delta, or nil) instead of being void.\n- **`startAgentTurn` MUST set `progressState.current` AFTER `resetProgressState`, not before.** `resetProgressState` clears `progressState.current` to nil. If the \"thinking\" initial state is set before `resetProgressState`, it gets wiped — the first tick sees `hasIterData=false` in `updateStreamingOnly`, falls back to `renderMessage` (empty content, no pulse spinner), and the user sees no visual feedback until the first structured progress event arrives (seconds later). Setting `progressState.current = {Phase:\"thinking\"}` after `resetProgressState` ensures `liveIterationBlocks` renders a pulse spinner on the very first tick — immediate visual feedback.\n- **`mergeSubAgentTrees` MUST be called in `applyProgressSnapshot` before replacing `progressState.current`.** Structured events don't always carry SubAgents (engine only includes them when `resolveSubAgents` returns non-empty). Without merging, the tree flickers — appears when SubAgents are present, vanishes when the next structured event omits them. The merge preserves prev's running agents and marks dropped ones as done. `mergeSubAgentTrees` also preserves children when `new.Children` is empty but `prev.Children` exists (marks all prev children as done via `markAllDone()`). Without this call, `mergeSubAgentTrees` is dead code (only called in tests).\n- **`finalizeTurnFromSnapshot` MUST create synthetic assistant message for ALL non-CLI channels.** The old code only did this for `m.channelName == \"agent\"` (SubAgent viewer). But when CLI `/su` switches to any non-CLI session (feishu, web, qq, etc.), the agent's outbound reply goes to the originating channel via `sendMessage(chName, ...)`, NOT to the CLI channel. The CLI receives progress events (including Content via structured progress) and PhaseDone, but never receives `cliOutboundMsg`. Without the synthetic message, the CLI stays stuck showing \"...\" after PhaseDone. The fix: `m.channelName != \"cli\"` (covers agent, feishu, web, etc.). Content comes from `cur.Content` (set by `structuredProgress.Content` via `notifyProgress` before PhaseDone). PhaseDone payload itself has no Content — must fall back to `cur` (captured before `endAgentTurn`).\n- **`ProgressEventHandler` MUST be created for ALL channels, not just cli/web/ProgressSender.** `buildMainRunConfig` previously only created `ProgressEventHandler` for `\"cli\"`, `\"web\"`, and plugin `ProgressSender` channels. When a CLI user `/su` switches to a feishu (or other non-cli/web) session and sends a message, the message's `Channel == \"feishu\"` → no `ProgressEventHandler` → `progressFinalizer`'s PhaseDone event is silently dropped → `finalizeTurnFromSnapshot` never runs → `m.typing` stays `true` forever (TUI stuck in busy). Fix: the `switch channel` in `buildMainRunConfig` now has a `default` branch that creates a `buildCLIProgressEventHandler(chatID, channel)` for non-cli/web/non-ProgressSender channels. This ensures PhaseDone, structured progress, and `lastProgressSnapshot` storage work for ALL channels when viewed via `/su`. Additionally, the `else` branch of the `ProgressNotifier` setup now sets a no-op notifier for non-cli/web channels without `autoNotify`, enabling `progressFinalizer` to generate PhaseDone events.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n- **`buildStreamCallbacks` only broadcasts to the ORIGINATING channel.** Each channel's `SendProgress` already broadcasts to ALL Hub subscribers (including other channels' clients via the shared Hub). Broadcasting to multiple channels causes duplicate delivery: each subscriber receives the same event N times (N = number of ProgressSender channels). This was the root cause of the \"two consecutive Assistant messages\" bug that only appeared when Web and TUI were both open on the same session — duplicate stream events arrived after state transitions, triggering `startAgentTurn` to create a duplicate streaming assistant.\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressSlot coalescing**(原 progressCh buffer=1 已替换):`SendProgress` 使用 mutex-protected `progressSlot` + buffer-1 `progressSignal` 替代旧 buffer-1 channel。旧设计中 eviction+re-insert 有 race window,结构化的 \"done\" 事件可被静默丢弃,导致工具永久卡在 \"running\"(● 不变 ✓)。新设计:`progressSlot` 始终持有最新合并后的事件,`handleProgressDrain` 读取并清空 slot。结构化事件永远不会丢失——stream-only 不能驱逐 structured,structured 替换时保留 stream fields/TokenUsage/CWD。`coalesceProgress`(asyncCh 层)仍保留用于合并连续 progress 消息。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **新用户消息开始前必须固化旧 streaming turn。** `PhaseDone` 后 `endAgentTurn` 会刻意保留 `streamingMsgIdx` 和 `progressState`,等待最终 `handleAgentMessage` 做无闪烁收尾。但 `sendMessage`/`sendToAgent` 会在 `startAgentTurn()` 前先 append 用户消息并刷新 viewport;如果不先调用 `finalizeStaleStreamingBeforeNewUserMessage()`,这次刷新会把上一个 turn 的 live stream 渲染到新用户消息下面。真实新消息入口必须在 append user message 前固化旧 assistant partial、清 `streamingMsgIdx`/旧 progress stream cache。**`startAgentTurn()` 内部也调用 `finalizeStaleStreamingBeforeNewUserMessage()`**,覆盖所有不经过 `sendMessage`/`sendToAgent` 的路径(auto-start、bg task 注入、`/compress`、`cliProcessingMsg`、AskUser 回调)。已有调用的路径因 `streamingMsgIdx=-1` 走 early return,无副作用。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "32e27ad8e586370ff392a6c512d130007317eead": {"t": 1785759455.768799, "n": 109293, "h": "fb4137c8a6faf983", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步、**UserContext 统一解析(ResolveUserContext)**、**单用户模式(single_user)**\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **Subscriptions, settings, and tier config are bound to the canonical user (`user_id`), NOT to `sender_id`.** The `user_llm_subscriptions`, `user_settings`, and `user_default_model` tables all have a `user_id` column (added in v45 migration, backfilled by v46). All identities linked to the same canonical user (e.g. `web-1`, `cli_user`, `ou_xxx`) MUST see the same subscriptions, settings, and tier config. RPC handlers that query subscriptions/settings MUST use `rpcUserID(ctx)` and call `*ByUserID` storage methods (`ListByUserID`, `GetByUserID`, `SetByUserID`, etc.). The old `sender_id`-keyed methods (`List(senderID)`, `Get(channel, senderID)`) are for the agent loop path only (`ResolveUserContext` → `ResolveLLM`), where `senderID` is already resolved to the canonical identity.\n- **`rpcBizID` is for per-session operations only** (tenants table, session switching, history). It must NOT be used for user-level queries (subscriptions, settings, tier, concurrency, model lists). Those use `rpcUserID`. If you see `rpcBizID` in a user-level RPC handler, it's a bug.\n- **All old/historical logic MUST be deleted.** Superseded functions, migration helpers, and workaround hacks must be removed when their replacements are in place. Leaving dead code or old patterns misleads future developers and causes divergence. Examples of deleted patterns: `senderIDFromParams` (replaced by `rpcUserID`), `migrateCLIUserSettingsFromGlobalIfNeeded` (dead after `get_settings` switched to `GetByUserID`), `userScopedSettingsFromGlobalCLI` (only caller was the deleted function), `ResolvePrimarySenderID` (hack replaced by proper `user_id` queries).\n- **Only `user_context_middleware.go` (`ResolveUserContext`) and the RPC layer (`rpc_table.go`) may access user-related DB tables directly.** Agent loop code (`engine.go`, `engine_run.go`, `llm_config_handler.go`, etc.) MUST go through `UserContext` closures. RPC handlers MUST use `rpcUserID(ctx)` / `rpcBizID(ctx)` from context — never call `IdentityResolver` or `LLMFactory` directly to resolve user identity. This is the architectural boundary: user identity resolution happens once at entry, results carried via context.\n- **`Agent.ResolveUserContext` is the SINGLE boundary between the user system and the agent loop.** It resolves ALL user-related components (LLM, identity, settings, sandbox, concurrency, subscription management) once at `processMessage` entry, carrying the result via `context.Context` (`WithUserContext`/`UserContextFromContext`). **NEVER access `a.llmFactory`/`a.settingsSvc`/`a.identityResolver` directly in agent loop code** (`engine.go`, `engine_run.go`, `engine_wire.go`, `agent_process.go`, `compress.go`, `context_handler.go`, `prompt_handler.go`, `card_handler.go`, `middleware_builtin.go`, `perm_control_helpers.go`, `command_builtin.go`, `llm_config_handler.go` loop paths). Read from `UserContext` instead. Adding new user-related logic in middleware-downstream code is **forbidden** — put it in `ResolveUserContext` or `UserContext` methods.\n- **`UserContext` carries management closures for command handlers.** `uc.SubSvc`, `uc.SettingsSvc`, `uc.ResolveLLM(chatID)`, `uc.ResolveActiveSub(chatID)`, `uc.SelectModel(chatID, subID, model)`, `uc.RefreshModels()`, `uc.InvalidateLLM()`, `uc.HasCustomLLM()` — command handlers (`/set-llm`, `/settings`, `/models`, `/set-model`) must use these closures, not `a.llmFactory` directly. External RPC API methods (no `ctx` parameter, e.g. `GetUserMaxContext`) are exempt — they're outside the loop.\n- **Single-user mode: `experimental.single_user: true` in config.json.** When enabled, `ResolveUserContext` maps all senderIDs to `\"cli_user\"` — sharing subscriptions, settings, identity, sandbox. The `a.singleUser` flag (set from `cfg.SingleUser`) controls this. Only `ResolveUserContext` changes; downstream code is completely unaware.\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39/v40 migration notes.** `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **Cancel interception must NOT send premature cancel ack.** The `/cancel` handler in the agent loop only sends the cancel signal to `cancelCh`. The cancel ack (with `cancelled=true` metadata) is sent exclusively by `chatProcessLoop`'s `wasCancelled` path AFTER Run returns. Sending it in the interception caused TUI to enter idle while the Run was still active — the cancel signal is asynchronous (consumed by `cancelListener` goroutine), so there was a window where TUI was idle but the agent was still working.\n- **Cancel records pending bg notifications for the cancelled session.** `handleCancelledRun` takes same-session notifications still pending in `bgRunPending` and records them into the interrupted turn as synthetic tool observations; it also records a `user_cancelled` synthetic tool so the next model call can see that Ctrl+C interrupted the previous turn. Notifications already drained into the Run were already persisted by the normal synthetic-tool injection path, so cancel clears only their `drainedThisRun` tracking metadata. Other-session pending notifications remain in their own session bucket.\n- **DrainBgNotifications is cancel-aware.** `engine_run.go` post-iteration drain checks `ctx.Err() == nil` before draining. If ctx is already cancelled (cancel signal arrived during the LLM call), drain is skipped so `handleCancelledRun` can record this session's pending notifications in the interrupted turn before the UI receives the cancel ack.\n- **`wireBgNotificationDrain` tracks drained notifications.** Drain copies consumed notifications into `bgSessionState.drainedThisRun` (guarded by `drainedThisRunMu`). On normal/error turn completion, `clearDrainedThisRun()` discards the tracking metadata after notifications were processed. On cancel, `handleCancelledRun` clears the tracking metadata without re-delivering those already-persisted notifications as a fresh user message.\n- **`drainAndProcessNotifications` batches all notifications into ONE user message.** All drained notifications are formatted and joined with `\\n\\n---\\n\\n` separators, then injected as a single `injectBgUserMessage` call. This avoids spamming the TUI with N separate messages and triggering N separate agent turns when multiple bg tasks complete simultaneously.\n- **`processMessage` early-exits on cancelled ctx.** Before calling `Run`, `processMessage` checks `ctx.Err()`. If cancelled during setup (DB tenant creation, MCP config, workspace init — especially heavy on the first message), it returns immediately with `cancelled=true` metadata instead of entering Run.\n- **Ctrl+C in TUI immediately cancels + clears queue.** `sendCancel` shows feedback BEFORE `sendInbound` (WS write can block). Ctrl+C while typing always clears `messageQueue` and sends cancel in one keystroke — no more N+1 presses to delete queued messages one by one before cancel takes effect.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **`dedupConsecutiveAssistants()` is the rendering-level guard** against two adjacent Assistant blocks. Called by both `appendNewMessagesToCache` and `fullRebuild`. Two cases: (1) SAME turnID → merge content+iterations; (2) DIFFERENT turnID + first is `isPartial=true` → remove the stale placeholder. Case 2 covers ALL `isPartial` messages (empty or with baked content/iterations from `finalizeTurnFromSnapshot`), not just empty placeholders. The old `upsertMessageByTurn`/`dedupMessagesGuard` O(N) per-frame scans were deleted — `dedupConsecutiveAssistants` is O(n) but only runs on cache rebuild, not every tick.\n- **`startAgentTurn()` calls `finalizeStaleStreamingBeforeNewUserMessage()` internally.** This covers all paths that bypass `sendMessage`/`sendToAgent` (auto-start, bg task injection, `/compress`, `cliProcessingMsg`, AskUser callback). Without this, `endAgentTurn`'s preserved `streamingMsgIdx` leaves the old assistant as `isPartial=true`, and `startAgentTurn` appends a new one right after it → two adjacent Assistant blocks. Callers that already called `finalizeStaleStreamingBeforeNewUserMessage` hit the early return (`streamingMsgIdx=-1`), no side effects.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`liveIterationBlocks` MUST suppress pulse when any tools are present.** `hasSpinner` was only set for `running`/`active` tools. When a tool transitions to `done`/`error`/`pending` (between sequential tool execution), `hasSpinner` goes `false` → pulse spinner appears (+1 line) → next tool starts running → pulse disappears (-1 line). This H→H+1→H oscillation is the visible jitter. Fix: `if len(tools) > 0 { hasSpinner = true }` — pulse is an idle indicator for the thinking phase only, not for tool execution phases.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`updateStreamingOnly` MUST skip liveLines when `!m.typing`.** `endAgentTurn` preserves `progressState.current` and `progressState.iterations` for flicker-free rendering between PhaseDone and `handleAgentMessage`. But `updateStreamingOnly` renders BOTH `completedLines` (from `progressState.iterations`, which includes the final iteration with Content + Reasoning) AND `liveLines` (from `progressState.current`, which has the SAME Content + Reasoning). This causes the final reasoning + response to appear twice. For CLI sessions this is momentary (`handleAgentMessage` clears `streamingMsgIdx` shortly after), but for non-CLI sessions (`/su` to feishu/web/qq), `handleAgentMessage` never arrives → `streamingMsgIdx` stays valid → `updateStreamingOnly` runs every tick → persistent duplication. Fix: the `liveLines` rendering block guards with `m.typing` — when the turn has ended, only completed iterations are rendered (they already contain the final content).\n- **`postRestoreSessionSetup` MUST call `refreshCachedModelName()` for LLM state resolution, NOT inline `LoadSessionLLMState` + `GetDefault` fallback.** The old code had its own LLM resolution path (local JSON → GetDefault) that bypassed the DB tenants table. In remote mode, `SaveSessionLLMState` passes `skipBackendFields=true` (local JSON never writes subID/model), so `LoadSessionLLMState` always returned zero → `GetDefault(\"\")` returned the subscription's default `Model` (often `\"\"`), overwriting the correct per-session model that `restoreSession()` had just restored from `savedSessions`. This only affected the default session (non-default sessions write local JSON via `SaveSessionLLMState` without skip at creation time). Fix: `postRestoreSessionSetup` now calls `refreshCachedModelName()` which checks DB tenants table (`GetSessionSubscription` RPC) first, then local JSON, then savedSessions, then GetDefault — the same 4-level priority chain used at startup. Context limits are then resolved from the now-correct `activeSubID` + `cachedModelName` via `ResolveEffectiveMaxContext`/`ResolveEffectiveMaxOutputTokens`. This makes default sessions and regular sessions follow the exact same restoration path.\n- **`ensureSessionModel` auto-binds a model to ALL new sessions on first use.** Both `ResolveLLM` (agent side, covers all channels including Web/Feishu) and `postRestoreSessionSetup` (CLI side, for immediate status bar display) call auto-bind when a session has no per-session binding in the tenants table. Priority: Balance tier config (`user_settings[\"tier_balance\"]` → `\"subID|model\"`) → last-used model (`user_default_model` table). The binding is persisted via `SelectModel` (writes to both tenants table and `user_default_model`), so subsequent calls hit the tenants table directly and skip auto-bind. If neither Balance tier nor last-used model exists, no binding is created (the caller falls through to system default). This ensures ALL sessions — CLI-created, `/chat new`, `/su` switch, Web/Feishu — get an explicit model binding on first use, not just those created via the CLI session panel. CLI's `ensureSessionModelBinding` is idempotent: it checks `GetSessionSubscription` first and returns immediately if the session already has a binding.\n- **Delta Push + Watermark Pull protocol replaces full-history push.** Structured progress has ONE channel-agnostic producer (`buildProgressEventHandler`): build one `protocol.ProgressEvent`, attach the iteration delta once, store the snapshot once, then broadcast an isolated clone of that semantic event to every `channel.ProgressSender` (CLI/Web/plugin). Channels are transports only — never derive their own history or add channel-specific reducers. `ProgressEvent.Seq` is the semantic log watermark used by reconnecting clients; SSE/WS envelope seq is transport-only. Clients install active snapshots, ignore events with `seq <= snapshot.seq`, and NEVER synthesize completed iterations locally. Push events carry ONLY the newly completed iteration as `IterationHistory` (0 or 1 entries) — not the full cumulative history. `attachIterationDelta` (formerly `recordIterationAdvanceAndAttachHistory`) calls `recordIterationSnapshot` which returns the delta (the previous iteration snapshot) or nil if no advance. `GetActiveProgress` gains a `fromIter` watermark parameter: it filters `iterationHistories` to only return iterations with `Iteration > fromIter`. The CLI sends `m.progressState.lastIter` as the watermark. Tick pull frequency reduced from 100ms to 2s (every 20th tick) — it's now a safety net for recovering iterations lost to progressSlot coalescing or WS disconnect, not the primary delivery path. Push events are the primary path. `restoreIterationsFromSnapshot` changed from count-based replace to per-iteration append-with-dedup (`slices.ContainsFunc` by iteration number). `coalesceProgress` merges delta slices via `mergeIterationDeltas` (concatenate + dedup by iteration number). `/su` switch and initial restore pass `fromIter=0` to get full history (one-time operation). Web callback passes `fromIter=0` (web doesn't use delta protocol). `recordIterationSnapshot` now returns `*protocol.ProgressEvent` (the delta, or nil) instead of being void.\n- **`startAgentTurn` MUST set `progressState.current` AFTER `resetProgressState`, not before.** `resetProgressState` clears `progressState.current` to nil. If the \"thinking\" initial state is set before `resetProgressState`, it gets wiped — the first tick sees `hasIterData=false` in `updateStreamingOnly`, falls back to `renderMessage` (empty content, no pulse spinner), and the user sees no visual feedback until the first structured progress event arrives (seconds later). Setting `progressState.current = {Phase:\"thinking\"}` after `resetProgressState` ensures `liveIterationBlocks` renders a pulse spinner on the very first tick — immediate visual feedback.\n- **`mergeSubAgentTrees` MUST be called in `applyProgressSnapshot` before replacing `progressState.current`.** Structured events don't always carry SubAgents (engine only includes them when `resolveSubAgents` returns non-empty). Without merging, the tree flickers — appears when SubAgents are present, vanishes when the next structured event omits them. The merge preserves prev's running agents and marks dropped ones as done. `mergeSubAgentTrees` also preserves children when `new.Children` is empty but `prev.Children` exists (marks all prev children as done via `markAllDone()`). Without this call, `mergeSubAgentTrees` is dead code (only called in tests).\n- **`finalizeTurnFromSnapshot` MUST create synthetic assistant message for ALL non-CLI channels.** The old code only did this for `m.channelName == \"agent\"` (SubAgent viewer). But when CLI `/su` switches to any non-CLI session (feishu, web, qq, etc.), the agent's outbound reply goes to the originating channel via `sendMessage(chName, ...)`, NOT to the CLI channel. The CLI receives progress events (including Content via structured progress) and PhaseDone, but never receives `cliOutboundMsg`. Without the synthetic message, the CLI stays stuck showing \"...\" after PhaseDone. The fix: `m.channelName != \"cli\"` (covers agent, feishu, web, etc.). Content comes from `cur.Content` (set by `structuredProgress.Content` via `notifyProgress` before PhaseDone). PhaseDone payload itself has no Content — must fall back to `cur` (captured before `endAgentTurn`).\n- **`ProgressEventHandler` MUST be created for ALL channels, not just cli/web/ProgressSender.** `buildMainRunConfig` previously only created `ProgressEventHandler` for `\"cli\"`, `\"web\"`, and plugin `ProgressSender` channels. When a CLI user `/su` switches to a feishu (or other non-cli/web) session and sends a message, the message's `Channel == \"feishu\"` → no `ProgressEventHandler` → `progressFinalizer`'s PhaseDone event is silently dropped → `finalizeTurnFromSnapshot` never runs → `m.typing` stays `true` forever (TUI stuck in busy). Fix: the `switch channel` in `buildMainRunConfig` now has a `default` branch that creates a `buildCLIProgressEventHandler(chatID, channel)` for non-cli/web/non-ProgressSender channels. This ensures PhaseDone, structured progress, and `lastProgressSnapshot` storage work for ALL channels when viewed via `/su`. Additionally, the `else` branch of the `ProgressNotifier` setup now sets a no-op notifier for non-cli/web channels without `autoNotify`, enabling `progressFinalizer` to generate PhaseDone events.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n- **`buildStreamCallbacks` only broadcasts to the ORIGINATING channel.** Each channel's `SendProgress` already broadcasts to ALL Hub subscribers (including other channels' clients via the shared Hub). Broadcasting to multiple channels causes duplicate delivery: each subscriber receives the same event N times (N = number of ProgressSender channels). This was the root cause of the \"two consecutive Assistant messages\" bug that only appeared when Web and TUI were both open on the same session — duplicate stream events arrived after state transitions, triggering `startAgentTurn` to create a duplicate streaming assistant.\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressSlot coalescing**(原 progressCh buffer=1 已替换):`SendProgress` 使用 mutex-protected `progressSlot` + buffer-1 `progressSignal` 替代旧 buffer-1 channel。旧设计中 eviction+re-insert 有 race window,结构化的 \"done\" 事件可被静默丢弃,导致工具永久卡在 \"running\"(● 不变 ✓)。新设计:`progressSlot` 始终持有最新合并后的事件,`handleProgressDrain` 读取并清空 slot。结构化事件永远不会丢失——stream-only 不能驱逐 structured,structured 替换时保留 stream fields/TokenUsage/CWD。`coalesceProgress`(asyncCh 层)仍保留用于合并连续 progress 消息。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **新用户消息开始前必须固化旧 streaming turn。** `PhaseDone` 后 `endAgentTurn` 会刻意保留 `streamingMsgIdx` 和 `progressState`,等待最终 `handleAgentMessage` 做无闪烁收尾。但 `sendMessage`/`sendToAgent` 会在 `startAgentTurn()` 前先 append 用户消息并刷新 viewport;如果不先调用 `finalizeStaleStreamingBeforeNewUserMessage()`,这次刷新会把上一个 turn 的 live stream 渲染到新用户消息下面。真实新消息入口必须在 append user message 前固化旧 assistant partial、清 `streamingMsgIdx`/旧 progress stream cache。**`startAgentTurn()` 内部也调用 `finalizeStaleStreamingBeforeNewUserMessage()`**,覆盖所有不经过 `sendMessage`/`sendToAgent` 的路径(auto-start、bg task 注入、`/compress`、`cliProcessingMsg`、AskUser 回调)。已有调用的路径因 `streamingMsgIdx=-1` 走 early return,无副作用。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "4e26e938d23397e1a73088bc16941950609a8d0b": {"t": 1785759458.1372237, "n": 106496, "h": "eec11acadfff9056", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步、**UserContext 统一解析(ResolveUserContext)**、**单用户模式(single_user)**\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`Agent.ResolveUserContext` is the SINGLE boundary between the user system and the agent loop.** It resolves ALL user-related components (LLM, identity, settings, sandbox, concurrency, subscription management) once at `processMessage` entry, carrying the result via `context.Context` (`WithUserContext`/`UserContextFromContext`). **NEVER access `a.llmFactory`/`a.settingsSvc`/`a.identityResolver` directly in agent loop code** (`engine.go`, `engine_run.go`, `engine_wire.go`, `agent_process.go`, `compress.go`, `context_handler.go`, `prompt_handler.go`, `card_handler.go`, `middleware_builtin.go`, `perm_control_helpers.go`, `command_builtin.go`, `llm_config_handler.go` loop paths). Read from `UserContext` instead. Adding new user-related logic in middleware-downstream code is **forbidden** — put it in `ResolveUserContext` or `UserContext` methods.\n- **`UserContext` carries management closures for command handlers.** `uc.SubSvc`, `uc.SettingsSvc`, `uc.ResolveLLM(chatID)`, `uc.ResolveActiveSub(chatID)`, `uc.SelectModel(chatID, subID, model)`, `uc.RefreshModels()`, `uc.InvalidateLLM()`, `uc.HasCustomLLM()` — command handlers (`/set-llm`, `/settings`, `/models`, `/set-model`) must use these closures, not `a.llmFactory` directly. External RPC API methods (no `ctx` parameter, e.g. `GetUserMaxContext`) are exempt — they're outside the loop.\n- **Single-user mode: `experimental.single_user: true` in config.json.** When enabled, `ResolveUserContext` maps all senderIDs to `\"cli_user\"` — sharing subscriptions, settings, identity, sandbox. The `a.singleUser` flag (set from `cfg.SingleUser`) controls this. Only `ResolveUserContext` changes; downstream code is completely unaware.\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39/v40 migration notes.** `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **Cancel interception must NOT send premature cancel ack.** The `/cancel` handler in the agent loop only sends the cancel signal to `cancelCh`. The cancel ack (with `cancelled=true` metadata) is sent exclusively by `chatProcessLoop`'s `wasCancelled` path AFTER Run returns. Sending it in the interception caused TUI to enter idle while the Run was still active — the cancel signal is asynchronous (consumed by `cancelListener` goroutine), so there was a window where TUI was idle but the agent was still working.\n- **Cancel records pending bg notifications for the cancelled session.** `handleCancelledRun` takes same-session notifications still pending in `bgRunPending` and records them into the interrupted turn as synthetic tool observations; it also records a `user_cancelled` synthetic tool so the next model call can see that Ctrl+C interrupted the previous turn. Notifications already drained into the Run were already persisted by the normal synthetic-tool injection path, so cancel clears only their `drainedThisRun` tracking metadata. Other-session pending notifications remain in their own session bucket.\n- **DrainBgNotifications is cancel-aware.** `engine_run.go` post-iteration drain checks `ctx.Err() == nil` before draining. If ctx is already cancelled (cancel signal arrived during the LLM call), drain is skipped so `handleCancelledRun` can record this session's pending notifications in the interrupted turn before the UI receives the cancel ack.\n- **`wireBgNotificationDrain` tracks drained notifications.** Drain copies consumed notifications into `bgSessionState.drainedThisRun` (guarded by `drainedThisRunMu`). On normal/error turn completion, `clearDrainedThisRun()` discards the tracking metadata after notifications were processed. On cancel, `handleCancelledRun` clears the tracking metadata without re-delivering those already-persisted notifications as a fresh user message.\n- **`drainAndProcessNotifications` batches all notifications into ONE user message.** All drained notifications are formatted and joined with `\\n\\n---\\n\\n` separators, then injected as a single `injectBgUserMessage` call. This avoids spamming the TUI with N separate messages and triggering N separate agent turns when multiple bg tasks complete simultaneously.\n- **`processMessage` early-exits on cancelled ctx.** Before calling `Run`, `processMessage` checks `ctx.Err()`. If cancelled during setup (DB tenant creation, MCP config, workspace init — especially heavy on the first message), it returns immediately with `cancelled=true` metadata instead of entering Run.\n- **Ctrl+C in TUI immediately cancels + clears queue.** `sendCancel` shows feedback BEFORE `sendInbound` (WS write can block). Ctrl+C while typing always clears `messageQueue` and sends cancel in one keystroke — no more N+1 presses to delete queued messages one by one before cancel takes effect.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **`dedupConsecutiveAssistants()` is the rendering-level guard** against two adjacent Assistant blocks. Called by both `appendNewMessagesToCache` and `fullRebuild`. Two cases: (1) SAME turnID → merge content+iterations; (2) DIFFERENT turnID + first is `isPartial=true` → remove the stale placeholder. Case 2 covers ALL `isPartial` messages (empty or with baked content/iterations from `finalizeTurnFromSnapshot`), not just empty placeholders. The old `upsertMessageByTurn`/`dedupMessagesGuard` O(N) per-frame scans were deleted — `dedupConsecutiveAssistants` is O(n) but only runs on cache rebuild, not every tick.\n- **`startAgentTurn()` calls `finalizeStaleStreamingBeforeNewUserMessage()` internally.** This covers all paths that bypass `sendMessage`/`sendToAgent` (auto-start, bg task injection, `/compress`, `cliProcessingMsg`, AskUser callback). Without this, `endAgentTurn`'s preserved `streamingMsgIdx` leaves the old assistant as `isPartial=true`, and `startAgentTurn` appends a new one right after it → two adjacent Assistant blocks. Callers that already called `finalizeStaleStreamingBeforeNewUserMessage` hit the early return (`streamingMsgIdx=-1`), no side effects.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`liveIterationBlocks` MUST suppress pulse when any tools are present.** `hasSpinner` was only set for `running`/`active` tools. When a tool transitions to `done`/`error`/`pending` (between sequential tool execution), `hasSpinner` goes `false` → pulse spinner appears (+1 line) → next tool starts running → pulse disappears (-1 line). This H→H+1→H oscillation is the visible jitter. Fix: `if len(tools) > 0 { hasSpinner = true }` — pulse is an idle indicator for the thinking phase only, not for tool execution phases.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`updateStreamingOnly` MUST skip liveLines when `!m.typing`.** `endAgentTurn` preserves `progressState.current` and `progressState.iterations` for flicker-free rendering between PhaseDone and `handleAgentMessage`. But `updateStreamingOnly` renders BOTH `completedLines` (from `progressState.iterations`, which includes the final iteration with Content + Reasoning) AND `liveLines` (from `progressState.current`, which has the SAME Content + Reasoning). This causes the final reasoning + response to appear twice. For CLI sessions this is momentary (`handleAgentMessage` clears `streamingMsgIdx` shortly after), but for non-CLI sessions (`/su` to feishu/web/qq), `handleAgentMessage` never arrives → `streamingMsgIdx` stays valid → `updateStreamingOnly` runs every tick → persistent duplication. Fix: the `liveLines` rendering block guards with `m.typing` — when the turn has ended, only completed iterations are rendered (they already contain the final content).\n- **`postRestoreSessionSetup` MUST call `refreshCachedModelName()` for LLM state resolution, NOT inline `LoadSessionLLMState` + `GetDefault` fallback.** The old code had its own LLM resolution path (local JSON → GetDefault) that bypassed the DB tenants table. In remote mode, `SaveSessionLLMState` passes `skipBackendFields=true` (local JSON never writes subID/model), so `LoadSessionLLMState` always returned zero → `GetDefault(\"\")` returned the subscription's default `Model` (often `\"\"`), overwriting the correct per-session model that `restoreSession()` had just restored from `savedSessions`. This only affected the default session (non-default sessions write local JSON via `SaveSessionLLMState` without skip at creation time). Fix: `postRestoreSessionSetup` now calls `refreshCachedModelName()` which checks DB tenants table (`GetSessionSubscription` RPC) first, then local JSON, then savedSessions, then GetDefault — the same 4-level priority chain used at startup. Context limits are then resolved from the now-correct `activeSubID` + `cachedModelName` via `ResolveEffectiveMaxContext`/`ResolveEffectiveMaxOutputTokens`. This makes default sessions and regular sessions follow the exact same restoration path.\n- **`ensureSessionModel` auto-binds a model to ALL new sessions on first use.** Both `ResolveLLM` (agent side, covers all channels including Web/Feishu) and `postRestoreSessionSetup` (CLI side, for immediate status bar display) call auto-bind when a session has no per-session binding in the tenants table. Priority: Balance tier config (`user_settings[\"tier_balance\"]` → `\"subID|model\"`) → last-used model (`user_default_model` table). The binding is persisted via `SelectModel` (writes to both tenants table and `user_default_model`), so subsequent calls hit the tenants table directly and skip auto-bind. If neither Balance tier nor last-used model exists, no binding is created (the caller falls through to system default). This ensures ALL sessions — CLI-created, `/chat new`, `/su` switch, Web/Feishu — get an explicit model binding on first use, not just those created via the CLI session panel. CLI's `ensureSessionModelBinding` is idempotent: it checks `GetSessionSubscription` first and returns immediately if the session already has a binding.\n- **Delta Push + Watermark Pull protocol replaces full-history push.** Push events carry ONLY the newly completed iteration as `IterationHistory` (0 or 1 entries) — not the full cumulative history. `attachIterationDelta` (formerly `recordIterationAdvanceAndAttachHistory`) calls `recordIterationSnapshot` which returns the delta (the previous iteration snapshot) or nil if no advance. `GetActiveProgress` gains a `fromIter` watermark parameter: it filters `iterationHistories` to only return iterations with `Iteration > fromIter`. The CLI sends `m.progressState.lastIter` as the watermark. Tick pull frequency reduced from 100ms to 2s (every 20th tick) — it's now a safety net for recovering iterations lost to progressSlot coalescing or WS disconnect, not the primary delivery path. Push events are the primary path. `restoreIterationsFromSnapshot` changed from count-based replace to per-iteration append-with-dedup (`slices.ContainsFunc` by iteration number). `coalesceProgress` merges delta slices via `mergeIterationDeltas` (concatenate + dedup by iteration number). `/su` switch and initial restore pass `fromIter=0` to get full history (one-time operation). Web callback passes `fromIter=0` (web doesn't use delta protocol). `recordIterationSnapshot` now returns `*protocol.ProgressEvent` (the delta, or nil) instead of being void.\n- **`startAgentTurn` MUST set `progressState.current` AFTER `resetProgressState`, not before.** `resetProgressState` clears `progressState.current` to nil. If the \"thinking\" initial state is set before `resetProgressState`, it gets wiped — the first tick sees `hasIterData=false` in `updateStreamingOnly`, falls back to `renderMessage` (empty content, no pulse spinner), and the user sees no visual feedback until the first structured progress event arrives (seconds later). Setting `progressState.current = {Phase:\"thinking\"}` after `resetProgressState` ensures `liveIterationBlocks` renders a pulse spinner on the very first tick — immediate visual feedback.\n- **`mergeSubAgentTrees` MUST be called in `applyProgressSnapshot` before replacing `progressState.current`.** Structured events don't always carry SubAgents (engine only includes them when `resolveSubAgents` returns non-empty). Without merging, the tree flickers — appears when SubAgents are present, vanishes when the next structured event omits them. The merge preserves prev's running agents and marks dropped ones as done. `mergeSubAgentTrees` also preserves children when `new.Children` is empty but `prev.Children` exists (marks all prev children as done via `markAllDone()`). Without this call, `mergeSubAgentTrees` is dead code (only called in tests).\n- **`finalizeTurnFromSnapshot` MUST create synthetic assistant message for ALL non-CLI channels.** The old code only did this for `m.channelName == \"agent\"` (SubAgent viewer). But when CLI `/su` switches to any non-CLI session (feishu, web, qq, etc.), the agent's outbound reply goes to the originating channel via `sendMessage(chName, ...)`, NOT to the CLI channel. The CLI receives progress events (including Content via structured progress) and PhaseDone, but never receives `cliOutboundMsg`. Without the synthetic message, the CLI stays stuck showing \"...\" after PhaseDone. The fix: `m.channelName != \"cli\"` (covers agent, feishu, web, etc.). Content comes from `cur.Content` (set by `structuredProgress.Content` via `notifyProgress` before PhaseDone). PhaseDone payload itself has no Content — must fall back to `cur` (captured before `endAgentTurn`).\n- **`ProgressEventHandler` MUST be created for ALL channels, not just cli/web/ProgressSender.** `buildMainRunConfig` previously only created `ProgressEventHandler` for `\"cli\"`, `\"web\"`, and plugin `ProgressSender` channels. When a CLI user `/su` switches to a feishu (or other non-cli/web) session and sends a message, the message's `Channel == \"feishu\"` → no `ProgressEventHandler` → `progressFinalizer`'s PhaseDone event is silently dropped → `finalizeTurnFromSnapshot` never runs → `m.typing` stays `true` forever (TUI stuck in busy). Fix: the `switch channel` in `buildMainRunConfig` now has a `default` branch that creates a `buildCLIProgressEventHandler(chatID, channel)` for non-cli/web/non-ProgressSender channels. This ensures PhaseDone, structured progress, and `lastProgressSnapshot` storage work for ALL channels when viewed via `/su`. Additionally, the `else` branch of the `ProgressNotifier` setup now sets a no-op notifier for non-cli/web channels without `autoNotify`, enabling `progressFinalizer` to generate PhaseDone events.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n- **`buildStreamCallbacks` only broadcasts to the ORIGINATING channel.** Each channel's `SendProgress` already broadcasts to ALL Hub subscribers (including other channels' clients via the shared Hub). Broadcasting to multiple channels causes duplicate delivery: each subscriber receives the same event N times (N = number of ProgressSender channels). This was the root cause of the \"two consecutive Assistant messages\" bug that only appeared when Web and TUI were both open on the same session — duplicate stream events arrived after state transitions, triggering `startAgentTurn` to create a duplicate streaming assistant.\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressSlot coalescing**(原 progressCh buffer=1 已替换):`SendProgress` 使用 mutex-protected `progressSlot` + buffer-1 `progressSignal` 替代旧 buffer-1 channel。旧设计中 eviction+re-insert 有 race window,结构化的 \"done\" 事件可被静默丢弃,导致工具永久卡在 \"running\"(● 不变 ✓)。新设计:`progressSlot` 始终持有最新合并后的事件,`handleProgressDrain` 读取并清空 slot。结构化事件永远不会丢失——stream-only 不能驱逐 structured,structured 替换时保留 stream fields/TokenUsage/CWD。`coalesceProgress`(asyncCh 层)仍保留用于合并连续 progress 消息。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **新用户消息开始前必须固化旧 streaming turn。** `PhaseDone` 后 `endAgentTurn` 会刻意保留 `streamingMsgIdx` 和 `progressState`,等待最终 `handleAgentMessage` 做无闪烁收尾。但 `sendMessage`/`sendToAgent` 会在 `startAgentTurn()` 前先 append 用户消息并刷新 viewport;如果不先调用 `finalizeStaleStreamingBeforeNewUserMessage()`,这次刷新会把上一个 turn 的 live stream 渲染到新用户消息下面。真实新消息入口必须在 append user message 前固化旧 assistant partial、清 `streamingMsgIdx`/旧 progress stream cache。**`startAgentTurn()` 内部也调用 `finalizeStaleStreamingBeforeNewUserMessage()`**,覆盖所有不经过 `sendMessage`/`sendToAgent` 的路径(auto-start、bg task 注入、`/compress`、`cliProcessingMsg`、AskUser 回调)。已有调用的路径因 `streamingMsgIdx=-1` 走 early return,无副作用。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "66777644ec2157f41ab21cf3bf616966e23a6399": {"t": 1785759460.985345, "n": 104967, "h": "76fdca09fb4689e6", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39 migration coexists with master's v38 (`tenants.runner_id`).** model-first originally used v38; after rebase it was bumped to v39 to avoid the schema-version collision. `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **Cancel interception must NOT send premature cancel ack.** The `/cancel` handler in the agent loop only sends the cancel signal to `cancelCh`. The cancel ack (with `cancelled=true` metadata) is sent exclusively by `chatProcessLoop`'s `wasCancelled` path AFTER Run returns. Sending it in the interception caused TUI to enter idle while the Run was still active — the cancel signal is asynchronous (consumed by `cancelListener` goroutine), so there was a window where TUI was idle but the agent was still working.\n- **Cancel records pending bg notifications for the cancelled session.** `handleCancelledRun` takes same-session notifications still pending in `bgRunPending` and records them into the interrupted turn as synthetic tool observations; it also records a `user_cancelled` synthetic tool so the next model call can see that Ctrl+C interrupted the previous turn. Notifications already drained into the Run were already persisted by the normal synthetic-tool injection path, so cancel clears only their `drainedThisRun` tracking metadata. Other-session pending notifications remain in their own session bucket.\n- **DrainBgNotifications is cancel-aware.** `engine_run.go` post-iteration drain checks `ctx.Err() == nil` before draining. If ctx is already cancelled (cancel signal arrived during the LLM call), drain is skipped so `handleCancelledRun` can record this session's pending notifications in the interrupted turn before the UI receives the cancel ack.\n- **`wireBgNotificationDrain` tracks drained notifications.** Drain copies consumed notifications into `bgSessionState.drainedThisRun` (guarded by `drainedThisRunMu`). On normal/error turn completion, `clearDrainedThisRun()` discards the tracking metadata after notifications were processed. On cancel, `handleCancelledRun` clears the tracking metadata without re-delivering those already-persisted notifications as a fresh user message.\n- **`drainAndProcessNotifications` batches all notifications into ONE user message.** All drained notifications are formatted and joined with `\\n\\n---\\n\\n` separators, then injected as a single `injectBgUserMessage` call. This avoids spamming the TUI with N separate messages and triggering N separate agent turns when multiple bg tasks complete simultaneously.\n- **`processMessage` early-exits on cancelled ctx.** Before calling `Run`, `processMessage` checks `ctx.Err()`. If cancelled during setup (DB tenant creation, MCP config, workspace init — especially heavy on the first message), it returns immediately with `cancelled=true` metadata instead of entering Run.\n- **Ctrl+C in TUI immediately cancels + clears queue.** `sendCancel` shows feedback BEFORE `sendInbound` (WS write can block). Ctrl+C while typing always clears `messageQueue` and sends cancel in one keystroke — no more N+1 presses to delete queued messages one by one before cancel takes effect.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **`dedupConsecutiveAssistants()` is the rendering-level guard** against two adjacent Assistant blocks. Called by both `appendNewMessagesToCache` and `fullRebuild`. Two cases: (1) SAME turnID → merge content+iterations; (2) DIFFERENT turnID + first is `isPartial=true` → remove the stale placeholder. Case 2 covers ALL `isPartial` messages (empty or with baked content/iterations from `finalizeTurnFromSnapshot`), not just empty placeholders. The old `upsertMessageByTurn`/`dedupMessagesGuard` O(N) per-frame scans were deleted — `dedupConsecutiveAssistants` is O(n) but only runs on cache rebuild, not every tick.\n- **`startAgentTurn()` calls `finalizeStaleStreamingBeforeNewUserMessage()` internally.** This covers all paths that bypass `sendMessage`/`sendToAgent` (auto-start, bg task injection, `/compress`, `cliProcessingMsg`, AskUser callback). Without this, `endAgentTurn`'s preserved `streamingMsgIdx` leaves the old assistant as `isPartial=true`, and `startAgentTurn` appends a new one right after it → two adjacent Assistant blocks. Callers that already called `finalizeStaleStreamingBeforeNewUserMessage` hit the early return (`streamingMsgIdx=-1`), no side effects.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`liveIterationBlocks` MUST suppress pulse when any tools are present.** `hasSpinner` was only set for `running`/`active` tools. When a tool transitions to `done`/`error`/`pending` (between sequential tool execution), `hasSpinner` goes `false` → pulse spinner appears (+1 line) → next tool starts running → pulse disappears (-1 line). This H→H+1→H oscillation is the visible jitter. Fix: `if len(tools) > 0 { hasSpinner = true }` — pulse is an idle indicator for the thinking phase only, not for tool execution phases.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`updateStreamingOnly` MUST skip liveLines when `!m.typing`.** `endAgentTurn` preserves `progressState.current` and `progressState.iterations` for flicker-free rendering between PhaseDone and `handleAgentMessage`. But `updateStreamingOnly` renders BOTH `completedLines` (from `progressState.iterations`, which includes the final iteration with Content + Reasoning) AND `liveLines` (from `progressState.current`, which has the SAME Content + Reasoning). This causes the final reasoning + response to appear twice. For CLI sessions this is momentary (`handleAgentMessage` clears `streamingMsgIdx` shortly after), but for non-CLI sessions (`/su` to feishu/web/qq), `handleAgentMessage` never arrives → `streamingMsgIdx` stays valid → `updateStreamingOnly` runs every tick → persistent duplication. Fix: the `liveLines` rendering block guards with `m.typing` — when the turn has ended, only completed iterations are rendered (they already contain the final content).\n- **`postRestoreSessionSetup` MUST call `refreshCachedModelName()` for LLM state resolution, NOT inline `LoadSessionLLMState` + `GetDefault` fallback.** The old code had its own LLM resolution path (local JSON → GetDefault) that bypassed the DB tenants table. In remote mode, `SaveSessionLLMState` passes `skipBackendFields=true` (local JSON never writes subID/model), so `LoadSessionLLMState` always returned zero → `GetDefault(\"\")` returned the subscription's default `Model` (often `\"\"`), overwriting the correct per-session model that `restoreSession()` had just restored from `savedSessions`. This only affected the default session (non-default sessions write local JSON via `SaveSessionLLMState` without skip at creation time). Fix: `postRestoreSessionSetup` now calls `refreshCachedModelName()` which checks DB tenants table (`GetSessionSubscription` RPC) first, then local JSON, then savedSessions, then GetDefault — the same 4-level priority chain used at startup. Context limits are then resolved from the now-correct `activeSubID` + `cachedModelName` via `ResolveEffectiveMaxContext`/`ResolveEffectiveMaxOutputTokens`. This makes default sessions and regular sessions follow the exact same restoration path.\n- **`ensureSessionModel` auto-binds a model to ALL new sessions on first use.** Both `ResolveLLM` (agent side, covers all channels including Web/Feishu) and `postRestoreSessionSetup` (CLI side, for immediate status bar display) call auto-bind when a session has no per-session binding in the tenants table. Priority: Balance tier config (`user_settings[\"tier_balance\"]` → `\"subID|model\"`) → last-used model (`user_default_model` table). The binding is persisted via `SelectModel` (writes to both tenants table and `user_default_model`), so subsequent calls hit the tenants table directly and skip auto-bind. If neither Balance tier nor last-used model exists, no binding is created (the caller falls through to system default). This ensures ALL sessions — CLI-created, `/chat new`, `/su` switch, Web/Feishu — get an explicit model binding on first use, not just those created via the CLI session panel. CLI's `ensureSessionModelBinding` is idempotent: it checks `GetSessionSubscription` first and returns immediately if the session already has a binding.\n- **Delta Push + Watermark Pull protocol replaces full-history push.** Push events carry ONLY the newly completed iteration as `IterationHistory` (0 or 1 entries) — not the full cumulative history. `attachIterationDelta` (formerly `recordIterationAdvanceAndAttachHistory`) calls `recordIterationSnapshot` which returns the delta (the previous iteration snapshot) or nil if no advance. `GetActiveProgress` gains a `fromIter` watermark parameter: it filters `iterationHistories` to only return iterations with `Iteration > fromIter`. The CLI sends `m.progressState.lastIter` as the watermark. Tick pull frequency reduced from 100ms to 2s (every 20th tick) — it's now a safety net for recovering iterations lost to progressSlot coalescing or WS disconnect, not the primary delivery path. Push events are the primary path. `restoreIterationsFromSnapshot` changed from count-based replace to per-iteration append-with-dedup (`slices.ContainsFunc` by iteration number). `coalesceProgress` merges delta slices via `mergeIterationDeltas` (concatenate + dedup by iteration number). `/su` switch and initial restore pass `fromIter=0` to get full history (one-time operation). Web callback passes `fromIter=0` (web doesn't use delta protocol). `recordIterationSnapshot` now returns `*protocol.ProgressEvent` (the delta, or nil) instead of being void.\n- **`startAgentTurn` MUST set `progressState.current` AFTER `resetProgressState`, not before.** `resetProgressState` clears `progressState.current` to nil. If the \"thinking\" initial state is set before `resetProgressState`, it gets wiped — the first tick sees `hasIterData=false` in `updateStreamingOnly`, falls back to `renderMessage` (empty content, no pulse spinner), and the user sees no visual feedback until the first structured progress event arrives (seconds later). Setting `progressState.current = {Phase:\"thinking\"}` after `resetProgressState` ensures `liveIterationBlocks` renders a pulse spinner on the very first tick — immediate visual feedback.\n- **`mergeSubAgentTrees` MUST be called in `applyProgressSnapshot` before replacing `progressState.current`.** Structured events don't always carry SubAgents (engine only includes them when `resolveSubAgents` returns non-empty). Without merging, the tree flickers — appears when SubAgents are present, vanishes when the next structured event omits them. The merge preserves prev's running agents and marks dropped ones as done. `mergeSubAgentTrees` also preserves children when `new.Children` is empty but `prev.Children` exists (marks all prev children as done via `markAllDone()`). Without this call, `mergeSubAgentTrees` is dead code (only called in tests).\n- **`finalizeTurnFromSnapshot` MUST create synthetic assistant message for ALL non-CLI channels.** The old code only did this for `m.channelName == \"agent\"` (SubAgent viewer). But when CLI `/su` switches to any non-CLI session (feishu, web, qq, etc.), the agent's outbound reply goes to the originating channel via `sendMessage(chName, ...)`, NOT to the CLI channel. The CLI receives progress events (including Content via structured progress) and PhaseDone, but never receives `cliOutboundMsg`. Without the synthetic message, the CLI stays stuck showing \"...\" after PhaseDone. The fix: `m.channelName != \"cli\"` (covers agent, feishu, web, etc.). Content comes from `cur.Content` (set by `structuredProgress.Content` via `notifyProgress` before PhaseDone). PhaseDone payload itself has no Content — must fall back to `cur` (captured before `endAgentTurn`).\n- **`ProgressEventHandler` MUST be created for ALL channels, not just cli/web/ProgressSender.** `buildMainRunConfig` previously only created `ProgressEventHandler` for `\"cli\"`, `\"web\"`, and plugin `ProgressSender` channels. When a CLI user `/su` switches to a feishu (or other non-cli/web) session and sends a message, the message's `Channel == \"feishu\"` → no `ProgressEventHandler` → `progressFinalizer`'s PhaseDone event is silently dropped → `finalizeTurnFromSnapshot` never runs → `m.typing` stays `true` forever (TUI stuck in busy). Fix: the `switch channel` in `buildMainRunConfig` now has a `default` branch that creates a `buildCLIProgressEventHandler(chatID, channel)` for non-cli/web/non-ProgressSender channels. This ensures PhaseDone, structured progress, and `lastProgressSnapshot` storage work for ALL channels when viewed via `/su`. Additionally, the `else` branch of the `ProgressNotifier` setup now sets a no-op notifier for non-cli/web channels without `autoNotify`, enabling `progressFinalizer` to generate PhaseDone events.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n- **`buildStreamCallbacks` only broadcasts to the ORIGINATING channel.** Each channel's `SendProgress` already broadcasts to ALL Hub subscribers (including other channels' clients via the shared Hub). Broadcasting to multiple channels causes duplicate delivery: each subscriber receives the same event N times (N = number of ProgressSender channels). This was the root cause of the \"two consecutive Assistant messages\" bug that only appeared when Web and TUI were both open on the same session — duplicate stream events arrived after state transitions, triggering `startAgentTurn` to create a duplicate streaming assistant.\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressSlot coalescing**(原 progressCh buffer=1 已替换):`SendProgress` 使用 mutex-protected `progressSlot` + buffer-1 `progressSignal` 替代旧 buffer-1 channel。旧设计中 eviction+re-insert 有 race window,结构化的 \"done\" 事件可被静默丢弃,导致工具永久卡在 \"running\"(● 不变 ✓)。新设计:`progressSlot` 始终持有最新合并后的事件,`handleProgressDrain` 读取并清空 slot。结构化事件永远不会丢失——stream-only 不能驱逐 structured,structured 替换时保留 stream fields/TokenUsage/CWD。`coalesceProgress`(asyncCh 层)仍保留用于合并连续 progress 消息。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **新用户消息开始前必须固化旧 streaming turn。** `PhaseDone` 后 `endAgentTurn` 会刻意保留 `streamingMsgIdx` 和 `progressState`,等待最终 `handleAgentMessage` 做无闪烁收尾。但 `sendMessage`/`sendToAgent` 会在 `startAgentTurn()` 前先 append 用户消息并刷新 viewport;如果不先调用 `finalizeStaleStreamingBeforeNewUserMessage()`,这次刷新会把上一个 turn 的 live stream 渲染到新用户消息下面。真实新消息入口必须在 append user message 前固化旧 assistant partial、清 `streamingMsgIdx`/旧 progress stream cache。**`startAgentTurn()` 内部也调用 `finalizeStaleStreamingBeforeNewUserMessage()`**,覆盖所有不经过 `sendMessage`/`sendToAgent` 的路径(auto-start、bg task 注入、`/compress`、`cliProcessingMsg`、AskUser 回调)。已有调用的路径因 `streamingMsgIdx=-1` 走 early return,无副作用。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "962bf737d33b7901a2321b8217c7690f9f7e37e8": {"t": 1785759462.6711082, "n": 103949, "h": "6affb3125d6795cb", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39 migration coexists with master's v38 (`tenants.runner_id`).** model-first originally used v38; after rebase it was bumped to v39 to avoid the schema-version collision. `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **Cancel interception must NOT send premature cancel ack.** The `/cancel` handler in the agent loop only sends the cancel signal to `cancelCh`. The cancel ack (with `cancelled=true` metadata) is sent exclusively by `chatProcessLoop`'s `wasCancelled` path AFTER Run returns. Sending it in the interception caused TUI to enter idle while the Run was still active — the cancel signal is asynchronous (consumed by `cancelListener` goroutine), so there was a window where TUI was idle but the agent was still working.\n- **Cancel records pending bg notifications for the cancelled session.** `handleCancelledRun` takes same-session notifications still pending in `bgRunPending` and records them into the interrupted turn as synthetic tool observations; it also records a `user_cancelled` synthetic tool so the next model call can see that Ctrl+C interrupted the previous turn. Notifications already drained into the Run were already persisted by the normal synthetic-tool injection path, so cancel clears only their `drainedThisRun` tracking metadata. Other-session pending notifications remain in their own session bucket.\n- **DrainBgNotifications is cancel-aware.** `engine_run.go` post-iteration drain checks `ctx.Err() == nil` before draining. If ctx is already cancelled (cancel signal arrived during the LLM call), drain is skipped so `handleCancelledRun` can record this session's pending notifications in the interrupted turn before the UI receives the cancel ack.\n- **`wireBgNotificationDrain` tracks drained notifications.** Drain copies consumed notifications into `bgSessionState.drainedThisRun` (guarded by `drainedThisRunMu`). On normal/error turn completion, `clearDrainedThisRun()` discards the tracking metadata after notifications were processed. On cancel, `handleCancelledRun` clears the tracking metadata without re-delivering those already-persisted notifications as a fresh user message.\n- **`drainAndProcessNotifications` batches all notifications into ONE user message.** All drained notifications are formatted and joined with `\\n\\n---\\n\\n` separators, then injected as a single `injectBgUserMessage` call. This avoids spamming the TUI with N separate messages and triggering N separate agent turns when multiple bg tasks complete simultaneously.\n- **`processMessage` early-exits on cancelled ctx.** Before calling `Run`, `processMessage` checks `ctx.Err()`. If cancelled during setup (DB tenant creation, MCP config, workspace init — especially heavy on the first message), it returns immediately with `cancelled=true` metadata instead of entering Run.\n- **Ctrl+C in TUI immediately cancels + clears queue.** `sendCancel` shows feedback BEFORE `sendInbound` (WS write can block). Ctrl+C while typing always clears `messageQueue` and sends cancel in one keystroke — no more N+1 presses to delete queued messages one by one before cancel takes effect.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`liveIterationBlocks` MUST suppress pulse when any tools are present.** `hasSpinner` was only set for `running`/`active` tools. When a tool transitions to `done`/`error`/`pending` (between sequential tool execution), `hasSpinner` goes `false` → pulse spinner appears (+1 line) → next tool starts running → pulse disappears (-1 line). This H→H+1→H oscillation is the visible jitter. Fix: `if len(tools) > 0 { hasSpinner = true }` — pulse is an idle indicator for the thinking phase only, not for tool execution phases.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`updateStreamingOnly` MUST skip liveLines when `!m.typing`.** `endAgentTurn` preserves `progressState.current` and `progressState.iterations` for flicker-free rendering between PhaseDone and `handleAgentMessage`. But `updateStreamingOnly` renders BOTH `completedLines` (from `progressState.iterations`, which includes the final iteration with Content + Reasoning) AND `liveLines` (from `progressState.current`, which has the SAME Content + Reasoning). This causes the final reasoning + response to appear twice. For CLI sessions this is momentary (`handleAgentMessage` clears `streamingMsgIdx` shortly after), but for non-CLI sessions (`/su` to feishu/web/qq), `handleAgentMessage` never arrives → `streamingMsgIdx` stays valid → `updateStreamingOnly` runs every tick → persistent duplication. Fix: the `liveLines` rendering block guards with `m.typing` — when the turn has ended, only completed iterations are rendered (they already contain the final content).\n- **`postRestoreSessionSetup` MUST call `refreshCachedModelName()` for LLM state resolution, NOT inline `LoadSessionLLMState` + `GetDefault` fallback.** The old code had its own LLM resolution path (local JSON → GetDefault) that bypassed the DB tenants table. In remote mode, `SaveSessionLLMState` passes `skipBackendFields=true` (local JSON never writes subID/model), so `LoadSessionLLMState` always returned zero → `GetDefault(\"\")` returned the subscription's default `Model` (often `\"\"`), overwriting the correct per-session model that `restoreSession()` had just restored from `savedSessions`. This only affected the default session (non-default sessions write local JSON via `SaveSessionLLMState` without skip at creation time). Fix: `postRestoreSessionSetup` now calls `refreshCachedModelName()` which checks DB tenants table (`GetSessionSubscription` RPC) first, then local JSON, then savedSessions, then GetDefault — the same 4-level priority chain used at startup. Context limits are then resolved from the now-correct `activeSubID` + `cachedModelName` via `ResolveEffectiveMaxContext`/`ResolveEffectiveMaxOutputTokens`. This makes default sessions and regular sessions follow the exact same restoration path.\n- **`ensureSessionModel` auto-binds a model to ALL new sessions on first use.** Both `ResolveLLM` (agent side, covers all channels including Web/Feishu) and `postRestoreSessionSetup` (CLI side, for immediate status bar display) call auto-bind when a session has no per-session binding in the tenants table. Priority: Balance tier config (`user_settings[\"tier_balance\"]` → `\"subID|model\"`) → last-used model (`user_default_model` table). The binding is persisted via `SelectModel` (writes to both tenants table and `user_default_model`), so subsequent calls hit the tenants table directly and skip auto-bind. If neither Balance tier nor last-used model exists, no binding is created (the caller falls through to system default). This ensures ALL sessions — CLI-created, `/chat new`, `/su` switch, Web/Feishu — get an explicit model binding on first use, not just those created via the CLI session panel. CLI's `ensureSessionModelBinding` is idempotent: it checks `GetSessionSubscription` first and returns immediately if the session already has a binding.\n- **Delta Push + Watermark Pull protocol replaces full-history push.** Push events carry ONLY the newly completed iteration as `IterationHistory` (0 or 1 entries) — not the full cumulative history. `attachIterationDelta` (formerly `recordIterationAdvanceAndAttachHistory`) calls `recordIterationSnapshot` which returns the delta (the previous iteration snapshot) or nil if no advance. `GetActiveProgress` gains a `fromIter` watermark parameter: it filters `iterationHistories` to only return iterations with `Iteration > fromIter`. The CLI sends `m.progressState.lastIter` as the watermark. Tick pull frequency reduced from 100ms to 2s (every 20th tick) — it's now a safety net for recovering iterations lost to progressSlot coalescing or WS disconnect, not the primary delivery path. Push events are the primary path. `restoreIterationsFromSnapshot` changed from count-based replace to per-iteration append-with-dedup (`slices.ContainsFunc` by iteration number). `coalesceProgress` merges delta slices via `mergeIterationDeltas` (concatenate + dedup by iteration number). `/su` switch and initial restore pass `fromIter=0` to get full history (one-time operation). Web callback passes `fromIter=0` (web doesn't use delta protocol). `recordIterationSnapshot` now returns `*protocol.ProgressEvent` (the delta, or nil) instead of being void.\n- **`startAgentTurn` MUST set `progressState.current` AFTER `resetProgressState`, not before.** `resetProgressState` clears `progressState.current` to nil. If the \"thinking\" initial state is set before `resetProgressState`, it gets wiped — the first tick sees `hasIterData=false` in `updateStreamingOnly`, falls back to `renderMessage` (empty content, no pulse spinner), and the user sees no visual feedback until the first structured progress event arrives (seconds later). Setting `progressState.current = {Phase:\"thinking\"}` after `resetProgressState` ensures `liveIterationBlocks` renders a pulse spinner on the very first tick — immediate visual feedback.\n- **`mergeSubAgentTrees` MUST be called in `applyProgressSnapshot` before replacing `progressState.current`.** Structured events don't always carry SubAgents (engine only includes them when `resolveSubAgents` returns non-empty). Without merging, the tree flickers — appears when SubAgents are present, vanishes when the next structured event omits them. The merge preserves prev's running agents and marks dropped ones as done. `mergeSubAgentTrees` also preserves children when `new.Children` is empty but `prev.Children` exists (marks all prev children as done via `markAllDone()`). Without this call, `mergeSubAgentTrees` is dead code (only called in tests).\n- **`finalizeTurnFromSnapshot` MUST create synthetic assistant message for ALL non-CLI channels.** The old code only did this for `m.channelName == \"agent\"` (SubAgent viewer). But when CLI `/su` switches to any non-CLI session (feishu, web, qq, etc.), the agent's outbound reply goes to the originating channel via `sendMessage(chName, ...)`, NOT to the CLI channel. The CLI receives progress events (including Content via structured progress) and PhaseDone, but never receives `cliOutboundMsg`. Without the synthetic message, the CLI stays stuck showing \"...\" after PhaseDone. The fix: `m.channelName != \"cli\"` (covers agent, feishu, web, etc.). Content comes from `cur.Content` (set by `structuredProgress.Content` via `notifyProgress` before PhaseDone). PhaseDone payload itself has no Content — must fall back to `cur` (captured before `endAgentTurn`).\n- **`ProgressEventHandler` MUST be created for ALL channels, not just cli/web/ProgressSender.** `buildMainRunConfig` previously only created `ProgressEventHandler` for `\"cli\"`, `\"web\"`, and plugin `ProgressSender` channels. When a CLI user `/su` switches to a feishu (or other non-cli/web) session and sends a message, the message's `Channel == \"feishu\"` → no `ProgressEventHandler` → `progressFinalizer`'s PhaseDone event is silently dropped → `finalizeTurnFromSnapshot` never runs → `m.typing` stays `true` forever (TUI stuck in busy). Fix: the `switch channel` in `buildMainRunConfig` now has a `default` branch that creates a `buildCLIProgressEventHandler(chatID, channel)` for non-cli/web/non-ProgressSender channels. This ensures PhaseDone, structured progress, and `lastProgressSnapshot` storage work for ALL channels when viewed via `/su`. Additionally, the `else` branch of the `ProgressNotifier` setup now sets a no-op notifier for non-cli/web channels without `autoNotify`, enabling `progressFinalizer` to generate PhaseDone events.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressSlot coalescing**(原 progressCh buffer=1 已替换):`SendProgress` 使用 mutex-protected `progressSlot` + buffer-1 `progressSignal` 替代旧 buffer-1 channel。旧设计中 eviction+re-insert 有 race window,结构化的 \"done\" 事件可被静默丢弃,导致工具永久卡在 \"running\"(● 不变 ✓)。新设计:`progressSlot` 始终持有最新合并后的事件,`handleProgressDrain` 读取并清空 slot。结构化事件永远不会丢失——stream-only 不能驱逐 structured,structured 替换时保留 stream fields/TokenUsage/CWD。`coalesceProgress`(asyncCh 层)仍保留用于合并连续 progress 消息。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **新用户消息开始前必须固化旧 streaming turn。** `PhaseDone` 后 `endAgentTurn` 会刻意保留 `streamingMsgIdx` 和 `progressState`,等待最终 `handleAgentMessage` 做无闪烁收尾。但 `sendMessage`/`sendToAgent` 会在 `startAgentTurn()` 前先 append 用户消息并刷新 viewport;如果不先调用 `finalizeStaleStreamingBeforeNewUserMessage()`,这次刷新会把上一个 turn 的 live stream 渲染到新用户消息下面。真实新消息入口必须在 append user message 前固化旧 assistant partial、清 `streamingMsgIdx`/旧 progress stream cache。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "d3098052953e98859b64bcd6b4bc04060defa5b5": {"t": 1785759464.3187046, "n": 102776, "h": "8e8d25847e0dd21a", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39 migration coexists with master's v38 (`tenants.runner_id`).** model-first originally used v38; after rebase it was bumped to v39 to avoid the schema-version collision. `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **Cancel interception must NOT send premature cancel ack.** The `/cancel` handler in the agent loop only sends the cancel signal to `cancelCh`. The cancel ack (with `cancelled=true` metadata) is sent exclusively by `chatProcessLoop`'s `wasCancelled` path AFTER Run returns. Sending it in the interception caused TUI to enter idle while the Run was still active — the cancel signal is asynchronous (consumed by `cancelListener` goroutine), so there was a window where TUI was idle but the agent was still working.\n- **Cancel discards bg notifications for the cancelled session.** `handleCancelledRun` discards both notifications already drained into the Run (`bgSessionState.drainedThisRun`) and same-session notifications still pending in `bgRunPending`. The user pressed Ctrl+C — they want everything to stop. Letting pending notifications drain after the cancel ack can start a fresh agent turn while the TUI is already idle.\n- **DrainBgNotifications is cancel-aware.** `engine_run.go` post-iteration drain checks `ctx.Err() == nil` before draining. If ctx is already cancelled (cancel signal arrived during the LLM call), drain is skipped so `handleCancelledRun` can discard this session's pending notifications before the UI receives the cancel ack.\n- **`wireBgNotificationDrain` tracks drained notifications.** Drain copies consumed notifications into `bgSessionState.drainedThisRun` (guarded by `drainedThisRunMu`). On normal/error turn completion, `clearDrainedThisRun()` discards the tracking metadata after notifications were processed. On cancel, `handleCancelledRun` discards the tracked notifications and pending same-session notifications (cancel = stop all work).\n- **`drainAndProcessNotifications` batches all notifications into ONE user message.** All drained notifications are formatted and joined with `\\n\\n---\\n\\n` separators, then injected as a single `injectBgUserMessage` call. This avoids spamming the TUI with N separate messages and triggering N separate agent turns when multiple bg tasks complete simultaneously.\n- **`processMessage` early-exits on cancelled ctx.** Before calling `Run`, `processMessage` checks `ctx.Err()`. If cancelled during setup (DB tenant creation, MCP config, workspace init — especially heavy on the first message), it returns immediately with `cancelled=true` metadata instead of entering Run.\n- **Ctrl+C in TUI immediately cancels + clears queue.** `sendCancel` shows feedback BEFORE `sendInbound` (WS write can block). Ctrl+C while typing always clears `messageQueue` and sends cancel in one keystroke — no more N+1 presses to delete queued messages one by one before cancel takes effect.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`liveIterationBlocks` MUST suppress pulse when any tools are present.** `hasSpinner` was only set for `running`/`active` tools. When a tool transitions to `done`/`error`/`pending` (between sequential tool execution), `hasSpinner` goes `false` → pulse spinner appears (+1 line) → next tool starts running → pulse disappears (-1 line). This H→H+1→H oscillation is the visible jitter. Fix: `if len(tools) > 0 { hasSpinner = true }` — pulse is an idle indicator for the thinking phase only, not for tool execution phases.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`updateStreamingOnly` MUST skip liveLines when `!m.typing`.** `endAgentTurn` preserves `progressState.current` and `progressState.iterations` for flicker-free rendering between PhaseDone and `handleAgentMessage`. But `updateStreamingOnly` renders BOTH `completedLines` (from `progressState.iterations`, which includes the final iteration with Content + Reasoning) AND `liveLines` (from `progressState.current`, which has the SAME Content + Reasoning). This causes the final reasoning + response to appear twice. For CLI sessions this is momentary (`handleAgentMessage` clears `streamingMsgIdx` shortly after), but for non-CLI sessions (`/su` to feishu/web/qq), `handleAgentMessage` never arrives → `streamingMsgIdx` stays valid → `updateStreamingOnly` runs every tick → persistent duplication. Fix: the `liveLines` rendering block guards with `m.typing` — when the turn has ended, only completed iterations are rendered (they already contain the final content).\n- **`postRestoreSessionSetup` MUST call `refreshCachedModelName()` for LLM state resolution, NOT inline `LoadSessionLLMState` + `GetDefault` fallback.** The old code had its own LLM resolution path (local JSON → GetDefault) that bypassed the DB tenants table. In remote mode, `SaveSessionLLMState` passes `skipBackendFields=true` (local JSON never writes subID/model), so `LoadSessionLLMState` always returned zero → `GetDefault(\"\")` returned the subscription's default `Model` (often `\"\"`), overwriting the correct per-session model that `restoreSession()` had just restored from `savedSessions`. This only affected the default session (non-default sessions write local JSON via `SaveSessionLLMState` without skip at creation time). Fix: `postRestoreSessionSetup` now calls `refreshCachedModelName()` which checks DB tenants table (`GetSessionSubscription` RPC) first, then local JSON, then savedSessions, then GetDefault — the same 4-level priority chain used at startup. Context limits are then resolved from the now-correct `activeSubID` + `cachedModelName` via `ResolveEffectiveMaxContext`/`ResolveEffectiveMaxOutputTokens`. This makes default sessions and regular sessions follow the exact same restoration path.\n- **`ensureSessionModel` auto-binds a model to ALL new sessions on first use.** Both `ResolveLLM` (agent side, covers all channels including Web/Feishu) and `postRestoreSessionSetup` (CLI side, for immediate status bar display) call auto-bind when a session has no per-session binding in the tenants table. Priority: Balance tier config (`user_settings[\"tier_balance\"]` → `\"subID|model\"`) → last-used model (`user_default_model` table). The binding is persisted via `SelectModel` (writes to both tenants table and `user_default_model`), so subsequent calls hit the tenants table directly and skip auto-bind. If neither Balance tier nor last-used model exists, no binding is created (the caller falls through to system default). This ensures ALL sessions — CLI-created, `/chat new`, `/su` switch, Web/Feishu — get an explicit model binding on first use, not just those created via the CLI session panel. CLI's `ensureSessionModelBinding` is idempotent: it checks `GetSessionSubscription` first and returns immediately if the session already has a binding.\n- **`snapshotIterationLocal` is REMOVED. Completed iterations come ONLY from `restoreIterationsFromSnapshot` (DB `IterationHistory`) and `finalizeTurnFromSnapshot` (PhaseDone).** The old `snapshotIterationLocal` captured incomplete iterations during streaming (empty Content/Tools), and its dedup logic prevented DB authoritative data from replacing them — causing tool lines and content to disappear. Push events (from `ProgressEventHandler`) do NOT carry `IterationHistory`; only `GetActiveProgress` returns it. The tick handler MUST call `GetActiveProgressFn` every 100ms during typing to pull `IterationHistory` from the agent's `iterationHistories` map. `restoreIterationsFromSnapshot` runs BEFORE the Seq check in `applyProgressSnapshot` — tick pulls reuse the same Seq as the last push event, so the Seq dedup would skip them, but the IterationHistory may have grown since the push event (recordIterationSnapshot runs AFTER SendProgress). `restoreIterationsFromSnapshot`'s own count check (O(1)) prevents unnecessary rebuilds when nothing changed.\n- **`mergeSubAgentTrees` MUST be called in `applyProgressSnapshot` before replacing `progressState.current`.** Structured events don't always carry SubAgents (engine only includes them when `resolveSubAgents` returns non-empty). Without merging, the tree flickers — appears when SubAgents are present, vanishes when the next structured event omits them. The merge preserves prev's running agents and marks dropped ones as done. `mergeSubAgentTrees` also preserves children when `new.Children` is empty but `prev.Children` exists (marks all prev children as done via `markAllDone()`). Without this call, `mergeSubAgentTrees` is dead code (only called in tests).\n- **`finalizeTurnFromSnapshot` MUST create synthetic assistant message for ALL non-CLI channels.** The old code only did this for `m.channelName == \"agent\"` (SubAgent viewer). But when CLI `/su` switches to any non-CLI session (feishu, web, qq, etc.), the agent's outbound reply goes to the originating channel via `sendMessage(chName, ...)`, NOT to the CLI channel. The CLI receives progress events (including Content via structured progress) and PhaseDone, but never receives `cliOutboundMsg`. Without the synthetic message, the CLI stays stuck showing \"...\" after PhaseDone. The fix: `m.channelName != \"cli\"` (covers agent, feishu, web, etc.). Content comes from `cur.Content` (set by `structuredProgress.Content` via `notifyProgress` before PhaseDone). PhaseDone payload itself has no Content — must fall back to `cur` (captured before `endAgentTurn`).\n- **`ProgressEventHandler` MUST be created for ALL channels, not just cli/web/ProgressSender.** `buildMainRunConfig` previously only created `ProgressEventHandler` for `\"cli\"`, `\"web\"`, and plugin `ProgressSender` channels. When a CLI user `/su` switches to a feishu (or other non-cli/web) session and sends a message, the message's `Channel == \"feishu\"` → no `ProgressEventHandler` → `progressFinalizer`'s PhaseDone event is silently dropped → `finalizeTurnFromSnapshot` never runs → `m.typing` stays `true` forever (TUI stuck in busy). Fix: the `switch channel` in `buildMainRunConfig` now has a `default` branch that creates a `buildCLIProgressEventHandler(chatID, channel)` for non-cli/web/non-ProgressSender channels. This ensures PhaseDone, structured progress, and `lastProgressSnapshot` storage work for ALL channels when viewed via `/su`. Additionally, the `else` branch of the `ProgressNotifier` setup now sets a no-op notifier for non-cli/web channels without `autoNotify`, enabling `progressFinalizer` to generate PhaseDone events.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressSlot coalescing**(原 progressCh buffer=1 已替换):`SendProgress` 使用 mutex-protected `progressSlot` + buffer-1 `progressSignal` 替代旧 buffer-1 channel。旧设计中 eviction+re-insert 有 race window,结构化的 \"done\" 事件可被静默丢弃,导致工具永久卡在 \"running\"(● 不变 ✓)。新设计:`progressSlot` 始终持有最新合并后的事件,`handleProgressDrain` 读取并清空 slot。结构化事件永远不会丢失——stream-only 不能驱逐 structured,structured 替换时保留 stream fields/TokenUsage/CWD。`coalesceProgress`(asyncCh 层)仍保留用于合并连续 progress 消息。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **新用户消息开始前必须固化旧 streaming turn。** `PhaseDone` 后 `endAgentTurn` 会刻意保留 `streamingMsgIdx` 和 `progressState`,等待最终 `handleAgentMessage` 做无闪烁收尾。但 `sendMessage`/`sendToAgent` 会在 `startAgentTurn()` 前先 append 用户消息并刷新 viewport;如果不先调用 `finalizeStaleStreamingBeforeNewUserMessage()`,这次刷新会把上一个 turn 的 live stream 渲染到新用户消息下面。真实新消息入口必须在 append user message 前固化旧 assistant partial、清 `streamingMsgIdx`/旧 progress stream cache。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "6dc89ec988bb9931633529dfdda96d4ca7ddc39c": {"t": 1785759467.1688428, "n": 94901, "h": "0e3bd501f22ab79d", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39 migration coexists with master's v38 (`tenants.runner_id`).** model-first originally used v38; after rebase it was bumped to v39 to avoid the schema-version collision. `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`mergeSubAgentTrees` MUST be called in `applyProgressSnapshot` before replacing `progressState.current`.** Structured events don't always carry SubAgents (engine only includes them when `resolveSubAgents` returns non-empty). Without merging, the tree flickers — appears when SubAgents are present, vanishes when the next structured event omits them. The merge preserves prev's running agents and marks dropped ones as done. `mergeSubAgentTrees` also preserves children when `new.Children` is empty but `prev.Children` exists (marks all prev children as done via `markAllDone()`). Without this call, `mergeSubAgentTrees` is dead code (only called in tests).\n- **`finalizeTurnFromSnapshot` MUST create synthetic assistant message for ALL non-CLI channels.** The old code only did this for `m.channelName == \"agent\"` (SubAgent viewer). But when CLI `/su` switches to any non-CLI session (feishu, web, qq, etc.), the agent's outbound reply goes to the originating channel via `sendMessage(chName, ...)`, NOT to the CLI channel. The CLI receives progress events (including Content via structured progress) and PhaseDone, but never receives `cliOutboundMsg`. Without the synthetic message, the CLI stays stuck showing \"...\" after PhaseDone. The fix: `m.channelName != \"cli\"` (covers agent, feishu, web, etc.). Content comes from `cur.Content` (set by `structuredProgress.Content` via `notifyProgress` before PhaseDone). PhaseDone payload itself has no Content — must fall back to `cur` (captured before `endAgentTurn`).\n- **`ProgressEventHandler` MUST be created for ALL channels, not just cli/web/ProgressSender.** `buildMainRunConfig` previously only created `ProgressEventHandler` for `\"cli\"`, `\"web\"`, and plugin `ProgressSender` channels. When a CLI user `/su` switches to a feishu (or other non-cli/web) session and sends a message, the message's `Channel == \"feishu\"` → no `ProgressEventHandler` → `progressFinalizer`'s PhaseDone event is silently dropped → `finalizeTurnFromSnapshot` never runs → `m.typing` stays `true` forever (TUI stuck in busy). Fix: the `switch channel` in `buildMainRunConfig` now has a `default` branch that creates a `buildCLIProgressEventHandler(chatID, channel)` for non-cli/web/non-ProgressSender channels. This ensures PhaseDone, structured progress, and `lastProgressSnapshot` storage work for ALL channels when viewed via `/su`. Additionally, the `else` branch of the `ProgressNotifier` setup now sets a no-op notifier for non-cli/web channels without `autoNotify`, enabling `progressFinalizer` to generate PhaseDone events.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressSlot coalescing**(原 progressCh buffer=1 已替换):`SendProgress` 使用 mutex-protected `progressSlot` + buffer-1 `progressSignal` 替代旧 buffer-1 channel。旧设计中 eviction+re-insert 有 race window,结构化的 \"done\" 事件可被静默丢弃,导致工具永久卡在 \"running\"(● 不变 ✓)。新设计:`progressSlot` 始终持有最新合并后的事件,`handleProgressDrain` 读取并清空 slot。结构化事件永远不会丢失——stream-only 不能驱逐 structured,structured 替换时保留 stream fields/TokenUsage/CWD。`coalesceProgress`(asyncCh 层)仍保留用于合并连续 progress 消息。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "7026da8395db60668157029a3193715421dc9eb3": {"t": 1785759469.5104437, "n": 93857, "h": "a281ab5cfc47096a", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39 migration coexists with master's v38 (`tenants.runner_id`).** model-first originally used v38; after rebase it was bumped to v39 to avoid the schema-version collision. `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`mergeSubAgentTrees` MUST be called in `applyProgressSnapshot` before replacing `progressState.current`.** Structured events don't always carry SubAgents (engine only includes them when `resolveSubAgents` returns non-empty). Without merging, the tree flickers — appears when SubAgents are present, vanishes when the next structured event omits them. The merge preserves prev's running agents and marks dropped ones as done. `mergeSubAgentTrees` also preserves children when `new.Children` is empty but `prev.Children` exists (marks all prev children as done via `markAllDone()`). Without this call, `mergeSubAgentTrees` is dead code (only called in tests).\n- **`finalizeTurnFromSnapshot` MUST create synthetic assistant message for ALL non-CLI channels.** The old code only did this for `m.channelName == \"agent\"` (SubAgent viewer). But when CLI `/su` switches to any non-CLI session (feishu, web, qq, etc.), the agent's outbound reply goes to the originating channel via `sendMessage(chName, ...)`, NOT to the CLI channel. The CLI receives progress events (including Content via structured progress) and PhaseDone, but never receives `cliOutboundMsg`. Without the synthetic message, the CLI stays stuck showing \"...\" after PhaseDone. The fix: `m.channelName != \"cli\"` (covers agent, feishu, web, etc.). Content comes from `cur.Content` (set by `structuredProgress.Content` via `notifyProgress` before PhaseDone). PhaseDone payload itself has no Content — must fall back to `cur` (captured before `endAgentTurn`).\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressSlot coalescing**(原 progressCh buffer=1 已替换):`SendProgress` 使用 mutex-protected `progressSlot` + buffer-1 `progressSignal` 替代旧 buffer-1 channel。旧设计中 eviction+re-insert 有 race window,结构化的 \"done\" 事件可被静默丢弃,导致工具永久卡在 \"running\"(● 不变 ✓)。新设计:`progressSlot` 始终持有最新合并后的事件,`handleProgressDrain` 读取并清空 slot。结构化事件永远不会丢失——stream-only 不能驱逐 structured,structured 替换时保留 stream fields/TokenUsage/CWD。`coalesceProgress`(asyncCh 层)仍保留用于合并连续 progress 消息。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "7838ba6034d8b9a1fa4842fe15f934f14aca0c50": {"t": 1785759471.4782257, "n": 92348, "h": "d52d9eaad12ebba1", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39 migration coexists with master's v38 (`tenants.runner_id`).** model-first originally used v38; after rebase it was bumped to v39 to avoid the schema-version collision. `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **History handlers share unified dedup via `dedupAppend`.** `handleSuHistoryLoad` and `handleHistoryLoad` both dedup-append incoming messages using `msgIdentity{role, timestamp}` (keyed on role + timestamp, NOT turnID — `protocol.HistoryMessage` from DB has no turnID, so including it would make the same logical message dedup differently by source). `handleHistoryReload` still does overall replace (not append) via `mergeMessagesPreservingCache`. All three convert `protocol.HistoryMessage → cliMessage` via the shared `toCLIMessage` helper to prevent copy-paste drift. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`mergeProgressState` replaces whole-replacement + carryForward.** Structured progress events merge field-by-field into `progressState.current` (in-place), preserving stream-only fields (`StreamContent`, `ReasoningStreamContent`, `StreamingTools`, `StreamTokens`). On iteration change, stream fields are cleared. This eliminates `carryForwardProgressState` (9 fields), `reasoningByIter`, `lastReasoning`, `lastCompletedTools`, and all their fallback chains. `StreamingTools` are filtered against `ActiveTools` names — when a tool transitions from \"generating\" to \"running\", the stale generating entry is removed (prevents \"preparing…\" persisting alongside \"running\").\n- **`snapshotIterationChange` reads directly from prev (pre-merge shallow copy).** `mergeProgressState` updates in-place, so `handleProgressMsg` shallow-copies `current` into `prev` BEFORE merging. Snapshot reads `prev.Reasoning` → `prev.ReasoningStreamContent` (2-level, no `reasoningByIter`/`lastReasoning`). No `lastCompletedTools` — CompletedTools come from `prev` directly.\n- **Ctrl+C cancel path captures streamed content as snapshot fallback.** When Ctrl+C interrupts mid-stream, structured fields are empty. The streamed content in `prev` (pre-merge copy) or `m.progressState.current` is the only data available. Priority: `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning; `prev.Thinking` → `prev.StreamContent` for content. `handleCancelAck` MUST call `updateViewportContent()` after `rerenderCachedMessage()` — without it, the viewport shows stale streaming content until the next 100ms tick (visually: \"history disappears then re-renders\").\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `histLines` + `dynamicLines` (rewind/dynamic part) are assembled in-place into the reused `allLines` slice and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. `renderProgressBlock` is now a no-op (progress renders inline via `renderTurnBody`/`renderLiveIteration`), so the tick path no longer assembles a progress-block section. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressCh coalescing**:buffer=1 channel 会丢弃事件。`mergeProgressState` 原地合并设计使流式字段不受结构化事件影响——丢弃的结构化事件不会清空流式数据,无需 carryForward 补偿。\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools)。`mergeProgressState` 在合并前做浅拷贝保护 prev,snapshot 直接从 prev 读取所有字段,无需 fallback 链。\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,CompletedTools 包含全部工具。按 `lastIter` 过滤 CompletedTools 防止跨迭代工具污染。无 `lastCompletedTools`、无 `pendingToolSummary`——iterations 直接保留在 `progressState.iterations` 中供 `handleAgentMessage` 读取。\n- **turnDoneFlags/pendingToolSummary 已删除**:用单一 `replyProcessed bool` 替代。`handleProgressDone` 完成后 iterations 保留在 `progressState` 中(endAgentTurn 不清除),`handleAgentMessage` 直接读取。队列刷新用 `!m.replyProcessed` 守卫 dead window。\n- **guide 颜色跳变已修复**:`updateStreamingOnly` 在 `!m.typing` 时立即使用 `DimGuideSt`,消除 PhaseDone→handleAgentMessage 之间的 bright→dim 跳变。\n- **GotoBottom 统一守卫**:所有 `GotoBottom()` 调用通过 `!m.userScrolledUp` 守卫。慢速路径(fullRebuild 后)不再无条件强制滚动。\n- **handleCancelAck 必须 `updateViewportContent()`**:cancel ack 更新缓存后必须立即推送到 viewport,否则 viewport 显示 stale streaming 内容直到下次 tick(用户感知为\"历史消失再重现\")。\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "76a0a2ee3afb058a062e84ddc9a42fcd018f3bb9": {"t": 1785759474.1503077, "n": 93919, "h": "6f878bd945a5b463", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39 migration coexists with master's v38 (`tenants.runner_id`).** model-first originally used v38; after rebase it was bumped to v39 to avoid the schema-version collision. `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` does NOT clear `streamingMsgIdx` or `progressState`.** Keeping `streamingMsgIdx` valid ensures the tick handler uses `updateStreamingOnly` (streaming path) instead of `appendNewMessagesToCache` (which caches incomplete content → flicker). Progress state (`iterations`, `current`, `reasoningByIter`) is preserved for flicker-free rendering between PhaseDone and handleAgentMessage. It is cleared by `startAgentTurn` → `resetProgressState` when the next turn begins, or by `handleAgentMessage` when the reply arrives. `handleAgentMessage` sets `streamingMsgIdx = -1` and calls `rerenderCachedMessage` for a single clean transition.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST return early, skipping `snapshotIterationChange` and `updateViewportContent`.** The handler clears `progressState.iterations=nil` and `lastIter=0`, but without `return`, execution falls through to `snapshotIterationChange(msg.payload, prev)` where `prev` (captured before the handler) holds pre-compression data. The mismatch path (`prev.Iteration != lastIter(0)`) creates a stale snapshot from pre-compression state, polluting iteration history. After compression, there is no useful `prev` data — the async reload rebuilds everything from DB. The handler MUST `return` after `reloadMessagesFromSession(true)`.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`progressState.current` is rendering data, NOT a state flag.** Busy/idle state is determined solely by `m.typing` (set by `startAgentTurn`, cleared by `endAgentTurn`). Using `progressState.current != nil` as a busy indicator causes the TUI to stay stuck in busy state after turn completion — `endAgentTurn` preserves `progressState.current` for flicker-free rendering, so it's never nil after a turn. All status bar, tick handler, and update-notice suppression checks must use `m.typing` only.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` must NOT use `prev.ReasoningStreamContent`/`prev.StreamContent` as fallback.** Stream-only messages (`StreamReasoningFunc`/`StreamContentFunc`) update `m.progressState.current.ReasoningStreamContent`/`StreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to the same struct), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. Only structured sources are used in snapshotIterationChange: `prev.Reasoning` → `reasoningByIter` → `m.lastReasoning` → `msg.payload.Reasoning`. If structured Reasoning is unavailable (progressCh coalescing), the snapshot has empty reasoning — better no reasoning than wrong reasoning.\n- **Ctrl+C cancel path MUST capture streamed content (`StreamContent`/`ReasoningStreamContent`) as snapshot fallback.** When Ctrl+C interrupts mid-stream, the LLM hasn't finished — `recordAssistantMsg` hasn't set `ThinkingContent`/`ReasoningContent`, so structured fields are empty. The only data available is the streamed content in `prev` (handleProgressDone cancel path) or `m.progressState.current` (cancelledTurnIterations). Using stream fields here is SAFE and CORRECT because we're snapshotting the CURRENT iteration — prev/current IS the live progress the user was watching. This differs from snapshotIterationChange where stream fields are contaminated by the next iteration. Without this fix, the last reasoning block and content vanish after Ctrl+C (they were only in stream fields). Priority in cancel path: structured `prev.Thinking` → `prev.StreamContent` for content; `reasoningByIter` → `lastReasoning` → `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n\n### No Hacks, No Fallbacks, No Defensive Programming\n\n**禁止 hack、兜底逻辑和防御性编程。从根源保证正确,而非叠加防护层。**\n\n这一原则是 TUI 渲染系统多年 bug 修复的教训总结。大量 bug 源于\"加一层防御\"而非\"修根因\"——每层防御本身引入新的边界条件,最终形成难以维护的防御栈,反而制造更多 bug。\n\n#### 核心规则\n\n1. **从根源修复,不加补丁**\n - ❌ 数据在传输中丢失 → 加 dedup 函数去重丢失后的重复\n - ✅ 数据在传输中丢失 → 修复传输层使数据不丢失\n - ❌ 状态可能陈旧 → 加 `alreadySnapped` 检查覆盖陈旧场景\n - ✅ 状态可能陈旧 → 修复状态管理使陈旧不可能发生\n\n2. **不写兜底链**\n - ❌ `if a == \"\" { a = b }; if a == \"\" { a = c }; if a == \"\" { a = d }` — 四级 fallback 链\n - ✅ 确定唯一权威数据源,只读那一个\n - 如果数据可能因 coalescing/并发丢失,修复 coalescing/并发设计,而非加 fallback\n\n3. **不写防御性检查**\n - ❌ `if prev != nil && prev.Iteration == expected && len(prev.ActiveTools) > 0` — 三重前提条件\n - ✅ 保证调用前置条件由架构不变量保证,函数只处理正常路径\n - 如果前置条件可能不满足,那是调用方的 bug,应在调用方修复\n\n4. **不写性能损害型防护**\n - ❌ 每次 updateViewportContent 全量扫描 messages 做去重(O(N) per frame)\n - ✅ 保证写入路径不产生重复(O(1),写入即唯一)\n - ❌ 每次 snapshot 分配 map 做去重\n - ✅ 数据源保证无重叠,不需要去重\n\n5. **审计现有防御层**\n - 修改代码时,检查周围的防御性代码是否因根因修复而变得多余\n - 多余的防御代码必须一并删除——它们不是\"安全网\",是噪声\n - 每一层防御都应该有明确的 bug ID 或场景说明为什么需要存在\n\n#### 具体到 TUI 渲染\n\n- **progressCh coalescing**:buffer=1 channel 会丢弃事件。正确做法是理解哪些字段会被丢弃,在**数据源头**保证不丢失(如 `snapshotIterationChange` 利用引擎不变量 \"iteration 切换时所有工具已完成\"),而非在消费端加 fallback/dedup\n- **snapshotIterationChange**:引擎保证 iteration 切换时 `snapshotCompletedIteration` 已执行(ActiveTools → CompletedTools),所以 CLI 端直接捕获所有 ActiveTools 即可,不需要按 status 过滤、不需要 dedup、不需要 lastCompletedTools fallback\n- **handleProgressDone**:PhaseDone 事件经过 progressFinalizer,ActiveTools 必然为空、CompletedTools 必然包含全部工具。不需要扫描 ActiveTools、不需要 lastCompletedTools fallback\n- **renderTurnBody 去重**:同一 LLM response 文本从 `iter.Thinking`(ThinkingContent)和 `fallbackContent`(msg.content)两条路径渲染。精确匹配去重是正确做法——它们源自同一数据,必然相等。前缀匹配/百分比阈值是 hack\n"}, "b4640f1f3f133e9e78b0fec92936d1fd30b5ff70": {"t": 1785759476.246141, "n": 91582, "h": "a203b65cb02c3a2a", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n- Issue templates: `.github/ISSUE_TEMPLATE/` — YAML forms (`*.yml`) for web UI, Markdown templates (`*.md`) for CLI/AI use with `gh issue create --template`. AI agents MUST read and fill the `.md` templates (not `.yml`) since YAML Issue Forms are web-UI-only and cannot be submitted via `gh issue create --body`.\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39 migration coexists with master's v38 (`tenants.runner_id`).** model-first originally used v38; after rebase it was bumped to v39 to avoid the schema-version collision. `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST immediately create a streaming message and clear `pendingUserMsg`.** The old code set `compReloading=true` to block auto-start until the async reload completed, then relied on `handleHistoryReload` to recreate the streaming message. But `reloadMessagesFromSession` dropped the message on full `asyncCh` (silent `default`), making `compReloading` permanently true — no streaming message was ever created, freezing the TUI (busy but no live updates). Now the streaming message is created immediately, progress events render via `updateStreamingOnly` before the reload even arrives. `pendingUserMsg` is cleared to prevent duplicates: the reload fetches the user message from DB with system-guide text prepended, which doesn't match `pendingUserMsg.content` (raw input), causing `handleHistoryReload` to append it again.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` must NOT use `prev.ReasoningStreamContent`/`prev.StreamContent` as fallback.** Stream-only messages (`StreamReasoningFunc`/`StreamContentFunc`) update `m.progressState.current.ReasoningStreamContent`/`StreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to the same struct), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. Only structured sources are used in snapshotIterationChange: `prev.Reasoning` → `reasoningByIter` → `m.lastReasoning` → `msg.payload.Reasoning`. If structured Reasoning is unavailable (progressCh coalescing), the snapshot has empty reasoning — better no reasoning than wrong reasoning.\n- **Ctrl+C cancel path MUST capture streamed content (`StreamContent`/`ReasoningStreamContent`) as snapshot fallback.** When Ctrl+C interrupts mid-stream, the LLM hasn't finished — `recordAssistantMsg` hasn't set `ThinkingContent`/`ReasoningContent`, so structured fields are empty. The only data available is the streamed content in `prev` (handleProgressDone cancel path) or `m.progressState.current` (cancelledTurnIterations). Using stream fields here is SAFE and CORRECT because we're snapshotting the CURRENT iteration — prev/current IS the live progress the user was watching. This differs from snapshotIterationChange where stream fields are contaminated by the next iteration. Without this fix, the last reasoning block and content vanish after Ctrl+C (they were only in stream fields). Priority in cancel path: structured `prev.Thinking` → `prev.StreamContent` for content; `reasoningByIter` → `lastReasoning` → `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "ae652c88d3b95dfd033ae3a7c27517020452e100": {"t": 1785759477.9958487, "n": 91269, "h": "b55d21b7976de5fe", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. The old `SetMaxContextTokens` / `set_max_context_tokens` RPC / `perChatMaxCtx` map / `max_context_tokens` setting handler have all been REMOVED — settings writes go directly to DB via `UpdatePerModelConfig`, and `GetEffectiveMaxContext` reads from DB via `resolveSubContextFor`. Must NOT pass `max_context_tokens` to `ApplySettings` — there is no handler for it anymore.\n- **`thinking_mode` is a GLOBAL user setting, NOT subscription-scoped (\"订阅是订阅,模型是模型\").** Stored in `user_settings` under canonical channel `channel.ThinkingModeChannel == \"cli\"` (constant lives in the `channel` package to avoid the `channel/cli ↔ agent` import cycle). Surfaced via the main chat status-bar indicator (`🧠 auto|on|off [Ctrl+M]`, `renderReadyStatus` + `thinkingZoneXStart/End` click zone) and `Ctrl+M` toggle (`toggleThinkingMode` cycles `\"\"→enabled→disabled→\"\"`). `ResolveLLM` reads it via `f.userThinkingMode(senderID)` — priority per-model override (`pmc.thinkingMode`) → global user setting → `\"\"`. `sub.ThinkingMode` is NO LONGER read (kept only as an internal fallback field). The `thinking_mode` `SettingHandlerRegistry` handler calls `f.InvalidateSender(senderID)` (no-op now — no in-memory cache to clear; DB is read fresh every turn). `cmd/xbot-cli/main.go` `ApplySettings` must NOT treat `thinking_mode` as an llm field (`updateActiveSubscription` no longer writes `sub.ThinkingMode`) — thinking flows only through `ApplyRuntimeSettings` → handler → `InvalidateSender`. Startup seeds the global user setting once (`serverapp`: `cfg.LLM.ThinkingMode` → default-sub `ThinkingMode` → `\"\"`). Subscription add/edit/display panels are credentials-only (name/provider/base_url/api_key); `setLLMCmdForSub` shows only credential params.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`PerModelConfigs` is a read-side projection populated by the storage layer (v42).** `subscription_models` is the sole source for per-model config; the `per_model_configs` JSON column was dropped. `LLMSubscriptionService.List`/`Get`/`GetDefault` call `loadPerModelConfigs` (which reads `subscription_models`) AFTER closing the rows cursor — a nested query inside `rows.Next()` deadlocks the single-connection SQLite pool. `Add` upserts the incoming map into the table; `UpdatePerModelConfigs` does delete+reinsert. The old `mergeSubscriptionModels` in rpc_table was deleted (no longer needed). Client-side `ResolveEffectiveMaxContext`/`sub.PerModelConfigs` now see table values without an explicit merge step.\n- **`IsDefault`/`Active` are read-side projections derived from `user_default_model` (v43).** The `is_default` column was dropped. `GetDefault` reads `user_default_model` → `Get(subID)` and marks `IsDefault=true`; `List`/`ListAll` mark via `markDefaultsFor`/`markDefaultsAll`. `SetDefault(id)` upserts `user_default_model`. `Add`/`Update` seed/sync `user_default_model` when `IsDefault=true`. `Get` does NOT populate `IsDefault` (stays false) — callers needing default-status use `GetDefault`/`List` or check `user_default_model` directly (e.g. `updateSubscription` RPC re-switches the user-level entry only if `GetUserDefaultModel` matches the edited sub).\n- **System subscription is the single source for the global default LLM (v44).** The global default LLM is a shared DB row (`user_llm_subscriptions` with `sender_id=\"__system__\"`, `is_system=1`, `id=\"system\"`), reconciled from `cfg.LLM`/env every boot by `serverapp.reconcileSystemSubscription`. Do NOT rebuild `defaultLLM` from `cfg.LLM` directly or via `SetDefaults` — use `LLMFactory.SetSystemLLM(client, model)` (which does NOT clear per-user caches). `GetDefault(senderID)` falls back to the system subscription when a user has no `user_default_model`. `List`/`ListAll` inject the system row for every user. The system subscription is read-only: `Remove`/`SetSubscriptionEnabled`/`Rename`/`Update` refuse `is_system=1` rows (storage guards + UI `subIsSystem` + 🔒 badge). `saveServerConfig`/`saveCLIConfig` must NOT write `cfg.LLM` credentials back to config.json — `cfg.LLM` may hold decrypted values refreshed from DB, and the DB system subscription is the single source (config.json keeps existing creds only as a boot seed via `SaveToFile` deep merge). Picker system models now come from the system subscription row (SubName=\"system\"), not from `defaultLLM.ListModels()` pseudo-entries.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+N switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n- **Model-first redesign (v39): `ResolveLLM` is the authoritative agent-loop entry.** It reads **DB directly** every call (no in-memory cache): ProxyLLM → tenants table → `user_default_model` → legacy `GetLLM` fallback. The old `sessionMemo`/`entries`/`hasCustomLLMCache`/`perChatMaxCtx` in-memory caches have been REMOVED — `Invalidate`/`InvalidateSender`/`InvalidateSession` are now no-ops (DB is authoritative), `InvalidateAll` only clears `clientCache`. `GetLLMForChat` reads per-session (subID, model) from tenants table, falls back to `GetLLM` (global default). `SetSessionLLM` writes to tenants table via `tenantSvc.SetTenantSubscription`. New LLM features must build on `ResolveLLM`/`SelectModel`, not the removed caches. See `docs/agent/subscription.md` \"Model-First Redesign\".\n- **Cross-subscription model switch 404: `switch_model` per-session must resolve the owner subscription, not use `GetDefault()`.** The old code paired `(defaultSub.ID, anyModel)` into tenants — switching to a model from another subscription built a client from the default sub's baseURL/apiKey but requested the other model name → 404 \"model not supported by any configured account\". Fix: `ResolveSubscriptionForModel(senderID, model)` finds the enabled subscription that provides the model, then `SelectModel(owner.ID, model, chatID)`. Fallback to default-sub only when no owner resolves.\n- **`clientCache` (keyed by `(subID, apiType)`) must be invalidated on every subscription CRUD.** `add/remove/update/set_default/set_subscription_model/update_per_model_config` RPCs and `LLMAdd/Remove/SetDefault/Update` callbacks must call `LLMFactory().InvalidateSubscription(subID)`, or `ResolveLLM` reuses a client built from stale credentials.\n- **Disabled models (`subscription_models.enabled=0`) are excluded from `ListAllModelsForUser` and rejected by `SelectModel`/`ResolveSubscriptionForModel`.** `protocol.PerModelConfig.Enabled` is a read-side projection (populated by `mergeSubscriptionModels`); writes go through the `set_model_enabled` RPC, never through `UpdatePerModelConfig`.\n- **v39 migration coexists with master's v38 (`tenants.runner_id`).** model-first originally used v38; after rebase it was bumped to v39 to avoid the schema-version collision. `schemaVersion=40` (v40 added `user_llm_subscriptions.enabled`). A live DB that reached v38 via the pre-rebase model-pool binary lacks `runner_id` and needs a manual `ALTER TABLE tenants ADD COLUMN runner_id TEXT DEFAULT ''` before running the new binary (dev-machine-only; not in migration code).\n- **Subscription-level enabled (v40) must be skipped in three places.** `user_llm_subscriptions.enabled=0` must be skipped by `ListAllModelsForUser` (both loops), `ResolveSubscriptionForModel` (`find` closure), and rejected by `SelectModel` — or disabling a subscription is cosmetic. `SetSubscriptionEnabled` must call `InvalidateSubscription(subID)`. `protocol.Subscription.Enabled` is a read-side projection from `user_llm_subscriptions.enabled` (via `subToChannel`/`LLMGetSubscription`); writes go through the `set_subscription_enabled` RPC.\n- **No subscription switching in the UI; one unified LLM panel.** `cli_panel_quickswitch.go` is a SINGLE panel (opened by `Ctrl+N` / click status-bar model name / palette \"Models & Subscriptions\") that combines subscriptions + models + add actions in one flat `[]qsRow` list (`qsSection`/`qsSub`/`qsModel`/`qsAddSub`/`qsAddModel`). There is NO separate \"subscription\" panel and NO `Ctrl+P`/`Ctrl+N` anymore. Subscription rows are management-only (add/disable/delete); model rows switch cross-subscription. The old `SwitchLLM` async + `cliSwitchLLMDoneMsg` path is retained only for startup restore (`scheduleSessionLLMRestore`). Key scheme (command mode, no filter active): `↑↓` navigate (skips section rows), `Enter` = row action (sub→toggle enabled, model→switch, add row→open add panel), `E` edit current row (sub creds / model params), `D` disable-or-delete (model→toggle enabled, sub→delete), `N` add model (prefills owner from cursor row), `S` toggle `quickSwitchShowAll`, `/` enter filter mode, `Esc` close. **`/` toggles filter mode** so command letters (`e`/`d`/`n`/`s`) never collide with typing — this replaced the old Ctrl-combo commands (`Ctrl+E`/`Ctrl+A`/`Ctrl+T`) which conflicted with global `Ctrl+E` (fold) and `Ctrl+T` (sessions). `applyQuickSwitch` reads `quickSwitchRows[cursor]` and **rejects `Status==\"disabled\"`** models (keeps panel open, hints press `E` to enable); `normal` AND `offline` are both selectable. Both switch paths use `applyModelSwitch`, which after `SwitchModel` re-reads the owner subscription via `GetSessionSubscription` to fix `activeSubID`/context limits (tenants table is source of truth in both local and remote modes). Status bar shows `模型名(订阅名)` from `cachedSubName` (refreshed by `refreshCachedSubName` on `activeSubID` changes — NOT per-frame). `handleQuickSwitchKey` intercepts Esc/Up/Down/Enter over `quickSwitchRows` BEFORE other handling; in filter mode letters feed `quickSwitchFilterInput` and `rebuildLLMRows` re-filters. **Panel scrolling**: `quickSwitchScrollY` tracks vertical scroll offset; `ensureQuickSwitchCursorVisible` keeps cursor in view; `viewQuickSwitch` slices rows to `height - overhead` visible lines with a `── N-M / Total ──` scroll indicator. **Contextual hints**: `buildQuickSwitchHint()` renders row-type-specific operation hints (sub/model/add/section) in `PanelHint` style + common keys in `TextMutedSt`. **Model selection**: `✓` mark and `cursorToActiveLLMRow` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match. **Panel colors**: non-cursor rows use `TextSecondarySt` (not `TextMutedSt`); section headers use `PanelDivider`; disabled/offline models use `TextMutedSt`; cursor row uses `Accent`. **Thinking indicator**: `thinkingModeLabel()` returns ASCII `think`/`think+`/`think-` (no emoji, no `[Ctrl+M]` suffix); `think+` uses `Accent` style, `think-` uses `TextMutedSt`.\n- **Model list is DB-driven; each entry has a Status (normal/offline/disabled).** `listModelEntriesCore` lists the **union** of `sub.CachedModels` + `sub.Model` + `subscription_models` rows — i.e. everything recorded in the DB, not just fetched. `Status` per (SubID,Model): `disabled` if `subscription_models.enabled=0`; else `normal` if in `CachedModels` or is `sub.Model`; else `offline` (has a record but not fetched — manually added, still selectable). `ListAllModelEntriesForUser` (includeDisabled=true, picker) emits all three; `ListAllModelsForUser` (includeDisabled=false, tier selectors) emits `normal`+`offline` only. Owner attribution picks the best sub by status rank (normal>offline>disabled). The `CachedModels` snapshot is populated by the `OnModelsLoaded` callback — which the model-first refactor had **dropped** (must be re-wired via `makeOnModelsLoaded(subID)` in `createClientFromSub` + both entry-construction paths; the callback nil-checks `Get(subID)` before `UpdateCachedModels` since the latter nil-derefs on missing sub). Opening the panel (`openLLMPanel`) kicks off `RefreshModelEntries()` → RPC `refresh_model_entries` → `LLMFactory.RefreshModelEntriesForUser`: parallel (cap 8, 8s/sub, soft-fail) `/models` fetch per enabled subscription, persist via `OnModelsLoaded`, return fresh entries (a fetched model flips `offline`→`normal`). CLI renders the DB snapshot immediately, shows `↻ 刷新模型列表…`, and rebuilds rows on `cliModelEntriesRefreshedMsg` (handled at top of `Update`). `rebuildLLMRows` clamps the cursor (never resets) so a background refresh doesn't yank the user's cursor; callers set it explicitly (`cursorToActiveLLMRow` on open, `0` while typing). `backendModelLister.EnsureModelsLoaded` is a no-op — refresh is driven by `RefreshModelEntries`, don't rely on `EnsureModelsLoaded`. The three entry points (Ctrl+N / click model name / palette Enter) must drain `m.pendingCmds` so the refresh cmd actually runs. **`/models` per-provider differences are expected** (different providers expose different model lists); the panel shows all DB records regardless, `N` lets users register models the provider didn't list (appears as `offline`), and `isNoiseModel` hides chat-unusable noise (image/realtime/whisper/tts/audio/embed/moderation/dated-snapshots) unless `S` shows all. **Per-model params are edited ONLY on the model row via `E`** (`openEditModelPanel` → `UpdatePerModelConfig`/`SetModelEnabled`); the subscription edit panel (`openEditSubscriptionPanel`) edits credentials/defaults only and NO longer carries per-model toggles — that was redundant with the model row editor.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`ConvertMessagesToHistory` infers tool status from tool messages when Detail is absent.** When the server crashes mid-turn, persisted assistant messages have ToolCalls but NO Detail (Detail is only set at end-of-turn in `agent_process.go`). The old code hard-coded `Status: \"done\"` for all tools in this fallback path — failed tools rendered as successful after TUI restart. Fix: pre-scan tool messages to build a `toolCallID → content` map; if content starts with `\"Error:\"` (the prefix `engine_run_tools.go:updateToolResultLine` adds for `IsError` results), status becomes `\"error\"`, otherwise `\"done\"`. `cursorToActiveLLMRow` and the `✓` mark in `viewQuickSwitch` match both `Model` AND `SubID == activeSubID` — same model name from different subscriptions is NOT a match.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST immediately create a streaming message and clear `pendingUserMsg`.** The old code set `compReloading=true` to block auto-start until the async reload completed, then relied on `handleHistoryReload` to recreate the streaming message. But `reloadMessagesFromSession` dropped the message on full `asyncCh` (silent `default`), making `compReloading` permanently true — no streaming message was ever created, freezing the TUI (busy but no live updates). Now the streaming message is created immediately, progress events render via `updateStreamingOnly` before the reload even arrives. `pendingUserMsg` is cleared to prevent duplicates: the reload fetches the user message from DB with system-guide text prepended, which doesn't match `pendingUserMsg.content` (raw input), causing `handleHistoryReload` to append it again.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` must NOT use `prev.ReasoningStreamContent`/`prev.StreamContent` as fallback.** Stream-only messages (`StreamReasoningFunc`/`StreamContentFunc`) update `m.progressState.current.ReasoningStreamContent`/`StreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to the same struct), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. Only structured sources are used in snapshotIterationChange: `prev.Reasoning` → `reasoningByIter` → `m.lastReasoning` → `msg.payload.Reasoning`. If structured Reasoning is unavailable (progressCh coalescing), the snapshot has empty reasoning — better no reasoning than wrong reasoning.\n- **Ctrl+C cancel path MUST capture streamed content (`StreamContent`/`ReasoningStreamContent`) as snapshot fallback.** When Ctrl+C interrupts mid-stream, the LLM hasn't finished — `recordAssistantMsg` hasn't set `ThinkingContent`/`ReasoningContent`, so structured fields are empty. The only data available is the streamed content in `prev` (handleProgressDone cancel path) or `m.progressState.current` (cancelledTurnIterations). Using stream fields here is SAFE and CORRECT because we're snapshotting the CURRENT iteration — prev/current IS the live progress the user was watching. This differs from snapshotIterationChange where stream fields are contaminated by the next iteration. Without this fix, the last reasoning block and content vanish after Ctrl+C (they were only in stream fields). Priority in cancel path: structured `prev.Thinking` → `prev.StreamContent` for content; `reasoningByIter` → `lastReasoning` → `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n- **Feishu interactive card has a ~50 element limit (error 11310 \"element exceeds the limit\").** Every `form`, `select_static`, `button`, `input`, `markdown`, `hr`, `column_set` counts as an element — nested form children count too. The model settings tab hit this limit with 3 independent tier forms (each = 1 markdown + 1 form + 1 select + 1 button = 4 elements × 3 = 12 + separators). Fix: consolidated 3 tier forms into 1 form with 3 selects + 1 submit (5 elements total). Always validate card JSON via Feishu send_message API before deploying — the error is `230099 / ErrCode: 11310`, not the usual `200673`.\n- **Feishu settings card `settings_select_submit` uses select name == form name convention.** Every `select_static` inside a form shares its name with the enclosing form, so the handler reads the selected value directly from `formStr(actionData, formName)`. No hardcoded select-name list. The tier form (`tier_form`) is a special case: it has 3 selects (`tier_vanguard_select` / `tier_balance_select` / `tier_swift_select`) routed to `settings_set_all_tiers` which reads all 3 from form_value on a single submit.\n- **`LLMCallbacks` max_context/max_output signatures take explicit `(subID, model)`.** `LLMGetMaxContext(senderID, subID, model)` / `LLMSetMaxContext(senderID, subID, model, maxContext)` — same for MaxOutputTokens. When subID/model are non-empty (feishu model tab), the callback writes per-(subID, model) PerModelConfig directly via `Agent.*ForSubModel` methods. When empty (web legacy), falls back to session resolution. Channel UIs MUST pass the (subID, model) from the model selector — never resolve subscription from model name alone (project policy). `settings_set_model` handler rejects option values without the `subID|model` separator.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "457662f1b686b14394a74d4516ff87d4385da090": {"t": 1785759479.467636, "n": 76347, "h": "0f013f0e821ea0a7", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. Must NOT pass `max_context_tokens` to `ApplySettings` — that calls `ag.SetMaxContextTokens(n)` globally, contaminating all models without per-model override.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`listSubscriptions`/`getDefaultSubscription` RPC must merge `subscription_models` table into `PerModelConfigs`.** The server stores per-model configs in two places: `subscription_models` table (v35+, authoritative) and `per_model_configs` JSON field. Without merging, client-side `ResolveEffectiveMaxContext` (which only checks `PerModelConfigs`) misses values from the table.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+L switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST immediately create a streaming message and clear `pendingUserMsg`.** The old code set `compReloading=true` to block auto-start until the async reload completed, then relied on `handleHistoryReload` to recreate the streaming message. But `reloadMessagesFromSession` dropped the message on full `asyncCh` (silent `default`), making `compReloading` permanently true — no streaming message was ever created, freezing the TUI (busy but no live updates). Now the streaming message is created immediately, progress events render via `updateStreamingOnly` before the reload even arrives. `pendingUserMsg` is cleared to prevent duplicates: the reload fetches the user message from DB with system-guide text prepended, which doesn't match `pendingUserMsg.content` (raw input), causing `handleHistoryReload` to append it again.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` must NOT use `prev.ReasoningStreamContent`/`prev.StreamContent` as fallback.** Stream-only messages (`StreamReasoningFunc`/`StreamContentFunc`) update `m.progressState.current.ReasoningStreamContent`/`StreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to the same struct), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. Only structured sources are used in snapshotIterationChange: `prev.Reasoning` → `reasoningByIter` → `m.lastReasoning` → `msg.payload.Reasoning`. If structured Reasoning is unavailable (progressCh coalescing), the snapshot has empty reasoning — better no reasoning than wrong reasoning.\n- **Ctrl+C cancel path MUST capture streamed content (`StreamContent`/`ReasoningStreamContent`) as snapshot fallback.** When Ctrl+C interrupts mid-stream, the LLM hasn't finished — `recordAssistantMsg` hasn't set `ThinkingContent`/`ReasoningContent`, so structured fields are empty. The only data available is the streamed content in `prev` (handleProgressDone cancel path) or `m.progressState.current` (cancelledTurnIterations). Using stream fields here is SAFE and CORRECT because we're snapshotting the CURRENT iteration — prev/current IS the live progress the user was watching. This differs from snapshotIterationChange where stream fields are contaminated by the next iteration. Without this fix, the last reasoning block and content vanish after Ctrl+C (they were only in stream fields). Priority in cancel path: structured `prev.Thinking` → `prev.StreamContent` for content; `reasoningByIter` → `lastReasoning` → `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. Runner CRUD callbacks (`RunnerCreate`/`RunnerList`/`RunnerDelete`/`RunnerGetActive`/`RunnerSetActive`/`RunnerRename`) are injected from `tools.RunnerTokenStore` via `tools.GetRunnerTokenDB()`. Works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **Runner switch is session-level, not user-level.** `config switch` writes to `SandboxRouter.sessionRunners` (in-memory sync.Map keyed by `\"channel:chatID\"`). Per-tool-call re-resolution in both `buildToolExecutor` (engine_wire.go) and `defaultToolExecutor` (engine.go) calls `SandboxForSession` before each Execute. All tools (Shell, Read, Grep, Glob, FileReplace) immediately route to the new runner. CWD auto-resets to runner's live workspace.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "c0557e0db3c72651a222eb6a3ef2ec72cb27cb76": {"t": 1785759481.7464206, "n": 75710, "h": "02ea24ea2a09b00c", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. Must NOT pass `max_context_tokens` to `ApplySettings` — that calls `ag.SetMaxContextTokens(n)` globally, contaminating all models without per-model override.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`listSubscriptions`/`getDefaultSubscription` RPC must merge `subscription_models` table into `PerModelConfigs`.** The server stores per-model configs in two places: `subscription_models` table (v35+, authoritative) and `per_model_configs` JSON field. Without merging, client-side `ResolveEffectiveMaxContext` (which only checks `PerModelConfigs`) misses values from the table.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+L switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST immediately create a streaming message and clear `pendingUserMsg`.** The old code set `compReloading=true` to block auto-start until the async reload completed, then relied on `handleHistoryReload` to recreate the streaming message. But `reloadMessagesFromSession` dropped the message on full `asyncCh` (silent `default`), making `compReloading` permanently true — no streaming message was ever created, freezing the TUI (busy but no live updates). Now the streaming message is created immediately, progress events render via `updateStreamingOnly` before the reload even arrives. `pendingUserMsg` is cleared to prevent duplicates: the reload fetches the user message from DB with system-guide text prepended, which doesn't match `pendingUserMsg.content` (raw input), causing `handleHistoryReload` to append it again.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` must NOT use `prev.ReasoningStreamContent`/`prev.StreamContent` as fallback.** Stream-only messages (`StreamReasoningFunc`/`StreamContentFunc`) update `m.progressState.current.ReasoningStreamContent`/`StreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to the same struct), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. Only structured sources are used in snapshotIterationChange: `prev.Reasoning` → `reasoningByIter` → `m.lastReasoning` → `msg.payload.Reasoning`. If structured Reasoning is unavailable (progressCh coalescing), the snapshot has empty reasoning — better no reasoning than wrong reasoning.\n- **Ctrl+C cancel path MUST capture streamed content (`StreamContent`/`ReasoningStreamContent`) as snapshot fallback.** When Ctrl+C interrupts mid-stream, the LLM hasn't finished — `recordAssistantMsg` hasn't set `ThinkingContent`/`ReasoningContent`, so structured fields are empty. The only data available is the streamed content in `prev` (handleProgressDone cancel path) or `m.progressState.current` (cancelledTurnIterations). Using stream fields here is SAFE and CORRECT because we're snapshotting the CURRENT iteration — prev/current IS the live progress the user was watching. This differs from snapshotIterationChange where stream fields are contaminated by the next iteration. Without this fix, the last reasoning block and content vanish after Ctrl+C (they were only in stream fields). Priority in cancel path: structured `prev.Thinking` → `prev.StreamContent` for content; `reasoningByIter` → `lastReasoning` → `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n- **Remote reconnect handler must NOT SetProcessing(true) when progress.Phase == \"done\".** `GetActiveProgress` may return a stale snapshot whose Phase is already \"done\" (server hasn't cleaned it up yet). Calling `SetProcessing(true)` on a done turn causes the TUI to start a new empty turn (spinner + streaming slot) with no further progress events arriving — permanent spinner. Fix: only `SetProcessing(true)` when `progress != nil && progress.Phase != \"done\"`.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. This works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **`connect()` replaces `t.conn` with a new WebSocket — old `readPump` must NOT clear the new connection.** Old `readPump` hits `ReadMessage` error after `old.Close()`, then unconditionally sets `t.conn = nil` and fires a second reconnect — causing an infinite reconnect loop, dropped `BindChat`, and permanent spinner. Fix: `readPump` checks `t.conn == conn` before clearing; if `connect()` already replaced the connection, old `readPump` returns silently without clearing or signalling.\n- **`setConnState` must NEVER be called while holding `t.connMu`.** `setConnState` internally acquires `t.connMu.Lock()`. Go `sync.Mutex` is NOT reentrant — calling it inside the same-goroutine `connMu` critical section deadlocks the entire event loop (TUI frozen, Ctrl+Z broken). Fix: set a local flag inside the lock, call `setConnState` after `Unlock()`.\n- **`RemoteTransport` events (conn_state, reconnect) must bridge to `Client`'s subscriber base.** `RemoteTransport` and `Client` each create their own `baseTransport` instance. Events emitted by `setConnState`/`reconnectLoop` on `RemoteTransport.baseTransport` never reach subscribers registered via `app.client.Subscribe()` (which uses `Client.base`). Fix: `Client.Start()` calls `ShareEventBase(&c.base)` on the transport; `RemoteTransport.emitLocal()` dispatches on the shared base. Any new local event in RemoteTransport must use `emitLocal`, not `t.emit()`.\n- **Disconnect detection must cover write failures, not just read errors. And the TUI must react immediately.** Fix: (1) transport: `sendPing`/`SendMessage` clear `t.conn` on write failure. (2) TUI: `sendInbound()` immediately sets `m.connState = \"disconnected\"` when write fails. **CRITICAL: The `sendInboundFn` callback in main.go was fire-and-forget (`clipanic.Go` goroutine) and ALWAYS returned `true` — making ALL TUI-layer checks dead code. Every `sendInbound`/`sendMessage`/`sendToAgent` fix was unreachable because the callback never reported failures. Fix: `sendInboundFn` must call `SendInbound` synchronously and return the real result. Also: `sendMessage()` (main path from handleEnterKey) AND `sendToAgent()` (slash commands) both check return value before `startAgentTurn()`. (3) View() reconnect overlay must come BEFORE `suLoading` — stale `suLoading` from a dead server permanently blocks the splash.**\n- **FINAL ARCHITECTURE: `showDisconnect` removed — `connState` is the single source of truth.** All splash, disconnect guard, and tick conditions use only `m.connState`. `SetConnState` writes `connState` directly to model (bypasses all channels). `sendInboundFn` is async (`clipanic.Go`) and does NOT touch `connState` — transport layer manages state independently. `handleSuHistoryLoad` skips redundant RPC when history already provided in message. **`readPump` must start BEFORE `emitLocal(ReconnectEvent{})`** — otherwise `BindChat`/`GetHistory` RPCs in the handler have no reader and timeout after 30s, freezing the TUI. No deadlocks, no race conditions, no redundant flags.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "e94c74452a58340e55196e8c6e58c404103f2f35": {"t": 1785759485.2875113, "n": 72237, "h": "7dc9fbd57ee7795a", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. Must NOT pass `max_context_tokens` to `ApplySettings` — that calls `ag.SetMaxContextTokens(n)` globally, contaminating all models without per-model override.\n- **`refreshRemoteValuesCache` MUST extract `max_context_tokens` from `PerModelConfigs[sub.Model].MaxContext`.** The function populates `valuesCache` which feeds `GetCurrentValues()` → `resolveMaxContext()` → context bar display. Without this extraction, the cache falls through to the `config.DefaultMaxContextTokens` (200000) fallback, causing the context bar to show wrong max context after settings save. The extraction must mirror the settings panel read path (`cli_settings.go:79-93`): PerModelConfigs[model].MaxContext → sub.MaxContext → config fallback. Also must NOT overwrite existing DB values from `GetSettings` (config tool wrote them via `SetSetting`).\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`listSubscriptions`/`getDefaultSubscription` RPC must merge `subscription_models` table into `PerModelConfigs`.** The server stores per-model configs in two places: `subscription_models` table (v35+, authoritative) and `per_model_configs` JSON field. Without merging, client-side `ResolveEffectiveMaxContext` (which only checks `PerModelConfigs`) misses values from the table.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+L switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST immediately create a streaming message and clear `pendingUserMsg`.** The old code set `compReloading=true` to block auto-start until the async reload completed, then relied on `handleHistoryReload` to recreate the streaming message. But `reloadMessagesFromSession` dropped the message on full `asyncCh` (silent `default`), making `compReloading` permanently true — no streaming message was ever created, freezing the TUI (busy but no live updates). Now the streaming message is created immediately, progress events render via `updateStreamingOnly` before the reload even arrives. `pendingUserMsg` is cleared to prevent duplicates: the reload fetches the user message from DB with system-guide text prepended, which doesn't match `pendingUserMsg.content` (raw input), causing `handleHistoryReload` to append it again.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` must NOT use `prev.ReasoningStreamContent`/`prev.StreamContent` as fallback.** Stream-only messages (`StreamReasoningFunc`/`StreamContentFunc`) update `m.progressState.current.ReasoningStreamContent`/`StreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to the same struct), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. Only structured sources are used in snapshotIterationChange: `prev.Reasoning` → `reasoningByIter` → `m.lastReasoning` → `msg.payload.Reasoning`. If structured Reasoning is unavailable (progressCh coalescing), the snapshot has empty reasoning — better no reasoning than wrong reasoning.\n- **Ctrl+C cancel path MUST capture streamed content (`StreamContent`/`ReasoningStreamContent`) as snapshot fallback.** When Ctrl+C interrupts mid-stream, the LLM hasn't finished — `recordAssistantMsg` hasn't set `ThinkingContent`/`ReasoningContent`, so structured fields are empty. The only data available is the streamed content in `prev` (handleProgressDone cancel path) or `m.progressState.current` (cancelledTurnIterations). Using stream fields here is SAFE and CORRECT because we're snapshotting the CURRENT iteration — prev/current IS the live progress the user was watching. This differs from snapshotIterationChange where stream fields are contaminated by the next iteration. Without this fix, the last reasoning block and content vanish after Ctrl+C (they were only in stream fields). Priority in cancel path: structured `prev.Thinking` → `prev.StreamContent` for content; `reasoningByIter` → `lastReasoning` → `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. This works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "fb8d3845b6e783c10b46c2c0d39d39804f53eb99": {"t": 1785759487.176, "n": 71570, "h": "5ea9b9a357179807", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. Must NOT pass `max_context_tokens` to `ApplySettings` — that calls `ag.SetMaxContextTokens(n)` globally, contaminating all models without per-model override.\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`listSubscriptions`/`getDefaultSubscription` RPC must merge `subscription_models` table into `PerModelConfigs`.** The server stores per-model configs in two places: `subscription_models` table (v35+, authoritative) and `per_model_configs` JSON field. Without merging, client-side `ResolveEffectiveMaxContext` (which only checks `PerModelConfigs`) misses values from the table.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+L switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Turn completion uses `rerenderCachedMessage` instead of `fullRebuild`.** When the complete reply arrives in `handleAgentMessage`, instead of setting `rc.valid = false` (which forces O(N) `fullRebuild` → flicker), call `rerenderCachedMessage(completedMsgIdx)` which truncates the render cache back to that message and re-renders only it via `appendNewMessagesToCache` — O(1 message) instead of O(N). The cancel ack path uses the same mechanism via `cancelledIdx`.\n- **`endAgentTurn` must NOT clear `streamingMsgIdx`.** Clearing it causes the subsequent `relayoutViewport→updateViewportContent` to call `appendNewMessagesToCache` and cache the streaming message with incomplete content (reply hasn't arrived yet). This forces a second viewport update when `rerenderCachedMessage` runs, causing double-flicker. Instead, keep `streamingMsgIdx` so `updateViewportContent` uses `updateStreamingOnly` (streaming path) — the message is NOT cached prematurely. `handleAgentMessage` will set `streamingMsgIdx = -1` and call `rerenderCachedMessage` for a single clean transition.\n- **`snapshotIterationChange` falls back to `ReasoningStreamContent`.** When an iteration changes and the previous iteration's reasoning hasn't been finalized (structured `Reasoning` is empty, `reasoningByIter` missed it), the snapshot would lose the reasoning block — causing it to briefly disappear during streaming. The fallback (`prev.ReasoningStreamContent`) captures the live-streamed reasoning, matching the pattern already used in `handleProgressDone`'s cancel path.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`HistoryCompacted` handler MUST immediately create a streaming message and clear `pendingUserMsg`.** The old code set `compReloading=true` to block auto-start until the async reload completed, then relied on `handleHistoryReload` to recreate the streaming message. But `reloadMessagesFromSession` dropped the message on full `asyncCh` (silent `default`), making `compReloading` permanently true — no streaming message was ever created, freezing the TUI (busy but no live updates). Now the streaming message is created immediately, progress events render via `updateStreamingOnly` before the reload even arrives. `pendingUserMsg` is cleared to prevent duplicates: the reload fetches the user message from DB with system-guide text prepended, which doesn't match `pendingUserMsg.content` (raw input), causing `handleHistoryReload` to append it again.\n- **`handleHistoryReload` MUST preserve the streaming message for ALL reloads, not just non-forceFullRebuild.** The old `!msg.forceFullRebuild` check meant forceFullRebuild (from compression) replaced messages WITHOUT preserving the streaming message. Combined with `compReloading` blocking auto-start, this left no streaming message at all. Now always preserves `streamingMsg` when `m.typing && m.streamingMsgIdx >= 0`.\n- **`reloadMessagesFromSession` MUST use blocking send with timeout, NOT silent `default` drop.** The old `select { case ... asyncCh <- msg: default: }` silently dropped the reload when asyncCh was full (256 buffer). There was no retry — the comment \"next progress event will retry\" was false. Now uses blocking send with 10s timeout.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` must NOT use `prev.ReasoningStreamContent`/`prev.StreamContent` as fallback.** Stream-only messages (`StreamReasoningFunc`/`StreamContentFunc`) update `m.progressState.current.ReasoningStreamContent`/`StreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to the same struct), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. Only structured sources are used in snapshotIterationChange: `prev.Reasoning` → `reasoningByIter` → `m.lastReasoning` → `msg.payload.Reasoning`. If structured Reasoning is unavailable (progressCh coalescing), the snapshot has empty reasoning — better no reasoning than wrong reasoning.\n- **Ctrl+C cancel path MUST capture streamed content (`StreamContent`/`ReasoningStreamContent`) as snapshot fallback.** When Ctrl+C interrupts mid-stream, the LLM hasn't finished — `recordAssistantMsg` hasn't set `ThinkingContent`/`ReasoningContent`, so structured fields are empty. The only data available is the streamed content in `prev` (handleProgressDone cancel path) or `m.progressState.current` (cancelledTurnIterations). Using stream fields here is SAFE and CORRECT because we're snapshotting the CURRENT iteration — prev/current IS the live progress the user was watching. This differs from snapshotIterationChange where stream fields are contaminated by the next iteration. Without this fix, the last reasoning block and content vanish after Ctrl+C (they were only in stream fields). Priority in cancel path: structured `prev.Thinking` → `prev.StreamContent` for content; `reasoningByIter` → `lastReasoning` → `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. This works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "1c2944b4d427bf4c60c7a4ce22de1d164b360942": {"t": 1785759488.74604, "n": 68798, "h": "6b24dfdd83397cde", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. Must NOT pass `max_context_tokens` to `ApplySettings` — that calls `ag.SetMaxContextTokens(n)` globally, contaminating all models without per-model override.\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`listSubscriptions`/`getDefaultSubscription` RPC must merge `subscription_models` table into `PerModelConfigs`.** The server stores per-model configs in two places: `subscription_models` table (v35+, authoritative) and `per_model_configs` JSON field. Without merging, client-side `ResolveEffectiveMaxContext` (which only checks `PerModelConfigs`) misses values from the table.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+L switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Cancel ack must NOT call `updateViewportContent()`.** The live streaming display already shows all iterations correctly. Forcing a full glamour re-render of ALL messages on cancel ack can lose the latest iterations' display data. The cancel ack just sets `m.rc.valid = false` and lets the next tick (100ms) pick up the `isPartial=false` change (guide color dimming) incrementally.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`** — but `generating` tools skip dedup. Streaming tools have no labels yet (args still being generated), so two same-name tools (e.g. two `Read` calls) would share the same `Name+\"\"` key and the second gets dropped. Fix: `Status==\"generating\"` tools are appended unconditionally, bypassing the `seen` map.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` must NOT use `prev.ReasoningStreamContent`/`prev.StreamContent` as fallback.** Stream-only messages (`StreamReasoningFunc`/`StreamContentFunc`) update `m.progressState.current.ReasoningStreamContent`/`StreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to the same struct), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. Only structured sources are used in snapshotIterationChange: `prev.Reasoning` → `reasoningByIter` → `m.lastReasoning` → `msg.payload.Reasoning`. If structured Reasoning is unavailable (progressCh coalescing), the snapshot has empty reasoning — better no reasoning than wrong reasoning.\n- **Ctrl+C cancel path MUST capture streamed content (`StreamContent`/`ReasoningStreamContent`) as snapshot fallback.** When Ctrl+C interrupts mid-stream, the LLM hasn't finished — `recordAssistantMsg` hasn't set `ThinkingContent`/`ReasoningContent`, so structured fields are empty. The only data available is the streamed content in `prev` (handleProgressDone cancel path) or `m.progressState.current` (cancelledTurnIterations). Using stream fields here is SAFE and CORRECT because we're snapshotting the CURRENT iteration — prev/current IS the live progress the user was watching. This differs from snapshotIterationChange where stream fields are contaminated by the next iteration. Without this fix, the last reasoning block and content vanish after Ctrl+C (they were only in stream fields). Priority in cancel path: structured `prev.Thinking` → `prev.StreamContent` for content; `reasoningByIter` → `lastReasoning` → `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. This works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "dc39fbac82b1de2b81d174915bc996bba2c52907": {"t": 1785759491.316428, "n": 68701, "h": "d5474022f14088be", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. Must NOT pass `max_context_tokens` to `ApplySettings` — that calls `ag.SetMaxContextTokens(n)` globally, contaminating all models without per-model override.\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`listSubscriptions`/`getDefaultSubscription` RPC must merge `subscription_models` table into `PerModelConfigs`.** The server stores per-model configs in two places: `subscription_models` table (v35+, authoritative) and `per_model_configs` JSON field. Without merging, client-side `ResolveEffectiveMaxContext` (which only checks `PerModelConfigs`) misses values from the table.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+L switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Cancel ack must NOT call `updateViewportContent()`.** The live streaming display already shows all iterations correctly. Forcing a full glamour re-render of ALL messages on cancel ack can lose the latest iterations' display data. The cancel ack just sets `m.rc.valid = false` and lets the next tick (100ms) pick up the `isPartial=false` change (guide color dimming) incrementally.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`.** When a tool transitions from `ActiveTools(done)` to `CompletedTools` across progress frames, both lists can temporarily contain it. The dedup set (`seen[key]`) prevents the same tool from rendering twice.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` must NOT use `prev.ReasoningStreamContent`/`prev.StreamContent` as fallback.** Stream-only messages (`StreamReasoningFunc`/`StreamContentFunc`) update `m.progressState.current.ReasoningStreamContent`/`StreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to the same struct), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. Only structured sources are used in snapshotIterationChange: `prev.Reasoning` → `reasoningByIter` → `m.lastReasoning` → `msg.payload.Reasoning`. If structured Reasoning is unavailable (progressCh coalescing), the snapshot has empty reasoning — better no reasoning than wrong reasoning.\n- **Ctrl+C cancel path MUST capture streamed content (`StreamContent`/`ReasoningStreamContent`) as snapshot fallback.** When Ctrl+C interrupts mid-stream, the LLM hasn't finished — `recordAssistantMsg` hasn't set `ThinkingContent`/`ReasoningContent`, so structured fields are empty. The only data available is the streamed content in `prev` (handleProgressDone cancel path) or `m.progressState.current` (cancelledTurnIterations). Using stream fields here is SAFE and CORRECT because we're snapshotting the CURRENT iteration — prev/current IS the live progress the user was watching. This differs from snapshotIterationChange where stream fields are contaminated by the next iteration. Without this fix, the last reasoning block and content vanish after Ctrl+C (they were only in stream fields). Priority in cancel path: structured `prev.Thinking` → `prev.StreamContent` for content; `reasoningByIter` → `lastReasoning` → `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n- **`StreamingTools` is a stream-only field — same protection rules as `StreamContent`/`ReasoningStreamContent`.** It carries tool names detected during LLM streaming (Status=\"generating\"), before arguments finish generating. Must NEVER enter `snapshotIterationChange` or any structured snapshot path. `isStreamOnly` must check `len(StreamingTools) > 0` and merge into `current.StreamingTools`. `CollectStreamWithCallback` now has 5 params (added `onToolCall func([]ToolCallDelta)`) — fires once per tool name arrival (first chunk), NOT per argument delta. `GenerateStreamAndCollect` and `generateResponse` signatures also gained the matching param. `buildStreamCallbacks` returns a third callback that converts deltas to `[]ToolProgress{Status:\"generating\"}` and broadcasts via `SendProgress` (not `SendStreamContent`, which only supports content/reasoning strings).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. This works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **Channel plugin prompt via `channel_prompt` protocol.** Channel plugin process sends `{\"type\":\"channel_prompt\",\"system_parts\":{\"05_channel_xxx\":\"...\"}}` to declare channel-specific system prompt. `ChannelPluginTransport.handleChannelPrompt()` stores parts in `channelPluginPromptProvider` (thread-safe via `sync.RWMutex`), then fires `OnChannelPrompt` callback → `Agent.AddChannelPromptProvider()` registers with `ChannelPromptMiddleware` (priority 5). `stdioChannelPluginProvider` wires `agentGetter` lazily — the `OnChannelPrompt` callback resolves the Agent at runtime. Hot-update: new `channel_prompt` replaces the entire parts map. Key naming convention: `\"05_channel_xxx\"` prefix (after `\"00_base\"`, before `\"10_skills\"`).\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "ce68c9fcbc5bd56358d2dba43dc774f4dd1634e5": {"t": 1785759493.2115648, "n": 67101, "h": "799a919abbcf0394", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. Must NOT pass `max_context_tokens` to `ApplySettings` — that calls `ag.SetMaxContextTokens(n)` globally, contaminating all models without per-model override.\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`listSubscriptions`/`getDefaultSubscription` RPC must merge `subscription_models` table into `PerModelConfigs`.** The server stores per-model configs in two places: `subscription_models` table (v35+, authoritative) and `per_model_configs` JSON field. Without merging, client-side `ResolveEffectiveMaxContext` (which only checks `PerModelConfigs`) misses values from the table.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+L switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Cancel ack must NOT call `updateViewportContent()`.** The live streaming display already shows all iterations correctly. Forcing a full glamour re-render of ALL messages on cancel ack can lose the latest iterations' display data. The cancel ack just sets `m.rc.valid = false` and lets the next tick (100ms) pick up the `isPartial=false` change (guide color dimming) incrementally.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`.** When a tool transitions from `ActiveTools(done)` to `CompletedTools` across progress frames, both lists can temporarily contain it. The dedup set (`seen[key]`) prevents the same tool from rendering twice.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` must NOT use `prev.ReasoningStreamContent`/`prev.StreamContent` as fallback.** Stream-only messages (`StreamReasoningFunc`/`StreamContentFunc`) update `m.progressState.current.ReasoningStreamContent`/`StreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to the same struct), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. Only structured sources are used in snapshotIterationChange: `prev.Reasoning` → `reasoningByIter` → `m.lastReasoning` → `msg.payload.Reasoning`. If structured Reasoning is unavailable (progressCh coalescing), the snapshot has empty reasoning — better no reasoning than wrong reasoning.\n- **Ctrl+C cancel path MUST capture streamed content (`StreamContent`/`ReasoningStreamContent`) as snapshot fallback.** When Ctrl+C interrupts mid-stream, the LLM hasn't finished — `recordAssistantMsg` hasn't set `ThinkingContent`/`ReasoningContent`, so structured fields are empty. The only data available is the streamed content in `prev` (handleProgressDone cancel path) or `m.progressState.current` (cancelledTurnIterations). Using stream fields here is SAFE and CORRECT because we're snapshotting the CURRENT iteration — prev/current IS the live progress the user was watching. This differs from snapshotIterationChange where stream fields are contaminated by the next iteration. Without this fix, the last reasoning block and content vanish after Ctrl+C (they were only in stream fields). Priority in cancel path: structured `prev.Thinking` → `prev.StreamContent` for content; `reasoningByIter` → `lastReasoning` → `prev.Reasoning` → `prev.ReasoningStreamContent` → `msg.payload.Reasoning` for reasoning.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. This works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **RPC responses MUST NOT be dropped.** `readPump` processes RPC synchronously in `web.go`. The old code used `select { case sendCh <- msg: default: drop }` for RPC responses. During `plugin_reload_all`, multiple widget pushes (`PushPluginWidgetsPerSession`) can fill `sendCh` (64 buffer) while the RPC handler runs. When the handler completes, its response hits a full `sendCh` and gets silently dropped → client blocks until 30s timeout. Fix: block with `time.After(10s)` instead of `default` drop. Also `writePump` must set `SetWriteDeadline(30s)` on all `WriteJSON` calls — without it, network back pressure causes `WriteJSON` to block indefinitely, freezing `writePump` so `sendCh` never drains.\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`pluginLogger` writes ONLY to per-plugin log file — NOT to global logrus.** Plugin operational logs (tool/hook registration, script execution, widget refresh) are isolated in `~/.xbot/plugins/<id>/logs/`. This keeps the main xbot log clean. Only framework-level lifecycle events (discovered/activated/deactivated/failed) use global `log.Xxx()` in `manager.go`. Script runtime logs go through `p.logger()` (helper with noopLogger fallback for tests). If `fileOut == nil` (writer creation failed), `pluginLogger.emit()` falls back to global logrus so no logs are lost.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "175b7d2071f1d2e14eba2ca046f438d34a347c28": {"t": 1785759494.6566927, "n": 64948, "h": "f4f5a661efe3a849", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. Must NOT pass `max_context_tokens` to `ApplySettings` — that calls `ag.SetMaxContextTokens(n)` globally, contaminating all models without per-model override.\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`listSubscriptions`/`getDefaultSubscription` RPC must merge `subscription_models` table into `PerModelConfigs`.** The server stores per-model configs in two places: `subscription_models` table (v35+, authoritative) and `per_model_configs` JSON field. Without merging, client-side `ResolveEffectiveMaxContext` (which only checks `PerModelConfigs`) misses values from the table.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`scheduleSessionLLMRestore` must use `m.cachedModelName` (per-session model from `refreshCachedModelName`), NOT `target.Model` (subscription default).** The old code always used `target.Model`, so per-session model choices (Ctrl+L switch) were lost on restart — the restored LLM used the subscription's default model. `handleSwitchLLMDoneMsg` must also call `SwitchModel(senderID, done.subModel, chatID)` AFTER `SetDefault(subID, chatID)`, because `SetDefault`→`SetSessionLLM` creates the per-chat entry with `sub.Model` (subscription default), overriding the per-session model. The `SwitchModel` call corrects the model field and re-persists to the tenants table.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Cancel ack must NOT call `updateViewportContent()`.** The live streaming display already shows all iterations correctly. Forcing a full glamour re-render of ALL messages on cancel ack can lose the latest iterations' display data. The cancel ack just sets `m.rc.valid = false` and lets the next tick (100ms) pick up the `isPartial=false` change (guide color dimming) incrementally.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`.** When a tool transitions from `ActiveTools(done)` to `CompletedTools` across progress frames, both lists can temporarily contain it. The dedup set (`seen[key]`) prevents the same tool from rendering twice.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` and `handleProgressDone` must NOT use `prev.ReasoningStreamContent` as reasoning fallback.** Stream-only messages (`StreamReasoningFunc`) update `m.progress.ReasoningStreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to `m.progress`), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. **FIXED**: removed `prev.ReasoningStreamContent` fallback from `snapshotIterationChange` entirely. Only structured sources are used: `prev.Reasoning` → `reasoningByIter` → `m.lastReasoning` → `msg.payload.Reasoning`. If structured Reasoning is unavailable (progressCh coalescing), the snapshot has empty reasoning — better no reasoning than wrong reasoning. Added `streamReasoningByIter` field to `progressState` for future per-iteration stream tracking.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. This works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "bfca957f49e9ee99c59a461e1edb00659d0db747": {"t": 1785759497.0955853, "n": 64279, "h": "c35b8877e05b6eef", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. Must NOT pass `max_context_tokens` to `ApplySettings` — that calls `ag.SetMaxContextTokens(n)` globally, contaminating all models without per-model override.\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`listSubscriptions`/`getDefaultSubscription` RPC must merge `subscription_models` table into `PerModelConfigs`.** The server stores per-model configs in two places: `subscription_models` table (v35+, authoritative) and `per_model_configs` JSON field. Without merging, client-side `ResolveEffectiveMaxContext` (which only checks `PerModelConfigs`) misses values from the table.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **Cancel ack must NOT call `updateViewportContent()`.** The live streaming display already shows all iterations correctly. Forcing a full glamour re-render of ALL messages on cancel ack can lose the latest iterations' display data. The cancel ack just sets `m.rc.valid = false` and lets the next tick (100ms) pick up the `isPartial=false` change (guide color dimming) incrementally.\n- **Server-side cancel barrier via `ctx.Done()`** prevents stale progress events from arriving after Ctrl+C. `buildMainRunConfig` wraps every `ProgressEventHandler` with `select { case <-ctx.Done(): return; default: }`. When `/cancel` triggers `reqCancel()`, the ctx is cancelled, and all subsequent progress event handler calls return immediately — no stale progress/PhaseDone events leak through to trigger re-renders. No extra fields needed: the ctx is the natural cancellation mechanism already wired through `chatProcessLoop → processMessage → buildMainRunConfig`.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`.** When a tool transitions from `ActiveTools(done)` to `CompletedTools` across progress frames, both lists can temporarily contain it. The dedup set (`seen[key]`) prevents the same tool from rendering twice.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` and `handleProgressDone` must NOT use `prev.ReasoningStreamContent` as reasoning fallback.** Stream-only messages (`StreamReasoningFunc`) update `m.progress.ReasoningStreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to `m.progress`), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. **FIXED**: removed `prev.ReasoningStreamContent` fallback from `snapshotIterationChange` entirely. Only structured sources are used: `prev.Reasoning` → `reasoningByIter` → `m.lastReasoning` → `msg.payload.Reasoning`. If structured Reasoning is unavailable (progressCh coalescing), the snapshot has empty reasoning — better no reasoning than wrong reasoning. Added `streamReasoningByIter` field to `progressState` for future per-iteration stream tracking.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n- **`handleSuHistoryLoad` acceptProgress assistant merge must NOT cross turns.** When TUI restarts mid-turn, the code merges the last history assistant into `startAgentTurn()`'s empty streaming slot. The old code searched backward for `role==\"assistant\"` and always merged — but if there's a `role==\"user\"` message between the found assistant and the streaming slot, the assistant belongs to a PREVIOUS turn. Merging it displays the previous turn's content as the current streaming message (Bug: stale content appears below latest iteration on restart). Both merge branches (the `canMerge` path and the `streamingMsgIdx < 0` path) must scan for intervening user messages before merging.\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. This works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **Channel-scoped tools via `Registry.channelTools`.** Feishu tools (Card + Feishu MCP) are registered with `RegisterForChannel(\"feishu\", tool)` instead of global `Register(tool)` + `SupportedChannels()` filtering. This eliminates the O(N) per-prompt `visibleToolDefs` overhead and keeps `globalTools` clean. `AsDefinitionsForSession` merges `channelTools[ChannelFromSessionKey(sessionKey)]` automatically. Tool execution uses `GetForSession(name, tenantID, sessionKey)` which checks channel → tenant → global.\n- **Channel plugin tools via `channel_tools` protocol.** Channel plugin process sends `{\"type\":\"channel_tools\",\"tools\":[...]}` to declare tools. `ChannelPluginTransport.handleChannelTools()` wraps each in `ChannelToolBridge` and registers via `RegisterForChannel`. Tool execution proxies via `Call(\"execute_tool\")` RPC to the channel process. Hot-update: new `channel_tools` message replaces the entire set.\n- **`RegisterForChannel` empty channel falls back to global.** Passing `\"\"` as channel calls `Register(tool)` instead.\n- **`ChannelFromSessionKey` is public.** Used by `AsDefinitionsForSession` and `GetForSession` to extract channel from `\"channel:chatID\"` format.\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "4adbe5c9b93030df5db2c2735d373fe9b5ab0a14": {"t": 1785759499.91006, "n": 61452, "h": "e88e83e8c7bf90d2", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. Must NOT pass `max_context_tokens` to `ApplySettings` — that calls `ag.SetMaxContextTokens(n)` globally, contaminating all models without per-model override.\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`listSubscriptions`/`getDefaultSubscription` RPC must merge `subscription_models` table into `PerModelConfigs`.** The server stores per-model configs in two places: `subscription_models` table (v35+, authoritative) and `per_model_configs` JSON field. Without merging, client-side `ResolveEffectiveMaxContext` (which only checks `PerModelConfigs`) misses values from the table.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`.** When a tool transitions from `ActiveTools(done)` to `CompletedTools` across progress frames, both lists can temporarily contain it. The dedup set (`seen[key]`) prevents the same tool from rendering twice.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` and `handleProgressDone` must NOT use `prev.ReasoningStreamContent` as reasoning fallback.** Stream-only messages (`StreamReasoningFunc`) update `m.progress.ReasoningStreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to `m.progress`), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. **FIXED**: removed `prev.ReasoningStreamContent` fallback from `snapshotIterationChange` entirely. Only structured sources are used: `prev.Reasoning` → `reasoningByIter` → `m.lastReasoning` → `msg.payload.Reasoning`. If structured Reasoning is unavailable (progressCh coalescing), the snapshot has empty reasoning — better no reasoning than wrong reasoning. Added `streamReasoningByIter` field to `progressState` for future per-iteration stream tracking.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. This works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "b85c49f336a1325d516528725b30a1c33fe90b6e": {"t": 1785759502.0082583, "n": 61101, "h": "920d3f31bf592503", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. Must NOT pass `max_context_tokens` to `ApplySettings` — that calls `ag.SetMaxContextTokens(n)` globally, contaminating all models without per-model override.\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`listSubscriptions`/`getDefaultSubscription` RPC must merge `subscription_models` table into `PerModelConfigs`.** The server stores per-model configs in two places: `subscription_models` table (v35+, authoritative) and `per_model_configs` JSON field. Without merging, client-side `ResolveEffectiveMaxContext` (which only checks `PerModelConfigs`) misses values from the table.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Cron Scheduler\n- **`cleanupExpiredJobs` must NOT delete `delay_seconds` one-shot jobs.** `delay_seconds` represents relative time (\"N seconds from creation\"). If the job expired during downtime, deleting it silently drops the user's scheduled reminder. Keep expired `delay_seconds` jobs so `checkAndFire` triggers them on the first tick. Only `at`-based one-shot jobs (absolute time point) should be removed when expired — the scheduled moment has passed.\n- **`every_seconds` next_run must be based on `job.NextRun`, NOT `now`.** The old code used `nextRun = now.Add(interval)`, where `now` is the actual fire time (delayed by 0–5s due to the 5s ticker). Each fire accumulates drift, causing the schedule to slide later and later. Fix: `nextRun = job.NextRun.Add(interval)` anchored to the planned schedule, with a forward-advance loop for missed intervals.\n- **Recurring jobs skip missed executions on restart (by design).** `cleanupExpiredJobs` recalculates `next_run` from `now` for recurring jobs. This matches traditional cron semantics — no catch-up execution.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`.** When a tool transitions from `ActiveTools(done)` to `CompletedTools` across progress frames, both lists can temporarily contain it. The dedup set (`seen[key]`) prevents the same tool from rendering twice.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` and `handleProgressDone` must NOT use `prev.ReasoningStreamContent` as reasoning fallback.** Stream-only messages (`StreamReasoningFunc`) update `m.progress.ReasoningStreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to `m.progress`), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. Use `prev.Reasoning` (server's `ReasoningContent`, set at LLM completion) or `m.lastReasoning` instead.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. This works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "de0bda11455f061c965b525d12fe31f87d2cb213": {"t": 1785759503.834275, "n": 60029, "h": "627ee444042d74ac", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is per-model, NOT per-subscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. `/settings` writes only the current session's model via `UpdatePerModelConfig`, preserving existing `MaxOutputTokens`. Must NOT pass `max_context_tokens` to `ApplySettings` — that calls `ag.SetMaxContextTokens(n)` globally, contaminating all models without per-model override.\n- **`SwitchModel` with chatID must NOT call `SetModel` on the subscription.** Per-session model switches (with chatID) only create per-chat cache entries. Calling `SetModel` contaminates all sessions sharing the same subscription. Only user-level switches (no chatID) update the subscription.\n- **`listSubscriptions`/`getDefaultSubscription` RPC must merge `subscription_models` table into `PerModelConfigs`.** The server stores per-model configs in two places: `subscription_models` table (v35+, authoritative) and `per_model_configs` JSON field. Without merging, client-side `ResolveEffectiveMaxContext` (which only checks `PerModelConfigs`) misses values from the table.\n- **`handleSwitchLLMDoneMsg` must NOT pass `done.maxCtx` as `state.MaxContextTokens`.** Those values come from `resolveSubMaxContext` using the subscription's DEFAULT model. Setting them poisons `ResolveEffectiveMaxContext` priority-1 shortcut, bypassing per-model lookup. Let `applySessionLLMState` resolve dynamically from `PerModelConfigs`.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`upsertMessageByTurn` now also purges zombie duplicates.** After updating a message, it scans for OTHER messages with the same (turnID, role) pair and removes them. Without this, fallback paths (e.g. isPartial fallback creating a second streaming message) could leave zombies that render as duplicates.\n- **`dedupMessagesGuard()` runs at the TOP of `updateViewportContent()`.** This is the algorithmic guarantee layer: it scans `m.messages` for any (turnID, role) duplicates (turnID > 0) and purges earlier occurrences before rendering. Uses O(n) map-based identity detection — NOT string matching. Even if a race condition or unguarded append path creates a duplicate, it is silently purged here before reaching the viewport.\n- **`endAgentTurn` clears `streamingMsgIdx = -1`.** Without this, a delayed complete message from the previous turn can overwrite the new turn's streaming message via the stale index. The non-streaming path in `handleAgentMessage` also checks `m.messages[m.streamingMsgIdx].turnID == turnID` before overwriting.\n- **`isPartial` fallback checks `findMessageByTurn` before creating new message.** When `streamingMsgIdx` is stale (cleared by endAgentTurn), the fallback now searches for an existing message with the same turnID+role and reuses it, instead of appending a duplicate.\n- **Cache generation counter (`histGen`/`allLinesGen`) prevents stale `allLines` reuse.** Every site that modifies `histLines` calls `bumpHistGen()`. The tick fast path only reuses `allLines` when `histGen == allLinesGen`. This replaces the old length-only comparison (`histLen == allLinesHistLen`) which could not detect content changes when line count coincidentally matched — causing stale cache + new content to render together as duplicates.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation. **Cleared on session switch** in `postRestoreSessionSetup` to prevent stale flags from causing premature queue flush.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n- **Cancelled turns must bake iterations into the streaming message BEFORE `endAgentTurn` clears `iterationHistory`.** The cancel path in `handleProgressDone` copies `iterationHistory` into `streamingMsg.iterations` before calling `endAgentTurn()`. Without this, all tool tags and reasoning rendered inline during the cancelled turn vanish after Ctrl+C. The cancel ack in `handleAgentMessage` also finalizes (not removes) the streaming message when it has iteration data — even if `content == \"\"` (e.g. agent was mid-tool with no text yet). Only truly empty messages with zero iterations are removed.\n- **`handleHistoryLoad` now deduplicates using identity keys.** Messages are keyed by `role + \":\" + turnID + \":\" + content` and duplicates are skipped during append. Prevents double-rendering when history reload races with in-flight messages.\n- **`compressionReloading` and `turnDoneFlags` are cleared in `postRestoreSessionSetup`.** Without clearing, a stale `compressionReloading=true` from the previous session permanently blocks auto-start in the new session, and stale `turnDoneFlags` can cause premature queue flush.\n- **`liveIterationBlocks` deduplicates tools by `Name + Label`.** When a tool transitions from `ActiveTools(done)` to `CompletedTools` across progress frames, both lists can temporarily contain it. The dedup set (`seen[key]`) prevents the same tool from rendering twice.\n- **`snapshotIterationChange` data mismatch path now creates a snapshot instead of discarding.** When `prev.Iteration != m.lastSeenIteration`, instead of skipping the snapshot entirely (which permanently loses iteration data), it creates a snapshot tagged with `prev.Iteration` after checking it wasn't already snapshotted.\n- **`mergeSubAgentTrees` preserves children when `new.Children` is empty but `prev.Children` exists.** Instead of returning nil (dropping the subtree), it marks all prev children as done via `markAllDone()`. This prevents child progress trees from flickering when the server doesn't include children in every update frame.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` and `handleProgressDone` must NOT use `prev.ReasoningStreamContent` as reasoning fallback.** Stream-only messages (`StreamReasoningFunc`) update `m.progress.ReasoningStreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to `m.progress`), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. Use `prev.Reasoning` (server's `ReasoningContent`, set at LLM completion) or `m.lastReasoning` instead.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. This works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}, "70a20c3e496940e21ba7c4bf58400bb54eb8e9d2": {"t": 1785759505.379293, "n": 54881, "h": "58dbd3b1fa794b97", "c": "# xbot\n\n> Go AI Agent framework with message bus + plugin architecture. Supports Feishu/QQ/CLI/Web channels, tool calling, pluggable memory, skills, subagents, MCP integration.\n\n## Quick Reference\n\n- Entry points: `cmd/xbot-cli/` (CLI), `cmd/runner/` (remote sandbox), `cmd/xbot/` (server)\n- Build: `go build ./...` | Test: `go test ./...` | Lint: `golangci-lint run ./...`\n- Config: `~/.xbot/config.json`, env var overrides\n- Subscriptions: `~/.xbot/config.json` (CLI) or DB `user_llm_subscriptions` (Server) — the single source of truth for LLM config\n- Pre-commit: gofmt → golangci-lint → go build → go test\n\n## Knowledge Files\n\n- `docs/agent/architecture.md` — package map, message flow, pipeline, Transport (Call+Close)/Backend/DirectBackend/Lifecycle separation, key interfaces, concurrency, TokenTracker, CompressPipeline, PersistenceBridge\n- `docs/agent/agent.md` — agent loop, middleware, SubAgent, context management, masking, dynamic context, reminder\n- `docs/agent/llm.md` — LLM clients, streaming pitfalls, retry behavior, model tiers (vanguard/balance/swift)\n- `docs/agent/subscription.md` — **subscription system 完整文档**: LLMFactory cache、GetLLM/GetLLMForChat/GetLLMForModel 解析链、max_context 优先级、所有切换场景(per-session/全局/settings/启动恢复)、会话隔离规则、Invalidate 速查表、TUI↔Backend 数据同步\n- `docs/agent/tools.md` — built-in tools: Shell, Read, Edit, Glob, Grep, Cd, Fetch, WebSearch, Cron, SubAgent, CreateChat, SendMessage, Worktree, config, tui_control, TodoWrite, context_edit, AskUser, DownloadFile, ChatHistory, ManageTools, Skill, EventTrigger, TaskManager, hooks system (agent/hooks/), sandbox types, ChannelPluginTransport (stdio channel plugin transport)\n- `docs/agent/settings.md` — settings system: single registry (agent/setting_runtime.go), cli_settings.go, UpdatePerModelConfig, subscription-scoped vs user-scoped, runtime apply chain\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build, **local/remote unification**\n- `docs/agent/hooks.md` — hooks lifecycle events, handler types, configuration, gotchas\n- `docs/agent/channel.md` — CLI (BubbleTea TUI), Feishu, Web, QQ adapters, asyncCh pattern, deterministic rendering, mouse support, settings panels\n- `docs/agent/memory.md` — letta vs flat providers\n- `docs/agent/conventions.md` — error handling, logging, testing, naming, build\n- `docs/agent/plugin.md` — plugin system architecture, runtimes, integration, RPC bridge\n- `docs/agent/worktree.md` — git worktree-based multi-agent workspace isolation, WorktreeRegistry, AutoDetectAndInit, peer discovery, path security\n\n## Gotchas — MUST READ Before Any Code Change\n\n### Concurrency\n- **Never `defer` semaphore release inside a loop.** Deadlock when iterations exceed capacity. Release immediately after Generate completes.\n- Non-blocking channel sends: always use `select` with `ctx.Done()` to prevent blocking on full channels during shutdown.\n- **User-scoped semaphores must not be hardcoded to capacity 1 when one sender can own multiple independent chats/sessions (for example remote CLI windows authenticated as `admin`).** Size them from configured concurrency or key them by session, otherwise different windows will block each other and look like a leaked semaphore.\n- **`SetMaxConcurrency` must clear `userSemaphores` cache.** The global semaphore is rebuilt with the new capacity, but `getUserSemaphore` caches per-user channels in a `sync.Map` via `LoadOrStore`. Without `Clear()`, users with custom LLM keep using the cached semaphore with the OLD capacity forever. Symptom: setting max_concurrency to 100 has no visible effect.\n- **`cancelChildSessions` must only cancel sessions with matching `parentKey`.** The old code called `cancelCurrent()` on ALL interactive sessions inside the `Range` loop before checking `parentKey`. This killed sibling/peer background agents when any single agent was unloaded or panicked — all N peer agents die simultaneously at whatever time the first one finishes. The `parentKey` check must happen BEFORE `cancelCurrent()`.\n- **SubAgents must never outlive their direct creator — ALL completion paths need `cancelChildSessions`.** Foreground SubAgents wrap their own `fgRunCtx` (with `bgSessionCtxKey`+`bgParentKey`) so children derive context from the creator, not a grandparent. Send path `runCtx` also has these markers. Every natural completion path (foreground, bg, send) calls `runCancel()` + `cancelChildSessions(key)`. Without the context wrapper, a foreground B creating bg C would have C's context chain skip B entirely → B finishes, C keeps running.\n\n### Subscription & Settings\n- **`agent/setting_runtime.go` is the SINGLE source of truth for runtime setting handlers.** Both CLI and server use it. Never create a second handler registry — it will silently diverge.\n- **`backendSubscriptionManager` is the SINGLE SubscriptionManager implementation.** No more local/remote/config variants. All subscription operations go through Backend interface → Transport (local or remote).\n- **Never use `UpdateSubscription` for PerModelConfig changes.** Use `UpdatePerModelConfig(subID, model, pmc)` — it only touches PerModelConfigs, never touches credentials. The old List→modify→Update pattern reads masked keys from the API, then writes them back, destroying real credentials.\n- **`serverapp/rpc_table.go:updateSubscription` starts from EXISTING subscription, only overlays non-masked fields.** Client sends masked keys (****) — the handler preserves real credentials from DB.\n- **`max_context_tokens` is ScopeSubscription.** Stored in `PerModelConfigs[model].MaxContext`, NOT in user_settings DB or config.Agent.MaxContextTokens. Changing it in `/settings` writes to subscription via `UpdatePerModelConfig`.\n- **`OnModelsLoaded` callback runs in `NewOpenAILLM`'s async goroutine** — must be concurrency-safe.\n- **Tier fallback**: unconfigured tier → vanguard→balance→swift chain. Empty tier must NOT return default client with wrong model.\n- **`createClientFromSub` uses sub's credentials with a *different* model** — verify target model is served by that endpoint.\n\n### Context Management & Token Estimation\n- **`maybeCompress` uses ONLY API-returned `prompt_tokens` via `TokenTracker.GetPromptTokens()`.** No local estimation (tiktoken/CountMessagesTokens) in the compression decision path. The `no_data` source (no API data yet) skips all compress/masking checks. Tests must set `cfg.LastPromptTokens` to simulate a previous Run. **`maybeCompress` does NOT add `completionTokens` to its check — `shouldCompact(totalTokens, promptBudget, threshold)` compares `prompt_tokens` against `(maxContextTokens - maxOutputTokens) * threshold`, pure prompt tokens only.** Both the context bar and maybeCompress use the same `tokenTracker.GetPromptTokens()` source — they never diverge.\n- **`ResetAfterCompress()` takes no arguments — it zeros ALL tracker fields** (promptTokens, completionTokens, hadLLMCall). Any non-zero value causes `maybeCompress` to re-trigger immediately, creating infinite compression loops. The tracker returns \"no_data\" until the next real LLM API call.\n- **`RecordLLMCall(prompt, completion)` takes 2 args only.** No msgCount — the old delta estimation via `CountMessagesTokens` has been removed. Offload handles large tool results; a single iteration cannot add enough tokens to justify local estimation.\n- **`RecordLLMCall` must NOT be called when `response.Usage.PromptTokens == 0`.** When a stream is cancelled mid-flight (Ctrl+C), `CollectStreamWithCallback` returns a non-nil response with zero Usage (usage events arrive at stream end). Calling `RecordLLMCall(0, 0)` overwrites the tracker's correct value from a previous iteration. This causes `SaveState` to skip (`promptTokens <= 0`), but `SaveTokenState(0, 0)` and `SaveContextTokens(0)` in `callLLM` still execute unconditionally, overwriting DB with 0. Next turn restores 0 → context bar empty → first callLLM corrects to real value → immediate compression. Fix: guard all three save paths (`RecordLLMCall`, `SaveContextTokens`, `SaveTokenState`) with `response.Usage.PromptTokens > 0`.\n- **`buildToolContextExtras` uses `TenantSession.MemoryService()` for `MemorySvc`/`TenantID`, NOT `LettaMemory` type assertion.** These are tenant-level fields that work for all memory providers. Only LettaMemory-specific fields (CoreMemory, ArchivalMemory, ToolIndexer) stay inside the type assertion.\n- **`ObservationMaskStore` and `OffloadStore` both persist to disk.** Mask uses `~/.xbot/mask/{tenantID}/{id}.json`, Offload uses `~/.xbot/offload_store/{session}/{id}.json`. `Recall` falls back to disk on memory miss. Both cleaned on compress and `/clear`.\n- **`PersistenceBridge` manages the persistence watermark (`lastPersistedCount`), not inline fields.** All compression paths use `ApplyCompress` pipeline which calls `PersistenceBridge.RewriteAfterCompress()` to atomically clear+rewrite+update watermark. The invariant `LastPersistedCount <= len(messages)` is verified by `ValidateInvariants()` at debug level after every LLM call, compression, and persistence operation.\n- **Shell binary output silently bypasses offload: `summarizeShell` splits by `\\n` and keeps last 5 lines.** Binary data (e.g. `cat libmujoco.so`) has very few newlines, so \"last 5 lines\" ≈ entire content. The offload summary then contains nearly all original binary data, causing context explosion (39k→2.2M tokens observed). `summarizeShell` now applies `maxLineRunes=500` truncation (same as `summarizeRead`) on output lines. But any new summary generator must handle the few-lines-but-megabytes-per-line case.\n- **`SanitizeMessages()` is the final validation gate before sending to LLM.** Five passes: (1) strip assistant messages with empty content AND no tool_calls — rejected by OpenAI/Anthropic; (2) strip tool_calls with invalid/malformed JSON arguments — caused by cancelled stream with partially-generated tool arguments, DeepSeek rejects with 400; (3) strip trailing unpaired tool_calls from cancelled runs; (4) ensure every tool_call_id has a matching tool response message — strip orphaned tool_calls, re-run Pass 1 to clean up emptied assistant messages; (5) strip orphaned tool messages whose tool_call_id doesn't appear in any assistant's tool_calls — Pass 2 and Pass 4 can strip tool_calls leaving orphaned tool messages, DeepSeek rejects with \"Messages with role 'tool' must be a response to a preceding message with 'tool_calls'\". Called in `buildPrompt` and `engine.go`.\n- **`CollectStreamWithCallback` must return partial content on `ctx.Done()`, not nil.** When user cancels streaming mid-reasoning, returning nil loses accumulated `reasoning_content` and partial `tool_calls`. The partial tool_calls may have broken JSON arguments, which then pass through `SanitizeMessages` Pass 2 for cleanup. Returning nil also prevents the engine from properly recording the cancellation state.\n- **`GenerateStreamAndCollect` must NOT use `perAttemptCtx` for streaming.** A per-attempt deadline binds to the underlying HTTP connection via ctx. The deadline kills active streams mid-generation even when chunks are still flowing (e.g., DeepSeek long reasoning > 120s). Stream timeout is managed by `CollectStreamWithCallback`'s idle timeout (120s without any chunk) — timer resets on every received chunk, so actively-streaming responses never time out.\n- **MCP stdio `exec.Command` uses process PATH, not `cmd.Env`.** Go's `exec.Command(\"npx\", ...)` resolves the executable using the *process* PATH (`os.Getenv(\"PATH\")`), ignoring `cmd.Env`. When xbot runs as a service with minimal PATH, tools like npx/nvm are invisible. `ConnectStdioServer` now calls `resolveCommand()` to pre-resolve the absolute path using the login shell PATH from `getLoginShellEnv()`. Also, `getLoginShellEnv` uses `bash -i -l` (interactive login shell) because `bash -l` alone skips `.bashrc` where nvm initializes.\n\n- **SQLite `datetime()` comparison with RFC3339 strings is a silent trap.** `created_at` stores `2026-05-24T14:00:00+08:00` (RFC3339 with timezone). `datetime(?, '-2 seconds')` returns `2026-05-24 05:59:58` (UTC, no timezone). Raw string comparison: `'T'` (0x54) > `' '` (0x20) at position 10, so `created_at > datetime(?)` is **always TRUE**, breaking any time-window dedup/filter. Always wrap both sides in `datetime()`: `datetime(created_at) > datetime(?, '-2 seconds')`.\n\n### Startup\n- `NewOpenAILLM` loads model list asynchronously. `ListModels()` returns fallback immediately.\n- Settings save is synchronous — all local I/O, no network calls.\n- **`SaveToFile` uses deep JSON merge to preserve unknown fields.** `json.Unmarshal` silently drops fields not in the Go struct. `SaveToFile` reads the existing disk file first and recursively merges struct JSON into it, so user-added custom fields (or future struct fields added in newer versions) survive load→save cycles. Never bypass `SaveToFile` with raw `json.Marshal` writes to config.json.\n\n### CLI / BubbleTea\n- **`CLIChannelConfig` is ALWAYS passed by pointer, NEVER by value.** `NewCLIChannel(cfg *CLIChannelConfig, ...)` stores the pointer; all later modifications (callbacks like `BindChatFn`, `GetActiveProgressFn`) are immediately visible to the channel. Passing by value (the old code) silently discards any wiring done after construction, causing nil callbacks at runtime. Tests must always initialize `config: &CLIChannelConfig{}`.\n- **`BindChatFn` must be set before `NewCLIChannel`.** The wiring order matters because the channel stores the pointer. All other callbacks (GetActiveProgressFn, GetTodosFn, etc.) follow the same rule — set on `cliCfg` before passing to `NewCLIChannel`.\n- **`parseKeyInput` with modifiers must NOT set `Text` field.** `Key.String()` returns `Text` if non-empty (ultraviolet `key.go:392`), bypassing `Keystroke()`. `{Code:'c', Text:\"c\", Mod:ModCtrl}.String()` → `\"c\"` not `\"ctrl+c\"`, breaking cancel.\n- **Iteration snapshot deduplication**: PhaseDone + handleAgentMessage can both snapshot the same iteration. Always dedup by iteration number, preferring PhaseDone (has reasoning from server).\n- **`ElapsedWall` must be set in ALL snapshot paths** — missing it causes total time to fall back to summing only last iteration's tool.Elapsed.\n- **Remote TUI progress: WS now sends `protocol.ProgressEvent` directly** (no conversion). Adding new fields to ProgressEvent is sufficient — no separate WsProgressPayload or conversion functions exist anymore.\n- **Cancel key must be `channel:chatID` only (no senderID).** Background task notifications inject messages with senderID=\"user\", while CLI users have different senderIDs. Including senderID in the cancel key makes cancel impossible for bg-task-triggered turns.\n- **SubAgent remote mode: tick chain breakage** — `tickCmd()` injection must be unconditional (not gated on `!m.fastTickActive`) in splashDoneMsg, PhaseDone return, and history reload paths. Conditional injection causes chain to break during session switches.\n- **SubAgent session view: viewport freeze on return** — when main session's turn ended while viewing agent session, PhaseDone is detected on return but assistant reply is missing. Tick handler with `busy=false` must check `!m.renderCacheValid` as fallback.\n- **Background interactive SubAgent completion MUST append final assistant reply to messages.** Both the initial spawn path (`placeholder.messages`) and the \"send\" action path (`ia.messages`) must append `llm.NewAssistantMessage(out.Content)` + carry `ReasoningContent` + save `iterationHistory` + persist to DB via `cfg.Session.AddMessage`. The foreground path does all of this; both background paths (spawn + send) must match. `GetAgentSessionDump` reads `ia.messages` — without the assistant message, the session view shows only user message + tool calls but no final reply. The background \"send\" path must also append the user message from `action=send` and use `out.Messages` (not `cfg.Messages`) for Run-produced messages.\n- **SubAgent CWD inheritance**: `parent_cwd` metadata must fallback to `parentCtx.WorkingDir` when `parentCtx.CurrentDir` is empty (parent never Cd'd). `buildParentToolContext` must also fallback to `workspaceRoot`. Otherwise SubAgent starts in `a.workDir` (config value) instead of the parent's actual working directory.\n- **`formatGlobalContext` must use XML+CDATA wrapping** (same as `formatProjectContext`). Global instructions from `~/.xbot/AGENTS.md` are user-editable content — without CDATA, instructions can escape their section and override system-level behavior. Both global and project context must use `<xxx_instructions><![CDATA[...]]></xxx_instructions>` boundaries.\n- **SubAgent must inject global context** (`~/.xbot/AGENTS.md`) via `LoadGlobalContextFile`. The old code only injected project-level context. `buildSubAgentRunConfig` now calls `LoadGlobalContextFile(a.xbotHome)` before `LoadProjectContextFile(workDir)`.\n- **Interactive SubAgent `action=send` while running must NOT reject.** When `ia.running=true`, queue the message in `ia.pendingMessages` and block the caller on `replyCh`. The SubAgent's `DrainBgNotifications` callback drains pending messages between Run iterations via `wirePendingMessageDrain`. Each message is injected as a `QueuedUserMessage` notification → `injectQueuedUserMessage` synthetic tool result. `ReplyFn(nil)` unblocks the sender. All 4 `Run()` call sites must set `cfg.DrainBgNotifications`.\n- **`wireSubAgentCLIProgress` must be called for ALL sessions (foreground AND background).** Background sessions gated by `!background` have no live progress when viewed via Ctrl+T panel. ChatID-based filtering in `handleProgressMsg` ensures events route to the correct session.\n- **CreateChat tool must set `background=true` in metadata** before `SpawnInteractive`. Without it, CreateChat blocks the parent agent's turn until the SubAgent finishes.\n- **Progress panel cursor overflow**: when typewriter cursor `▋` would overflow the line width, render it on a separate line with placeholder (guide-only when hidden) to prevent height jumping during blink.\n- **Progress panel tool lines**: use `toolLine()` helper (lipgloss.Width-based) instead of `len()` for width calculation — byte length ≠ visual width for styled/unicode content.\n- **SubAgent tree description**: skip description when `descW <= 0` instead of forcing `descW >= 10` minimum — the old minimum caused overflow on narrow terminals.\n- **Group chat members must be pre-spawned**: `CreateChat(type=\"group\")` must auto-spawn each member agent and register AgentChannel in Dispatcher. Otherwise `@mentions` in SendMessage fail with \"unknown channel: agent:role/instance\".\n- **Panel navigation stack (push/pop)**: `panelStack []panelStackEntry` stores parent panel state for nested navigation. `pushPanel()` saves state, `popPanel()` restores it. `pushPanelFromPalette()` marks `fromPalette=true` so ESC reopens the palette. Only the **caller** (e.g. Settings entering Runner) should push — `openXxxPanel()` never pushes itself. `closePanel()` clears the entire stack.\n- **Settings inline overlay (Crush-style)**: edit/combo overlay renders inline right below the cursor item, NOT at the end of the list. `trackSettingsZones` must account for inline overlay lines. `ensureSettingsCursorVisible(extraLines)` adjusts `panelScrollY` so the overlay stays visible.\n- **Settings click handlers must `commitPanelEdit()` first**: clicking a different item while in edit/combo mode must save the current edit value and close the overlay before activating the new item. Otherwise stale overlay state causes rendering bugs.\n- **Panel mouse wheel uses `isYInPanelBox(y)`, not zone matching**: zone-based scroll detection left gaps (category headers, blank lines). Y-range check covers the entire panel box area with no dead zones.\n- **textinput `placeholderView()` emits `\\x00` NUL bytes that break lipgloss word-wrap.** When placeholder (e.g. \"在此输入...\") has fewer runes than `Width+1`, `placeholderView()` fills the gap with NUL bytes. `lipgloss.Width()` counts these as 0-width, but lipgloss `Style.Render()` treats them as 1-column during word-wrap, causing the scrollbar `▐` to wrap to the next line in PanelBox. Fix: `strings.Map` to strip `\\x00` from textinput `View()` output before rendering. Also: `placeholderView()` outputs `Width+1` visible chars total; `applyScrollbar` must use `>= contentWidth` (not `>`) when truncating.\n- **`ensureAskUserCursorVisible` must use actual question line count, not hardcoded estimate.** `hardWrapRunes` can split a long question into many lines. The old code used `headerLines=2` (or 4), causing `cursorLine` to be grossly underestimated — every arrow key press reset scroll to the top. Fix: compute exact header height via `hardWrapRunes + strings.Count`. Also use `askUserPanelVisibleHeight()` (not `panelVisibleHeight()`) for the askuser split layout.\n- **`m.defaultChatID` may contain session suffix, not just workDir.** When `GetLastActiveSession` returns a full chatID like `/path:Agent-xxx`, `defaultChatID` becomes that full chatID. `SetLastActiveSession` uses `ParseChatID` internally to extract the bare workDir for correct sessions file hashing — callers can pass either format safely.\n- **Remote CLI `ChatRenameFn` must use upsert (INSERT ON CONFLICT), not UPDATE.** CLI sessions may not have a `user_chats` row (created locally, never INSERT'd). Plain `UPDATE` silently affects 0 rows. The upsert creates the row if missing. Both CLI-local and server-side rename paths now call `DeduplicateSessionName` to auto-suffix colliding names (adj-noun from session pool, then numeric fallback).\n- **Session JSON `created_at` uses `flexTime` — must handle space-separated legacy format.** Older sessions stored timestamps as `\"2026-05-08 20:40:28+08:00\"` (space separator). Go's `time.Time.UnmarshalJSON` only accepts RFC3339 (`T` separator). A single malformed timestamp causes the entire `LoadDirSessions` to fail, making ALL sessions invisible and preventing new session creation. `flexTime` tries RFC3339 first, then falls back to space-separated formats.\n- **`RenameSession` never changes chatID — only the display name.** chatID is the primary key in DB `tenants` table. Changing it disconnects the session from its message history. The function now returns `(actualName, error)` where `actualName` may differ from requested name after dedup.\n- **RPC `get_history` must call `GetOrCreateTenantID` to update `last_active_at`.** Without this, all CLI sessions have NULL `last_active_at` in the DB, making `ListTenants` unable to find the most recent session.\n- **Palette tab zone is a single line**: `trackPaletteZones` tracks tabs as ONE zone, not `tabCount` zones. The old code tracked one zone per tab, causing Y offset = `tabCount - 1` for all items below.\n- **Session deletion must delete DB FIRST, then local JSON.** `deleteLocalSession` used to remove from local JSON before calling backend `SessionsDeleteFn`. If the backend delete fails (network error, DB lock), local JSON is already cleaned but the DB tenant persists. Creating a new session later with the same random name reuses the old tenant via `INSERT OR IGNORE` in `GetOrCreateTenantID`, restoring all deleted messages — a data leak. The fix swaps the order: backend first (fail fast, keep local JSON intact for retry), local JSON second. `showSessionCreateDialog` also explicitly calls `SessionsDeleteFn` as a safety net to clean up any residual DB tenant from a previously-failed deletion.\n\n### CLI Deterministic Rendering\n- **Every assistant/tool_summary message is keyed by `agentTurnID` + `role`.** `upsertMessageByTurn(turnID, role, msg)` finds existing entries and updates in-place instead of appending duplicates. This prevents duplicate messages when PhaseDone and cliOutboundMsg arrive out of order.\n- **`turnDoneFlags` tracks per-turn completion state**: `doneProcessed` (PhaseDone created tool_summary) and `replyReceived` (handleAgentMessage appended assistant reply). `handleAgentMessage` checks `doneProcessed` to skip redundant tool_summary creation.\n- **Queue flush requires `replyReceived` or `doneProcessed+turnCancelled`.** The old heuristic (`!typing` on next tick) could flush before the assistant reply arrived. Now the tick handler waits for `replyReceived=true` on the completed turn. A 2s timeout fallback prevents permanent queue stalls when replies are lost.\n- **`pendingToolSummary` is no longer used for PhaseDone→handleAgentMessage handoff.** The upsert-by-turn mechanism replaces it — `handleProgressDone` creates the tool_summary via upsert, and `handleAgentMessage` either finds it already there (doneProcessed) or creates it from local iteration history.\n\n### SubAgent Progress Identity\n- **All SubAgent progress structs MUST carry an `Instance` field** (`CLISubAgent`, `WsSubAgent`, `SubAgentNode`, `childAgentStatus`). `mergeSubAgentTrees` uses `Role + \":\" + Instance` as the unique key — without Instance, same-role different-instance SubAgents collapse into a single tree node.\n- **`RunSubAgent` interface must include `instance` parameter.** One-shot SubAgent goes through `spawnAgentAdapter.RunSubAgent` → `buildMsg` → metadata. Without this, instance never reaches `SubAgentProgressDetail` and the progress tree can't distinguish parallel SubAgents.\n- **`isPlausibleAgentRole` must strip `[instance]` suffix before the space check.** The formatted line `\"> 🔄 explore [mem-1]: desc\"` has `\"explore [mem-1]\"` as the role candidate, which contains a space and would be rejected. Strip `[instance]` before `strings.Contains(name, \" \")` — otherwise ALL SubAgent progress lines with instance are filtered out by `isStatusEmojiLine`.\n- **`parseSubAgentLine` no-colon completion path must also extract instance.** For `\"✅ explore [mem-1]\"` (legacy format), instance extraction must happen before the early return, or the Instance field stays empty.\n\n### Reasoning Contamination\n- **`snapshotIterationChange` and `handleProgressDone` must NOT use `prev.ReasoningStreamContent` as reasoning fallback.** Stream-only messages (`StreamReasoningFunc`) update `m.progress.ReasoningStreamContent` directly between structured progress updates. Since `prev` is captured at the START of `handleProgressMsg` (pointer to `m.progress`), `prev.ReasoningStreamContent` can contain the NEXT iteration's reasoning when the iteration changes. Use `prev.Reasoning` (server's `ReasoningContent`, set at LLM completion) or `m.lastReasoning` instead.\n- **Reasoning stream without iteration advance contaminates previous snapshot.** `isStreamOnly` reasoning updates bypass `snapshotIterationChange`. When the next structured progress arrives with a new iteration, the old progress (with accumulated reasoning) gets snapshotted under the OLD iteration. Fix: `advanceIterationForReasoning()` detects when reasoning arrives for a completed iteration and advances `m.progress.Iteration`. Must also call after `restoreIterationHistory` + `carryForwardProgressState` for the TUI restart case.\n- **`handleAgentMessage` must NOT use `m.progress.ReasoningStreamContent` as `m.lastReasoning`/`m.reasoningByIter` fallback.** When the agent's text reply arrives after a structured progress has advanced `m.progress.Iteration` (e.g. #3→#4), `ReasoningStreamContent` may still contain the previous iteration's reasoning content. This causes misattribution: the previous iteration's reasoning gets stored under `m.reasoningByIter[newIter]`, and the next iteration's snapshot will show the wrong reasoning text (symptom: \"#4 shows #3's opening content\"). Only use `m.progress.Reasoning` (the authoritative per-iteration field, set at LLM completion).\n\n### CLI Rendering Panics\n- **All render bodies (`renderGlobBody`, `renderShellBody`, `renderReadBody`, etc.) must use `ansi.Truncate`, NEVER manual `runes[:maxW-N]`.** On narrow viewports, `maxW-N` goes negative causing `slice bounds out of range [:-1]` panic. Glob and Shell both had this pattern — fix one, grep for the other. `ansi.Truncate` handles negative/zero widths safely.\n- **`regexp.MustCompile` must be package-level `var`, never inside function bodies.** Chroma-powered render functions (`renderReadBody`, `renderDiffStyled`) are called per-frame and re-compiling regexps on every call wastes CPU. All other channel files (feishu.go, mermaid.go, qq.go) already use package-level vars — follow the convention.\n- **`currentTheme` writes must be guarded when called from non-BubbleTea goroutines.** `ApplyTheme()` is exported and can be called from plugin/settings handlers. `setTheme()` now holds `currentThemeMu` — any new write path to `currentTheme` must also acquire this mutex.\n\n### CLI Rendering Performance\n- **`renderProgressBlock` must use `padProgressLines` instead of `blockStyle.Render`.** `lipgloss.Style.Render` runs `ansi.StringWidth` on every character — O(total_content) per tick. Since progress block content is already hard-wrapped to `innerWidth`, manual `\" \" + line + \" \"` padding produces identical visual output at O(lines) cost. This was the #1 CPU consumer at 55% of total in profiles with many iterations.\n- **`progressBlockCompositeFP` must include `m.ticker.ticks` for spinner animation.** Without ticks in the fingerprint, the cache stays stable across 100ms ticks (elapsed is quantized to whole seconds), freezing the spinner/pulse animation. The composite FP combines pre-computed sub-block FPs + ticks + active tools for O(active_tools) instead of O(total_content) full-string hashing.\n- **Progress history rendering must be incremental, not full rebuild.** `renderHistoryRange` renders only newly added iterations (`cachedProgressHistory[oldLen:]`) and appends to cache. Old code re-rendered all N iterations on every new iteration — O(N) spike proportional to iteration count. The three-branch logic: cache hit (O(1)) / incremental append (O(new_iters)) / full rebuild (only on width change).\n- **Tick path must use direct line array assembly, not `setViewportContent` string path.** `cachedHistoryLines` + `cachedProgressBlockLines` + `cachedDynamicLines` are directly `append`ed into `[]string` and passed to `viewportSetLinesBypassMaxWidth`. This avoids O(total_content) string concatenation + O(N_iters) `lipgloss.Width` + `wrapPreservingGuide` per tick. Scroll behavior (atBottom/GotoBottom/newContentHint) is preserved identically.\n- **`syncProgressTodos` must do change detection before calling `relayoutViewport`.** High-frequency progress events carry the same Todos every time. Without `todosEqual()` check, each event triggers `relayoutViewport` → `fullRebuild` → `glamour.Render` + `chroma.Highlight` on ALL messages — ~34% CPU waste during agent work. Only call `relayoutViewport` when todo count changes; for content-only changes (e.g. item marked done), just set `renderCacheValid = false`.\n- **`relayoutViewport` only invalidates render caches when width changes.** Height-only changes (todo bar, panel open/close, `endAgentTurn`) just resize the viewport without rebuilding all messages. Without this guard, every `endAgentTurn` / `handleProgressDone` triggers O(N) `fullRebuild` with `glamour.Render` on every message — accounts for ~36% CPU during spike periods.\n- **`viewport.SetContent` calls `maxLineWidth` internally which runs `ansi.StringWidth` on every line — ~49% CPU with large content.** `cli_viewport_bypass.go` bypasses this by directly setting viewport's private `lines` and `longestLineWidth` fields via `unsafe` (offsets computed via `reflect.FieldByName` at init). **The caller must pass the REAL measured max width, NOT cw.** Viewport's `visibleLines()` trusts `longestLineWidth <= maxWidth` to skip truncation. If `maxW = cw` but some wrapped lines are actually wider (hardwrap edge cases, glamour output), those lines escape truncation, then lipgloss v2's `Wrap()` in `viewport.View()` re-wraps them, producing extra rendered lines that overflow the viewport height and push the input box off screen. `setViewportContent` tracks actual `maxW` via `lipgloss.Width()` during the wrap loop; `cachedHistoryMaxWidth` caches this for the fast path.\n\n### CLI Todo & Session Persistence\n- **`cliModel.todoManager` MUST be initialized.** It was always nil (no assignment), making ALL `if m.todoManager != nil` dead code. `syncProgressTodos`, `endAgentTurn`, and `restoreSession` all relied on TodoManager for cross-turn/cross-session todo persistence, but silently did nothing. Now created in `cli.go:Start()`. Any new model construction must also set it.\n- **`syncProgressTodos` must call `persistTodosToManager()` after updating `m.todos`.** Without this, todos live only in `m.todos` (volatile per-session slice) and are lost on turn end/session switch. The helper converts `[]CLITodoItem` → `[]tools.TodoItem` and calls `SetTodos` on the CLI-side TodoManager. `saveCurrentSession()` later calls `SaveToFile` for disk persistence.\n- **`/su` and `/chat` commands MUST call `saveCurrentSession()` before changing `m.chatID`.** They were directly mutating identifiers without saving the old session's state (typing, progress, messageQueue, inputDraft) — permanent data loss on session switch. Now also reset critical fields (`typing`, `progress`, `inputReady`, `tickGen++`, etc.) after switching, mirroring `postRestoreSessionSetup`.\n- **`syncProgressTodos` MUST NOT clear `m.todos` when `payload.Todos` is empty.** An empty `Todos` field only means \"this progress event carries no todo data\" (e.g. early thinking phase before `todo_write` executes), NOT \"todos were deleted\". Clearing on empty caused TODOs to vanish as soon as iteration started. Clear only on: user sending a message (`todosDoneCleared` guard), turn ending with all done (`endAgentTurn`), or explicit `todo_write([])`.\n\n### CLI Race Conditions (Remote Mode Session Switching)\n- **`handleSuHistoryLoad` must NOT clear `suLoading` before the stale check.** The old code set `m.suLoading = false` unconditionally on line 876, BEFORE checking `msg.chatID != m.chatID`. A stale RPC callback from the old session clears the new session's loading guard, letting progress events through prematurely and corrupting typing/idle state. The `suLoading=false` now only executes after the stale guard passes.\n- **`handleHistoryReload` needs `channelName`/`chatID` stale check.** `cliHistoryReloadMsg` had no session identifiers — an old session's async history load could overwrite `m.messages` after the user already switched away. Added `channelName`/`chatID` fields to the message and a guard in the handler.\n- **`splashTickMsg` must carry `gen` (tickGen) for stale rejection.** Rapid session switching creates multiple splash tick chains. Without gen checking, old ticks from cancelled sessions compete with new ticks, causing animation jitter and potential state corruption. Handler now returns early when `msg.gen != m.tickGen`.\n- **`handleSuHistoryLoad` default branch does NOT restore `lastTokenUsage` — context bar shows 0 on first switch to idle session.** The `acceptProgress` branch restores token usage from `activeProgress.TokenUsage`, but when the target session has no active turn, the `default` branch runs and leaves `lastTokenUsage` nil. Fix: `suLoadHistoryCmd` now fetches token state via `GetTokenStateFn` and `suHistoryLoadMsg` carries `tokenPrompt`/`tokenCompletion`; the handler falls back to this when `lastTokenUsage` is still nil after processing.\n\n### TUI Control & Config Tools (AI-Native)\n- **`SyncLayoutSettings` and 5s polling goroutine REMOVED.** Layout sync is now event-driven: `ApplyInitialLayout` at startup, `doSaveSettings` → `cliSettingsSavedMsg` for user changes, `tui_control` for AI changes. `refreshRemoteValuesCache` runs ONCE at startup, not every 5s. `valuesCache` is updated on-demand by settings panel save, subscription switch, and config tool set callbacks. Adding polling back will cause ~30% CPU waste from unnecessary `invalidateAllCache` → `fullRebuild` cycles (7+ GB/min temporary string allocations observed).\n- **`relayoutViewport` → `newGlamourRenderer(cw - 4)` must only be called from the event loop goroutine.** The glamour renderer is NOT goroutine-safe. Background goroutine calls cause concurrent access to `m.renderer` and crash.\n- **`sidebar_width` and other layout keys are NOT in `config.Config` struct.** `SaveToFile` deep merge only PRESERVES unknown keys from disk, never writes new ones. Use `saveLayoutToConfig()` (raw JSON read-modify-write) to persist these keys to `config.json`.\n- **Remote `tui_control` flow**: Server → `tui_control_req` WS message → client `readPump` → `tuiControlReqCb` → `SendTUIControl` → `asyncCh` → `handleAsyncDrain` → `program.Send` → event loop → `handleSessionControlMsg`. The WS callback in `transport_remote.go` MUST be wrapped in `go func()` to keep `readPump` responsive — otherwise RPC calls within handlers (e.g. `SessionsDeleteFn`) deadlock waiting for responses.\n- **`config` tool uses `SettingsSvc` auto-injection, not Agent callbacks.** `buildToolContext` injects `ConfigGet`/`ConfigSet` from `cfg.SettingsSvc`. This works in ALL modes (local + remote via RPC). Do NOT rely on Agent `SetTUICallbacks` for config.\n- **`config` tool masks sensitive keys on read** (`api_key` → `sk-a***`, `runner_token` → first 4 chars + `***`). Writes are NOT blocked — users can type API keys anyway, our responsibility ends at masking.\n- **`DeleteTenant`/other `TenantService` methods must nil-guard `s.db`.** Interactive session cleanup races with DB initialization — `destroyInteractiveSession` can fire before the DB is connected, causing nil pointer dereference.\n\n### Hooks System\n- **Old `ToolHook`/`HookChain` is gone.** Replaced by `agent/hooks/Manager`. Any code referencing `HookChain`, `ToolHook`, `executeWithHooks` is stale.\n- **Manager.Emit() is shared across Agent + SubAgents** (same instance). Must be concurrency-safe.\n- **Decision priority**: `deny > defer > ask > allow`. Low-priority layer deny cannot be overridden by high-priority allow.\n- **Script plugin triggers are global hooks** (`hookRegistration.Global=true`). `bridge.Dispatch` skips session isolation for them — they manage per-workDir state and must fire for all sessions. Without this, multi-session remote CLI silently drops triggers for all but the last session that called `RefreshWorkDir`.\n\n### Backend/Transport Architecture\n- **AgentChannel processing loop MUST be concurrent (each request in its own goroutine).** The old serial processing loop (`case req := <-ac.inbox: runFn(...)`) caused circular-wait deadlocks when two agents SendMessage to each other — A's runFn blocks waiting for B's reply, but B's inbox can't be processed because A's loop is stuck in runFn. Fix: dispatch each request to `ac.wg.Go(func() { processRequest(req) })`.\n- **AgentChannel.Send() MUST respect caller context via `msg.Ctx`.** `OutboundMsg.Ctx` (json:\"-\") carries the caller's context so `Send()` can detect Ctrl+C/tool-timeout. Without this, `Send()` only checks `ac.ctx.Done()` (from `context.Background()`), making Ctrl+C unable to unblock pending RPCs. `Dispatcher.SendMessageCtx(ctx, ...)` sets `OutboundMsg.Ctx`. `MessageSenderCtx` interface extends `MessageSender` with context-aware sending. `sendMessageWithCtx()` in send_message.go uses type assertion to prefer `MessageSenderCtx`.\n- **AgentChannel.StartWithContext(parentCtx) binds lifecycle to parent.** When the parent agent is cancelled (Ctrl+C → `agentCtx` cancelled → `Agent.Close()`), all AgentChannels stop. Use this for agent-owned channels instead of plain `Start()`.\n- **`registerAgentChannel` callback creates AgentChannel with `Start()`.** For parent-context binding, use `StartWithContext(agent.agentCtx)` — but note `agentCtx` is only set during `Run()`. Current code uses `Start()` and relies on `Dispatcher.Stop()` during shutdown to clean up. The caller-context cancellation via `msg.Ctx` is the primary Ctrl+C propagation mechanism.\n- **CLI always uses Client → Transport → Server, even in local mode.** Client (agent/client.go) is a pure RPC client — every method is `Transport.Call(\"method\", params)`.\n- **Transport is the pure transmission layer** (agent/transport.go). Only 2 methods: `Call(method, payload)` and `Close()`. `ChannelTransport` (agent/transport_channel.go) dispatches directly to `ServerCore.HandleRPC`. `RemoteTransport` (agent/transport_remote.go) sends JSON-RPC over WebSocket.\n- **ServerCore** (serverapp/server_core.go) is the shared server core for both local and remote modes. Owns Agent + RPCTable + Dispatcher + Bus. Created by CLI (local mode) and server.go (remote mode).\n- **RPCTable is the single handler truth source** (serverapp/rpc_table.go). Handlers access `h.Ag` (the *Agent) directly — no intermediate Backend/DirectBackend layer.\n- **Lifecycle interfaces** (agent/lifecycle.go): AgentRunner, EventRouter, CallbackRegistry — only used by the legacy Backend struct for remote mode.\n- **Adding a new RPC method**: 1 handler in rpc_table.go (using h.Ag) + 1 method in client.go (Transport.Call) + 1 MethodXxx constant in req_types.go.\n- **Adding new transports** (gRPC, MCP) only requires implementing 2 Transport methods (Call + Close).\n- **ChannelPluginTransport (`agent/transport_channel_plugin.go`) is a separate concept from Transport.** It wraps a channel plugin process's stdin/stdout as a bidirectional JSON-RPC channel (not an agent Transport). The plugin acts as a full RPC client (like remote CLI over WS). Created by `stdioChannelPluginProvider` (`serverapp/channel_plugin.go`) which spawns a **dedicated** process — NOT the same process used for plugin activation.\n- **`ChannelProviderFactory` replaces the old `GrpcChannelBridgeFactory`.** Defined in `plugin/channel_provider.go`. The factory is set by serverapp during init with a lazy RPCTable reference (plugins activate during `InitServer` before RPCTable exists).\n\n### Channel Configuration\n- **TUI channel config changes require live channel restart.** Writing config.json is not enough — Feishu/Web/QQ/NapCat channels are created once at startup via `registerChannels()`. `SetChannelConfig()` now calls `reconfigureFn` (set by server.go) to start/stop the affected channel. Any new channel type must be added to both `channelShouldRun()` and `createChannelInstance()`.\n- **Key naming must be consistent across all channels.** Web used `enable` while Feishu/QQ/NapCat used `enabled` — the RPC handler only checked `enabled`, so web changes were silently ignored. All channels now use `enabled` (with backward compat for `enable`).\n- **New client methods must be added to `*Client` in `agent/client.go`.** The `Client` struct is the unified RPC client used by both CLI and server modes. Both local (ChannelTransport) and remote (RemoteTransport) modes go through the same RPC path.\n- **Command hooks disabled by default** — requires `enable_command_hooks: true` in config.\n- **Max 10 handlers per event**, total timeout 60s. Excess silently truncated with warning log.\n\n### Plugin System\n- **Plugin system is opt-in** — only activates when `plugins.enabled: true` in config.json. No plugin loading happens without explicit user consent.\n- **`pm.workDir` is `atomic.Value` (not `string`).** `activate()` may be called while `pm.mu` write lock is held — reading workDir must be lock-free. Never change it back to `string` or `activate`/`InstallPlugin` will deadlock.\n- **`runAndUpdate()` does NOT write global slot cache.** It calls `NotifyUpdated()` instead of `UpdateWidget()`. Writing global cache causes cross-session overwrites (session B's git branch overwrites session A's).\n- **CLI WS clients must NOT auto-subscribe to senderID (\"admin\").** `client_type=cli` connections skip p2p subscribe. Subscribing CLI to \"admin\" causes `PushPluginWidgetsPerSession` to send stale content to wrong windows.\n- **`PushPluginWidgetsPerSession` skips non-path chatIDs.** Only chatIDs starting with `/` are session chatIDs. Virtual chatIDs like \"admin\" or \"web-123\" are not rendered.\n- **`OnPluginWidgets` callback filters by chatID.** Client-side rejects pushes for other sessions. Double protection against cross-session widget corruption.\n- **Script plugin outputs map is per-workDir.** `RenderForWorkDir(width, workDir)` reads `outputs[workDir]`. `Render()` falls back to shared `pctx.WorkingDir()` — never use for remote multi-session.\n- **`HookPayload.ToolOutput` is truncated to 8KB.** Don't rely on it for full file content. Plugins needing full output should use dedicated tool result channels.\n- **PluginManager.ActivateAll() collects capabilities; WireAll() connects them.** Never call registerCapabilities manually — WireAll is the single integration point.\n- **PluginEntry.stateMu protects state transitions.** Use CAS pattern (check state → set activating → set active/error) to prevent concurrent activation races.\n- **gRPC plugin processes are killed on timeout/cancellation.** The `call()` method kills the process and marks it as not-running to prevent goroutine leaks from blocked stdout reads.\n- **PluginToolBridge auto-detects PluginToolV2.** If a plugin tool implements V2, the bridge passes ToolCallContext. Otherwise falls back to V1 Execute(ctx, input).\n- **Plugin IDs validated with regex `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`.** This prevents path traversal, null bytes, and injection attacks in storage paths.\n- **Storage files use 0600 permissions and atomic write (tmp+rename).** Never use 0644 for plugin storage.\n- **WASM runtime is skeleton-only.** It compiles and loads but Activate() is a no-op. Phase 2 requires wazero dependency.\n- **PluginContext provides 4 type-safe Storage helpers:** `StorageInt`, `StorageBool`, `StorageJSON`, `StorageGetJSON`. These wrap the base `StorageAccessor` with parse/unmarshal and return typed results. Failed parses return zero-value + false (not errors) for Int/Bool, and errors for GetJSON.\n- **Auto-retry runs in a background goroutine.** `SetAutoRetry(true, maxRetries)` starts `retryLoop` with exponential backoff (1s→30s cap). **`DeactivateAll()` cancels the retry context** — if you call `activate()` manually after `DeactivateAll()`, you must re-enable auto-retry or failed plugins won't recover automatically.\n- **Manifest `timeout` field accepts Go duration strings** (`\"30s\"`, `\"1m\"`, `\"500ms\"`), parsed via `time.ParseDuration`. Empty or missing defaults to `DefaultPluginTimeout` (30s). Max allowed: 5 minutes.\n- **EventBus requires `bus.plugin` permission** in addition to `bus.read`/`bus.write`. Subscribe needs `bus.plugin` + `bus.read`; Publish needs `bus.plugin` + `bus.write`. This separates plugin-to-plugin events from the core message bus.\n- **`InstallPlugin` uses `filepath.EvalSymlinks`** to resolve the real directory path before deletion check, preventing symlink-based path traversal attacks. Only directories under `xbotHome` are deleted.\n- **`WatchConfig` polls config.json every 30 seconds** (configurable, min 5s). It compares `plugins.disabled_plugins` lists via diff and reactively deactivates newly disabled / activates newly enabled plugins. Returns a stop channel for shutdown.\n- **DependencyResolver uses Kahn's algorithm (BFS topological sort).** Circular dependencies return an error (not panic). `AddManifest` with duplicate ID replaces the existing entry. Resolve() returns activation order — plugins with no dependencies first, then in dependency order.\n- **Profiler is safe for concurrent use** (sync.Mutex). `Profile(pluginID)` returns a **copy** of PluginProfile — safe to mutate without affecting internal state. Unprofiled plugins return zero-value PluginProfile.\n- **ExportConfig acquires RLock on PluginManager.** Must not be called while holding a write lock (e.g., inside custom Activate/Deactivate that calls pm.mu.Lock). ImportConfig acquires write lock internally — do not nest inside another write-locked operation.\n- **MockPlugin/MockTool chain API returns the same pointer** — each `With*` call mutates and returns `*MockPlugin`/`*MockTool`. Do not share a single mock across parallel tests without cloning.\n- **PluginRegistry MVP only supports local sources** for installation. Search operates on locally installed plugins only. GitHub/URL sources are defined but InstallFromSource is not yet implemented — Phase 3 scope.\n- **Plugin migration `Migrator` creates backup before applying migrations.** Backup is stored in `~/.xbot/plugins/<id>/backups/<version>/`. Rollback restores from the most recent backup. Migrations run sequentially by version order.\n- **`toolHint` zone plugins run synchronously on PostToolUse hook.** When `isHintPlugin=true`, the hook trigger runs `runScript` inline (not via triggerCh). The engine calls `PluginManager.GetToolHints()` immediately after the hook returns to populate `ToolProgress.ToolHints`. **`GetToolHints()` consumes (clears) the hint after reading** to prevent stale content from attaching to the next tool.\n- **`snapshotIterationChange` must include ActiveTools(done).** When an iteration ends, completed tools may still be in `ActiveTools` (status=done) rather than `CompletedTools` (which is populated later by `progressFinalizer`). Only checking `CompletedTools` loses ToolHints data.\n- **Do NOT use glamour to render diff inside progress panel.** Glamour's output (background fills, margins, line wrapping) corrupts the progress panel border layout. Use direct ANSI coloring (`renderDiffANSI`) with width truncation instead.\n- **`runScript` must `os.Stat(workDir)` before setting `cmd.Dir`.** On Windows parallel tests, temp dirs may be cleaned up before the script runs, causing `chdir` failure. If dir doesn't exist, skip setting `cmd.Dir` and run in plugin's own directory.\n- **`runScript` injects `XBOT_WIDGET_ID` env var.** When a plugin declares multiple widgets, each widget gets a `widgetAdapter` that passes its `widgetID` to `runScript`. Scripts can branch on `$XBOT_WIDGET_ID` to produce different output per widget. Without this, all widgets of the same plugin produce identical output (the reported \"5 duplicates of `main +10 ↑13`\" bug). The `outputs` cache is a two-level map: `workDir → widgetID → output`.\n- **Platform-specific entry points.** `PluginManifest` supports `entry_windows`, `entry_darwin`, `entry_linux` fields that override the generic `entry` for matching OS. `scriptPlugin.resolvedEntry()` selects the correct entry at runtime. Use this when a plugin needs bash on Unix but PowerShell on Windows. Generic `entry` is always the fallback.\n- **Session context env vars for plugins.** `runScript` injects `XBOT_MODEL`, `XBOT_MAX_CONTEXT`, `XBOT_TOKEN_USAGE` (format: `\"prompt/completion\"`), `XBOT_PROMPT_TOKENS`, `XBOT_COMP_TOKENS`. Data flows via `hooks.SessionContext` (mutable pointer in context, updated by engine after each LLM call and compression) → `plugin_bridge.go` extracts into `HookPayload.Extra` → `runScript` serializes to env vars. Widgets can display model name, context usage bar, and token costs.\n\n### Windows\n- `syscall.PROCESS_QUERY_LIMITED_INFORMATION` and `STILL_ACTIVE` not in Go stdlib — define as uint32 constants.\n- `exec.ExitError.ExitCode()` is cross-platform; avoid `syscall.WaitStatus` type assertion.\n- `signal.Notify(sigCh, syscall.SIGTSTP)` doesn't compile on Windows — use build-tagged files.\n- PowerShell env output is newline-delimited, not null-delimited.\n\n### Worktree\n- **`RegisterPeer` always uses role=\"peer\" — no primary concept.** Every session is an equal peer for awareness purposes. `WorktreeTool.init` and `AutoDetectAndInit` both create physical worktrees for each session. Only worktree creation requires `git worktree add` which uses `--detach HEAD` (works on dirty trees too).\n- **`createWorktree` uses `--detach` not `-b`.** `git worktree add --detach HEAD` followed by `checkout -b` in the worktree. This avoids dirty-tree failures that `-b` would trigger.\n- **AutoDetectAndInit runs in `buildPrompt` (not `processMessage`).** `buildPrompt` is the common code path for ALL message types. Session CWD is updated before system prompt construction.\n- **`IsWorktreeIsolated` overrides `isUnrestricted()`.** CLI mode (`sandbox=\"none\"`) normally bypasses all path checks, but when `IsWorktreeIsolated=true`, `isUnrestricted()` returns `false`.\n- **Worktree paths must be outside main repo.** Git rejects `git worktree add` inside the main working tree. Paths: `{repo}/../.xbot-worktrees/{role}-{instance}/`.\n- **Worktree creation is serialized via `GlobalWorktreeRegistry.mu`.** Two agents creating worktrees simultaneously would race on `.git/worktrees/` lockfiles.\n- **Registry persisted per-repo to `{repo}/../.xbot-worktrees/registry.json`.** `saveRepoLocked` uses atomic tmp+rename. `ensureLoaded` lazily restores on first access. Orphaned worktree directories are skipped on load.\n- **`BuildSystemReminder` takes `sessionKey` parameter.** Used to query `GlobalWorktreeRegistry` for worktree/peer info injected into sys_reminder per iteration.\n- **`MethodSetCWD` only sets CWD when empty.** Prevents CLI session switch from overwriting worktree CWD with main workspace path. Session-specific CWDs (worktree, Cd tool) are preserved.\n- **`go:embed embed_skills/*` auto-discovers new skills.** Adding a directory under `tools/embed_skills/` requires zero code changes.\n- **`buildPrompt` 绝不恢复 CWD 到 worktree 路径。** 当 session 已注册后 `AutoDetectAndInit` 跳过,这是正确行为。agent 通过 Cd 工具主动切换的目录必须被尊重,如果在后续 buildPrompt 中强制恢复会破坏 agent 的 Cd 操作,产生人机对抗。\n- **Cd 工具绝不强制 worktree 边界。** CLI 模式下 Cd 允许 agent 自由导航到任何目录,包括主仓库。如果对 Cd 加边界限制会导致 agent 无法读取 worktree 外的文件(如 AGENTS.md、知识库、配置文件),这是严重 bug。worktree 隔离是软性的工作指引,不是硬性监狱。\n- **新会话绝不能进入老 worktree(纵深防御 6 层)。** `loadPersistedCWD` 拒绝加载 worktree 路径和不存在目录 → `autoDetectAndInitInto` 拒绝从 worktree CWD 创建 worktree + 自动清理 worktreeDir 不存在的孤儿条目 → `buildPrompt` 验证 worktree 所有权(不匹配则重置到 workspaceRoot)→ `showSessionCreateDialog` 和 `/chat new` 新建前清理 worktree registry + persisted CWD + savedSessions + todos。详见 `docs/agent/worktree.md`。\n\n## Development Principles\n\n### Never Blame the User's Binary\n\n**永远不假设用户用了旧二进制。** 如果怀疑版本问题,说明自己的排查逻辑有漏洞,不是用户的问题。\n\n### Always Prefer Explicit\n\n**核心原则:永远优先使用显式 API,避免隐式假设。**\n\n本项目遵循 \"always prefer explicit\" 开发原则。大量 bug 源于隐式设计——调用者无法从 API 签名推断出所有必要参数或行为。\n\n#### 具体实践\n\n1. **避免直接使用结构体作为公共 API 参数**\n - ❌ `func NewFoo(cfg FooConfig) *Foo` — 调用者可能遗漏 `FooConfig` 中的关键字段\n - ✅ `func NewFoo(opts ...FooOption) *Foo` — 使用私有结构体 + 构造函数 + 显式 Option 模式\n - ✅ `func NewFoo(required string, optional ...string) *Foo` — 必填参数显式列出\n\n2. **假设调用者只看到你的 API 签名**\n - 调用者没有义务阅读实现细节\n - API 签名应自解释:参数名、类型、顺序应清晰表达意图\n - 使用 `// WithXxx` 风格的 Option 函数提供可选配置\n\n3. **宁可冗长,不要隐晦**\n - 5 个显式参数优于 1 个包含 20 个字段的结构体\n - 如果必须用结构体,确保必填字段在构造函数中强制提供\n - 使用 `Must` 前缀函数(如 `MustParse`)在编译期捕获错误\n\n4. **文档即合同**\n - 每个公共函数/类型必须有 godoc 注释\n - 注释应说明 \"什么\" 和 \"为什么\",而不仅仅是 \"如何\"\n - 参数约束(如 \"must not be empty\")应在注释中明确说明\n\n#### 为什么重要\n\n- 减少运行时 panic 和零值 bug\n- 提高代码可读性和可维护性\n- 让新贡献者能快速理解 API 用法\n- 编译器帮你捕获更多错误\n\n## Project Context\n\n`ProjectContextMiddleware` auto-loads this file into system prompt. After code changes, update relevant Knowledge Files to keep documentation in sync.\n"}}}}