1package daemon
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "runtime"
9 "slices"
10 "sort"
11 "strings"
12
13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/usecase/command"
16 "github.com/bnema/vev/internal/usecase/layout"
17)
18
19// handleCommand serves one one-shot control request. The leading version is
20// checked before decoding the versioned body so newer layouts fail cleanly.
21func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) error {
22 defer func() { _ = tr.Close() }()
23
24 if version, ok := ports.PeekCommandVersion(f.Payload); !ok || version != ports.ProtocolVersion {
25 return d.sendCommandResult(tr, ports.CommandResult{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"})
26 }
27 request, err := ports.UnmarshalCommandRequest(f.Payload)
28 if err != nil {
29 return d.sendCommandResult(tr, ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"})
30 }
31 if request.Attached {
32 return d.sendCommandResult(tr, ports.CommandResult{
33 RequestID: request.RequestID,
34 Code: ports.ErrNotScriptable,
35 Text: "attached command relay is not enabled",
36 })
37 }
38
39 tracker := NewCommandRequestTracker()
40 const generation = uint64(1)
41 outcome, _ := tracker.Track(request.RequestID, generation)
42 commandCtx := d.serveCtx
43 if commandCtx == nil {
44 commandCtx = context.Background()
45 }
46 commandCtx, cancel := context.WithCancel(commandCtx)
47 defer cancel()
48 go func() {
49 result := d.dispatchCommand(commandCtx, request)
50 result.RequestID = request.RequestID
51 tracker.Complete(generation, result)
52 }()
53 commandClock := d.clock
54 if commandClock == nil {
55 commandClock = systemClock{}
56 }
57 result, waitErr := tracker.Wait(commandCtx, commandClock, request.RequestID, generation, outcome)
58 if waitErr != nil {
59 return d.sendCommandResult(tr, ports.CommandResult{
60 RequestID: request.RequestID,
61 Code: ports.ErrInternal,
62 Text: waitErr.Error(),
63 })
64 }
65 return d.sendCommandResult(tr, result)
66}
67
68// sendCommandResult gives one-shot control responses the same observable
69// transport-failure behavior regardless of which validation path produced it.
70func (d *Daemon) sendCommandResult(tr ports.Transport, result ports.CommandResult) error {
71 if err := d.boundedControlSend(tr, frameCommandResult(result)); err != nil {
72 d.log.Warn("command response send failed", "err", err)
73 return err
74 }
75 return nil
76}
77
78// boundedControlSend keeps one-shot control handlers from waiting forever on a
79// client that stopped reading. A timeout closes the exact transport so a
80// blocked Send can unwind before the handler returns.
81func (d *Daemon) boundedControlSend(tr ports.Transport, frame ports.Frame) error {
82 _, err := d.boundedSendWith(tr, func() error { return tr.Send(frame) })
83 if errors.Is(err, errSendTimedOut) {
84 _ = tr.Close()
85 }
86 return err
87}
88
89func frameCommandResult(result ports.CommandResult) ports.Frame {
90 return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}
91}
92
93func (d *Daemon) dispatchCommand(ctx context.Context, request ports.CommandRequest) ports.CommandResult {
94 cmd, ok := command.BySlug(request.Slug)
95 if !ok {
96 return commandFailure(ports.ErrUnknownCommand, "unknown command: "+request.Slug)
97 }
98 if !cmd.Scriptable || cmd.Control == nil {
99 return commandFailure(ports.ErrNotScriptable, request.Slug+" requires an attached client")
100 }
101 if cmd.Target == command.TargetNone && !request.Self {
102 return d.runControl(cmd, controlExec{ctx: ctx, d: d, recoveryName: request.TargetSession}, request)
103 }
104
105 sess, code, text := d.resolveTargetSession(request)
106 if sess == nil {
107 return commandFailure(code, text)
108 }
109 if cmd.Scope == command.CommandScopeCrossSession {
110 // Cross-session moves intentionally skip sess.dispatchMu: movePane and
111 // moveTab acquire both session dispatch locks through lockMoveDispatch in
112 // global order. Pre-locking the source here could recreate the
113 // opposite-direction deadlock covered by
114 // TestHandleCommandOppositeMoveCommandsDoNotDeadlock.
115 tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
116 if code != 0 {
117 return commandFailure(code, text)
118 }
119 return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
120 }
121
122 for {
123 select {
124 case <-ctx.Done():
125 return commandFailure(ports.ErrInternal, ctx.Err().Error())
126 default:
127 }
128 if sess.dispatchMu.TryLock() {
129 break
130 }
131 runtime.Gosched()
132 }
133 defer sess.dispatchMu.Unlock()
134
135 tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
136 if code != 0 {
137 return commandFailure(code, text)
138 }
139 return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
140}
141
142func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports.CommandRequest) ports.CommandResult {
143 result, err := cmd.Control(exec, request.Args, command.ControlOptions{JSON: request.JSON})
144 if err == nil {
145 return ports.CommandResult{OK: true, Output: result.Output}
146 }
147 switch {
148 case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired), errors.Is(err, domain.ErrInvalidSessionName):
149 return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)
150 case errors.Is(err, errSessionNameInUse):
151 return commandFailure(ports.ErrNameTaken, err.Error())
152 case isMoveCommandError(err):
153 return moveCommandFailure(err)
154 case errors.Is(err, layout.ErrNotInSplit):
155 return commandFailure(ports.ErrNoSuchTarget, "pane is not in a split")
156 case errors.Is(err, layout.ErrTooSmall):
157 return commandFailure(ports.ErrNoSuchTarget, "pane cannot be resized further")
158 default:
159 return commandFailure(ports.ErrInternal, err.Error())
160 }
161}
162
163func commandFailure(code uint16, text string) ports.CommandResult {
164 return ports.CommandResult{Code: code, Text: text}
165}
166
167// resolveTargetSession applies explicit-name, stable-ID, then unique-session
168// resolution. A name paired with complete stable IDs is always advisory because
169// a process keeps its original VEV session component after relocation.
170func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, uint16, string) {
171 d.mu.Lock()
172 defer d.mu.Unlock()
173
174 named := d.findByNameLocked(request.TargetSession)
175 if request.Self && (request.TargetTab == "" || request.TargetPane == "") {
176 return nil, ports.ErrNoSuchTarget, "--self requires target tab and pane IDs"
177 }
178 if request.TargetSession != "" && request.TargetTab == "" && request.TargetPane == "" {
179 if named == nil {
180 return nil, ports.ErrNoSuchTarget, "no such session: " + request.TargetSession
181 }
182 return named, 0, ""
183 }
184 if request.TargetTab != "" || request.TargetPane != "" {
185 if request.TargetTab == "" || request.TargetPane == "" {
186 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
187 }
188 var match *session
189 for _, sess := range d.sessions {
190 if sess == nil || !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
191 continue
192 }
193 if match != nil {
194 return nil, ports.ErrAmbiguousTarget, "target tab and pane IDs are ambiguous"
195 }
196 match = sess
197 }
198 if match != nil {
199 return match, 0, ""
200 }
201 return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"
202 }
203 locals := sessionsSnapshot(d.sessions)
204 if len(locals) == 0 {
205 return nil, ports.ErrNoSuchTarget, "no live sessions"
206 }
207 if len(locals) != 1 {
208 return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane"
209 }
210 return locals[0], 0, ""
211}
212
213func (s *session) containsStableIDs(tabID, paneID string) bool {
214 s.mu.Lock()
215 tabs := append([]*tab(nil), s.tabs...)
216 s.mu.Unlock()
217 for _, tb := range tabs {
218 tb.mu.Lock()
219 pane := paneByStableIDLocked(tb, paneID)
220 match := tb.stableID == tabID && pane != nil
221 tb.mu.Unlock()
222 if match {
223 return true
224 }
225 }
226 return false
227}
228
229func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) {
230 sess.mu.Lock()
231 tabs := append([]*tab(nil), sess.tabs...)
232 sess.mu.Unlock()
233 if request.Self {
234 for _, tb := range tabs {
235 tb.mu.Lock()
236 if tb.stableID != request.TargetTab {
237 tb.mu.Unlock()
238 continue
239 }
240 target := paneByStableIDLocked(tb, request.TargetPane)
241 tb.mu.Unlock()
242 if target == nil {
243 return nil, nil, ports.ErrNoSuchTarget, "target pane does not belong to target tab"
244 }
245 return tb, target, 0, ""
246 }
247 return nil, nil, ports.ErrNoSuchTarget, "no such target tab"
248 }
249 if len(tabs) == 0 {
250 return nil, nil, ports.ErrNoSuchTarget, "target session has no tabs"
251 }
252 tb := tabs[0]
253 if kind != command.TargetPane {
254 return tb, nil, 0, ""
255 }
256 tb.mu.Lock()
257 target := tb.focusedPane()
258 tb.mu.Unlock()
259 if target == nil {
260 return nil, nil, ports.ErrNoSuchTarget, "no such target pane"
261 }
262 return tb, target, 0, ""
263}
264
265func paneByStableIDLocked(tb *tab, stableID string) *pane {
266 for _, pane := range tb.panes {
267 if pane.stableID == stableID {
268 return pane
269 }
270 }
271 return nil
272}
273
274type daemonActionKind uint8
275
276const (
277 daemonActionCreateTab daemonActionKind = iota
278 daemonActionCloseTab
279 daemonActionSplitPane
280 daemonActionStackPane
281 daemonActionToggleStack
282 daemonActionClosePane
283 daemonActionFocusPane
284 daemonActionNextTab
285 daemonActionPreviousTab
286 daemonActionRenameSession
287 daemonActionRenameTab
288 daemonActionResizePane
289 daemonActionEqualizePanes
290 daemonActionConsumeOrExpelPane
291)
292
293type daemonActionTarget struct {
294 session *session
295 attachment *attachedClient
296 tab *tab
297 pane *pane
298}
299
300type daemonActionRequest struct {
301 kind daemonActionKind
302 target daemonActionTarget
303 effect *attachmentEffectTicket
304 direction layout.Direction
305 axis layout.Axis
306 delta int
307 viewport domain.Size
308 name string
309}
310
311type daemonActionRunner interface {
312 Run(daemonActionRequest) error
313}
314
315func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) daemonActionTarget {
316 if sess == nil {
317 return daemonActionTarget{}
318 }
319 if ac != nil {
320 tb, pane := sess.paneForAttachment(ac)
321 if tb == nil {
322 return daemonActionTarget{session: sess, attachment: ac}
323 }
324 return daemonActionTarget{session: sess, attachment: ac, tab: tb, pane: pane}
325 }
326 tb := sess.firstTab()
327 if tb == nil {
328 return daemonActionTarget{session: sess}
329 }
330 tb.mu.Lock()
331 pane := tb.focusedPane()
332 tb.mu.Unlock()
333 return daemonActionTarget{session: sess, tab: tb, pane: pane}
334}
335
336// daemonActions is the daemon-owned mutation seam shared by palette and
337// control. Requests contain already-resolved targets and no attachment state;
338// adapters remain responsible for their attached-client UI lifecycle.
339type daemonActions struct{ d *Daemon }
340
341func (a daemonActions) Run(request daemonActionRequest) error {
342 target := request.target
343 switch request.kind {
344 case daemonActionCreateTab:
345 return a.d.createTabForAttachment(target.session, target.attachment, request.viewport)
346 case daemonActionCloseTab:
347 return a.d.closeTabLockedWithEffect(target.session, target.tab, true, request.effect)
348 case daemonActionSplitPane:
349 change, err := a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction)
350 if err == nil {
351 publishPaneFocusForAttachment(target.session, target.attachment, change)
352 }
353 return err
354 case daemonActionStackPane:
355 change, err := a.d.stackPaneAt(target.session, target.tab, target.pane)
356 if err == nil {
357 publishPaneFocusForAttachment(target.session, target.attachment, change)
358 }
359 return err
360 case daemonActionToggleStack:
361 return a.d.toggleStackAt(target.session, target.tab, target.pane)
362 case daemonActionClosePane:
363 if a.d.ptys == nil {
364 return nil
365 }
366 if !a.d.hasDaemonActionPaneTarget(target) {
367 return layout.ErrNotFound
368 }
369 if err := a.d.closePaneLockedWithEffect(target.session, target.tab, target.pane.id, nil, true, request.effect); err != nil {
370 return err
371 }
372 if a.d.hasDaemonActionPaneTarget(target) {
373 return errors.New("pane close did not complete")
374 }
375 return nil
376 case daemonActionFocusPane:
377 change, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
378 if err == nil {
379 publishPaneFocusForAttachment(target.session, target.attachment, change)
380 }
381 return err
382 case daemonActionNextTab:
383 return a.switchRelative(target.session, target.attachment, 1)
384 case daemonActionPreviousTab:
385 return a.switchRelative(target.session, target.attachment, -1)
386 case daemonActionRenameSession:
387 return a.d.renameSession(target.session, request.name)
388 case daemonActionRenameTab:
389 return a.d.renameTab(target.session, target.tab, request.name)
390 case daemonActionResizePane:
391 return a.d.resizePane(target, request.axis, request.delta)
392 case daemonActionEqualizePanes:
393 return a.d.equalizePanes(target)
394 case daemonActionConsumeOrExpelPane:
395 return a.d.consumeOrExpelPane(target, request.direction)
396 default:
397 return errors.New("daemon: unknown action")
398 }
399}
400
401// hasDaemonActionPaneTarget verifies that an already-resolved pane still
402// belongs to the live tab and session. Close-pane uses it both before mutation
403// and as a postcondition because closing a final pane delegates to close-tab.
404func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool {
405 if target.session == nil || target.tab == nil || target.pane == nil {
406 return false
407 }
408 d.mu.Lock()
409 defer d.mu.Unlock()
410 if d.sessions[target.session.id] != target.session {
411 return false
412 }
413 target.session.mu.Lock()
414 defer target.session.mu.Unlock()
415 if !slices.Contains(target.session.tabs, target.tab) {
416 return false
417 }
418 target.tab.mu.Lock()
419 defer target.tab.mu.Unlock()
420 return target.tab.panes[target.pane.id] == target.pane &&
421 target.tab.tree != nil && layout.ContainsLeaf(target.tab.tree.Root, target.pane.id)
422}
423
424func (a daemonActions) switchRelative(sess *session, ac *attachedClient, delta int) error {
425 if sess.switchAttachmentRelativeForDispatch(ac, delta) {
426 a.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, nil)
427 }
428 return nil
429}
430
431// controlExec implements command.ControlContext against resolved daemon-owned
432// targets. It deliberately contains no attached-client state.
433type controlExec struct {
434 ctx context.Context
435 d *Daemon
436 sess *session
437 tab *tab
438 recoveryName string
439 target daemonActionTarget
440 actions daemonActionRunner
441}
442
443func (e controlExec) runAction(request daemonActionRequest) error {
444 if request.target.session == nil {
445 request.target = e.target
446 }
447 runner := e.actions
448 if runner == nil {
449 runner = daemonActions{d: e.d}
450 }
451 err := runner.Run(request)
452 if errors.Is(err, errDaemonActionNoChange) {
453 return nil
454 }
455 if err != nil {
456 return err
457 }
458 if e.actions == nil {
459 finishDaemonActionForClient(e.d, request, request.target.attachment, "control.go")
460 }
461 return nil
462}
463
464func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
465 if ac == nil {
466 return
467 }
468 if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {
469 d.finishPaneFocusForClient(request.target.session, ac, request.target.tab, request.target.pane.id, producer)
470 return
471 }
472 d.invalidateRender(request.target.session, ac, true, producer)
473}
474
475func (e controlExec) CreateTab() error {
476 return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()})
477}
478func (e controlExec) CreateSessionNamed(name string) error {
479 if err := domain.ValidateSessionName(name); err != nil {
480 return command.ErrInvalidArguments
481 }
482 e.sess.mu.Lock()
483 cwd, term, env := e.sess.cwd, e.sess.terminal, copyEnvironment(e.sess.env)
484 e.sess.mu.Unlock()
485 size := e.sess.fullViewportSize()
486 e.d.mu.Lock()
487 defer e.d.mu.Unlock()
488 if e.d.closing {
489 return errors.New("daemon is shutting down")
490 }
491 if e.d.nameLiveOrStoppedLocked(name) {
492 return errSessionNameInUse
493 }
494 _, err := e.d.createSessionLocked(name, false, cwd, size, term, env)
495 return err
496}
497func (e controlExec) CloseTab() error {
498 return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
499}
500func (e controlExec) ClosePane() error {
501 return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
502}
503
504func (e controlExec) MovePane(destinationSession, destinationTabID string) error {
505 destination := e.d.liveSessionByName(destinationSession)
506 if destination == nil {
507 return errMoveStaleTarget
508 }
509 if e.sess == nil || e.tab == nil || e.target.pane == nil {
510 return errMovePaneInvalid
511 }
512 attachment := e.target.attachment
513 token := attachmentConnectionToken{}
514 if attachment != nil {
515 token = e.sess.attachmentToken(attachment, attachment.transport())
516 if token.ac == nil {
517 return errMoveStaleTarget
518 }
519 }
520 return e.d.movePane(movePaneRequest{
521 Attachment: attachment,
522 AttachmentToken: token,
523 Source: sessionMoveLocator(e.sess),
524 SourceTabID: domain.TabStableID(e.tab.stableID),
525 SourcePaneID: domain.PaneStableID(e.target.pane.stableID),
526 Destination: sessionMoveLocator(destination),
527 DestinationTabID: domain.TabStableID(destinationTabID),
528 })
529}
530
531func (e controlExec) MoveTab(destinationSession string) error {
532 destination := e.d.liveSessionByName(destinationSession)
533 if destination == nil {
534 return errMoveStaleTarget
535 }
536 if e.sess == nil || e.tab == nil {
537 return errMovePaneInvalid
538 }
539 attachment := e.target.attachment
540 token := attachmentConnectionToken{}
541 if attachment != nil {
542 token = e.sess.attachmentToken(attachment, attachment.transport())
543 if token.ac == nil {
544 return errMoveStaleTarget
545 }
546 }
547 return e.d.moveTab(moveTabRequest{
548 Attachment: attachment,
549 AttachmentToken: token,
550 Source: sessionMoveLocator(e.sess),
551 SourceTabID: domain.TabStableID(e.tab.stableID),
552 Destination: sessionMoveLocator(destination),
553 })
554}
555
556func (d *Daemon) liveSessionByName(name string) *session {
557 d.mu.Lock()
558 defer d.mu.Unlock()
559 return d.findByNameLocked(name)
560}
561
562func sessionMoveLocator(sess *session) moveSessionLocator {
563 sess.mu.Lock()
564 name := sess.name
565 sess.mu.Unlock()
566 return moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: name}
567}
568func (e controlExec) split(direction layout.Direction) error {
569 return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
570}
571func (e controlExec) SplitRight() error { return e.split(layout.Right) }
572func (e controlExec) SplitLeft() error { return e.split(layout.Left) }
573func (e controlExec) SplitUp() error { return e.split(layout.Up) }
574func (e controlExec) SplitDown() error { return e.split(layout.Down) }
575func (e controlExec) consumeOrExpelPane(direction layout.Direction) error {
576 return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
577}
578func (e controlExec) ConsumeOrExpelPaneLeft() error {
579 return e.consumeOrExpelPane(layout.Left)
580}
581func (e controlExec) ConsumeOrExpelPaneRight() error {
582 return e.consumeOrExpelPane(layout.Right)
583}
584func (e controlExec) StackPane() error {
585 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
586}
587func (e controlExec) ToggleStack() error {
588 return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
589}
590func (e controlExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) }
591func (e controlExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) }
592func (e controlExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) }
593func (e controlExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) }
594func (e controlExec) resize(axis layout.Axis, delta int) error {
595 return e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta})
596}
597func (e controlExec) EqualizePanes() error {
598 return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes}))
599}
600func (e controlExec) focus(direction layout.Direction) error {
601 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
602}
603func (e controlExec) FocusPaneLeft() error { return e.focus(layout.Left) }
604func (e controlExec) FocusPaneRight() error { return e.focus(layout.Right) }
605func (e controlExec) FocusPaneUp() error { return e.focus(layout.Up) }
606func (e controlExec) FocusPaneDown() error { return e.focus(layout.Down) }
607func (e controlExec) NextTab() error {
608 return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
609}
610func (e controlExec) PrevTab() error {
611 return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
612}
613func (e controlExec) RenameSessionTo(name string) error {
614 return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
615}
616func (e controlExec) RenameTabTo(name string) error {
617 return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, name: name})
618}
619func (e controlExec) Toast(severity, message string) error {
620 var level domain.NoticeSeverity
621 switch severity {
622 case "info":
623 level = domain.NoticeInfo
624 case "warn":
625 level = domain.NoticeWarn
626 case "error":
627 level = domain.NoticeError
628 default:
629 return command.ErrInvalidArguments
630 }
631 if strings.TrimSpace(message) == "" {
632 return command.ErrInvalidArguments
633 }
634 e.d.notify(e.sess, level, domain.NoticeUser, message, nil)
635 return nil
636}
637
638func (e controlExec) SessionRecovery(action string) (string, error) {
639 if e.d == nil || e.d.recovery == nil || e.recoveryName == "" || action != "discard" {
640 return "", command.ErrInvalidArguments
641 }
642 ctx := e.ctx
643 if ctx == nil {
644 ctx = e.d.serveCtx
645 }
646 if ctx == nil {
647 ctx = context.Background()
648 }
649 if err := e.d.recovery.Discard(ctx, e.recoveryName); err != nil {
650 return "", err
651 }
652 record, ok, err := e.d.catalogue.Record(e.recoveryName)
653 if err != nil {
654 return "", err
655 }
656 if ok {
657 e.d.setStoppedRecovery(record, ports.SessionDown)
658 }
659 return "", nil
660}
661
662func (e controlExec) ListSessions(asJSON bool) (string, error) {
663 type row struct {
664 Name string `json:"name"`
665 Ephemeral bool `json:"ephemeral"`
666 Tabs int `json:"tabs"`
667 Attached bool `json:"attached"`
668 Active bool `json:"active"`
669 }
670 e.d.mu.Lock()
671 sessions := sessionsSnapshot(e.d.sessions)
672 e.d.mu.Unlock()
673 snaps := make([]sessionView, 0, len(sessions))
674 for _, sess := range sessions {
675 snaps = append(snaps, sess.snapshotView(viewOptions{}))
676 }
677 activeIdx := -1
678 for i, snap := range snaps {
679 if activeIdx == -1 || snap.mruAt > snaps[activeIdx].mruAt {
680 activeIdx = i
681 }
682 }
683 rows := make([]row, 0, len(snaps))
684 for i, snap := range snaps {
685 rows = append(rows, row{Name: snap.name, Ephemeral: snap.ephemeral, Tabs: snap.tabCount, Attached: snap.attached, Active: i == activeIdx})
686 }
687 sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
688 if asJSON {
689 return marshalListing(rows)
690 }
691 var out strings.Builder
692 out.WriteString("NAME\tSTATE\tTABS\tATTACHED\tACTIVE\n")
693 for _, row := range rows {
694 state := "named"
695 if row.Ephemeral {
696 state = "ephemeral"
697 }
698 fmt.Fprintf(&out, "%s\t%s\t%d\t%t\t%t\n", row.Name, state, row.Tabs, row.Attached, row.Active)
699 }
700 return out.String(), nil
701}
702
703func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
704 if !asJSON {
705 return "", command.ErrInvalidArguments
706 }
707 e.d.mu.Lock()
708 sessions := sessionsSnapshot(e.d.sessions)
709 stopped := make([]stoppedSession, 0, len(e.d.stopped))
710 for _, entry := range e.d.stopped {
711 if !entry.purging {
712 stopped = append(stopped, entry)
713 }
714 }
715 e.d.mu.Unlock()
716
717 // Test doubles and pre-parity daemons may not have durable lifecycle
718 // identities. Keep their count-only catalogue form readable so direct CLI
719 // discovery remains compatible; every production session has a non-zero
720 // incarnation and therefore takes the strict complete-schema path below.
721 legacy := false
722 for _, sess := range sessions {
723 if sess.incarnation == (domain.IncarnationID{}) {
724 legacy = true
725 break
726 }
727 }
728 if !legacy {
729 for _, entry := range stopped {
730 if entry.incarnation == (domain.IncarnationID{}) {
731 legacy = true
732 break
733 }
734 }
735 }
736 if legacy {
737 return e.remoteCatalogLegacy(sessions)
738 }
739
740 rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped))
741 for _, sess := range sessions {
742 e.d.refreshSessionFocusedTitles(sess)
743 snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false})
744 tabs, err := remoteCatalogTabs(snap)
745 if err != nil {
746 return "", err
747 }
748 row := ports.RemoteCatalogSession{
749 LifecycleID: snap.incarnation,
750 Name: snap.name,
751 State: "up",
752 Ephemeral: snap.ephemeral,
753 Tabs: tabs,
754 Attached: snap.attached,
755 LastUsedSeq: snap.mruAt,
756 }
757 if len(tabs) != 0 {
758 row.ActiveTabID = tabs[min(snap.defaultTab, len(tabs)-1)].ID
759 }
760 rows = append(rows, row)
761 }
762 for _, entry := range stopped {
763 tabs, err := remoteCatalogStoppedTabs(entry)
764 if err != nil {
765 return "", err
766 }
767 state := "down"
768 reason := ""
769 if entry.state == ports.SessionBroken || entry.record.DegradedReason != "" {
770 state = "broken"
771 reason = "session_broken"
772 }
773 rows = append(rows, ports.RemoteCatalogSession{
774 LifecycleID: entry.incarnation,
775 Name: entry.name,
776 State: state,
777 Ephemeral: false,
778 Tabs: tabs,
779 LastUsedSeq: entry.lastUsedSeq,
780 Reason: reason,
781 })
782 }
783 sort.Slice(rows, func(i, j int) bool {
784 if rows[i].Name != rows[j].Name {
785 return rows[i].Name < rows[j].Name
786 }
787 return rows[i].LifecycleID.String() < rows[j].LifecycleID.String()
788 })
789 catalog := ports.RemoteCatalog{
790 ProtocolVersion: ports.ProtocolVersion,
791 SchemaVersion: ports.RemoteCatalogSchemaVersion,
792 Sessions: rows,
793 }
794 if err := ports.ValidateRemoteCatalog(catalog); err != nil {
795 return "", err
796 }
797 return marshalListing(catalog)
798}
799
800func (e controlExec) remoteCatalogLegacy(sessions []*session) (string, error) {
801 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
802 for _, sess := range sessions {
803 snap := sess.snapshotView(viewOptions{})
804 rows = append(rows, ports.RemoteCatalogSession{
805 Name: snap.name,
806 State: "up",
807 Ephemeral: snap.ephemeral,
808 Tabs: ports.SaturateUint16(snap.tabCount),
809 Attached: snap.attached,
810 })
811 }
812 sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
813 catalog := ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: rows}
814 if err := ports.ValidateRemoteCatalog(catalog); err != nil {
815 return "", err
816 }
817 return marshalListing(catalog)
818}
819
820func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) {
821 if len(view.tabs) > ports.RemoteCatalogMaxTabsPerSess {
822 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", view.name)
823 }
824 tabs := make([]ports.RemoteCatalogTab, 0, len(view.tabs))
825 for i, tab := range view.tabs {
826 tabs = append(tabs, ports.RemoteCatalogTab{
827 ID: string(tab.id),
828 Index: uint16(i),
829 Name: tab.name,
830 Detail: tabTitleDetail(tab.name, tab.focusedTitle),
831 Attention: tab.attention,
832 })
833 }
834 return tabs, nil
835}
836
837func remoteCatalogStoppedTabs(entry stoppedSession) ([]ports.RemoteCatalogTab, error) {
838 records := entry.tabRecords
839 if len(records) == 0 && len(entry.tabNames) != 0 {
840 records = make([]domain.CatalogueTabRecord, len(entry.tabNames))
841 for i, name := range entry.tabNames {
842 records[i].Name = name
843 }
844 }
845 if len(records) > ports.RemoteCatalogMaxTabsPerSess {
846 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", entry.name)
847 }
848 tabs := make([]ports.RemoteCatalogTab, 0, len(records))
849 for i, record := range records {
850 tabs = append(tabs, ports.RemoteCatalogTab{ID: string(record.StableID), Index: uint16(i), Name: record.Name})
851 }
852 return tabs, nil
853}
854
855func (e controlExec) ListTabs(asJSON bool) (string, error) {
856 type row struct {
857 Index int `json:"index"`
858 ID string `json:"id"`
859 Name string `json:"name"`
860 Panes int `json:"panes"`
861 Active bool `json:"active"`
862 }
863 e.sess.mu.Lock()
864 tabs := append([]*tab(nil), e.sess.tabs...)
865 e.sess.mu.Unlock()
866 active, _ := e.sess.tabIndexForAttachment(e.target.attachment)
867 rows := make([]row, 0, len(tabs))
868 for i, tb := range tabs {
869 tb.mu.Lock()
870 rows = append(rows, row{Index: i, ID: tb.stableID, Name: tb.name, Panes: len(tb.panes), Active: i == active})
871 tb.mu.Unlock()
872 }
873 if asJSON {
874 return marshalListing(rows)
875 }
876 var out strings.Builder
877 out.WriteString("INDEX\tID\tNAME\tPANES\tACTIVE\n")
878 for _, row := range rows {
879 fmt.Fprintf(&out, "%d\t%s\t%s\t%d\t%t\n", row.Index, row.ID, row.Name, row.Panes, row.Active)
880 }
881 return out.String(), nil
882}
883
884func (e controlExec) ListPanes(asJSON bool) (string, error) {
885 type row struct {
886 ID string `json:"id"`
887 Pane string `json:"pane"`
888 Size string `json:"size"`
889 CWD string `json:"cwd"`
890 Focused bool `json:"focused"`
891 }
892 tb := e.tab
893 if tb == nil {
894 if e.target.attachment == nil {
895 tb = e.sess.firstTab()
896 } else {
897 tb = e.sess.tabForAttachment(e.target.attachment)
898 }
899 }
900 if tb == nil {
901 return "", errors.New("target session has no active tab")
902 }
903 e.sess.mu.Lock()
904 fallbackCWD := e.sess.cwd
905 e.sess.mu.Unlock()
906 var focus layout.PaneID
907 var focusStableID domain.PaneStableID
908 if e.target.attachment != nil {
909 focusStableID = e.target.attachment.viewSnapshot().paneID
910 }
911 tb.mu.Lock()
912 if e.target.attachment == nil {
913 if focused := tb.focusedPane(); focused != nil {
914 focus = focused.id
915 }
916 }
917 panes := tb.panesSnapshot()
918 tb.mu.Unlock()
919 rows := make([]row, 0, len(panes))
920 for _, pane := range panes {
921 pane.mu.Lock()
922 size := domain.Size{Cols: pane.rect.Width, Rows: pane.rect.Height}
923 pid := 0
924 if pane.pty != nil {
925 pid = pane.pty.Pid()
926 }
927 pane.mu.Unlock()
928 cwd := fallbackCWD
929 if e.d.procCwd != nil && pid > 0 {
930 if live, err := e.d.procCwd(pid); err == nil && live != "" {
931 cwd = live
932 }
933 }
934 focused := pane.id == focus
935 if e.target.attachment != nil {
936 focused = domain.PaneStableID(pane.stableID) == focusStableID
937 }
938 rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: focused})
939 }
940 sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane })
941 if asJSON {
942 return marshalListing(rows)
943 }
944 var out strings.Builder
945 out.WriteString("ID\tPANE\tSIZE\tCWD\tFOCUSED\n")
946 for _, row := range rows {
947 fmt.Fprintf(&out, "%s\t%s\t%s\t%s\t%t\n", row.ID, row.Pane, row.Size, row.CWD, row.Focused)
948 }
949 return out.String(), nil
950}
951
952func marshalListing(value any) (string, error) {
953 encoded, err := json.Marshal(value)
954 if err != nil {
955 return "", err
956 }
957 return string(encoded) + "\n", nil
958}