v0.3.0 99dc266

session.go RawHistoryFolder

1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5	"context"
6	"crypto/rand"
7	"errors"
8	"fmt"
9	"os"
10	"path/filepath"
11	"slices"
12	"strconv"
13	"strings"
14	"sync"
15	"sync/atomic"
16	"time"
17
18	"github.com/bnema/vev/internal/domain"
19	"github.com/bnema/vev/internal/ports"
20	"github.com/bnema/vev/internal/usecase/layout"
21)
22
23var (
24	errSessionNameRequired = errors.New("name required")
25	errSessionNameInUse    = errors.New("name already in use")
26)
27
28type terminalEnv struct {
29	TrueColor bool
30}
31
32type session struct {
33	sessionCore
34
35	ctx    context.Context
36	cancel context.CancelFunc
37
38	// dispatchMu serializes user-initiated mutating command dispatch (palette
39	// and vev-cmd control) for this session, so a one-shot command cannot
40	// resolve focus against one state and mutate a later one. Lock order:
41	// dispatchMu strictly before d.mu, sess.mu, or any tab mu.
42	dispatchMu sync.Mutex
43	// teardownMu guards teardown ownership and is the outermost per-session
44	// lifecycle lock: never acquire it while holding daemon, session, snapshot,
45	// tab, or pane locks. The owner performs teardown without holding teardownMu;
46	// duplicate daemon-shutdown callers can therefore bound their ownership wait
47	// with Serve's shared snapshot deadline.
48	teardownMu        sync.Mutex
49	teardownActive    bool
50	teardownDone      chan struct{}
51	moveReservations  uint
52	teardownWaiters   uint
53	teardownChanged   chan struct{}
54	lifecycleStopOnce sync.Once
55	// sessionCore.mu guards tabs, attachment membership, restoreDone, clipFiles,
56	// and clipboard queue state.
57	// restoreDone remains attached to a restored session after it is published in
58	// the live registry, so racing attaches cannot bypass restoration completion.
59	restoreDone            chan struct{}
60	themeMu                sync.Mutex
61	tabs                   []*tab
62	clipboardQueue         []clipboardForward
63	clipboardWorkerRunning bool
64	cwd                    string
65	terminal               terminalEnv
66	// env is the authoritative immutable environment snapshot for future PTY children.
67	// It is guarded by mu and is always copied on ingress and egress.
68	env          []string
69	snapDirty    atomic.Bool
70	snapEligible atomic.Bool
71	// snapshotMu serializes mutation revisions and repository publication
72	// generations with worker completion. It is intentionally independent from
73	// mu: persistence never holds session state locks while encoding or writing.
74	snapshotMu                        sync.Mutex
75	snapshotGeneration                uint64 // newest mutation revision
76	snapshotPublishedGeneration       uint64 // newest repository generation
77	snapshotPublishedCheckpoint       *domain.CheckpointRef
78	snapshotPublishedMutationRevision uint64
79	snapshotNextEligibleAt            time.Time
80	// The coordinator state below is guarded by snapshotMu. A capture can be
81	// queued globally or in flight, never more than one of each per session.
82	// Quarantine cancels the session publication context before destructive
83	// repository operations and publicationDone joins a started publication.
84	snapshotPending         bool
85	snapshotPendingCaptures uint
86	// snapshotForcedGeneration is the newest mutation revision a forced
87	// checkpoint must publish. It survives an older routine capture so worker
88	// completion can enqueue exactly one forced successor.
89	snapshotForcedGeneration   uint64
90	snapshotQueuedCapture      *snapshotCapture
91	snapshotInFlightCapture    *snapshotCapture
92	snapshotPublicationDone    chan struct{}
93	snapshotPublicationContext context.Context
94	snapshotPublicationCancel  context.CancelFunc
95	snapshotQuarantined        bool
96	// snapshotChunkCache retains encoded sealed history only for this named
97	// session. snapshotMu owns it so cache touches never contend with pane
98	// state or mutate bytes retained by queued/in-flight publications.
99	snapshotChunkCache *snapshotChunkCache
100	snapshotChanged    chan struct{}
101	snapshotWake       chan struct{}
102	// syncGen makes synchronized-output watchdog generations unique across all
103	// panes in this session.
104	syncGen atomic.Uint64
105	// layoutApplyMu serializes whole-session resize prepare/apply/admit/publish
106	// transactions. It is deliberately not an architecture lock and may span
107	// external PTY.Resize calls.
108	layoutApplyMu sync.Mutex
109	// clipFiles records clipboard-image-transfer temp file paths (see
110	// clipboard.go) written for this session, removed best-effort in
111	// killSession.
112	clipFiles []string
113
114	// floatingLaunchMu is ownership synchronization for external floating
115	// launches. It is separate from mu so PTYFactory.Open never holds a
116	// session, tab, or daemon architecture lock.
117	floatingLaunchMu       sync.Mutex
118	floatingLaunchStopping bool
119	floatingLaunches       map[*floatingLaunch]struct{}
120}
121
122func (s *session) nameSnapshot() string {
123	if s == nil {
124		return ""
125	}
126	s.mu.Lock()
127	defer s.mu.Unlock()
128	return s.name
129}
130
131// tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
132type tab struct {
133	mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership
134
135	// layoutGeneration invalidates prepared geometry whenever live tab layout
136	// state changes. layoutApplyMu serializes the lock-free PTY apply boundary.
137	layoutGeneration uint64
138	layoutApplyMu    sync.Mutex
139	// layoutRetryMu owns the one bounded delayed retry worker for accepted
140	// tiled-layout degradation. Its context is derived from ctx, so tab/session
141	// teardown cancels a waiting retry before it can touch PTY state.
142	layoutRetryMu      sync.Mutex
143	layoutRetryCancel  context.CancelFunc
144	layoutRetryRunning bool
145
146	stableID   string
147	name       string
148	tree       *layout.Tree
149	panes      map[layout.PaneID]*pane
150	nextPaneID int
151	size       domain.Size
152	ctx        context.Context
153	cancel     context.CancelFunc
154	// floating is independent from the normal layout tree and pane map.
155	floating floatingSlot
156
157	// attention fields are guarded by the owning session.mu.
158	attention             bool
159	attentionAt           time.Time
160	attentionVisiblePaint bool
161}
162
163// attachedClient is a client currently attached to a session's tab. rend is
164// its private renderer shadow (so each client gets output minimised against
165// what it has actually seen). sendMu serialises the two senders — the render
166// coordinator and the connection handler — so the transport's single-writer
167
168func (d *Daemon) touchMRU(entry *session) {
169	if d == nil || entry == nil || entry.core() == nil {
170		return
171	}
172	core := entry.core()
173	seq := d.mruSeq.Add(1)
174	for {
175		old := core.mruAt.Load()
176		if old >= seq {
177			return
178		}
179		if core.mruAt.CompareAndSwap(old, seq) {
180			break
181		}
182	}
183	entry.mu.Lock()
184	if !entry.ephemeral {
185		d.markCatalogueDirty(entry.persistRecordLocked(max(d.nowUnixNano(), entry.createdAt, int64(1))).MetadataUpdate())
186	}
187	entry.mu.Unlock()
188}
189
190func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
191	return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, term, env, restoredTabNames...)
192}
193
194func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
195	env = copyEnvironment(env)
196	if _, reserved := d.creating[name]; reserved {
197		return nil, errSessionNameInUse
198	}
199	d.creating[name] = struct{}{}
200	defer delete(d.creating, name)
201	stopped, resuming := d.stopped[name]
202	var authoritative domain.CatalogueRecord
203	var authoritativeExists bool
204	if !ephemeral && resuming && d.persistEnabled {
205		d.mu.Unlock()
206		var err error
207		authoritative, authoritativeExists, err = d.catalogueRecord(name)
208		d.mu.Lock()
209		if err != nil {
210			return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't read session catalogue", err)
211		}
212		if d.closing {
213			return nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
214		}
215		stopped, resuming = d.stopped[name]
216	}
217	var createdAt int64
218	var incarnation domain.IncarnationID
219	if !ephemeral {
220		if resuming {
221			// A stopped session is the same lifecycle when resumed; it must retain
222			// the persisted identity rather than receive a fresh timestamp.
223			createdAt = stopped.createdAt
224			incarnation = stopped.incarnation
225			if authoritativeExists {
226				createdAt = authoritative.CreatedAt
227				incarnation = authoritative.IncarnationID
228			}
229			if createdAt > d.lastAllocatedCreatedAt {
230				d.lastAllocatedCreatedAt = createdAt
231			}
232		} else {
233			var err error
234			createdAt, err = d.allocateLifecycleCreatedAtLocked()
235			if err != nil {
236				return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
237			}
238		}
239	}
240	if incarnation == (domain.IncarnationID{}) {
241		var err error
242		incarnation, err = domain.NewSessionLifecycleID(rand.Reader)
243		if err != nil {
244			return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", fmt.Errorf("generate lifecycle identity: %w", err))
245		}
246	}
247
248	if d.ptys == nil {
249		return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable"))
250	}
251	tbSize := contentSize(sz)
252	var names []string
253	var tabRecords []domain.CatalogueTabRecord
254	if resuming && len(stopped.tabRecords) != 0 {
255		tabRecords = append([]domain.CatalogueTabRecord(nil), stopped.tabRecords...)
256		names = make([]string, len(tabRecords))
257		for i, record := range tabRecords {
258			names[i] = record.Name
259		}
260	} else if len(restoredTabNames) > 0 {
261		names = append([]string(nil), restoredTabNames[0]...)
262	}
263	tabs := make([]*tab, 0, max(1, len(names)))
264	for i := range max(1, len(names)) {
265		tabStableID, paneStableID, err := d.newTabPaneStableIDs()
266		if i < len(tabRecords) && tabRecords[i].StableID != "" {
267			tabStableID = string(tabRecords[i].StableID)
268		}
269		if err != nil {
270			closeTabs(tabs)
271			return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
272		}
273		launch := d.shellLaunch(env)
274		lifetime := d.newPaneProcessLifetime(d.serveCtx)
275		pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
276		if err != nil {
277			lifetime.abort()
278			if pty != nil {
279				_ = pty.Close()
280			}
281			closeTabs(tabs)
282			d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
283			return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)
284		}
285		tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)
286		if !lifetime.publish(tb.focusedPane()) {
287			lifetime.abort()
288			_ = pty.Close()
289			closeTabs(tabs)
290			return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", lifetime.ctx.Err())
291		}
292		if i < len(names) {
293			tb.name = names[i]
294		}
295		tabs = append(tabs, tb)
296	}
297
298	id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
299	d.nextID++
300
301	sctx, cancel := context.WithCancel(d.serveCtx)
302	for _, tb := range tabs {
303		tb.ctx, tb.cancel = context.WithCancel(sctx)
304	}
305	lastUsedSeq := uint64(0)
306	if !ephemeral && resuming {
307		lastUsedSeq = stopped.lastUsedSeq
308	}
309	if lastUsedSeq == 0 {
310		lastUsedSeq = d.mruSeq.Add(1)
311	}
312	sess := &session{
313		sessionCore: sessionCore{
314			id:          id,
315			name:        name,
316			ephemeral:   ephemeral,
317			createdAt:   createdAt,
318			incarnation: incarnation,
319		},
320		ctx:          sctx,
321		cancel:       cancel,
322		tabs:         tabs,
323		cwd:          cwd,
324		terminal:     term,
325		env:          env,
326		snapshotWake: d.snapshotWake,
327	}
328	if !ephemeral && name != "" {
329		sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
330	}
331	sess.mruAt.Store(lastUsedSeq)
332	sess.snapEligible.Store(!ephemeral && name != "")
333	// These tabs are still private construction state. Publish every tiled
334	// owner before the session enters d.sessions or any PTY reader starts.
335	for _, tb := range tabs {
336		publishTiledPaneOwners(sess, tb)
337	}
338	var rollbackDurable func() error
339	if !ephemeral && d.persistEnabled {
340		record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, TabRecords: tabRecordsFromTabs(tabs)}
341		if d.recovery == nil {
342			closeTabs(tabs)
343			cancel()
344			return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("durable session authority is not configured"))
345		}
346		var err error
347		if resuming && authoritativeExists {
348			err = d.catalogue.UpdateMetadata(record.MetadataUpdate())
349			if err == nil {
350				rollbackDurable = func() error { return d.catalogue.Replace(name, authoritative) }
351				err = d.catalogue.Sync()
352			}
353		} else {
354			record, err = d.recovery.Create(d.serveCtx, record)
355			if err == nil {
356				rollbackDurable = func() error {
357					current, ok, recordErr := d.catalogue.Record(name)
358					if recordErr != nil {
359						return recordErr
360					}
361					if !ok {
362						return nil
363					}
364					if current.IncarnationID != record.IncarnationID {
365						return errors.New("daemon: created session authority changed before rollback")
366					}
367					return d.catalogue.Delete(name)
368				}
369			}
370		}
371		if err != nil {
372			if rollbackDurable != nil {
373				err = errors.Join(err, rollbackDurable())
374			}
375			closeTabs(tabs)
376			cancel()
377			return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
378		}
379		sess.incarnation = record.IncarnationID
380		if resuming && authoritativeExists && authoritative.Committed != nil && authoritative.IncarnationID == sess.incarnation {
381			checkpoint := *authoritative.Committed
382			sess.snapshotPublishedGeneration = checkpoint.Generation
383			sess.snapshotPublishedCheckpoint = &checkpoint
384		}
385	}
386	if !d.registerSessionLocked(sess) {
387		err := errors.New("daemon: session registry rejected local session")
388		if rollbackDurable != nil {
389			err = errors.Join(err, rollbackDurable())
390		}
391		closeTabs(tabs)
392		cancel()
393		return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
394	}
395	if !ephemeral {
396		// Keep stopped lifecycle authority intact until runtime publication and
397		// its durable side effects have both succeeded.
398		delete(d.stopped, name)
399	}
400	d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
401	for i, tb := range tabs {
402		d.log.Info("tab created", "session", name, "tab", i)
403		d.startTabGoroutines(sess, tb)
404	}
405	return sess, nil
406}
407
408func closeTabs(tabs []*tab) {
409	for _, tb := range tabs {
410		tb.closeAllPanes()
411	}
412}
413
414func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error {
415	if name == "" {
416		return errSessionNameRequired
417	}
418	if err := domain.ValidateSessionName(name); err != nil {
419		return err
420	}
421	sz := ac.sizeSnapshot()
422	d.mu.Lock()
423	if d.closing {
424		d.mu.Unlock()
425		return errors.New("daemon is shutting down")
426	}
427	if d.nameLiveOrStoppedLocked(name) {
428		d.mu.Unlock()
429		return errSessionNameInUse
430	}
431	from.mu.Lock()
432	cwd := from.cwd
433	term := from.terminal
434	env := copyEnvironment(from.env)
435	_, attached := from.attachments[ac]
436	from.mu.Unlock()
437	if !attached {
438		d.mu.Unlock()
439		return errors.New("client detached")
440	}
441
442	newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env)
443	d.mu.Unlock()
444	if err != nil {
445		return err
446	}
447	// The newly created target is registered before the attachment transition, but
448	// remains headless. Freeze/drain must occur with no daemon/routing/session
449	// lock held; the transition revalidates both lifecycles before publication.
450	transition, err := d.transitionAttachment(attachmentTransitionRequest{
451		source: from,
452		target: newSess,
453		next:   ac,
454
455		expectedTransport: ac.transportSnapshot(),
456		ready:             true,
457	})
458	if err != nil {
459		_ = d.killSession(newSess, ports.ReasonSessionKilled, true)
460		return err
461	}
462
463	d.touchMRU(newSess)
464	d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
465	d.deferAttachmentTransitionCleanups(transition)
466	d.firstPaintForTransition(transition.published)
467	return nil
468}
469
470// createSessionAndSwitchForAttachment creates no target until the initiating role
471// ticket has ended and the exact source capability is frozen and revalidated.
472func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionToken, name string) error {
473	if name == "" {
474		return errSessionNameRequired
475	}
476	if err := domain.ValidateSessionName(name); err != nil {
477		return err
478	}
479	if token.sess == nil || token.ac == nil || token.effect == nil {
480		return errAttachmentTransition
481	}
482	token.effect.bindActionEnd(d, "create-session")
483	token.effect.End()
484
485	var created *session
486	transition, err := d.transitionAttachment(attachmentTransitionRequest{
487		source: token.sess, next: token.ac,
488
489		expectedTransport: token.transport, sourceToken: &token, action: "create-session",
490		copySourceEnvironment: true, ready: true,
491		createTargetLocked: func() (*session, error) {
492			source := token.sess
493			if source == nil {
494				return nil, errAttachmentTransition
495			}
496			if d.closing {
497				return nil, errors.New("daemon is shutting down")
498			}
499			if d.nameLiveOrStoppedLocked(name) {
500				return nil, errSessionNameInUse
501			}
502			source.mu.Lock()
503			cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
504			source.mu.Unlock()
505			var createErr error
506			created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.sizeSnapshot(), term, env)
507			return created, createErr
508		},
509	})
510	if err != nil {
511		if created != nil {
512			_ = d.killSession(created, ports.ReasonSessionKilled, true)
513		}
514		return err
515	}
516
517	d.touchMRU(created)
518	d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
519	d.deferAttachmentTransitionCleanups(transition)
520	d.firstPaintForTransition(transition.published)
521	return nil
522}
523
524func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClient) error {
525	sz := ac.sizeSnapshot()
526	d.mu.Lock()
527	if d.closing {
528		d.mu.Unlock()
529		return errors.New("daemon is shutting down")
530	}
531	from.mu.Lock()
532	cwd, term, env := from.cwd, from.terminal, copyEnvironment(from.env)
533	_, attached := from.attachments[ac]
534	from.mu.Unlock()
535	if !attached {
536		d.mu.Unlock()
537		return errors.New("client detached")
538	}
539	name := d.allocEphemeralNameLocked()
540	newSess, err := d.createSessionLockedWithMode(name, true, cwd, sz, term, env)
541	d.mu.Unlock()
542	if err != nil {
543		return err
544	}
545	transition, err := d.transitionAttachment(attachmentTransitionRequest{
546		source: from, target: newSess, next: ac,
547		expectedTransport: ac.transportSnapshot(), ready: true,
548	})
549	if err != nil {
550		_ = d.killSession(newSess, ports.ReasonSessionKilled, true)
551		return err
552	}
553	d.touchMRU(newSess)
554	d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
555	d.deferAttachmentTransitionCleanups(transition)
556	d.firstPaintForTransition(transition.published)
557	return nil
558}
559
560func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(token attachmentConnectionToken) error {
561	if token.sess == nil || token.ac == nil || token.effect == nil {
562		return errAttachmentTransition
563	}
564	token.effect.bindActionEnd(d, "create-ephemeral-session")
565	token.effect.End()
566
567	var created *session
568	transition, err := d.transitionAttachment(attachmentTransitionRequest{
569		source: token.sess, next: token.ac,
570		expectedTransport: token.transport, sourceToken: &token, action: "create-ephemeral-session",
571		copySourceEnvironment: true, ready: true,
572		createTargetLocked: func() (*session, error) {
573			if token.sess == nil || d.closing {
574				return nil, errAttachmentTransition
575			}
576			source := token.sess
577			source.mu.Lock()
578			cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
579			source.mu.Unlock()
580			name := d.allocEphemeralNameLocked()
581			var createErr error
582			created, createErr = d.createSessionLockedWithMode(name, true, cwd, token.ac.sizeSnapshot(), term, env)
583			return created, createErr
584		},
585	})
586	if err != nil {
587		if created != nil {
588			_ = d.killSession(created, ports.ReasonSessionKilled, true)
589		}
590		return err
591	}
592	d.touchMRU(created)
593	d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
594	d.deferAttachmentTransitionCleanups(transition)
595	d.firstPaintForTransition(transition.published)
596	return nil
597}
598
599func (d *Daemon) createTab(sess *session, sz domain.Size) error {
600	return d.createTabForAttachment(sess, nil, sz)
601}
602
603func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ domain.Size) error {
604	sess.mu.Lock()
605	name := sess.name
606	cwd := sess.cwd
607	term := sess.terminal
608	env := copyEnvironment(sess.env)
609	attachments := sess.snapshotAttachmentsLocked()
610	sess.mu.Unlock()
611	tbSize := contentSize(sess.fullViewportSize())
612	tabStableID, paneStableID, err := d.newTabPaneStableIDs()
613	if err != nil {
614		return err
615	}
616	launch := d.shellLaunch(env)
617	lifetime := d.newPaneProcessLifetime(sess.ctx)
618	pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
619	if err != nil {
620		lifetime.abort()
621		if pty != nil {
622			_ = pty.Close()
623		}
624		d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
625		return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
626	}
627	tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)
628	themeClient := ac
629	if themeClient == nil && len(attachments) != 0 {
630		themeClient = attachments[0]
631	}
632	if themeClient != nil {
633		t := d.effectiveTheme(themeClient.getClientTheme())
634		tb.mu.Lock()
635		p := tb.focusedPane()
636		tb.mu.Unlock()
637		if p != nil {
638			p.mu.Lock()
639			applyPaneThemeLocked(p, t, false)
640			p.mu.Unlock()
641		}
642	}
643	d.mu.Lock()
644	if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil {
645		d.mu.Unlock()
646		lifetime.abort()
647		_ = pty.Close()
648		return errors.New("daemon: session closed")
649	}
650	tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
651	sess.mu.Lock()
652	p := tb.focusedPane()
653	if !lifetime.publish(p) {
654		sess.mu.Unlock()
655		d.mu.Unlock()
656		lifetime.abort()
657		_ = pty.Close()
658		return errors.New("daemon: session closed")
659	}
660	// Publish ownership before appending the tab to the visible session.
661	publishPaneOwner(p, sess, tb, 0)
662	sess.tabs = append(sess.tabs, tb)
663	tabIndex := len(sess.tabs) - 1
664	if !sess.ephemeral {
665		d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
666	}
667	if ac != nil {
668		sess.activateAttachmentViewLocked(ac, tabIndex)
669	}
670	sess.mu.Unlock()
671	d.mu.Unlock()
672	d.log.Info("tab created", "session", name, "tab", tabIndex)
673	d.startTabGoroutines(sess, tb)
674	d.activateTab(sess, tb)
675	markSnapshotDirty(sess)
676	return nil
677}
678
679func newTab(pty ports.PTY, sz domain.Size) *tab {
680	return newTabWithStableID(fallbackStableID("t"), fallbackStableID("p"), pty, sz)
681}
682
683func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {
684	return newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, sz, defaultShellTitle)
685}
686
687func newTabWithStableIDAndTitle(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size, title string) *tab {
688	id := layout.PaneID("pane-1")
689	p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title)
690	return &tab{
691		stableID:   tabStableID,
692		tree:       layout.NewTree(id),
693		panes:      map[layout.PaneID]*pane{id: p},
694		nextPaneID: 2,
695		size:       sz,
696	}
697}
698
699func (tb *tab) bumpLayoutGenerationLocked() {
700	tb.layoutGeneration++
701}
702
703func (tb *tab) focusedPane() *pane {
704	if tb == nil || tb.tree == nil || tb.panes == nil {
705		return nil
706	}
707	return tb.panes[tb.tree.Focus]
708}
709
710func (tb *tab) panesSnapshot() []*pane {
711	if tb == nil {
712		return nil
713	}
714	out := make([]*pane, 0, len(tb.panes))
715	for _, p := range tb.panes {
716		out = append(out, p)
717	}
718	return out
719}
720
721func (tb *tab) closeAllPanes() {
722	tb.mu.Lock()
723	panes := tb.panesSnapshot()
724	// Invalidate before cancellation so a concurrently unblocked floating
725	// reader cannot reap a subsequently reused slot.
726	floating := tb.takeFloatingLocked()
727	tb.mu.Unlock()
728	if floating != nil {
729		panes = append(panes, floating)
730	}
731	for _, p := range panes {
732		closePaneProcess(p)
733	}
734}
735
736// tabChromeRows is the vertical chrome (tab bar + status bar) between a
737// client viewport and a tab's content area.
738const tabChromeRows = 2
739
740func tabSize(clientSize domain.Size) domain.Size {
741	return contentSize(clientSize)
742}
743
744// contentSize maps the negotiated viewport to PTY geometry.
745func contentSize(clientSize domain.Size) domain.Size {
746	if !clientSize.Valid() {
747		clientSize = defaultSize
748	}
749	rows := max(clientSize.Rows-tabChromeRows, 1)
750	return domain.Size{Cols: clientSize.Cols, Rows: rows}
751}
752
753// fullViewportSize derives a full client-equivalent viewport from the active
754// tab's retained content size, for actions that need a viewport while no
755// client is attached. It falls back to defaultSize when no valid size exists.
756func (s *session) fullViewportSize() domain.Size {
757	s.mu.Lock()
758	var tb *tab
759	if len(s.tabs) != 0 {
760		tb = s.tabs[0]
761	}
762	s.mu.Unlock()
763
764	var content domain.Size
765	if tb != nil {
766		tb.mu.Lock()
767		content = tb.size
768		tb.mu.Unlock()
769	}
770	if !content.Valid() {
771		return defaultSize
772	}
773	return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}
774}
775
776func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
777	tb.mu.Lock()
778	panes := tb.panesSnapshot()
779	tb.mu.Unlock()
780	for _, p := range panes {
781		d.startPaneGoroutines(sess, tb, p)
782	}
783}
784
785func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
786	if p == nil {
787		return
788	}
789	owner := p.ownerSnapshot()
790	if owner == nil {
791		// Teardown may win after publication but before reader startup. In that
792		// case the process is already closed and no ownerless reader may start.
793		return
794	}
795	if owner.session != sess || owner.tab != tb {
796		return
797	}
798	sess.mu.Lock()
799	name := sess.name
800	sess.mu.Unlock()
801	d.log.Info("pane created", "session", name, "pane", p.id)
802	// Scheduler ownership was removed; this launch creates exactly one reader.
803	d.sessWg.Add(1)
804	go d.readPanePTY(p)
805}
806
807func (s *session) detachIfCurrent(ac *attachedClient) bool {
808	s.mu.Lock()
809	defer s.mu.Unlock()
810	if !attachmentRegisteredLocked(s, ac) || ac.currentAttachmentSession() != s {
811		return false
812	}
813	s.unregisterAttachmentLocked(ac)
814	ac.setSession(nil)
815	return true
816}
817
818// detachIfCurrent publishes terminal attachment invalidation through the attachment
819// gate. Freeze/drain happens before routing or session locks are acquired.
820func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
821	return d.detachIfCurrentTransport(sess, ac, transportSnapshot{})
822}
823
824// detachIfCurrentTransport is detachIfCurrent with an optional exact-link
825// fence. When expected.transport is non-nil, a rebound incarnation of the same
826// attachment object cannot be detached by a stale connection loop.
827func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, expected transportSnapshot) bool {
828	if sess == nil || ac == nil {
829		return false
830	}
831	frozen := freezeAttachmentEffectGates(ac)
832	defer frozen.unfreeze()
833	if d.afterDetachAttachmentEffectsFrozen != nil {
834		d.afterDetachAttachmentEffectsFrozen()
835	}
836	d.notices.routingMu.Lock()
837	sess.mu.Lock()
838	current := attachmentRegisteredLocked(sess, ac) && ac.currentAttachmentSession() == sess
839	if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
840		current = false
841	}
842	if current {
843		sess.unregisterAttachmentLocked(ac)
844		ac.connectionGeneration.Add(1)
845		ac.setSession(nil)
846		ac.invalidateFrozenAttachmentCapability()
847	}
848	sess.mu.Unlock()
849	d.notices.routingMu.Unlock()
850	return current
851}
852
853// detachIfAttachmentCurrent invalidates only the exact attachment generation and transport
854// whose admitted send failed. A queued stale error cannot detach a later
855// attachment publication or a rebound transport on the same attachment object.
856func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool {
857	return d.detachIfAttachmentCurrentUntil(token, nil)
858}
859
860func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {
861	if token.sess == nil || token.ac == nil || token.transport.transport == nil {
862		return false
863	}
864	// Attachments without the session's optional primary render lease still
865	// own an independent connection lifecycle and detach through the same exact
866	// transport/generation fence.
867	if token.lease == nil {
868		return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)
869	}
870	frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
871	defer frozen.unfreeze()
872	if !frozen.acquired || !frozen.drained {
873		return false
874	}
875	if d.afterDetachAttachmentEffectsFrozen != nil {
876		d.afterDetachAttachmentEffectsFrozen()
877	}
878	d.mu.Lock()
879	core := token.sess.core()
880	if core == nil || d.sessions[core.id] != token.sess {
881		d.mu.Unlock()
882		return false
883	}
884	d.notices.routingMu.Lock()
885	core.mu.Lock()
886	coordinator := core.coordinator.Load()
887	if coordinator != nil {
888		coordinator.mu.Lock()
889	}
890	req := attachmentTransitionRequest{
891		source: token.sess, next: token.ac, expectedTransport: token.transport,
892	}
893	current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
894	if current {
895		unregisterAttachmentSessionLocked(token.sess, token.ac)
896		token.ac.connectionGeneration.Add(1)
897		token.ac.setSession(nil)
898		token.ac.invalidateFrozenAttachmentCapability()
899	}
900	if coordinator != nil {
901		coordinator.mu.Unlock()
902	}
903	core.mu.Unlock()
904	d.notices.routingMu.Unlock()
905	d.mu.Unlock()
906	return current
907}
908
909func (d *Daemon) renameSession(sess *session, name string) error {
910	if name == "" {
911		return errSessionNameRequired
912	}
913	if err := domain.ValidateSessionName(name); err != nil {
914		return err
915	}
916
917	// Hold daemon then session state across the durable identity write. The
918	// incarnation-keyed snapshot namespace is never relocated.
919	d.mu.Lock()
920	if taken := d.findByNameLocked(name); taken != nil && taken != sess {
921		d.mu.Unlock()
922		return errSessionNameInUse
923	}
924	sess.mu.Lock()
925	if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {
926		sess.mu.Unlock()
927		d.mu.Unlock()
928		return errSessionNameInUse
929	}
930	oldName := sess.name
931	wasEphemeral := sess.ephemeral
932	createdAt := sess.createdAt
933	priorIncarnation := sess.incarnation
934	if wasEphemeral {
935		var err error
936		createdAt, err = d.allocateLifecycleCreatedAtLocked()
937		if err != nil {
938			sess.mu.Unlock()
939			d.mu.Unlock()
940			return err
941		}
942		if sess.incarnation == (domain.IncarnationID{}) {
943			sess.incarnation, err = domain.NewSessionLifecycleID(rand.Reader)
944			if err != nil {
945				sess.mu.Unlock()
946				d.mu.Unlock()
947				return fmt.Errorf("generate durable identity: %w", err)
948			}
949		}
950	}
951	lastUsedSeq := sess.mruAt.Load()
952	record := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
953	record.Name = name
954	record.CreatedAt = createdAt
955	record.LastUsedSeq = lastUsedSeq
956	rollback := func(err error) error {
957		sess.incarnation = priorIncarnation
958		sess.mu.Unlock()
959		d.mu.Unlock()
960		return err
961	}
962	// Durable named renames are one atomic catalogue batch. The incarnation-keyed
963	// snapshot namespace is intentionally untouched.
964	if (wasEphemeral || oldName != name) && d.persistEnabled {
965		if d.recovery == nil {
966			return rollback(errors.New("durable session authority is not configured"))
967		}
968		{
969			var committed domain.CatalogueRecord
970			var err error
971			if wasEphemeral {
972				committed, err = d.recovery.Create(d.serveCtx, record)
973			} else {
974				committed, err = d.recovery.Rename(d.serveCtx, oldName, name)
975			}
976			if err != nil {
977				return rollback(err)
978			}
979			record = committed
980		}
981	}
982
983	delete(d.stopped, oldName)
984	delete(d.stopped, name)
985	sess.name = name
986	sess.createdAt = createdAt
987	sess.incarnation = record.IncarnationID
988	sess.ephemeral = false
989	sess.snapEligible.Store(name != "")
990	sess.mu.Unlock()
991	d.mu.Unlock()
992	if wasEphemeral {
993		sess.snapshotMu.Lock()
994		sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
995		sess.snapshotMu.Unlock()
996	}
997	markSnapshotDirty(sess)
998	d.publishRenamedSessionRouteIdentity(sess)
999	return nil
1000}
1001
1002// publishRenamedSessionRouteIdentity updates each ready client's exact route
1003// target after a session rename. A renamed session retains its lifecycle but
1004// its old name is no longer attachable, so clients must replace—not retain—the
1005// prior route record.
1006func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) {
1007	for _, ac := range sess.snapshotAttachments() {
1008		if ac.deferRouteIdentityUntilSnapshot() {
1009			continue
1010		}
1011		token := sess.attachmentToken(ac, ac.transport())
1012		if token.ac == nil {
1013			continue
1014		}
1015		effect, admitted := ac.beginAttachmentEffect(token)
1016		if !admitted {
1017			continue
1018		}
1019		token = effect.connectionToken()
1020		err := d.sendCommittedRouteIdentityForAttachment(token)
1021		effect.End()
1022		if err != nil {
1023			d.detachOnAttachmentSendError(token, token.transport.transport)
1024		}
1025	}
1026}
1027
1028func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
1029	if tb == nil {
1030		return errors.New("tab required")
1031	}
1032	d.mu.Lock()
1033	if d.sessions[sess.id] != sess {
1034		d.mu.Unlock()
1035		return errors.New("daemon: session closed")
1036	}
1037	sess.mu.Lock()
1038	if !slices.Contains(sess.tabs, tb) {
1039		sess.mu.Unlock()
1040		d.mu.Unlock()
1041		return errors.New("tab not found")
1042	}
1043	tb.name = name
1044	if !sess.ephemeral {
1045		d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
1046	}
1047	sess.mu.Unlock()
1048	d.mu.Unlock()
1049	return nil
1050}
1051
1052func tabRecordsFromTabs(tabs []*tab) []domain.CatalogueTabRecord {
1053	if len(tabs) == 0 {
1054		return nil
1055	}
1056	records := make([]domain.CatalogueTabRecord, len(tabs))
1057	for i, tb := range tabs {
1058		if tb == nil {
1059			continue
1060		}
1061		records[i] = domain.CatalogueTabRecord{StableID: domain.TabStableID(tb.stableID), Name: tb.name}
1062	}
1063	return records
1064}
1065
1066func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord {
1067	createdAt := s.createdAt
1068	tabNames := make([]string, len(s.tabs))
1069	lastCustom := -1
1070	for i, tb := range s.tabs {
1071		tabNames[i] = tb.name
1072		if tb.name != "" {
1073			lastCustom = i
1074		}
1075	}
1076	if lastCustom == -1 {
1077		tabNames = nil
1078	} else {
1079		tabNames = tabNames[:lastCustom+1]
1080	}
1081	return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames, TabRecords: tabRecordsFromTabs(s.tabs)}
1082}
1083
1084// closeTab is the public close boundary for callers that are not already
1085// inside session.runMutation. Mutation dispatchers use closeTabLocked.
1086func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
1087	if sess == nil {
1088		return layout.ErrNotFound
1089	}
1090	sess.dispatchMu.Lock()
1091	defer sess.dispatchMu.Unlock()
1092	return d.closeTabLocked(sess, tb, repaint)
1093}
1094
1095// closeTabLocked requires sess.dispatchMu before entering daemon or session
1096// locks. Existing runMutation callers use it to avoid nested dispatch locking.
1097func (d *Daemon) closeTabLocked(sess *session, tb *tab, repaint bool) error {
1098	return d.closeTabLockedWithEffect(sess, tb, repaint, nil)
1099}
1100
1101func (d *Daemon) closeTabLockedWithEffect(sess *session, tb *tab, repaint bool, effect *attachmentEffectTicket) error {
1102	if sess == nil || tb == nil {
1103		return layout.ErrNotFound
1104	}
1105	d.mu.Lock()
1106	if d.sessions[sess.id] != sess {
1107		d.mu.Unlock()
1108		return errors.New("daemon: session closed")
1109	}
1110	sess.mu.Lock()
1111	idx := -1
1112	for i, w := range sess.tabs {
1113		if w == tb {
1114			idx = i
1115			break
1116		}
1117	}
1118	if idx == -1 {
1119		sess.mu.Unlock()
1120		d.mu.Unlock()
1121		return errors.New("tab not found")
1122	}
1123	if len(sess.tabs) == 1 {
1124		name := sess.name
1125		sess.mu.Unlock()
1126		d.mu.Unlock()
1127		var err error
1128		if effect != nil {
1129			effect.bindActionEnd(d, "close-tab")
1130			err = d.killSessionForAttachment(sess, ports.ReasonSessionKilled, false, effect.connectionToken(), "close-tab")
1131		} else {
1132			err = d.killSession(sess, ports.ReasonSessionKilled, false)
1133		}
1134		if err != nil {
1135			d.log.Warn("closing last tab failed", "session", name, "err", err)
1136			d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated,
1137				"couldn't close tab: session state not yet saved; try again", err))
1138			return err
1139		}
1140		d.log.Info("tab closed", "session", name, "last", true)
1141		return nil
1142	}
1143	ringing := tb.attention
1144	sess.prepareAttachmentViewsForRemovedTabLocked(tb, idx)
1145	sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
1146	attachments := sess.snapshotAttachmentsLocked()
1147	name := sess.name
1148	if !sess.ephemeral {
1149		d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
1150	}
1151	sess.invalidateViewsLocked()
1152	sess.mu.Unlock()
1153	d.mu.Unlock()
1154	d.log.Info("tab closed", "session", name)
1155	markSnapshotDirty(sess)
1156
1157	d.clearDestroyedTabPreview(tb)
1158	tb.mu.Lock()
1159	panes := tb.panesSnapshot()
1160	tb.mu.Unlock()
1161	for _, ac := range attachments {
1162		ac.pruneCaptureFrames(panes...)
1163	}
1164	if tb.cancel != nil {
1165		tb.cancel()
1166	}
1167	for _, ac := range attachments {
1168		d.teardownFloating(tb, ac)
1169	}
1170	if rc := sess.renderCoordinator(); rc != nil {
1171		for _, p := range panes {
1172			rc.noteSyncPaneRemoved(p)
1173		}
1174	}
1175	tb.closeAllPanes()
1176	if repaint {
1177		for _, ac := range attachments {
1178			d.invalidateRender(sess, ac, true, "session.go")
1179		}
1180	}
1181	if ringing {
1182		d.repaintAllAttachedClients()
1183	}
1184	return nil
1185}
1186
1187// ptyReader drains child output into the VT screen and pokes the dirty channel
1188// (non-blocking: a full channel already means a render is pending). On any read
1189
1190// beginSnapshotPurge commits a logical named-session kill before its live
1191// identity or persisted metadata is removed. It never runs under daemon or
1192// session locks.
1193func (d *Daemon) beginSnapshotPurge(_ string, _ domain.IncarnationID) error {
1194	if d.persistEnabled && d.recovery == nil {
1195		return errors.New("durable session authority is not configured")
1196	}
1197	// The lifecycle coordinator performs the complete ordered protocol after
1198	// snapshot writers have joined.
1199	return nil
1200}
1201
1202// finishSnapshotPurge removes catalogue metadata first, then deletes the
1203// incarnation directory. Startup garbage collection removes the directory if
1204// the second step is interrupted.
1205func (d *Daemon) finishSnapshotPurge(ctx context.Context, name string, _ domain.IncarnationID) error {
1206	if !d.persistEnabled {
1207		return nil
1208	}
1209	if d.recovery == nil {
1210		return errors.New("durable session authority is not configured")
1211	}
1212	if ctx == nil {
1213		ctx = d.serveCtx
1214	}
1215	purgeCtx, cancel := context.WithTimeout(ctx, snapshotFinalFlushTimeout)
1216	defer cancel()
1217	return d.recovery.Delete(purgeCtx, name)
1218}
1219
1220// retryStoppedPurge completes a previously closed session's durable purge.
1221// It keeps the stopped record hidden until all sources and metadata cross their
1222// deletion boundaries.
1223func (d *Daemon) retryStoppedPurge(name string) error {
1224	return d.retryStoppedPurgeContext(d.serveCtx, name)
1225}
1226
1227func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error {
1228	d.mu.Lock()
1229	stopped, ok := d.stopped[name]
1230	if !ok {
1231		d.mu.Unlock()
1232		return nil
1233	}
1234	stopped.purging = true
1235	d.stopped[name] = stopped
1236
1237	if err := d.beginSnapshotPurge(name, stopped.incarnation); err != nil {
1238		d.mu.Unlock()
1239		return err
1240	}
1241	if err := d.finishSnapshotPurge(ctx, name, stopped.incarnation); err != nil {
1242		d.mu.Unlock()
1243		return err
1244	}
1245	if current, ok := d.stopped[name]; ok && current.purging {
1246		delete(d.stopped, name)
1247	}
1248	d.mu.Unlock()
1249	return nil
1250}
1251
1252func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
1253	return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, nil)
1254}
1255
1256type sessionKillAdmission struct {
1257	token  attachmentConnectionToken
1258	action string
1259}
1260
1261type sessionKillParticipants struct {
1262	attachments map[*attachedClient]struct{}
1263	transports  map[*attachedClient]transportSnapshot
1264	effectGates []*attachedClient
1265	interrupts  []attachmentTransportInterrupt
1266	source      *session
1267	sourceToken *attachmentConnectionToken
1268	target      *session
1269}
1270
1271func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge bool, token attachmentConnectionToken, action string) error {
1272	return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action})
1273}
1274
1275// snapshotSessionKillParticipants captures attachment membership under the canonical
1276// architecture locks, but releases every lock before any attachment gate is frozen or
1277// drained. The initiator is part of the same deduplicated global gate set even
1278// when it belongs to a different source session.
1279func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) {
1280	snapshot := sessionKillParticipants{target: target}
1281	if admission != nil {
1282		token := admission.token
1283		if token.sess == nil || token.ac == nil || token.transport.transport == nil {
1284			return sessionKillParticipants{}, false
1285		}
1286		snapshot.source = token.sess
1287		if snapshot.source == nil {
1288			return sessionKillParticipants{}, false
1289		}
1290		snapshot.sourceToken = &admission.token
1291	}
1292
1293	d.mu.Lock()
1294	if d.sessions[target.id] != target || snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source {
1295		d.mu.Unlock()
1296		return sessionKillParticipants{}, false
1297	}
1298	d.notices.routingMu.Lock()
1299	source := snapshot.source
1300	if source == nil {
1301		source = target
1302	}
1303	unlockSessions := lockAttachmentSessions(source, target)
1304	snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments))
1305	snapshot.transports = make(map[*attachedClient]transportSnapshot, len(target.attachments))
1306	for ac := range target.attachments {
1307		snapshot.attachments[ac] = struct{}{}
1308		snapshot.transports[ac] = ac.transportSnapshot()
1309	}
1310
1311	unlockSessions()
1312	d.notices.routingMu.Unlock()
1313	d.mu.Unlock()
1314
1315	seen := make(map[*attachedClient]struct{}, 1+len(snapshot.attachments))
1316	add := func(ac *attachedClient) {
1317		if ac == nil {
1318			return
1319		}
1320		if _, duplicate := seen[ac]; duplicate {
1321			return
1322		}
1323		seen[ac] = struct{}{}
1324		snapshot.effectGates = append(snapshot.effectGates, ac)
1325	}
1326	if admission != nil {
1327		add(admission.token.ac)
1328	}
1329	for ac := range snapshot.attachments {
1330		add(ac)
1331	}
1332	for ac, transport := range snapshot.transports {
1333		if transport.transport != nil {
1334			snapshot.interrupts = append(snapshot.interrupts, attachmentTransportInterrupt{ac: ac, transport: transport})
1335		}
1336	}
1337	return snapshot, true
1338}
1339
1340// sessionKillParticipantsCurrent repeats lifecycle, source-token, and exact
1341// target-membership validation while every discovered attachment gate is frozen.
1342func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool {
1343	d.mu.Lock()
1344	defer d.mu.Unlock()
1345	if d.sessions[snapshot.target.id] != snapshot.target ||
1346		snapshot.source != nil && d.sessions[snapshot.source.id] != snapshot.source {
1347		return false
1348	}
1349	d.notices.routingMu.Lock()
1350	defer d.notices.routingMu.Unlock()
1351	source := snapshot.source
1352	if source == nil {
1353		source = snapshot.target
1354	}
1355	sourceCoordinator := source.renderCoordinator()
1356	targetCoordinator := snapshot.target.renderCoordinator()
1357	unlockSessions := lockAttachmentSessions(source, snapshot.target)
1358	defer unlockSessions()
1359	unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, snapshot.target, targetCoordinator)
1360	defer unlockCoordinators()
1361	return sessionKillParticipantsCurrentLocked(snapshot, sourceCoordinator)
1362}
1363
1364// sessionKillParticipantsCurrentLocked requires the ordered source/target
1365// session and coordinator locks.
1366func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool {
1367	target := snapshot.target
1368	if len(target.attachments) != len(snapshot.attachments) {
1369		return false
1370	}
1371	for ac := range snapshot.attachments {
1372		if _, current := target.attachments[ac]; !current {
1373			return false
1374		}
1375	}
1376
1377	for ac, transport := range snapshot.transports {
1378		if !ac.transportSnapshotCurrent(transport) {
1379			return false
1380		}
1381	}
1382	if snapshot.sourceToken == nil {
1383		return true
1384	}
1385	token := *snapshot.sourceToken
1386	req := attachmentTransitionRequest{
1387		source: token.sess, next: token.ac, expectedTransport: token.transport,
1388	}
1389	return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
1390}
1391
1392// stopInMemoryLifecycle cancels every session producer and closes every PTY
1393// independently of teardown ownership. Repository work may remain blocked, but
1394// repeated shutdown passes cannot leave pane readers or launches running.
1395func (s *session) stopInMemoryLifecycle() {
1396	if s == nil {
1397		return
1398	}
1399	s.lifecycleStopOnce.Do(func() {
1400		s.stopFloatingLaunches()
1401		if s.cancel != nil {
1402			s.cancel()
1403		}
1404		s.mu.Lock()
1405		tabs := append([]*tab(nil), s.tabs...)
1406		s.mu.Unlock()
1407		for _, tb := range tabs {
1408			tb.closeAllPanes()
1409		}
1410	})
1411}
1412
1413func (s *session) teardownChangeLocked() chan struct{} {
1414	if s.teardownChanged == nil {
1415		s.teardownChanged = make(chan struct{})
1416	}
1417	return s.teardownChanged
1418}
1419
1420func (s *session) signalTeardownChangedLocked() {
1421	close(s.teardownChangeLocked())
1422	s.teardownChanged = make(chan struct{})
1423}
1424
1425// beginTeardown waits for ownership without holding teardownMu across teardown.
1426// A false result means Serve's shared shutdown budget expired while another
1427// lifecycle path owned the session. Ordinary callers wait and retry ownership,
1428// preserving failed-teardown and stopped-purge retry semantics.
1429func (s *session) beginTeardown(deadline *snapshotShutdownDeadline) bool {
1430	for {
1431		if deadline != nil {
1432			select {
1433			case <-deadline.Done():
1434				return false
1435			default:
1436			}
1437		}
1438
1439		s.teardownMu.Lock()
1440		if !s.teardownActive && s.moveReservations == 0 {
1441			if deadline != nil {
1442				select {
1443				case <-deadline.Done():
1444					s.teardownMu.Unlock()
1445					return false
1446				default:
1447				}
1448			}
1449			s.teardownActive = true
1450			s.teardownDone = make(chan struct{})
1451			s.signalTeardownChangedLocked()
1452			s.teardownMu.Unlock()
1453			return true
1454		}
1455		s.teardownWaiters++
1456		s.signalTeardownChangedLocked()
1457		changed := s.teardownChangeLocked()
1458		s.teardownMu.Unlock()
1459
1460		timedOut := false
1461		if deadline == nil {
1462			<-changed
1463		} else {
1464			select {
1465			case <-changed:
1466			case <-deadline.Done():
1467				timedOut = true
1468			}
1469		}
1470		s.teardownMu.Lock()
1471		s.teardownWaiters--
1472		s.signalTeardownChangedLocked()
1473		s.teardownMu.Unlock()
1474		if timedOut {
1475			return false
1476		}
1477	}
1478}
1479
1480func (s *session) finishTeardown() {
1481	s.teardownMu.Lock()
1482	s.teardownActive = false
1483	close(s.teardownDone)
1484	s.teardownDone = nil
1485	s.signalTeardownChangedLocked()
1486	s.teardownMu.Unlock()
1487}
1488
1489// killSessionWithSnapshotDeadline shares Serve's shutdown budget with its
1490// terminal checkpoint and coordinator join. A timed-out repository call keeps
1491// only immutable capture state; it never observes closed worker channels or
1492// session-owned cache that teardown has released.
1493func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline, admission *sessionKillAdmission) error {
1494	if sess == nil {
1495		return nil
1496	}
1497	// Discover every attachment whose membership can be invalidated before freezing
1498	// any one of them. The common gate helper deduplicates and freezes the whole
1499	// immutable identity set in the same global order used by transitions.
1500	participants, ok := d.snapshotSessionKillParticipants(sess, admission)
1501	if !ok {
1502		return nil
1503	}
1504	if admission != nil {
1505		d.endActionAttachmentEffect(admission.token.effect, admission.action)
1506	}
1507	action := ""
1508	if admission != nil {
1509		action = admission.action
1510	}
1511	if d.afterAttachmentEffectParticipantsSnapshotted != nil {
1512		d.afterAttachmentEffectParticipantsSnapshotted(action, participants.effectGates)
1513	}
1514	var drainDone func() <-chan struct{}
1515	var drainDeadline *attachmentEffectDrainDeadline
1516	if deadline != nil {
1517		drainDone = deadline.Done
1518	} else {
1519		drainDeadline = newAttachmentEffectDrainDeadline(d.clock)
1520		drainDone = drainDeadline.Done
1521		defer drainDeadline.stop()
1522	}
1523	frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
1524		if d.afterAttachmentEffectGateFrozen != nil {
1525			d.afterAttachmentEffectGateFrozen(action, ac)
1526		}
1527	}}, participants.effectGates...)
1528	defer func() { frozen.unfreeze() }()
1529	if !frozen.acquired {
1530		// The acquisition helper has already rolled back exactly the gates it
1531		// owned. Do not enter architecture locks or publish terminal ownership
1532		// changes unless the complete canonical participant set is frozen.
1533		return nil
1534	}
1535	if !d.sessionKillParticipantsCurrent(participants) {
1536		return nil
1537	}
1538
1539	// Pane EOF, explicit close, and daemon shutdown can all converge on the same
1540	// session. Only one owner may create the terminal mutation and checkpoint;
1541	// later callers observe the completed registry transition instead of
1542	// manufacturing a newer generation that the owner will quarantine. A
1543	// shutdown duplicate abandons this wait when the shared budget expires.
1544	if !sess.beginTeardown(deadline) {
1545		return nil
1546	}
1547	defer sess.finishTeardown()
1548
1549	d.mu.Lock()
1550	current := d.sessions[sess.id]
1551	d.mu.Unlock()
1552	if current != sess {
1553		if purge {
1554			sess.mu.Lock()
1555			name := sess.name
1556			sess.mu.Unlock()
1557			return d.retryStoppedPurge(name)
1558		}
1559		return nil
1560	}
1561
1562	ringing := sess.anyAttention()
1563	sess.mu.Lock()
1564	isEphemeral := sess.ephemeral
1565	name := sess.name
1566	incarnation := sess.incarnation
1567	sess.mu.Unlock()
1568	// Join snapshot publication before changing live identity or deleting the
1569	// incarnation directory, so an older publication cannot recreate it.
1570	if purge && !isEphemeral {
1571		if err := d.beginSnapshotPurge(name, incarnation); err != nil {
1572			return err
1573		}
1574		if d.snapshotRepository != nil {
1575			<-quarantineSnapshotCoordinator(sess)
1576		}
1577	}
1578	if !purge && !isEphemeral && d.persistEnabled {
1579		d.refreshSessionCwd(sess)
1580	}
1581	var terminalSnapshotErr error
1582	retainSnapshotRetry := false
1583	if d.snapsEnabled && !isEphemeral && !purge {
1584		markSnapshotDirty(sess)
1585		sess.snapshotMu.Lock()
1586		terminalGeneration := sess.snapshotGeneration
1587		sess.snapshotMu.Unlock()
1588		if !d.scheduleFinalSnapshot(sess) || !d.waitForSnapshotGenerationWithDeadline(sess, terminalGeneration, deadline) {
1589			sess.mu.Lock()
1590			name := sess.name
1591			sess.mu.Unlock()
1592			terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable, saturated, or timed out", name)
1593			if reason != ports.ReasonServerShutdown {
1594				return terminalSnapshotErr
1595			}
1596			// Preserve any already-queued routine capture until the blocked
1597			// worker can safely discard it. This retains the dirty generation
1598			// and forced retry intent without adding another queue entry.
1599			retainSnapshotRetry = true
1600			d.persistShutdownSnapshotFailure(name, terminalSnapshotErr)
1601		}
1602	}
1603	if !isEphemeral {
1604		// Joining first preserves byte ownership for a publication that is still
1605		// encoding while this session is being torn down. Once Serve's shared
1606		// budget expires, the worker is cancelled by its one final join instead;
1607		// keep the cache reachable because an uncooperative writer may still be
1608		// encoding immutable state.
1609		var quarantineDone <-chan struct{}
1610		if retainSnapshotRetry {
1611			quarantineDone = quarantineSnapshotCoordinatorRetainingQueuedCapture(sess)
1612		} else {
1613			quarantineDone = quarantineSnapshotCoordinator(sess)
1614		}
1615		joined := true
1616		if deadline != nil {
1617			select {
1618			case <-quarantineDone:
1619			case <-deadline.Done():
1620				joined = false
1621			}
1622		} else {
1623			<-quarantineDone
1624		}
1625		if joined {
1626			sess.snapshotMu.Lock()
1627			sess.snapshotChunkCache = nil
1628			sess.snapshotMu.Unlock()
1629		}
1630	}
1631	// Revalidate the exact source capability, target lifecycle, and attachment
1632	// memberships under canonical architecture locks immediately before
1633	// publication. A changed preflight aborts without mutating either lifecycle.
1634	d.mu.Lock()
1635	if d.sessions[sess.id] != sess || participants.source != nil && d.sessions[participants.source.id] != participants.source {
1636		d.mu.Unlock()
1637		return nil
1638	}
1639	d.notices.routingMu.Lock()
1640	source := participants.source
1641	if source == nil {
1642		source = sess
1643	}
1644	sourceCoordinator := source.renderCoordinator()
1645	targetCoordinator := sess.renderCoordinator()
1646	unlockSessions := lockAttachmentSessions(source, sess)
1647	unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, sess, targetCoordinator)
1648	if !sessionKillParticipantsCurrentLocked(participants, sourceCoordinator) {
1649		unlockCoordinators()
1650		unlockSessions()
1651		d.notices.routingMu.Unlock()
1652		d.mu.Unlock()
1653		return nil
1654	}
1655
1656	// d.mu -> routingMu -> ordered sessions -> ordered coordinators are held
1657	// here. Registry removal and every attachment invalidation are one publication
1658	// while the complete immutable gate set remains frozen.
1659	if !d.unregisterSessionLocked(sess) {
1660		unlockCoordinators()
1661		unlockSessions()
1662		d.notices.routingMu.Unlock()
1663		d.mu.Unlock()
1664		return errAttachmentTransition
1665	}
1666	d.clearBarScriptsForSession(sess.id)
1667	d.purgeParkingForSessionLocked(sess)
1668	parkedRetirements := d.purgeParkedForSessionLocked(sess)
1669	attachments := make([]detachedAttachmentSnapshot, 0, len(participants.effectGates))
1670	capture := func(attached *attachedClient) {
1671		if attached == nil {
1672			return
1673		}
1674		attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})
1675		attached.connectionGeneration.Add(1)
1676		attached.setSession(nil)
1677		attached.invalidateFrozenAttachmentCapability()
1678	}
1679	for attached := range sess.attachments {
1680		capture(attached)
1681	}
1682	clear(sess.attachments)
1683	stoppedName := sess.name
1684	stoppedCwd := sess.cwd
1685	createdAt := sess.createdAt
1686	stoppedRecord := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
1687	tabNames := stoppedRecord.TabNames
1688	tabRecords := stoppedRecord.TabRecords
1689	ephemeral := sess.ephemeral
1690	unlockCoordinators()
1691	unlockSessions()
1692	d.notices.routingMu.Unlock()
1693	if !ephemeral {
1694		stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, tabRecords: tabRecords, record: stoppedRecord, state: ports.SessionDown, purging: purge}
1695		d.stopped[stoppedName] = stopped
1696	}
1697	empty := len(d.sessions) == 0
1698	if empty {
1699		// Shutdown is now inevitable and irreversible (doneOnce below): stop
1700		// route from inserting new sessions from this instant, while we still
1701		// hold the same lock route checks under.
1702		d.closing = true
1703	}
1704	d.mu.Unlock()
1705	d.finishParkedAttachmentRetirements(parkedRetirements)
1706	frozen.unfreeze()
1707	frozen = frozenAttachmentEffectGates{}
1708	d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
1709
1710	var purgeErr error
1711	if rc := sess.renderCoordinator(); rc != nil {
1712		// Terminal teardown has two phases: this session owner first prevents any
1713		// new worker registration and detaches all tokens, then stops and waits
1714		// outside coordinator callbacks and session locks.
1715		rc.beginSessionTeardown().finish()
1716		rc.waitForTimerWorkers()
1717	}
1718	for _, attachment := range attachments {
1719		d.unregisterPreview(attachment.ac)
1720		attachment.ac.clearCaptureFrames()
1721	}
1722
1723	// Prevent queued launches from entering Open, cancel the parent context, and
1724	// close PTYs. Serve may already have performed this phase while repository
1725	// work owned by this teardown was blocked.
1726	sess.stopInMemoryLifecycle()
1727	sess.mu.Lock()
1728	tabs := append([]*tab(nil), sess.tabs...)
1729	clipFiles := sess.clipFiles
1730	sess.clipFiles = nil
1731	sess.mu.Unlock()
1732	for _, tb := range tabs {
1733		d.clearDestroyedTabPreview(tb)
1734		for _, attachment := range attachments {
1735			d.teardownFloating(tb, attachment.ac)
1736		}
1737	}
1738	for _, path := range clipFiles {
1739		if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
1740			d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)
1741		}
1742	}
1743	// The coordinator and all panes are now stopped, so no producer can publish
1744	// another generation after this destructive source deletion. Keep the hidden
1745	// stopped record when deletion fails so a repeated live kill can retry.
1746	if !ephemeral && purge {
1747		d.mu.Lock()
1748		sess.mu.Lock()
1749		err := d.finishSnapshotPurge(d.serveCtx, stoppedName, incarnation)
1750		sess.mu.Unlock()
1751		if err != nil {
1752			purgeErr = errors.Join(purgeErr, err)
1753			d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName)
1754		} else if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging {
1755			delete(d.stopped, stoppedName)
1756		}
1757		d.mu.Unlock()
1758	}
1759	if empty {
1760		d.doneOnce.Do(func() { close(d.done) })
1761	} else if ringing {
1762		d.repaintAllAttachedClients()
1763	}
1764
1765	for _, attachment := range attachments {
1766		d.notifyDetachedSnapshotAsync(attachment, reason)
1767	}
1768	return errors.Join(purgeErr, terminalSnapshotErr)
1769}
1770
1771// allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
1772// d.mu.
1773func (d *Daemon) allocEphemeralNameLocked() string {
1774	used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))
1775	for _, s := range d.sessions {
1776		if s != nil {
1777			used[s.name] = struct{}{}
1778		}
1779	}
1780	for name := range d.stopped {
1781		used[name] = struct{}{}
1782	}
1783	for i := 0; ; i++ {
1784		n := strconv.Itoa(i)
1785		if _, taken := used[n]; !taken {
1786			return n
1787		}
1788	}
1789}
1790
1791func (d *Daemon) findByNameLocked(name string) *session {
1792	for _, s := range d.sessions {
1793		if s != nil && s.name == name {
1794			return s
1795		}
1796	}
1797	return nil
1798}
1799
1800func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
1801	if d.findByNameLocked(name) != nil {
1802		return true
1803	}
1804	if _, ok := d.stopped[name]; ok {
1805		return true
1806	}
1807	_, reserved := d.creating[name]
1808	return reserved
1809}
1810
1811func (d *Daemon) cwdSampler(ctx context.Context) {
1812	t := d.clock.NewTimer(5 * time.Second)
1813	defer t.Stop()
1814	for {
1815		select {
1816		case <-ctx.Done():
1817			return
1818		case <-t.C():
1819		}
1820		if d.procCwd != nil {
1821			d.refreshNamedSessionCwds()
1822		}
1823		if err := d.flushCatalogue(); err != nil {
1824			d.log.Warn("flushing session catalogue failed", "err", err)
1825		}
1826		t.Reset(5 * time.Second)
1827	}
1828}
1829
1830func (d *Daemon) refreshNamedSessionCwds() {
1831	d.mu.Lock()
1832	sessions := d.sessionsSnapshotLocked()
1833	d.mu.Unlock()
1834	for _, sess := range sessions {
1835		sess.mu.Lock()
1836		ephemeral := sess.ephemeral
1837		sess.mu.Unlock()
1838		if !ephemeral {
1839			d.refreshSessionCwd(sess)
1840		}
1841	}
1842}
1843
1844func (d *Daemon) refreshSessionCwd(sess *session) {
1845	if d.procCwd == nil {
1846		return
1847	}
1848	tb := sess.firstTab()
1849	if tb == nil {
1850		return
1851	}
1852	tb.mu.Lock()
1853	p := tb.focusedPane()
1854	tb.mu.Unlock()
1855	if p == nil {
1856		return
1857	}
1858	cwd, err := d.procCwd(p.pty.Pid())
1859	if err != nil || cwd == "" {
1860		return
1861	}
1862	d.mu.Lock()
1863	if d.sessions[sess.id] != sess {
1864		d.mu.Unlock()
1865		return
1866	}
1867	sess.mu.Lock()
1868	if sess.cwd == cwd {
1869		sess.mu.Unlock()
1870		d.mu.Unlock()
1871		return
1872	}
1873	sess.cwd = cwd
1874	if !sess.ephemeral {
1875		d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
1876	}
1877	sess.mu.Unlock()
1878	d.mu.Unlock()
1879	markSnapshotDirty(sess)
1880}
1881
1882// childEnv retains the daemon-environment helper for daemon-local legacy callers.
1883// Interactive PTY launch paths use childEnvFrom with their session snapshot.
1884func (d *Daemon) childEnv(name, tabStableID, paneStableID string, term terminalEnv) []string {
1885	return childEnvFrom(d.baseEnv, name, tabStableID, paneStableID, term)
1886}
1887
1888func copyEnvironment(env []string) []string {
1889	return append([]string(nil), env...)
1890}
1891
1892func environmentEntry(entry string) (name, value string, ok bool) {
1893	name, value, ok = strings.Cut(entry, "=")
1894	return name, value, ok
1895}
1896
1897func terminalEnvFromEnvironment(env []string) terminalEnv {
1898	var term, colorTerm string
1899	for _, entry := range env {
1900		name, value, ok := environmentEntry(entry)
1901		if !ok {
1902			continue
1903		}
1904		switch name {
1905		case "TERM":
1906			term = value
1907		case "COLORTERM":
1908			colorTerm = value
1909		}
1910	}
1911	return terminalEnv{TrueColor: ports.DetectTrueColor(term, colorTerm)}
1912}
1913
1914const defaultShellCommand = "/bin/sh"
1915const defaultShellTitle = "sh"
1916
1917type shellLaunchSpec struct {
1918	command string
1919	args    []string
1920	title   string
1921}
1922
1923// shellLaunch resolves the command and display fallback for one normal PTY.
1924// Both values come from the same client environment snapshot (or explicit
1925// daemon override), so headless catalogues identify the shell they actually run.
1926func (d *Daemon) shellLaunch(env []string) shellLaunchSpec {
1927	command := shellFromEnvironment(env)
1928	var args []string
1929	if d.shellOverride {
1930		command = d.shell
1931		args = append([]string(nil), d.shellArgs...)
1932	}
1933	return shellLaunchSpec{command: command, args: args, title: shellDisplayName(command)}
1934}
1935
1936func shellDisplayName(command string) string {
1937	title := filepath.Base(command)
1938	if title == "." || title == string(filepath.Separator) || title == "" {
1939		return command
1940	}
1941	return title
1942}
1943
1944func shellFromEnvironment(env []string) string {
1945	shell := ""
1946	for _, entry := range env {
1947		if name, value, ok := environmentEntry(entry); ok && name == "SHELL" {
1948			shell = value
1949		}
1950	}
1951	if shell == "" {
1952		return defaultShellCommand
1953	}
1954	return shell
1955}
1956
1957// childEnvFrom preserves the supplied environment byte-for-byte and in order,
1958// except for exact reserved variable names which vev owns.
1959func childEnvFrom(env []string, name, tabStableID, paneStableID string, term terminalEnv) []string {
1960	out := make([]string, 0, len(env)+4)
1961	for _, entry := range env {
1962		key, _, ok := environmentEntry(entry)
1963		if ok && (key == "TERM" || key == "COLORTERM" || key == "TERM_PROGRAM" || key == "VEV") {
1964			continue
1965		}
1966		out = append(out, entry)
1967	}
1968	if term.TrueColor {
1969		out = append(out, "TERM=xterm-direct", "COLORTERM=truecolor")
1970	} else {
1971		out = append(out, "TERM=xterm-256color")
1972	}
1973	return append(out,
1974		"TERM_PROGRAM=vev",
1975		"VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID,
1976	)
1977}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close