-
Notifications
You must be signed in to change notification settings - Fork 111
Expand file tree
/
Copy pathuserconfig.go
More file actions
4793 lines (4185 loc) · 181 KB
/
Copy pathuserconfig.go
File metadata and controls
4793 lines (4185 loc) · 181 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package session
import (
"bytes"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/BurntSushi/toml"
dark "github.com/thiagokokada/dark-mode-go"
"github.com/asheshgoplani/agent-deck/internal/agentpaths"
"github.com/asheshgoplani/agent-deck/internal/atomicfile"
"github.com/asheshgoplani/agent-deck/internal/logging"
"github.com/asheshgoplani/agent-deck/internal/platform"
"github.com/asheshgoplani/agent-deck/internal/safeio"
"github.com/asheshgoplani/agent-deck/internal/tmux"
)
// UserConfigFileName is the TOML config file for user preferences
const UserConfigFileName = "config.toml"
// ErrRefusingConfigSectionDrop is returned by SaveUserConfig when the config it
// is asked to write would empty an entire top-level section ([mcps] or [groups])
// that currently has entries on disk. These are the exact sections lost in the
// 2026-06-04 data-loss incident: a partially-constructed config saved over the
// live file silently dropped the whole MCP catalog and group overrides.
//
// S3 data-loss safeguard: a save that zeroes a populated section is almost
// always a bug in the caller (it built a config without loading the existing
// one), not a deliberate "clear everything". Refuse it. A caller that genuinely
// means to clear all MCPs/groups must go through SaveUserConfigWithIntent with
// allowSectionDrop=true so the destructive intent is explicit and greppable.
var ErrRefusingConfigSectionDrop = fmt.Errorf("session: refusing to save config.toml that would drop a populated [mcps] or [groups] section to empty (use SaveUserConfigWithIntent to intentionally clear)")
// UserConfig represents user-facing configuration in TOML format.
//
// TOML serialization: every field must use omitempty (string/bool/slice/map/pointer)
// or omitzero (int/struct) so zero-value fields are not written to disk. Without
// this, SaveUserConfig bloats the file with sections the user never configured.
// TestSaveUserConfig_ZeroValueConfigProducesNoSections enforces this invariant.
type UserConfig struct {
// DefaultTool is the pre-selected AI tool when creating new sessions
// Valid values: "claude", "gemini", "opencode", "codex", "pi", or any custom tool name
// If empty or invalid, defaults to "shell" (no pre-selection)
DefaultTool string `toml:"default_tool,omitempty"`
// DefaultPath is the global fallback project directory for `agent-deck add`
// when no explicit path or group default_path is provided.
DefaultPath string `toml:"default_path,omitempty"`
// Hotkeys overrides default keyboard shortcuts in the TUI.
// Keys are action names, values are key bindings (e.g., "delete" = "backspace").
// Set an action to "" to explicitly unbind it.
Hotkeys map[string]string `toml:"hotkeys,omitempty"`
// Theme sets the color scheme: "dark" (default), "light", or "system"
Theme string `toml:"theme,omitempty"`
// Tools defines custom AI tool configurations
Tools map[string]ToolDef `toml:"tools,omitempty"`
// MCPDefaultScope sets the default scope for MCP operations
// Valid values: "local" (default), "global", "user"
MCPDefaultScope string `toml:"mcp_default_scope,omitempty"`
// ManageMCPJson controls whether agent-deck writes to .mcp.json in project directories.
// Set to false to prevent agent-deck from touching any .mcp.json files, which is useful
// when you manage that file manually or via another tool.
// Default: true (nil = true)
ManageMCPJson *bool `toml:"manage_mcp_json,omitempty"`
// SyncTitle controls whether agent-deck overwrites a session's Title with the
// agent's own session-name (e.g. Claude's `--name` / `/rename`, issues #572/#697).
// Tool-agnostic, global switch. Set false to keep the title you gave the session.
// The per-session TitleLocked flag remains available as a finer-grained override.
// Default: true (nil = true)
SyncTitle *bool `toml:"sync_title,omitempty"`
// GroupSort controls the order of sessions within a group.
// "creation" (default) — fixed creation order; honors K/J manual reorder.
// "actionable" — issue #857 status→recency→Order surfacing.
// Empty or unrecognized values normalize to "creation".
GroupSort string `toml:"group_sort,omitempty"`
// MCPs defines available MCP servers for the MCP Manager
// These can be attached/detached per-project via the MCP Manager (M key)
MCPs map[string]MCPDef `toml:"mcps,omitempty"`
// Plugins defines available Claude Code plugins for per-session attach
// (RFC docs/rfc/PLUGIN_ATTACH.md). Catalog-only in v1: every name passed
// via `--plugin <name>` must resolve to an entry here. Each entry maps a
// short catalog name (e.g. "octopus") to a Claude Code plugin id
// (`<name>@<source>`) plus per-plugin policy (auto-install, channel link).
Plugins map[string]PluginDef `toml:"plugins,omitempty"`
// Claude defines Claude Code integration settings
Claude ClaudeSettings `toml:"claude,omitempty"`
// Profiles defines optional per-profile overrides.
// Example:
// [profiles.work.claude]
// config_dir = "~/.claude-work"
Profiles map[string]ProfileSettings `toml:"profiles,omitempty"`
// Groups defines optional per-group overrides.
// Example:
// [groups."my-group".claude]
// config_dir = "~/.claude-my-group"
Groups map[string]GroupSettings `toml:"groups,omitempty"`
// GroupDefaults holds defaults applied to NEWLY-created groups only.
// Existing groups (loaded from state.db) are never affected.
GroupDefaults GroupDefaultsSettings `toml:"group_defaults,omitempty"`
// Conductors defines optional per-conductor overrides.
// Keyed by conductor name (matches Instance.Title minus "conductor-" prefix).
// Mirrors Groups — see ConductorOverrides for the sub-table shape.
// Closes issue #602.
// Example:
// [conductors.gsd-v154.claude]
// config_dir = "~/.claude-work"
// env_file = "~/git/work/.envrc"
Conductors map[string]ConductorOverrides `toml:"conductors,omitempty"`
// Gemini defines Gemini CLI integration settings
Gemini GeminiSettings `toml:"gemini,omitempty"`
// OpenCode defines OpenCode CLI integration settings
OpenCode OpenCodeSettings `toml:"opencode,omitempty"`
// Codex defines Codex CLI integration settings
Codex CodexSettings `toml:"codex,omitempty"`
// Cursor defines Cursor Agent CLI integration settings (Issue #1672)
Cursor CursorSettings `toml:"cursor,omitempty"`
// Copilot defines GitHub Copilot CLI integration settings (Issue #556)
Copilot CopilotSettings `toml:"copilot,omitempty"`
// Crush defines charmbracelet/crush CLI integration settings (Issue #940)
Crush CrushSettings `toml:"crush,omitempty"`
// Hermes defines Hermes Agent CLI integration settings
Hermes HermesSettings `toml:"hermes,omitempty"`
// Worktree defines git worktree preferences
Worktree WorktreeSettings `toml:"worktree,omitempty"`
// GlobalSearch defines global conversation search settings
GlobalSearch GlobalSearchSettings `toml:"global_search,omitempty"`
// Logs defines session log management settings
Logs LogSettings `toml:"logs,omitempty"`
// MCPPool defines HTTP MCP pool settings for shared MCP servers
MCPPool MCPPoolSettings `toml:"mcp_pool,omitempty"`
// Updates defines auto-update settings
Updates UpdateSettings `toml:"updates,omitempty"`
// Preview defines preview pane display settings
Preview PreviewSettings `toml:"preview,omitempty"`
// Experiments defines experiment folder settings for 'try' command
Experiments ExperimentsSettings `toml:"experiments,omitempty"`
// Notifications defines waiting session notification bar settings
Notifications NotificationsConfig `toml:"notifications,omitempty"`
// Instances defines multiple instance behavior settings
Instances InstanceSettings `toml:"instances,omitempty"`
// Shell defines global shell environment settings for sessions
Shell ShellSettings `toml:"shell,omitempty"`
// Maintenance defines automatic maintenance worker settings
Maintenance MaintenanceSettings `toml:"maintenance,omitempty"`
// Status defines session status detection settings
Status StatusSettings `toml:"status,omitempty"`
// Conductor defines conductor (meta-agent orchestration) settings
Conductor ConductorSettings `toml:"conductor,omitempty"`
// Tmux defines tmux option overrides applied to every session
Tmux TmuxSettings `toml:"tmux,omitempty"`
// Docker defines Docker sandbox settings for containerized sessions
Docker DockerSettings `toml:"docker,omitempty"`
// Fork defines quick-fork (f) and fork-dialog (Shift+F) default behavior.
Fork ForkSettings `toml:"fork,omitempty"`
// Remotes defines named SSH remote agent-deck instances
Remotes map[string]RemoteConfig `toml:"remotes,omitempty"`
// OpenClaw defines OpenClaw gateway integration settings
OpenClaw OpenClawSettings `toml:"openclaw,omitempty"`
// Display defines rendering and display settings
Display DisplaySettings `toml:"display,omitempty"`
// Costs defines cost tracking and budget settings
Costs CostsSettings `toml:"costs,omitempty"`
// SystemStats defines system stats display settings (CPU, RAM, etc.)
SystemStats SystemStatsSettings `toml:"system_stats,omitempty"`
// Watcher defines event watcher settings
Watcher WatcherSettings `toml:"watcher,omitempty"`
// Feedback defines in-product feedback prompt settings (v1.7.38+).
// Mirrors the opt-out in ~/.agent-deck/feedback-state.json so it is visible
// to the user and editable without running `agent-deck feedback`.
Feedback FeedbackSettings `toml:"feedback,omitempty"`
// Terminal defines outer-terminal chrome settings — sequences agent-deck
// writes directly to the host terminal (iTerm2 badge, etc), distinct
// from anything tmux draws. Empty/absent uses defaults; see TerminalSettings.
Terminal TerminalSettings `toml:"terminal,omitempty"`
// Web defines `agent-deck web` HTTP server settings.
Web WebSettings `toml:"web,omitempty"`
// UI defines TUI layout settings (split ratios, etc).
UI UISettings `toml:"ui,omitempty"`
// SelfHeal defines self-heal supervision settings (SELF-HEAL-DESIGN.md).
// Stage 1 (v1.9.67) is observe-only: it logs what it WOULD do, takes no
// action. See SelfHealSettings.
SelfHeal SelfHealSettings `toml:"selfheal,omitempty"`
// Performance holds opt-in resource tuning for multi-instance setups.
Performance PerformanceSettings `toml:"performance,omitempty"`
}
// SelfHealSettings controls the self-heal supervision policy (SELF-HEAL-DESIGN.md
// §3.7, §6). The shipped default is fully observe-only: it detects truly-stuck
// sessions, exercises the safety state-machine, and LOGS what it would do —
// taking ZERO recovery action. Modes single_action / full are DEFINED but GUARDED
// (they refuse to act) until Stages 2-3 are re-approved by Ashesh + the three §9
// gap-fixes land.
type SelfHealSettings struct {
// Enabled is the global kill switch (§3.7). When false (the default),
// self-heal does nothing at all — not even observe-mode logging. Set true to
// run the observe-only Stage 1.
Enabled bool `toml:"enabled,omitempty"`
// Mode is the authority level: "observe" (default, the only acting mode in
// v1.9.67 — logs would_have, takes no action), "single_action" / "full"
// (Stages 2-3, DEFINED but GUARDED, refuse to act). An unknown/empty value
// is normalized to "observe".
Mode string `toml:"mode,omitempty"`
// AuditPath overrides where the durable NDJSON audit log lands. Empty uses
// the per-profile default under the agent-deck data dir (see
// SelfHealAuditPath). The audit is the dataset reviewed over the ≥1-week
// observe window before any Stage-2 re-approval.
AuditPath string `toml:"audit_path,omitempty"`
// PerSessionPerWindow overrides the per-session recovery cap (default 2 / 6h;
// auth_401 is always 1). 0 uses the default. Starting dial; tuned from
// observe data.
PerSessionPerWindow int `toml:"per_session_per_window,omitzero"`
// GlobalPerHour overrides the fleet-wide hourly recovery cap (default 5 =
// TriageMaxPerHour). 0 uses the default.
GlobalPerHour int `toml:"global_per_hour,omitzero"`
// OptOutGroups lists group paths that opt OUT of self-heal entirely
// (deliberate long-waiting stream leads, sensitive scopes — §3.7). Checked
// in the stuck predicate as a quick disqualifier.
OptOutGroups []string `toml:"opt_out_groups,omitempty"`
// OptOutSessions lists session ids/titles that opt OUT of self-heal.
OptOutSessions []string `toml:"opt_out_sessions,omitempty"`
}
// SelfHealMode normalizes the configured mode to a known value. Empty / unknown
// → "observe" (the safe default). Used by the daemon when constructing the
// engine. The string return matches selfheal.Mode values.
func (s SelfHealSettings) SelfHealMode() string {
switch s.Mode {
case "single_action", "full":
return s.Mode
default:
return "observe"
}
}
// IsGroupOptedOut reports whether a group path opts out of self-heal.
func (s SelfHealSettings) IsGroupOptedOut(groupPath string) bool {
for _, g := range s.OptOutGroups {
if g != "" && g == groupPath {
return true
}
}
return false
}
// IsSessionOptedOut reports whether a session (by id or title) opts out.
func (s SelfHealSettings) IsSessionOptedOut(id, title string) bool {
for _, sn := range s.OptOutSessions {
if sn == "" {
continue
}
if sn == id || sn == title {
return true
}
}
return false
}
// PerformanceSettings tunes background-work sharing between concurrent
// agent-deck instances.
type PerformanceSettings struct {
// ClaimPolling enables per-session ownership claims in state.db: each
// session is actively polled by exactly one instance; others render its
// status from the DB. Default false (every instance polls everything,
// today's behavior).
//
// [performance]
// claim_polling = true
ClaimPolling *bool `toml:"claim_polling,omitempty"`
}
// ClaimPollingEnabled reports whether claim-based polling is enabled.
func (c *UserConfig) ClaimPollingEnabled() bool {
if c == nil || c.Performance.ClaimPolling == nil {
return false
}
return *c.Performance.ClaimPolling
}
// UISettings controls TUI layout proportions.
// See issue #1092.
type UISettings struct {
// PreviewPct is the percentage of horizontal width allocated to the
// preview pane (sessions list gets the remainder). Valid range: 10-90.
// Default: 65 (current behavior — sessions 35 / preview 65).
// Adjustable at runtime via < and > keybindings (5% step).
PreviewPct int `toml:"preview_pct,omitzero"`
// PreviewOrientation controls where the PREVIEW pane sits relative to
// the SESSIONS list on wide terminals (>= 80 cols). "right" (default)
// keeps the historical side-by-side split; "below" stacks PREVIEW under
// SESSIONS (useful on tall/portrait monitors). Narrow terminals always
// stack regardless. Toggle at runtime with the `O` keybinding.
PreviewOrientation string `toml:"preview_orientation,omitempty"`
// ITermOpenAs controls whether Shift+Enter pops the focused session
// into a new iTerm2 *tab* or a new iTerm2 *window* on macOS. Valid
// values: "tab", "window". Empty defaults to "tab" (iTerm's natural
// UX). Issue #1100, follow-up to #1098 — credit @ddorman-dn.
ITermOpenAs string `toml:"iterm_open_as,omitempty"`
// ShellSplit controls the terminal used by the open_shell_here hotkey.
// Valid values:
// "iterm" — always open an iTerm2 vertical split pane (macOS only)
// "tmux" — always open a new tmux window
// "" — auto: use iTerm2 split when LC_TERMINAL=iTerm2 or
// TERM_PROGRAM=iTerm.app, otherwise tmux
// Default: "" (auto). Issue #1470.
ShellSplit string `toml:"shell_split,omitempty"`
// RemoteLatencyRefreshSecs sets how often the TUI re-measures the
// round-trip latency to each configured remote (issue #1103). Valid
// range: 2-300. Default: matches [system_stats].refresh_seconds (5s)
// so the latency marker ticks alongside CPU/RAM/load.
RemoteLatencyRefreshSecs int `toml:"remote_latency_refresh_secs,omitzero"`
// RemoteSessionRefreshSecs sets how often the TUI re-fetches the remote
// session list over SSH (issue #1170). Remote sessions created after the
// TUI launched were invisible until quit+relaunch; this is the poll
// cadence that reconciles the list. Valid range: 5-300. Default: 15s,
// tightening the visibility latency reported on v1.9.30.
RemoteSessionRefreshSecs int `toml:"remote_session_refresh_secs,omitzero"`
// ShowOnlyInstalledTools, when true, hides tools from the new-session
// dialogs (TUI + web) whose command does not resolve on the host PATH
// (issue #1259). Default false: no PATH probing happens and the dialogs are
// byte-identical to before. shell is always shown; if nothing else resolves
// the dialog falls back to showing all tools plus a one-line hint. This is a
// display filter only — `agent-deck launch -c <tool>` still spawns a hidden
// tool.
ShowOnlyInstalledTools bool `toml:"show_only_installed_tools,omitempty"`
// HiddenTools lists tool names to hide from the new-session picker (TUI + web).
// Denylist: absent or empty shows every tool (subject to show_only_installed_tools).
// shell is always shown and cannot be hidden.
HiddenTools []string `toml:"hidden_tools,omitempty"`
// Footer controls the style of the bottom hint bar. Valid values:
// "full" (default) — the historic verbose bar: filled key chips,
// width-adaptive, advertising every action. This is
// today's behavior and stays the default so the look
// never changes without an explicit opt-in.
// "curated" — lighter, dim inline text advertising only the
// actions relevant to the selected row, with the
// settings and help keys always last (opt-in).
// "compact" — force the abbreviated chip tier regardless of width.
// "minimal" — force the keys-only tier regardless of width.
// Empty or unknown values fall back to "full". This is purely a
// rendering preference (TUI UX initiative, item 1): no keybinding is
// added, removed, or changed — only what the footer advertises. Every
// action remains reachable by its key and is fully listed under help (?).
Footer string `toml:"footer,omitempty"`
// NewSessionEnterAdvances controls what Enter does on the free-text
// Name/Branch fields of the new-session dialog. As of the UX top-3 pass this
// is ON BY DEFAULT (the mechanism shipped opt-in in PR #1295): Enter advances
// focus to the next field, so typing a name and pressing Enter no longer
// silently creates a session with all defaults — the #1 reported new-session
// trap. Ctrl+S is the explicit submit shortcut and submits in BOTH modes;
// Enter still submits from non-text rows (tool/checkboxes). The pointer lets
// us distinguish "unset" (nil → default true) from an explicit opt-OUT
// (`new_session_enter_advances = false` → restores the legacy Enter-submits
// behavior). Set `= true` (or leave unset) to keep the new default.
NewSessionEnterAdvances *bool `toml:"new_session_enter_advances"`
// AttachOnCreate controls whether creating a session in the TUI (the `n`
// new-session dialog) immediately attaches to the new session's pane
// instead of only moving the cursor to it. Default false: creating a
// session selects it (today's behavior) and the user presses Enter to
// attach. Set `= true` to "instantly open" each new session. CLI
// `add`/`session start` are unaffected by this flag — they attach only
// with an explicit `--attach`.
AttachOnCreate bool `toml:"attach_on_create,omitempty"`
}
// normalizeUIHiddenTools lowercases, dedupes, and drops unknown entries from
// [ui].hidden_tools. shell cannot be hidden. Unknown names log a warning.
func normalizeUIHiddenTools(ui *UISettings, customTools map[string]ToolDef) {
if ui == nil || len(ui.HiddenTools) == 0 {
return
}
known := make(map[string]bool, len(builtinTools())+len(customTools))
for _, bt := range builtinTools() {
known[strings.ToLower(strings.TrimSpace(bt.Name))] = true
}
for name := range customTools {
n := strings.ToLower(strings.TrimSpace(name))
if n != "" {
known[n] = true
}
}
seen := make(map[string]bool, len(ui.HiddenTools))
out := make([]string, 0, len(ui.HiddenTools))
for _, raw := range ui.HiddenTools {
name := strings.ToLower(strings.TrimSpace(raw))
if name == "" || name == "shell" {
continue
}
if !known[name] {
registryLog.Warn("ignored unknown hidden_tools entry",
"name", raw,
"hint", "use a built-in or custom tool name from config.toml")
continue
}
if seen[name] {
continue
}
seen[name] = true
out = append(out, name)
}
sort.Strings(out)
ui.HiddenTools = out
}
// DefaultPreviewPct is the default preview-pane width percentage.
// Matches the historical hardcoded 0.35 sessions / 0.65 preview split.
const DefaultPreviewPct = 65
// MinPreviewPct and MaxPreviewPct bound the preview width to keep both
// panes usable.
const (
MinPreviewPct = 10
MaxPreviewPct = 90
)
// iTerm "open as" modes for Shift+Enter dispatch.
const (
ITermOpenAsTab = "tab"
ITermOpenAsWindow = "window"
DefaultITermOpenAs = ITermOpenAsTab
)
// ShellSplit modes for the open_shell_here hotkey (issue #1470).
const (
ShellSplitITerm = "iterm"
ShellSplitTmux = "tmux"
)
// Preview-pane orientation modes for wide terminals (>= 80 cols).
// "right" is the historical side-by-side split; "below" stacks the
// PREVIEW pane under the SESSIONS list (portrait-monitor friendly).
const (
PreviewOrientationRight = "right"
PreviewOrientationBelow = "below"
DefaultPreviewOrientation = PreviewOrientationRight
)
// Footer hint-bar styles. See UISettings.Footer.
const (
FooterCurated = "curated"
FooterFull = "full"
FooterCompact = "compact"
FooterMinimal = "minimal"
// DefaultFooter is the historic verbose bar ("full"). Keeping it as the
// default preserves today's look; curated/compact/minimal are opt-in via
// config.toml [ui] footer.
DefaultFooter = FooterFull
)
// GetFooter returns the configured footer style, normalized to one of the
// known values. Empty or unknown input falls back to DefaultFooter
// ("full"). Matching is case-insensitive so users may write "Full" or
// "MINIMAL" in TOML.
func (u UISettings) GetFooter() string {
switch strings.ToLower(strings.TrimSpace(u.Footer)) {
case FooterFull:
return FooterFull
case FooterCompact:
return FooterCompact
case FooterMinimal:
return FooterMinimal
case FooterCurated:
return FooterCurated
}
return DefaultFooter
}
// GetPreviewPct returns the configured preview percentage, clamped to
// [MinPreviewPct, MaxPreviewPct]. Falls back to DefaultPreviewPct when
// unset or out of range.
func (u UISettings) GetPreviewPct() int {
if u.PreviewPct <= 0 {
return DefaultPreviewPct
}
if u.PreviewPct < MinPreviewPct {
return MinPreviewPct
}
if u.PreviewPct > MaxPreviewPct {
return MaxPreviewPct
}
return u.PreviewPct
}
// GetITermOpenAs returns the configured iTerm open mode. Unknown or
// empty values fall through to the default ("tab"). Matching is
// case-insensitive so users can write "Tab" or "WINDOW" in TOML.
func (u UISettings) GetITermOpenAs() string {
switch strings.ToLower(strings.TrimSpace(u.ITermOpenAs)) {
case ITermOpenAsWindow:
return ITermOpenAsWindow
case ITermOpenAsTab:
return ITermOpenAsTab
}
return DefaultITermOpenAs
}
// GetShellSplit returns the configured shell-split mode. Unknown or empty
// values return "" (auto-detect). Matching is case-insensitive.
func (u UISettings) GetShellSplit() string {
switch strings.ToLower(strings.TrimSpace(u.ShellSplit)) {
case ShellSplitITerm:
return ShellSplitITerm
case ShellSplitTmux:
return ShellSplitTmux
}
return ""
}
// GetPreviewOrientation returns the configured preview-pane orientation
// for wide terminals. Unknown or empty values fall through to the default
// ("right"). Matching is case-insensitive so users can write "Below" or
// "RIGHT" in TOML.
func (u UISettings) GetPreviewOrientation() string {
switch strings.ToLower(strings.TrimSpace(u.PreviewOrientation)) {
case PreviewOrientationBelow:
return PreviewOrientationBelow
case PreviewOrientationRight:
return PreviewOrientationRight
}
return DefaultPreviewOrientation
}
// Remote session-list poll cadence bounds (issue #1170). The default is
// deliberately tighter than the historical hardcoded 30s so new remote
// sessions surface promptly; the min keeps a floor on SSH frequency.
const (
DefaultRemoteSessionRefreshSecs = 15
MinRemoteSessionRefreshSecs = 5
MaxRemoteSessionRefreshSecs = 300
)
// GetRemoteSessionRefreshSecs returns the remote session-list poll interval
// in seconds, clamped to [MinRemoteSessionRefreshSecs,
// MaxRemoteSessionRefreshSecs]. Unset (<= 0) falls back to
// DefaultRemoteSessionRefreshSecs. See issue #1170.
func (u UISettings) GetRemoteSessionRefreshSecs() int {
val := u.RemoteSessionRefreshSecs
if val <= 0 {
return DefaultRemoteSessionRefreshSecs
}
if val < MinRemoteSessionRefreshSecs {
return MinRemoteSessionRefreshSecs
}
if val > MaxRemoteSessionRefreshSecs {
return MaxRemoteSessionRefreshSecs
}
return val
}
// GetNewSessionEnterAdvances reports whether Enter on the new-session dialog's
// free-text Name/Branch fields should advance focus (true) instead of
// submitting the form (false). Defaults to true when unset: Enter-advances is
// the default so typing a name + Enter no longer silently submits with all
// defaults. A literal `new_session_enter_advances = false` opts out and
// restores the legacy Enter-submits behavior. Ctrl+S submits in both modes.
func (u UISettings) GetNewSessionEnterAdvances() bool {
if u.NewSessionEnterAdvances == nil {
return true // Default: ON (Enter advances; Ctrl+S submits).
}
return *u.NewSessionEnterAdvances
}
// GetAttachOnCreate reports whether the TUI should attach to a newly created
// session immediately instead of only selecting it. Default false.
func (u UISettings) GetAttachOnCreate() bool {
return u.AttachOnCreate
}
// GetRemoteLatencyRefreshSecs returns the remote latency refresh interval
// in seconds, clamped to [2, 300]. When the user has not set this value
// it falls back to fallbackSecs (typically the system_stats refresh
// interval, so the latency marker ticks at the same cadence as CPU/RAM
// per #1103). fallbackSecs <= 0 maps to 5.
func (u UISettings) GetRemoteLatencyRefreshSecs(fallbackSecs int) int {
val := u.RemoteLatencyRefreshSecs
if val <= 0 {
val = fallbackSecs
}
if val < 2 {
val = 5
}
if val > 300 {
val = 300
}
return val
}
// WebSettings configures the `agent-deck web` HTTP server.
type WebSettings struct {
// MutationsEnabled controls whether POST/PATCH/DELETE endpoints accept
// requests. nil (omitted) defaults to true. Forced off by --read-only.
MutationsEnabled *bool `toml:"mutations_enabled,omitempty"`
// TrustedDomains lists hosts whose links open from the web terminal
// without the "this link could potentially be dangerous" confirm
// (issue #1682). Entries are hosts, not URLs — a full URL is accepted
// and reduced to its host. A leading `*.` matches subdomains only
// (`*.corp.example` matches `git.corp.example`, not `corp.example`).
// Everything not on the list still confirms.
TrustedDomains []string `toml:"trusted_domains,omitempty"`
// ConfirmLinkOpen controls the web terminal's link-open confirm for
// hosts that are NOT on TrustedDomains. nil (omitted) defaults to true.
// Setting it false accepts the risk and opens every link directly.
ConfirmLinkOpen *bool `toml:"confirm_link_open,omitempty"`
}
// FeedbackSettings controls the in-product feedback prompts.
// When Disabled is true, neither the auto-prompt (TUI) nor the post-launch
// auto-trigger (CLI, if any) will fire. Explicit `agent-deck feedback`
// invocations still run but show a re-enable prompt first. v1.7.38+.
type FeedbackSettings struct {
// Disabled suppresses all passive feedback prompts when true.
// Defaults to false. Set by RecordOptOut paths; cleared on re-enable.
Disabled bool `toml:"disabled,omitempty"`
}
// OpenClawSettings configures the OpenClaw gateway connection.
type OpenClawSettings struct {
// GatewayURL is the WebSocket URL of the OpenClaw gateway (default: "ws://127.0.0.1:31337")
GatewayURL string `toml:"gateway_url,omitempty"`
// Password is the gateway authentication password.
// Supports env var references (e.g. "$OPENCLAW_PASSWORD" or "${OPENCLAW_PASSWORD}").
// Falls back to OPENCLAW_PASSWORD env var if not set.
Password string `toml:"password,omitempty"`
// AutoSync syncs OpenClaw agents as agent-deck sessions on TUI startup
AutoSync bool `toml:"auto_sync,omitempty"`
// GroupName is the agent-deck group name for OpenClaw sessions (default: "openclaw")
GroupName string `toml:"group_name,omitempty"`
}
// RemoteConfig defines a remote agent-deck instance accessible via SSH.
type RemoteConfig struct {
// Host is the SSH destination (e.g., "user@host" or "user@host:port")
Host string `toml:"host,omitempty"`
// AgentDeckPath is the path to agent-deck binary on the remote (default: "agent-deck")
AgentDeckPath string `toml:"agent_deck_path,omitempty"`
// Profile is the remote profile to use (default: "default")
Profile string `toml:"profile,omitempty"`
}
// GetAgentDeckPath returns the agent-deck binary path, defaulting to "agent-deck".
func (rc RemoteConfig) GetAgentDeckPath() string {
if rc.AgentDeckPath != "" {
return rc.AgentDeckPath
}
return "agent-deck"
}
// GetProfile returns the remote profile, defaulting to "default".
func (rc RemoteConfig) GetProfile() string {
if rc.Profile != "" {
return rc.Profile
}
return "default"
}
// ProfileSettings defines per-profile configuration overrides.
type ProfileSettings struct {
// Claude defines Claude Code overrides for a specific profile.
Claude ProfileClaudeSettings `toml:"claude,omitempty"`
// Codex defines Codex CLI overrides for a specific profile.
Codex ProfileCodexSettings `toml:"codex,omitempty"`
// Costs defines profile-specific cost-tracking overrides.
// Nil pointer means "no [profiles.<name>.costs] block in TOML"; the
// resolver falls through to global [costs] settings.
Costs *ProfileCosts `toml:"costs,omitempty"`
}
// ProfileClaudeSettings defines profile-specific Claude overrides.
type ProfileClaudeSettings struct {
// ConfigDir overrides [claude].config_dir for this profile only.
ConfigDir string `toml:"config_dir,omitempty"`
}
// ProfileCodexSettings defines profile-specific Codex overrides.
type ProfileCodexSettings struct {
// ConfigDir overrides [codex].config_dir for this profile only.
ConfigDir string `toml:"config_dir,omitempty"`
}
// GroupSettings defines per-group configuration overrides.
type GroupSettings struct {
// Create ensures the group exists on startup.
Create bool `toml:"create,omitempty"`
// DefaultPath sets the default working directory for new sessions in this group.
DefaultPath string `toml:"default_path,omitempty"`
// Claude defines Claude Code overrides for a specific group.
Claude GroupClaudeSettings `toml:"claude,omitempty"`
// Hermes defines Hermes overrides for a specific group.
Hermes GroupHermesSettings `toml:"hermes,omitempty"`
}
// GroupDefaultsSettings carries [group_defaults] — defaults stamped onto new
// groups at creation time. Distinct from per-group [groups."<path>"] overrides.
type GroupDefaultsSettings struct {
// MaxConcurrent is the max_concurrent value assigned to new groups created
// via `group create`, the TUI dialog, the web API, and the launch/session
// auto-create paths. Pointer to distinguish:
// nil → unset → built-in serial default (1) [byte-for-byte v1.9.1]
// *0 → new groups are unlimited
// *N (N>0) → new groups capped at N
// An explicit `group create --max-concurrent` flag overrides this.
MaxConcurrent *int `toml:"max_concurrent,omitempty"`
}
// GroupClaudeSettings defines group-specific Claude overrides.
//
// The key surface deliberately mirrors ConductorClaudeSettings (CFG-08
// established the two blocks as mirrors); keep them in sync when adding
// keys. New keys use omitempty so SaveUserConfig does not emit zero-value
// fields into every group stanza (see issue #1360).
type GroupClaudeSettings struct {
// ConfigDir overrides [claude].config_dir for sessions in this group.
ConfigDir string `toml:"config_dir,omitempty"`
// EnvFile overrides [claude].env_file for sessions in this group.
EnvFile string `toml:"env_file,omitempty"`
// Command overrides [claude].command for sessions in this group
// (e.g. a wrapper like "claude-vertex"). Same parity Hermes already
// has via GroupHermesSettings.Command. Resolution:
// conductor > group (ancestor-walking) > global [claude].command > "claude".
Command string `toml:"command,omitempty"`
// Model is the model default for sessions in this group (e.g.
// "claude-sonnet-4-6" or an alias like "sonnet"). An explicit
// per-session model (CLI --model, new-session dialog) wins; empty
// falls through (#1172 semantics).
Model string `toml:"model,omitempty"`
// Env is an inline env map exported in the spawn command AFTER the
// env_file source, so an inline key deterministically wins over the
// same key from the file. Precedent: [tools.X].env.
Env map[string]string `toml:"env,omitempty"`
// Skills lists declarative skill-loadout entries ("<source>/<name>")
// attached to sessions in this group at create and re-asserted on
// every start (ApplyConfiguredLoadout — attach-only floor semantics).
Skills []string `toml:"skills,omitempty"`
// Plugins lists [plugins.X] catalog keys unioned into Instance.Plugins.
// Catalog resolution remains the single plugin enablement path.
Plugins []string `toml:"plugins,omitempty"`
// MCPs lists [mcps.X] catalog names appended to the local .mcp.json
// of sessions in this group. Same floor semantics as Skills.
MCPs []string `toml:"mcps,omitempty"`
}
// GroupHermesSettings defines group-specific Hermes overrides.
type GroupHermesSettings struct {
Command string `toml:"command,omitempty"`
EnvFile string `toml:"env_file,omitempty"`
YoloMode bool `toml:"yolo_mode,omitempty"`
GatewayURL string `toml:"gateway_url,omitempty"`
DashboardURL string `toml:"dashboard_url,omitempty"`
APITokenEnv string `toml:"api_token_env,omitempty"`
}
// ConductorOverrides defines per-conductor configuration overrides.
// Mirrors GroupSettings — conductors are first-class entities keyed by
// conductor name (derived from Instance.Title via strings.TrimPrefix at the
// call site, same pattern as env.go getConductorEnv).
//
// Named ConductorOverrides (not ConductorSettings) to avoid collision with
// the pre-existing global [conductor] meta-agent orchestration block
// declared in conductor.go:49 (heartbeat, telegram, slack, discord).
// Closes issue #602.
type ConductorOverrides struct {
// Claude defines Claude Code overrides for a specific conductor.
Claude ConductorClaudeSettings `toml:"claude,omitempty"`
// Hermes defines Hermes overrides for a specific conductor.
Hermes ConductorHermesSettings `toml:"hermes,omitempty"`
}
// ConductorClaudeSettings defines conductor-specific Claude overrides.
// Semantics mirror GroupClaudeSettings — ExpandPath is applied on read via
// GetConductorClaudeConfigDir; env_file resolution is deferred to the spawn
// builder (resolvePath handles path expansion at use).
type ConductorClaudeSettings struct {
// ConfigDir overrides [claude].config_dir for this conductor only.
ConfigDir string `toml:"config_dir,omitempty"`
// EnvFile is sourced before claude exec for this conductor.
// Matches CFG-03 semantics — missing file logs a warning, does not block.
EnvFile string `toml:"env_file,omitempty"`
// Command overrides [claude].command for this conductor only.
// Mirrors GroupClaudeSettings.Command; conductor beats group.
Command string `toml:"command,omitempty"`
// Model is the model default for this conductor's sessions. An
// explicit per-session model wins; empty falls through (#1172).
Model string `toml:"model,omitempty"`
// Env is an inline env map exported AFTER the env_file source and
// AFTER the group env map (conductor wins per key on conflict).
Env map[string]string `toml:"env,omitempty"`
// Skills lists declarative skill-loadout entries ("<source>/<name>")
// unioned on top of the group floor for this conductor's sessions.
Skills []string `toml:"skills,omitempty"`
// Plugins lists [plugins.X] catalog keys unioned on top of the group floor.
Plugins []string `toml:"plugins,omitempty"`
// MCPs lists [mcps.X] catalog names unioned on top of the group
// floor. Same semantics as Skills.
MCPs []string `toml:"mcps,omitempty"`
}
// ConductorHermesSettings defines conductor-specific Hermes overrides.
type ConductorHermesSettings struct {
Command string `toml:"command,omitempty"`
EnvFile string `toml:"env_file,omitempty"`
YoloMode bool `toml:"yolo_mode,omitempty"`
GatewayURL string `toml:"gateway_url,omitempty"`
DashboardURL string `toml:"dashboard_url,omitempty"`
APITokenEnv string `toml:"api_token_env,omitempty"`
}
// MCPPoolSettings defines HTTP MCP pool configuration
type MCPPoolSettings struct {
// Enabled enables HTTP pool mode (default: false)
Enabled bool `toml:"enabled,omitempty"`
// AutoStart starts pool when agent-deck launches (default: true)
AutoStart *bool `toml:"auto_start,omitempty"`
// PortStart is the first port in the pool range (default: 8001)
PortStart int `toml:"port_start,omitzero"`
// PortEnd is the last port in the pool range (default: 8050)
PortEnd int `toml:"port_end,omitzero"`
// StartOnDemand starts MCPs lazily on first attach (default: false)
StartOnDemand bool `toml:"start_on_demand,omitempty"`
// ShutdownOnExit stops HTTP servers when agent-deck quits (default: true)
ShutdownOnExit *bool `toml:"shutdown_on_exit,omitempty"`
// PoolMCPs is the list of MCPs to run in pool mode
// Empty = auto-detect common MCPs (memory, exa, firecrawl, etc.)
PoolMCPs []string `toml:"pool_mcps,omitempty"`
// FallbackStdio uses stdio for MCPs without socket support (default: true)
FallbackStdio *bool `toml:"fallback_to_stdio,omitempty"`
// ShowStatus shows pool status in TUI (default: true)
ShowStatus *bool `toml:"show_pool_status,omitempty"`
// PoolAll pools all MCPs by default (default: false)
PoolAll bool `toml:"pool_all,omitempty"`
// ExcludeMCPs excludes specific MCPs from pool when pool_all = true
ExcludeMCPs []string `toml:"exclude_mcps,omitempty"`
// SocketWaitTimeout is seconds to wait for socket to become ready (default: 5)
SocketWaitTimeout int `toml:"socket_wait_timeout,omitzero"`
}
func (p MCPPoolSettings) GetAutoStart() bool {
if p.AutoStart == nil {
return true
}
return *p.AutoStart
}
func (p MCPPoolSettings) GetShutdownOnExit() bool {
if p.ShutdownOnExit == nil {
return true
}
return *p.ShutdownOnExit
}
func (p MCPPoolSettings) GetFallbackStdio() bool {
if p.FallbackStdio == nil {
return true
}
return *p.FallbackStdio
}
func (p MCPPoolSettings) GetShowStatus() bool {
if p.ShowStatus == nil {
return true
}
return *p.ShowStatus
}
// LogSettings defines log file management configuration
type LogSettings struct {
// MaxSizeMB is the maximum size in MB before a log file is truncated
// When a log exceeds this size, it keeps only the last MaxLines lines
// Default: 10 (10MB)
MaxSizeMB int `toml:"max_size_mb,omitzero"`
// MaxLines is the number of lines to keep when truncating
// Default: 10000
MaxLines int `toml:"max_lines,omitzero"`
// RemoveOrphans removes log files for sessions that no longer exist
// Default: true (nil = true)
RemoveOrphans *bool `toml:"remove_orphans,omitempty"`
// DebugLevel sets the minimum log level: "debug", "info", "warn", "error"
// Default: "info"
DebugLevel string `toml:"debug_level,omitempty"`
// DebugFormat sets the log format: "json" (default) or "text"
DebugFormat string `toml:"debug_format,omitempty"`
// DebugMaxMB is the max size in MB for debug.log before rotation
// Default: 10
DebugMaxMB int `toml:"debug_max_mb,omitzero"`
// DebugBackups is the number of rotated debug.log files to keep
// Default: 5
DebugBackups int `toml:"debug_backups,omitzero"`
// DebugRetentionDays is the number of days to keep rotated debug logs