@@ -37,6 +37,131 @@ func shouldResumeExisting(sess *session.Session, requestedMode string) bool {
3737 return sess != nil && sess .Mode == requestedMode
3838}
3939
40+ // modeDecision is the action a yolo/safe launch must take given the
41+ // state of the store at launch time.
42+ type modeDecision int
43+
44+ const (
45+ // decisionFresh: no stored session — create from scratch.
46+ decisionFresh modeDecision = iota
47+ // decisionResume: stored session matches requested mode — preflight + reattach.
48+ decisionResume
49+ // decisionRecreate: stored session is in a different mode — kill+delete then create.
50+ decisionRecreate
51+ )
52+
53+ // decideModeAction maps the (store-lookup result, requested mode) pair to
54+ // one of three actions. Pure function — easy to unit-test.
55+ func decideModeAction (sess * session.Session , getErr error , requestedMode string ) modeDecision {
56+ if getErr != nil {
57+ return decisionFresh
58+ }
59+ if shouldResumeExisting (sess , requestedMode ) {
60+ return decisionResume
61+ }
62+ return decisionRecreate
63+ }
64+
65+ // bannerFor returns the banner text and styling flag for a given launch mode.
66+ // magenta=true → out.Magenta; false → out.Success (green). Unknown modes fall
67+ // back to safe-style green so the screen never goes silent.
68+ func bannerFor (mode string ) (text string , magenta bool ) {
69+ if mode == "yolo" {
70+ return ">>> YOLO MODE" , true
71+ }
72+ upper := ""
73+ for _ , r := range mode {
74+ if r >= 'a' && r <= 'z' {
75+ upper += string (r - 32 )
76+ } else {
77+ upper += string (r )
78+ }
79+ }
80+ return fmt .Sprintf (">>> %s MODE" , upper ), false
81+ }
82+
83+ // eventsFor returns the (user-hook event, serve-hub event) pair for a mode.
84+ // Yolo fires "on_yolo" to both. Safe fires "on_safe" to user hooks but maps
85+ // to "session_attached" on the serve hub — the hub does not model a separate
86+ // safe-mode lifecycle, only the attach transition.
87+ func eventsFor (mode string ) (hookEvent , serveEvent string ) {
88+ if mode == "yolo" {
89+ return "on_yolo" , "on_yolo"
90+ }
91+ return "on_" + mode , "session_attached"
92+ }
93+
94+ // fireLaunchEvents fires both the user-defined shell hook and the serve-hub
95+ // event for a launch in the given mode. Failures inside fireHook /
96+ // fireServeEvent are already swallowed; this wrapper just composes them.
97+ func fireLaunchEvents (store * session.Store , name , workdir , mode string ) {
98+ hookEvent , serveEvent := eventsFor (mode )
99+ intent := yoloIntent (store , name , workdir , mode )
100+ fireHook (hookEvent , intent )
101+ fireServeEvent (serveEvent , intent )
102+ }
103+
104+ // resolveSimpleName returns args[0] when present, else "claude". This is the
105+ // name-resolution rule shared by `ctm yolo!` and `ctm safe`. (`ctm yolo` has a
106+ // richer rule that also handles 2-arg form and prompts for a path, so it
107+ // stays inline.)
108+ func resolveSimpleName (args []string ) string {
109+ if len (args ) > 0 {
110+ return args [0 ]
111+ }
112+ return "claude"
113+ }
114+
115+ // resolveModeTarget produces the (name, workdir) pair used by `ctm yolo!` and
116+ // `ctm safe`. Validates the name and resolves the workdir from the store, the
117+ // running tmux pane, or the current working directory in that order.
118+ func resolveModeTarget (args []string , store * session.Store , tc * tmux.Client ) (string , string , error ) {
119+ name := resolveSimpleName (args )
120+ if err := session .ValidateName (name ); err != nil {
121+ return "" , "" , err
122+ }
123+ workdir , err := resolveWorkdir (name , store , tc )
124+ if err != nil {
125+ return "" , "" , err
126+ }
127+ return name , workdir , nil
128+ }
129+
130+ // tearDownForRecreate drops the tmux session and store record so that a fresh
131+ // UUID can be minted. Used when the requested mode differs from the stored
132+ // mode, or when `ctm yolo!` forces fresh state.
133+ //
134+ // loudOnDeleteErr controls the original yolo/safe asymmetry: `ctm yolo`
135+ // warns on a store.Delete failure; `ctm yolo!` swallows the error (it's a
136+ // force-reset path). Preserved verbatim so this is a pure refactor.
137+ func tearDownForRecreate (name string , store * session.Store , tc * tmux.Client , out * output.Printer , loudOnDeleteErr bool ) {
138+ if tc .HasSession (name ) {
139+ if err := tc .KillSession (name ); err != nil {
140+ out .Warn ("could not kill existing session: %v" , err )
141+ }
142+ }
143+ if err := store .Delete (name ); err != nil {
144+ if loudOnDeleteErr {
145+ out .Warn ("could not remove session from store: %v" , err )
146+ }
147+ // Silent branch: `ctm yolo!` ignores not-found and IO errors here.
148+ _ = err
149+ }
150+ }
151+
152+ // printBanner prints the launch banner using the appropriate color for mode.
153+ // We pass the text via `%s` so the banner string is never treated as a format
154+ // string — defensive against future refactors where the banner becomes
155+ // data-driven (silences `go vet` non-constant format string warnings).
156+ func printBanner (out * output.Printer , mode string ) {
157+ text , magenta := bannerFor (mode )
158+ if magenta {
159+ out .Magenta ("%s" , text )
160+ } else {
161+ out .Success ("%s" , text )
162+ }
163+ }
164+
40165var yoloCmd = & cobra.Command {
41166 Use : "yolo [name] [path]" ,
42167 Short : "Launch or relaunch a session in YOLO (unrestricted) mode" ,
@@ -117,32 +242,22 @@ func runYolo(cmd *cobra.Command, args []string) error {
117242 gitCheckpoint (workdir , out )
118243 }
119244
120- out .Magenta (">>> YOLO MODE" )
121- {
122- intent := yoloIntent (store , name , workdir , "yolo" )
123- fireHook ("on_yolo" , intent )
124- fireServeEvent ("on_yolo" , intent )
125- }
245+ printBanner (out , "yolo" )
246+ fireLaunchEvents (store , name , workdir , "yolo" )
126247
127248 // If session exists and mode matches → preflight. preflight handles both
128249 // live tmux (plain reattach) and dead tmux (recreate with --resume UUID),
129250 // so the session's claude history survives `claude` exiting on its own.
130251 // Only kill/delete when the mode actually changes (safe → yolo) or when
131252 // the user forces fresh state via `ctm yolo!` / `ctm kill`.
132- if sess , err := store .Get (name ); err == nil {
133- if shouldResumeExisting (sess , "yolo" ) {
134- out .Debug (Verbose , "existing yolo session %q — running pre-flight" , name )
135- return preflight (sess , cfg , store , tc , out )
136- }
253+ sess , getErr := store .Get (name )
254+ switch decideModeAction (sess , getErr , "yolo" ) {
255+ case decisionResume :
256+ out .Debug (Verbose , "existing yolo session %q — running pre-flight" , name )
257+ return preflight (sess , cfg , store , tc , out )
258+ case decisionRecreate :
137259 // Mode change: drop tmux + store record so a fresh UUID is minted.
138- if tc .HasSession (name ) {
139- if err := tc .KillSession (name ); err != nil {
140- out .Warn ("could not kill existing session: %v" , err )
141- }
142- }
143- if err := store .Delete (name ); err != nil {
144- out .Warn ("could not remove session from store: %v" , err )
145- }
260+ tearDownForRecreate (name , store , tc , out , true )
146261 }
147262
148263 out .Debug (Verbose , "creating yolo session: %s" , name )
@@ -161,16 +276,7 @@ func runYoloBang(cmd *cobra.Command, args []string) error {
161276 store := session .NewStore (config .SessionsPath ())
162277 tc := tmux .NewClient (config .TmuxConfPath ())
163278
164- name := "claude"
165- if len (args ) > 0 {
166- name = args [0 ]
167- }
168- if err := session .ValidateName (name ); err != nil {
169- return err
170- }
171-
172- // Get workdir from existing session or pane path
173- workdir , err := resolveWorkdir (name , store , tc )
279+ name , workdir , err := resolveModeTarget (args , store , tc )
174280 if err != nil {
175281 return err
176282 }
@@ -179,22 +285,13 @@ func runYoloBang(cmd *cobra.Command, args []string) error {
179285 gitCheckpoint (workdir , out )
180286 }
181287
182- out .Magenta (">>> YOLO MODE" )
183- {
184- intent := yoloIntent (store , name , workdir , "yolo" )
185- fireHook ("on_yolo" , intent )
186- fireServeEvent ("on_yolo" , intent )
187- }
288+ printBanner (out , "yolo" )
289+ fireLaunchEvents (store , name , workdir , "yolo" )
188290
189- if tc .HasSession (name ) {
190- if err := tc .KillSession (name ); err != nil {
191- out .Warn ("could not kill existing session: %v" , err )
192- }
193- }
194- if err := store .Delete (name ); err != nil {
195- // ignore "not found" errors
196- _ = err
197- }
291+ // `yolo!` forces fresh state unconditionally — store.Delete errors are
292+ // swallowed (loud=false) because the historic behavior treated this as
293+ // a best-effort reset.
294+ tearDownForRecreate (name , store , tc , out , false )
198295
199296 return createAndAttach (name , workdir , "yolo" , store , tc , out )
200297}
@@ -211,47 +308,26 @@ func runSafe(cmd *cobra.Command, args []string) error {
211308 store := session .NewStore (config .SessionsPath ())
212309 tc := tmux .NewClient (config .TmuxConfPath ())
213310
214- name := "claude"
215- if len (args ) > 0 {
216- name = args [0 ]
217- }
218- if err := session .ValidateName (name ); err != nil {
219- return err
220- }
221-
222- // Get workdir from existing session or pane path
223- workdir , err := resolveWorkdir (name , store , tc )
311+ name , workdir , err := resolveModeTarget (args , store , tc )
224312 if err != nil {
225313 return err
226314 }
227315
228- out .Success (">>> SAFE MODE" )
229- {
230- intent := yoloIntent (store , name , workdir , "safe" )
231- fireHook ("on_safe" , intent )
232- // Map "on_safe" to a serve session_attached — the hub doesn't
233- // model safe-mode separately, only the lifecycle transition.
234- fireServeEvent ("session_attached" , intent )
235- }
316+ printBanner (out , "safe" )
317+ fireLaunchEvents (store , name , workdir , "safe" )
236318
237319 // If session exists and mode matches → preflight. preflight handles both
238320 // live tmux (plain reattach) and dead tmux (recreate with --resume UUID),
239321 // so the session's claude history survives `claude` exiting on its own.
240322 // Force-fresh escape hatches: `ctm kill <name>` / `ctm forget <name>`.
241- if sess , err := store .Get (name ); err == nil {
242- if shouldResumeExisting (sess , "safe" ) {
243- out .Debug (Verbose , "existing safe session %q — running pre-flight" , name )
244- return preflight (sess , cfg , store , tc , out )
245- }
246- // Mode change: drop tmux + store record so a fresh UUID is minted.
247- if tc .HasSession (name ) {
248- if err := tc .KillSession (name ); err != nil {
249- out .Warn ("could not kill existing session: %v" , err )
250- }
251- }
252- if err := store .Delete (name ); err != nil {
253- _ = err
254- }
323+ sess , getErr := store .Get (name )
324+ switch decideModeAction (sess , getErr , "safe" ) {
325+ case decisionResume :
326+ out .Debug (Verbose , "existing safe session %q — running pre-flight" , name )
327+ return preflight (sess , cfg , store , tc , out )
328+ case decisionRecreate :
329+ // safe matches yolo's silent-on-delete-failure historical behavior.
330+ tearDownForRecreate (name , store , tc , out , false )
255331 }
256332
257333 return createAndAttach (name , workdir , "safe" , store , tc , out )
0 commit comments