返回 DeepSeek-Reasonix
cli_resume_start.go
根目录 / internal / cli / cli_resume_start.go
1 package cli
2
3 import (
4 "errors"
5 "fmt"
6 "io"
7 "os"
8 "strings"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/control"
12 "reasonix/internal/i18n"
13 "reasonix/internal/session"
14 )
15
16 // headlessResumeTarget resolves --resume/--continue for the non-interactive
17 // agent path, before any heavy assembly, so --copy and the session lease are
18 // settled first. --resume takes precedence over --continue, matching the
19 // Resume call downstream, and accepts file paths, branch IDs, preview text,
20 // opaque machine session IDs (#7429) and final-format session identities.
21 // The int is a process exit code: 0 means the (possibly empty) target is
22 // usable, anything else is the code the command must exit with.
23 func headlessResumeTarget(resume string, cont, copySession bool) (cliResumeTarget, int) {
24 target := cliResumeTarget{}
25 if strings.TrimSpace(resume) != "" {
26 resolved, rc := resumeQueryTarget(resolveCLISessionDir(), strings.TrimSpace(resume), os.Stderr, nil)
27 if rc != 0 {
28 return cliResumeTarget{}, rc
29 }
30 target = resolved
31 }
32 if target.empty() && cont {
33 continued, rc := continueResumeTarget()
34 if rc != 0 {
35 return cliResumeTarget{}, rc
36 }
37 target = continued
38 }
39 return target, copySessionExitCode(copySession, target)
40 }
41
42 // interactiveResumeTarget resolves the same selection for the TUI, where the
43 // picker sentinel opens the chooser instead of failing. resumeValue must
44 // already be normalized by normalizedResumeFlag.
45 func interactiveResumeTarget(resumeValue string, cont, copySession bool) (cliResumeTarget, int) {
46 var target cliResumeTarget
47 switch {
48 case resumeValue == resumePickerSentinel:
49 picked, rc := pickSessionToResume()
50 if rc != 0 {
51 return cliResumeTarget{}, rc
52 }
53 target = picked
54 case resumeValue != "":
55 var choose func([]resumeEntry) (cliResumeTarget, int)
56 if isInteractive() {
57 choose = chooseResumeEntry
58 }
59 resolved, rc := resumeQueryTarget(resolveCLISessionDir(), resumeValue, os.Stderr, choose)
60 if rc != 0 {
61 return cliResumeTarget{}, rc
62 }
63 target = resolved
64 case cont:
65 continued, rc := continueResumeTarget()
66 if rc != 0 {
67 return cliResumeTarget{}, rc
68 }
69 target = continued
70 }
71 return target, copySessionExitCode(copySession, target)
72 }
73
74 // resumeQueryTarget resolves --resume QUERY in dir. A query matching several
75 // conversations goes to choose when one is given; otherwise the candidates are
76 // listed on w and the start fails.
77 func resumeQueryTarget(dir, query string, w io.Writer, choose func([]resumeEntry) (cliResumeTarget, int)) (cliResumeTarget, int) {
78 target, err := resolveSessionQuery(dir, query)
79 if err == nil {
80 return target, 0
81 }
82 var ambiguous *ambiguousSessionQueryError
83 if errors.As(err, &ambiguous) && choose != nil {
84 return choose(ambiguous.matches)
85 }
86 fmt.Fprintln(w, i18n.M.ErrorPrefix, err)
87 if ambiguous != nil {
88 for _, entry := range ambiguous.matches {
89 fmt.Fprintf(w, " %s %s %s\n", entry.session.ModTime.Local().Format("01-02 15:04"),
90 resumeEntryID(entry), sessionSummary(entry.session))
91 }
92 fmt.Fprintln(w, i18n.M.AmbiguousResumeHint)
93 }
94 return cliResumeTarget{}, 1
95 }
96
97 // chooseResumeEntry shows entries in the terminal menu and returns the pick.
98 // The caller has already checked isInteractive.
99 func chooseResumeEntry(entries []resumeEntry) (cliResumeTarget, int) {
100 items := make([]menuItem, len(entries))
101 for i, s := range entries {
102 when := s.session.ModTime.Local().Format("01-02 15:04")
103 items[i] = menuItem{
104 name: when,
105 desc: sessionSummary(s.session),
106 }
107 }
108 idx, err := selectOne(i18n.M.PickSessionLabel, items)
109 if err != nil {
110 return cliResumeTarget{}, 1
111 }
112 return entries[idx].target, 0
113 }
114
115 // resumeEntryID is the identity --resume matches exactly for entry.
116 func resumeEntryID(entry resumeEntry) string {
117 if entry.target.canonical() {
118 return entry.target.ref.SessionID
119 }
120 return agent.BranchID(entry.target.path)
121 }
122
123 // normalizedResumeFlag maps the bare-flag spellings of --resume onto the
124 // values the resolver understands. The result also decides the telemetry
125 // session mode, so it is computed once and shared.
126 func normalizedResumeFlag(resume string) string {
127 value := strings.TrimSpace(resume)
128 switch strings.ToLower(value) {
129 case "true":
130 return resumePickerSentinel
131 case "false":
132 return ""
133 }
134 return value
135 }
136
137 // continueResumeTarget implements --continue: sweep the recovery branches this
138 // workspace left behind, then take the newest conversation.
139 func continueResumeTarget() (cliResumeTarget, int) {
140 sessionDir := resolveCLISessionDir()
141 reclaimCLIRecoveryBranches(sessionDir)
142 target, ok := newestResumeTarget(sessionDir)
143 if !ok {
144 fmt.Fprintln(os.Stderr, i18n.M.NoSessionToResume)
145 return cliResumeTarget{}, 1
146 }
147 return target, 0
148 }
149
150 // copySessionExitCode validates --copy against the resolved target: forking
151 // needs something to fork, and a final-format session has no transcript file
152 // to copy yet.
153 func copySessionExitCode(copySession bool, target cliResumeTarget) int {
154 switch {
155 case !copySession:
156 return 0
157 case target.empty():
158 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "--copy requires --resume or --continue")
159 case target.canonical():
160 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "--copy does not support final-format sessions yet")
161 default:
162 return 0
163 }
164 return 2
165 }
166
167 // commitStartupResume binds the resolved target to ctrl. A final-format
168 // session whose writer is held elsewhere is taken over only when approve
169 // agrees, which is the sole difference between --takeover and the TUI prompt.
170 func commitStartupResume(binding *cliTakeoverBinding, manager *cliTakeoverManager, ctrl *control.Controller,
171 resumed *agent.Session, target cliResumeTarget, approve func(error) bool) error {
172 err := commitResumedSession(binding, manager, ctrl, resumed, target)
173 if err == nil || !target.canonical() || !errors.Is(err, session.ErrWriterOwned) || !approve(err) {
174 return err
175 }
176 return cliStartupCanonicalTakeover(ctrl, manager, target)
177 }
178
179 // flagTakeoverApproval answers for --takeover, which commits before the
180 // conflict is known.
181 func flagTakeoverApproval(enabled bool) func(error) bool {
182 return func(error) bool { return enabled }
183 }
184
185 // promptTakeoverApproval asks on the terminal; a non-interactive start declines.
186 func promptTakeoverApproval(err error) bool {
187 return isInteractive() && promptSessionTakeover(err)
188 }
189
189 lines GO