daemon.go RawHistoryFolder

1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5	"context"
6	cryptorand "crypto/rand"
7	"encoding/binary"
8	"errors"
9	"log/slog"
10	"math"
11	"os"
12	"reflect"
13	"sort"
14	"sync"
15	"sync/atomic"
16	"time"
17
18	"github.com/bnema/vev/internal/domain"
19	"github.com/bnema/vev/internal/domain/terminalcap"
20	"github.com/bnema/vev/internal/ports"
21	"github.com/bnema/vev/internal/protocol"
22	"github.com/bnema/vev/internal/usecase/keys"
23	recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
24)
25
26// Scheduler debounce bounds. Idle updates use the minimum for low latency;
27// sustained floods step toward the maximum to reduce frame/syscall pressure.
28const (
29	minDebounceInterval   = 2 * time.Millisecond
30	maxSyncUpdateDuration = 500 * time.Millisecond
31)
32
33const debounceInterval = minDebounceInterval
34
35// ptyReadBufSize is the PTY reader's read buffer.
36const ptyReadBufSize = 32 * 1024
37
38// New and restored panes have independent line and uncompressed logical-byte
39// ceilings. Visible primary and alternate screens are outside this budget.
40const (
41	defaultScrollbackRows  = domain.DefaultScrollbackLines
42	defaultScrollbackBytes = domain.DefaultScrollbackMegabytes * 1_000_000
43)
44
45// detachNotifyTimeout bounds the best-effort Detached notification on the
46// detach/kill/shutdown paths: if a wedged client (full kernel send buffer)
47// blocks the write for this long, its transport is force-closed, which fails
48// the in-flight send. Teardown is never gated on a client draining its socket.
49const detachNotifyTimeout = time.Second
50
51// maxUnackedOutputStates caps how many output states may be in flight (sent but
52// not yet acked by the client) before paint defers rather than composing
53// another diff. It bounds the daemon's paint rate to the client's ack rate, so
54// heavy output degrades to lower fps on a slow link instead of overflowing the
55// transport. It must stay well under the datagram carriage's 32-frame client
56// window so its reliable queue never fills from painting alone.
57const maxUnackedOutputStates = protocol.MaxOutputWindow
58
59// normalizeOutputWindow bounds the untrusted Hello value. Zero deliberately
60// means the legacy/default window, so malformed or absent values remain safe.
61func normalizeOutputWindow(window uint8) uint8 {
62	if window == 0 || window > maxUnackedOutputStates {
63		return maxUnackedOutputStates
64	}
65	return window
66}
67
68const defaultResumeParkGrace = 15 * time.Minute
69
70// defaultSuspendedSafetyExpiry bounds how long the daemon retains a suspended
71// attachment. Suspension keeps transport ownership and session membership but
72// removes interactive authority, so a client that never returns must not keep
73// the underlying process alive indefinitely. Expiry is a terminal, no-notice
74// eviction that never mints a resume credential.
75const defaultSuspendedSafetyExpiry = 24 * time.Hour
76
77// defaultSize is retained for headless layout helpers that have no client
78// window; Hello routing rejects invalid dimensions instead of using it.
79var defaultSize = domain.Size{Cols: 80, Rows: 24}
80
81type Daemon struct {
82	mu       sync.Mutex
83	sessions map[domain.SessionID]*session
84	inactive map[string]inactiveSession
85	// creating reserves names while durable creation I/O runs without mu.
86	creating map[string]struct{}
87	nextID   uint64
88	// lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
89	// mark. It is guarded by mu and prevents a wall-clock regression from
90	// reusing a lifecycle identity.
91	lastAllocatedCreatedAt int64
92	mruSeq                 atomic.Uint64
93	creationRequestSeq     atomic.Uint64
94	// closing marks that explicit daemon shutdown (KillDaemon/process
95	// cancellation) has irreversibly begun. It is set under mu when
96	// shutdownAll starts, and checked by route under the same mutex — so a Hello
97	// racing shutdown can never insert a new session that nobody would tear
98	// down. Neither an empty registry (final session removal) nor a KillAll
99	// purge sets it: the daemon survives empty→occupied→empty and survives a
100	// purge, and only an explicit stop/process cancellation ends it.
101	closing bool
102
103	// moveLifecycleMu is the daemon-level admission gate for transferable
104	// ownership changes. It is acquired before any session teardownMu, and is
105	// never held while a move, teardown, or external operation runs.
106	moveLifecycleMu      sync.Mutex
107	moveLifecycleClosing bool
108	moveLifecycleActive  uint
109	moveLifecycleChanged chan struct{}
110	// paneProcessCtx roots transferable pane processes outside any one session.
111	// Shutdown cancels it only after the global move gate drains.
112	paneProcessCtx    context.Context
113	paneProcessCancel context.CancelFunc
114
115	// purgeAllMu grants one daemon-wide purge (KillAll) owner at a time so two
116	// concurrent kill-all requests never interleave their exact lifecycle sets.
117	// It is not an admission gate; see purgeAdmissionClosing below.
118	purgeAllMu sync.Mutex
119	// purgeAdmissionClosing is the transient KillAll admission gate. Unlike
120	// closing and moveLifecycleClosing it is re-openable: purgeAllSessions sets
121	// it under mu, removes exactly the live and stopped/broken/durable records
122	// captured at that instant, then clears it so the daemon keeps serving.
123	// Creation and restoration wait on the changed channel (a blocked writer
124	// keeps new admissions out), while moves and internal creations reject, so
125	// no transition can be inserted into the purge's exact lifecycle set.
126	// purgeAdmissionActive counts admitted transitions the purge must drain.
127	// purgeEpoch increments once at each purge that acquires the gate (after the
128	// drain) so a restoration worker blocked on admission can detect a purge
129	// that completed while it waited and must not resurrect the records captured
130	// before that purge.
131	// All three fields are guarded by mu.
132	purgeAdmissionClosing bool
133	purgeAdmissionActive  uint
134	purgeAdmissionChanged chan struct{}
135	purgeEpoch            uint64
136
137	// notifies holds one completion channel per in-flight async Detached
138	// notification (guarded by mu, pruned on insert). Channels rather than a
139	// WaitGroup: notifications are spawned from arbitrary goroutines while
140	// Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent
141	// with Wait.
142	notifies []chan struct{}
143	// resume owns parked and in-flight parking resume credentials. Guarded by mu.
144	resume resumeCredentials
145	// suspended retains one daemon-owned safety expiry per suspended
146	// attachment. Expiry is exact: the record carries the suspension's
147	// lifecycle generation and transport incarnation, so a later activation,
148	// re-suspension, detach, or session teardown can never be retired by a
149	// stale timer. Guarded by mu.
150	suspended map[*attachedClient]*suspendedAttachmentRetention
151	// graphics reserves attachment/session-scoped Kitty ID blocks. Guarded by
152	// mu; see graphicsNamespacePool.
153	graphics graphicsNamespacePool
154
155	attnMu    sync.Mutex
156	animFrame int
157	animWake  chan struct{}
158
159	// paletteHistory is the daemon-wide recent-command order; its mu is a leaf.
160	paletteHistory paletteHistory
161	// daemonTestHooks holds deterministic test seams; production leaves every
162	// hook nil.
163	daemonTestHooks
164	ptys                     ports.PTYFactory
165	clock                    ports.Clock
166	log                      *slog.Logger
167	runtimeObserver          ports.RuntimeObserver
168	baseEnv                  []string
169	shell                    string
170	shellArgs                []string
171	shellOverride            bool
172	persistEnabled           bool
173	catalogue                ports.Catalogue
174	catalogueRecords         []domain.CatalogueRecord
175	catalogueRecordsProvided bool
176	// snapshotRepository is the sole checkpoint storage contract.
177	snapshotRepository        ports.SnapshotRepository
178	recovery                  *recoveryusecase.Coordinator
179	snapshotGarbageCollection bool
180	snapsEnabled              bool
181	noticeStore               ports.NoticeStore
182	// snapshots owns the snapshot worker, the durable-writer lifecycle
183	// signals, and the global persistence-failure notice state.
184	snapshots               snapshotWorker
185	shutdownNoticeMu        sync.Mutex
186	shutdownNoticedSessions map[string]struct{}
187	restoreDone             chan struct{}
188	restoreOnce             sync.Once
189	// proc inspects pane processes (cwd, foreground command, argv). Nil means
190	// no inspection is available; see WithProcessInspector and WithCwdReader.
191	proc      ports.ProcessInspector
192	dirOrHome func(string) string
193	// bindings is read by keys.Router through its own atomic pointer, so it is
194	// published next to (not inside) the runtime config snapshot.
195	bindings atomic.Pointer[keys.Bindings]
196	// config is the immutable runtime configuration snapshot, published with
197	// defaults by New and replaced by every ApplyConfig; read it through
198	// currentRuntimeConfig.
199	config                atomic.Pointer[runtimeConfig]
200	barScripts            *barScriptState
201	notices               *noticeCenter
202	resumeParkGrace       time.Duration
203	suspendedSafetyExpiry time.Duration
204	// tempDir overrides os.TempDir() for clipboard-image-transfer writes
205	// (see clipboard.go); empty means use os.TempDir().
206	tempDir string
207
208	serveCtx    context.Context
209	serveCancel context.CancelFunc
210
211	// hardCtx force-closes connection transports on shutdown, but only after
212	// shutdownAll has delivered graceful Detached notices — keeping it separate
213	// from serveCtx so a parent-context cancel never races the notice.
214	hardCtx    context.Context
215	hardCancel context.CancelFunc
216
217	// done is closed exactly once by an explicit daemon stop (shutdownAll) to
218	// break Serve's accept loop. It is deliberately not closed by final session
219	// removal or by a KillAll purge: an empty registry and a purged registry
220	// both keep the daemon serving.
221	done     chan struct{}
222	doneOnce sync.Once
223
224	// sessWg owns attention animation, bar-script polling, CWD sampling,
225	// snapshot save/restore, and floating-session launch goroutines.
226	sessWg sync.WaitGroup
227	connWg sync.WaitGroup // per-connection handler goroutines
228	// attachmentCleanupWg owns replacement transport closes and retired render
229	// worker joins. Only connection handlers add work; Serve waits for those
230	// handlers before joining this group, so no Add races its terminal Wait.
231	attachmentCleanupWg sync.WaitGroup
232}
233
234// daemonTestHooks are deterministic seams that let tests pause or observe the
235// daemon inside narrow concurrency windows. Production never sets them, and no
236// hook may perform external work while locks are held.
237type daemonTestHooks struct {
238	// beforeRecentSessionHandoff is a deterministic test seam for the narrow
239	// interval between JRS validation and its committed hand-off.
240	beforeRecentSessionHandoff func()
241	// beforeCopyModeRevalidate is a deterministic test seam between staging a
242	// copy-mode candidate and revalidating its pane membership.
243	beforeCopyModeRevalidate func()
244	// beforeCopyMouseMap is a deterministic seam after an immutable copy-input
245	// snapshot is captured and before its mapped position is applied.
246	beforeCopyMouseMap func()
247	// beforeSessionResizePublication runs after final external PTY validation
248	// and before coordinator epoch admission. It is a deterministic regression
249	// seam for stale resize publication.
250	beforeSessionResizePublication func()
251	// beforeResizeOwnerPostEffect pauses after a resize's optimistic owner check
252	// and immediately before a post-commit effect is published. Tests use it to
253	// move a pane through the ordered resize fences at the former TOCTOU window.
254	beforeResizeOwnerPostEffect func(resizeOwnerPostEffect)
255	// afterConnectionSessionSnapshot is a deterministic test seam after the
256	// connection loop reads its current session and before it captures that role.
257	afterConnectionSessionSnapshot func(*session)
258	// afterAttachmentFrameDispatch is a deterministic test seam after the
259	// connection loop snapshots a token and before a decoded frame takes effect.
260	afterAttachmentFrameDispatch func(attachmentCapability)
261	// afterAttachmentEffectParticipantsSnapshotted observes immutable role-gate
262	// participants after architecture preflight locks are released and before
263	// the globally ordered freeze begins.
264	afterAttachmentEffectParticipantsSnapshotted func(string, []*attachedClient)
265	// afterAttachmentEffectGateFrozen observes each participant after it is frozen in
266	// immutable identity order and before drain/publication continues.
267	afterAttachmentEffectGateFrozen func(string, *attachedClient)
268	// afterAttachmentEffectsFrozen observes the lock-free boundary after all affected
269	// attachment gates are frozen and drained, before architecture publication.
270	afterAttachmentEffectsFrozen func()
271	// afterPurgeAdmission is a deterministic test seam that runs after a KillAll
272	// purge has closed purge admission and taken the closing fence, while it
273	// still owns purgeAllMu. It lets tests observe purge serialization.
274	afterPurgeAdmission func()
275	// beforePurgeLiveSessionKill is a deterministic test seam that runs after a
276	// KillAll purge's per-unit shutdown precheck and before that live unit
277	// attempts teardown ownership. Tests use it to let an explicit stop win that
278	// exact window, proving a stop-owned unit is never reported as a success.
279	beforePurgeLiveSessionKill func(*session)
280	// afterSessionKillQuarantine is a deterministic test seam that runs while a
281	// teardown owns the session and its snapshot coordinator is quarantined, but
282	// before the pre-publication revalidation. Tests use it to change the
283	// participant set in that window and prove a post-quarantine abort rolls the
284	// quarantine back so a surviving session keeps checkpointing.
285	afterSessionKillQuarantine func(*session)
286	// beforeAttachmentTransitionIdentityAdmission is a deterministic test seam
287	// after a ready transition published its capability and before the committed
288	// route identity is admitted, so tests can supersede that exact capability in
289	// the window before the transition's first paint and completion.
290	beforeAttachmentTransitionIdentityAdmission func(attachmentCapability)
291	// afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order
292	// seam used by transition validation tests.
293	afterAttachmentTransitionCoordinatorsLocked func()
294	// beforeMoveDispatch, afterMoveLifecycleReserved, and
295	// afterMovePaneSourceSnapshot are test-only seams. They run before dispatch
296	// admission, after lifecycle reservation, and after the pre-fence source
297	// attachment snapshot respectively. beforeMovePaneCommit runs inside the
298	// non-failing publication section. None is set in production, and none may
299	// perform external work while locks are held.
300	beforeMoveDispatch                        func()
301	afterMoveLifecycleReserved                func()
302	afterMoveLifecycleGateBeforeTeardownLocks func()
303	afterMovePaneSourceSnapshot               func()
304	afterMoveTabSourceSnapshot                func()
305	beforeMovePaneCommit                      func()
306	beforeMoveTabCommit                       func()
307	// beforeMoveFollowCompletion is a deterministic test seam after the composite
308	// follow's first paint and before the postcommit admits the capability that
309	// reports completion, so tests can supersede the published capability.
310	beforeMoveFollowCompletion func(attachmentCapability)
311	// afterDetachAttachmentEffectsFrozen observes terminal detach after it wins the
312	// attachment gate but before it checks session ownership.
313	afterDetachAttachmentEffectsFrozen func()
314	// beforeClientGoneDetach pauses clientGone after the stale-transport
315	// precheck and before exact transport/incarnation detach validation.
316	beforeClientGoneDetach func()
317	// beforeMarkParkingInFlight pauses resumeLiveAttachment after the live
318	// credential matches and before markParkingInFlight, so tests can win
319	// explicit detach or session removal before late marker publication.
320	beforeMarkParkingInFlight func()
321	// afterClientGoneDetach pauses finishClientGone after detach won and before
322	// parkAttachment. The parking-in-flight marker must already be published.
323	afterClientGoneDetach func()
324	// afterResumeLiveDetach pauses resumeLiveAttachment after detach won and
325	// before parkAttachment. The parking-in-flight marker must already be
326	// published so a concurrent same-token resume can wait the gap.
327	afterResumeLiveDetach func()
328	// afterParkingWaitArmed observes waitParkingInFlight after it has resolved a
329	// matching in-flight entry and before it blocks on done.
330	afterParkingWaitArmed func()
331	// beforeResumeParkedSendMu pauses resumeParked after the initial parked
332	// lookup and before the attachment send lock, so tests can consume or
333	// replace the credential while the handshake waits.
334	beforeResumeParkedSendMu func()
335	// afterAttachmentEffectAdmitted is a deterministic test seam after a frame/paint
336	// reserves its exact capability and before its first observable mutation.
337	afterAttachmentEffectAdmitted func(attachmentCapability)
338	// beforeFirstPaintSendWait is a test-only seam immediately before a
339	// transition first paint waits for the attachment send lock.
340	beforeFirstPaintSendWait func(attachmentCapability)
341	// afterDelayedKeyEffectAttempt observes whether a timer callback acquired a
342	// fresh exact capability before producing PTY, action, or overlay effects.
343	afterDelayedKeyEffectAttempt func(bool)
344	// afterActionAttachmentEffectEnded observes action-specific admission release. It
345	// is a deterministic seam for proving no role-bound mutation follows release.
346	afterActionAttachmentEffectEnded func(string)
347	// beforeAttachmentSendErrorCleanup pauses asynchronous render-failure retirement
348	// after the render ticket ends and before exact lifecycle validation.
349	beforeAttachmentSendErrorCleanup func(attachmentCapability)
350	afterAttachmentSendErrorCleanup  func()
351	// beforeSuspendedExpiryFreeze pauses the safety expiry after its timer fired
352	// and before it freezes the attachment effect gate, so tests can win
353	// activation, disconnect, or replacement first. It is a test-only seam.
354	beforeSuspendedExpiryFreeze func(*attachedClient)
355}
356
357type parkedAttachment struct {
358	sess              *session
359	ac                *attachedClient
360	pickerGeneration  uint64
361	paletteGeneration uint64
362	timer             ports.Timer
363	claimed           bool
364	done              chan struct{}
365	doneOnce          sync.Once
366}
367
368// parkingAttachment is the observable detach→park lifecycle for one resume
369// credential. done closes when the token is published into parked or parking
370// is abandoned.
371type parkingAttachment struct {
372	sess     *session
373	ac       *attachedClient
374	done     chan struct{}
375	doneOnce sync.Once
376}
377
378func (p *parkingAttachment) closeDone() {
379	if p == nil || p.done == nil {
380		return
381	}
382	p.doneOnce.Do(func() { close(p.done) })
383}
384
385// inactiveSession is the durable authority for a named session without a live
386// runtime. It remains present while healthy-down, restoring, broken, degraded,
387// or purge-fenced; callers must use its predicates instead of inferring those
388// states from map membership.
389type inactiveSession struct {
390	name        string
391	cwd         string
392	createdAt   int64
393	incarnation domain.IncarnationID
394	lastUsedSeq uint64
395	tabNames    []string
396	tabRecords  []domain.CatalogueTabRecord
397	purging     bool
398	record      domain.CatalogueRecord
399	state       protocol.SessionState
400	restoreDone chan struct{}
401}
402
403func (s inactiveSession) restorePending() bool {
404	if s.restoreDone == nil {
405		return false
406	}
407	select {
408	case <-s.restoreDone:
409		return false
410	default:
411		return true
412	}
413}
414
415func (s inactiveSession) broken() bool {
416	return s.state == protocol.SessionBroken || s.record.DegradedReason != ""
417}
418
419func (s inactiveSession) visible() bool {
420	return !s.purging
421}
422
423func (s inactiveSession) canResume() bool {
424	return s.visible() && s.state == protocol.SessionDown && !s.broken()
425}
426
427func (s inactiveSession) sameLifecycle(other inactiveSession) bool {
428	return s.name == other.name && s.createdAt == other.createdAt && s.incarnation == other.incarnation
429}
430
431type Option func(*Daemon)
432
433// WithRuntimeObserver accepts only a composition-root serialized observer.
434// The application owns its lifecycle; the daemon never creates or closes a
435// second reporting worker around it.
436func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
437	return func(d *Daemon) { d.runtimeObserver = observer }
438}
439
440// WithShell overrides the command (and its args) each session spawns. The
441// default is $SHELL (or /bin/sh) with no arguments; tests use this to run a
442// deterministic program.
443func WithShell(cmd string, args []string) Option {
444	return func(d *Daemon) {
445		d.shell = cmd
446		d.shellArgs = args
447		d.shellOverride = true
448	}
449}
450
451// WithCatalogue installs the singular catalogue and the strictly opened
452// records that define the daemon's expected-session registry at startup.
453func WithCatalogue(catalogue ports.Catalogue, records []domain.CatalogueRecord) Option {
454	return func(d *Daemon) {
455		d.catalogue = catalogue
456		d.persistEnabled = catalogue != nil
457		d.catalogueRecords = append([]domain.CatalogueRecord(nil), records...)
458		d.catalogueRecordsProvided = true
459	}
460}
461
462func (d *Daemon) catalogueRecord(name string) (domain.CatalogueRecord, bool, error) {
463	if d == nil || d.catalogue == nil {
464		return domain.CatalogueRecord{}, false, nil
465	}
466	return d.catalogue.Record(name)
467}
468
469// markCatalogueDirty buffers metadata. The periodic catalogue timer, the next
470// identity write, or shutdown provides the durability barrier.
471func (d *Daemon) markCatalogueDirty(update domain.CatalogueMetadataUpdate) {
472	if d == nil || d.catalogue == nil {
473		return
474	}
475	if err := d.catalogue.UpdateMetadata(update); err != nil {
476		d.log.Warn("buffering session metadata failed", "err", err, "session", update.Name)
477	}
478}
479
480func (d *Daemon) flushCatalogue() error {
481	if d == nil || d.catalogue == nil {
482		return nil
483	}
484	return d.catalogue.Sync()
485}
486
487// WithRecoveryCoordinator installs the durable recovery coordinator.
488func WithRecoveryCoordinator(coordinator *recoveryusecase.Coordinator) Option {
489	return func(d *Daemon) {
490		d.recovery = coordinator
491	}
492}
493
494// WithSnapshotRepository enables content-addressed incremental snapshots.
495func WithSnapshotRepository(repository ports.SnapshotRepository) Option {
496	return func(d *Daemon) {
497		if isNilSnapshotRepository(repository) {
498			return
499		}
500		d.snapshotRepository = repository
501		d.snapsEnabled = true
502	}
503}
504
505func isNilSnapshotRepository(repository ports.SnapshotRepository) bool {
506	if repository == nil {
507		return true
508	}
509	value := reflect.ValueOf(repository)
510	switch value.Kind() {
511	case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
512		return value.IsNil()
513	default:
514		return false
515	}
516}
517
518// WithNoticeStore enables persisting undeliverable notices across daemon
519// restarts. A nil store keeps the daemon in no-op notice-persistence mode.
520func WithNoticeStore(store ports.NoticeStore) Option {
521	return func(d *Daemon) { d.noticeStore = store }
522}
523
524// WithSnapshotGarbageCollection runs background snapshot GC through the
525// recovery coordinator installed by WithRecoveryCoordinator.
526func WithSnapshotGarbageCollection() Option {
527	return func(d *Daemon) { d.snapshotGarbageCollection = true }
528}
529
530// WithCwdReader overrides the process cwd reader used for persistence tests.
531// Every other inspection keeps using the installed process inspector, or stays
532// unavailable when none is installed.
533func WithCwdReader(fn func(int) (string, error)) Option {
534	return func(d *Daemon) {
535		if fn != nil {
536			d.proc = cwdOverrideInspector{base: unwrapCwdOverride(d.proc), cwd: fn}
537		}
538	}
539}
540
541// WithProcessInspector installs the platform process-inspection implementation.
542func WithProcessInspector(ins ports.ProcessInspector) Option {
543	return func(d *Daemon) {
544		if ins == nil {
545			return
546		}
547		d.proc = ins
548	}
549}
550
551// WithDirOrHome installs path fallback behavior from the application layer.
552func WithDirOrHome(fn func(string) string) Option {
553	return func(d *Daemon) {
554		if fn != nil {
555			d.dirOrHome = fn
556		}
557	}
558}
559
560// WithTempDir overrides the directory clipboard-image-transfer writes temp
561// files into (production default: os.TempDir()); tests use this with
562// t.TempDir() so writes are isolated and auto-cleaned.
563func WithTempDir(dir string) Option {
564	return func(d *Daemon) {
565		d.tempDir = dir
566	}
567}
568
569// WithBarScriptCommandRunner installs the shell command runner used by bar scripts.
570func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option {
571	return func(d *Daemon) {
572		if runner != nil {
573			d.barScripts.runner = barScriptRunner{runner: runner}
574		}
575	}
576}
577
578// WithResumeParkGrace overrides how long detached resume-capable clients stay
579// parked for reconnection. Non-positive durations keep the default.
580func WithResumeParkGrace(grace time.Duration) Option {
581	return func(d *Daemon) {
582		if grace > 0 {
583			d.resumeParkGrace = grace
584		}
585	}
586}
587
588// WithSuspendedSafetyExpiry overrides how long the daemon retains a suspended
589// attachment before terminal eviction. Non-positive durations keep the default.
590// Fake-clock tests set a short duration so the real timer path stays under test.
591func WithSuspendedSafetyExpiry(expiry time.Duration) Option {
592	return func(d *Daemon) {
593		if expiry > 0 {
594			d.suspendedSafetyExpiry = expiry
595		}
596	}
597}
598
599// WithConfig applies the initial user configuration.
600func WithConfig(cfg domain.Config) Option {
601	return func(d *Daemon) {
602		d.ApplyConfig(cfg)
603	}
604}
605
606// allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller
607// holds d.mu. The persisted high-water mark is loaded by New before any named
608// session can be resumed or created.
609func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) {
610	now := d.nowUnixNano()
611	if now <= d.lastAllocatedCreatedAt {
612		if d.lastAllocatedCreatedAt == math.MaxInt64 {
613			return 0, errors.New("daemon: lifecycle identities exhausted")
614		}
615		now = d.lastAllocatedCreatedAt + 1
616	}
617	d.lastAllocatedCreatedAt = now
618	return now, nil
619}
620
621func (d *Daemon) nowUnixNano() int64 {
622	return d.daemonNow().UnixNano()
623}
624
625// daemonNow reports the daemon clock. New defaults a nil clock to the
626// system clock, so the fallback below only serves hand-built fixtures
627// and stays explicit about its provenance instead of hiding a bare
628// wall-clock read.
629func (d *Daemon) daemonNow() time.Time {
630	if d == nil || d.clock == nil {
631		return systemClock{}.Now()
632	}
633	return d.clock.Now()
634}
635
636type systemClock struct{}
637type systemTimer struct{ *time.Timer }
638
639var graphicsNamespaceFallbackSalt atomic.Uint64
640
641func newGraphicsNamespaceSalt() uint64 {
642	var bytes [8]byte
643	if _, err := cryptorand.Read(bytes[:]); err == nil {
644		if salt := binary.BigEndian.Uint64(bytes[:]); salt != 0 {
645			return salt
646		}
647	}
648	for {
649		salt := graphicsNamespaceFallbackSalt.Add(1)
650		if salt != 0 {
651			return salt
652		}
653	}
654}
655
656func (systemClock) Now() time.Time { return time.Now() }
657func (systemClock) NewTimer(delay time.Duration) ports.Timer {
658	return systemTimer{Timer: time.NewTimer(delay)}
659}
660func (t systemTimer) C() <-chan time.Time { return t.Timer.C }
661
662// New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
663// render debounce, and log receives diagnostics (defaults to slog.Default).
664func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
665	if log == nil {
666		log = slog.Default()
667	}
668	if clock == nil {
669		clock = systemClock{}
670	}
671	paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
672	d := &Daemon{
673		sessions:          make(map[domain.SessionID]*session),
674		inactive:          make(map[string]inactiveSession),
675		creating:          make(map[string]struct{}),
676		resume:            newResumeCredentials(),
677		suspended:         make(map[*attachedClient]*suspendedAttachmentRetention),
678		graphics:          newGraphicsNamespacePool(),
679		paneProcessCtx:    paneProcessCtx,
680		paneProcessCancel: paneProcessCancel,
681		ptys:              ptys,
682		clock:             clock,
683		log:               log,
684		baseEnv:           os.Environ(),
685		shell:             defaultShellCommand,
686		dirOrHome:         dirOrHome,
687		done:              make(chan struct{}),
688		restoreDone:       make(chan struct{}),
689		animWake:          make(chan struct{}, 1),
690		snapshots: snapshotWorker{
691			jobs:     make(chan *snapshotCapture, snapshotQueueCapacity),
692			admitted: make(map[*snapshotCapture]struct{}),
693			wake:     make(chan struct{}, 1),
694		},
695		notices:               newNoticeCenter(),
696		resumeParkGrace:       defaultResumeParkGrace,
697		suspendedSafetyExpiry: defaultSuspendedSafetyExpiry,
698		barScripts: &barScriptState{
699			cfg:         barConfigFromDomain(domain.Defaults().Bar),
700			outputs:     make(map[domain.SessionID]barScriptOutputs),
701			lastRefresh: make(map[domain.SessionID]time.Time),
702			lastContext: make(map[domain.SessionID]barScriptContext),
703			running:     make(map[domain.SessionID]bool),
704			reload:      make(chan struct{}, 1),
705		},
706	}
707	d.config.Store(defaultRuntimeConfig())
708	for _, o := range opts {
709		o(d)
710	}
711	if d.dirOrHome == nil {
712		d.dirOrHome = dirOrHome
713	}
714	if d.bindings.Load() == nil {
715		d.bindings.Store(keys.DefaultBindings())
716	}
717	records := d.catalogueRecords
718	var maxSeq uint64
719	var maxCreatedAt int64
720	hasCreatedAt := false
721	for _, r := range records {
722		if d.catalogueRecordsProvided {
723			state, done := initialSessionState(r)
724			d.inactive[r.Name] = inactiveSessionFromRecord(r, state, done)
725		} else {
726			d.inactive[r.Name] = inactiveSessionFromRecord(r, protocol.SessionDown, nil)
727		}
728		if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
729			maxCreatedAt = r.CreatedAt
730			hasCreatedAt = true
731		}
732		if r.LastUsedSeq > maxSeq {
733			maxSeq = r.LastUsedSeq
734		}
735	}
736	if hasCreatedAt {
737		d.lastAllocatedCreatedAt = maxCreatedAt
738	}
739	d.mruSeq.Store(maxSeq)
740	return d
741}
742
743// Serve runs the accept loop over l, owning it for the loop's lifetime. It
744// returns only on ctx cancellation or an explicit daemon shutdown request
745// (KillDaemon), never when the session registry drains to empty: final
746// session removal is independent of daemon shutdown so the daemon survives
747// empty→occupied→empty and can accept fresh sessions again. A KillAll purge
748// leaves Serve running too. On the ctx path
749// attached clients are detached with ReasonServerShutdown.
750func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
751	d.serveCtx, d.serveCancel = context.WithCancel(ctx)
752	defer d.serveCancel()
753	d.hardCtx, d.hardCancel = context.WithCancel(context.Background())
754	defer d.hardCancel()
755
756	d.sessWg.Go(func() {
757		d.attentionAnimator(d.serveCtx)
758	})
759	d.sessWg.Go(func() {
760		d.barScriptPoller(d.serveCtx)
761	})
762	d.sessWg.Go(func() {
763		d.historyMaintenance(d.serveCtx)
764	})
765	if d.persistEnabled {
766		d.sessWg.Go(func() {
767			d.cwdSampler(d.serveCtx)
768		})
769	}
770	if d.snapsEnabled {
771		d.startSnapshotEncodeWorker()
772		d.sessWg.Go(func() {
773			d.snapshotRepositorySaver(d.serveCtx)
774		})
775		d.startSnapshotRestoration()
776		d.startDurableMaintenance()
777	} else {
778		d.closeRestoreDone()
779	}
780	// Break the accept loop only on an explicit shutdown: the parent context
781	// being cancelled or an explicit daemon stop request (d.done). An empty
782	// registry never closes the listener, so the daemon keeps accepting.
783	go func() {
784		select {
785		case <-d.serveCtx.Done():
786		case <-d.done:
787		}
788		_ = l.Close()
789	}()
790
791	for {
792		tr, err := l.Accept()
793		if err != nil {
794			if d.serveCtx.Err() != nil {
795				d.log.Info("accept loop exiting", "err", err, "reason", "context canceled")
796			} else {
797				d.log.Warn("accept loop exiting", "err", err)
798			}
799			break
800		}
801		d.connWg.Go(func() {
802			d.handleConn(tr)
803		})
804	}
805
806	// Tear down. shutdownAll marks the daemon closing (route now rejects any
807	// racing Hello) and kills every session: PTYs are closed and contexts
808	// cancelled unconditionally, with the Detached notices sent asynchronously
809	// under a deadline. Wait for those notices (bounded — a wedged client's
810	// transport is force-closed by the deadline) before hard-closing the
811	// remaining parked connections, so graceful notices are never raced by the
812	// force-close. Finally drain the conn handlers, run one defensive sweep in
813	// case any ordering ever leaves a session behind, and join the session
814	// goroutines (readers unblock via pty.Close, coordinators via ctx cancel).
815	// One deadline owns every terminal checkpoint wait and the final worker
816	// join. A repository is allowed to ignore cancellation, so spending a fresh
817	// interval at either stage would make shutdown exceed its documented bound.
818	var snapshotDeadline *snapshotShutdownDeadline
819	if d.snapsEnabled {
820		snapshotDeadline = newSnapshotShutdownDeadline(d.clock)
821		defer snapshotDeadline.stop()
822	}
823	d.terminateAllForShutdown(protocol.ReasonServerShutdown, snapshotDeadline)
824	d.waitNotifies()
825	d.hardCancel()
826	d.serveCancel()
827	// Wait for handlers before flushing snapshots: a handler that already
828	// entered killSession may still submit its terminal capture after the
829	// registry snapshot has been removed.
830	d.connWg.Wait()
831	d.attachmentCleanupWg.Wait()
832	// Forced terminal checkpoints run before shutdown stops producers or the
833	// worker. StopDurableWriters reports every session whose final capture still
834	// owns work when the shared checkpoint budget expires. Persist one notice per
835	// affected session, then retain ownership until every writer actually exits.
836	stopCtx, stopCancel := snapshotStopContext(snapshotDeadline)
837	timedOutSessions := d.StopDurableWriters(stopCtx)
838	stopCancel()
839	for _, name := range timedOutSessions {
840		d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded)
841	}
842	d.WaitDurableWriters()
843	d.terminateAllForShutdown(protocol.ReasonServerShutdown, snapshotDeadline)
844	d.waitSessionWorkersWithSnapshotDeadline(snapshotDeadline)
845	d.waitNotifies()
846	if err := d.flushCatalogue(); err != nil {
847		d.log.Warn("flushing session catalogue at shutdown failed", "err", err)
848	}
849	if d.catalogue != nil {
850		if err := d.catalogue.Close(); err != nil {
851			d.log.Warn("closing session catalogue failed", "err", err)
852		}
853	}
854	return nil
855}
856
857// shutdownAll is the explicit daemon-stop operation (wire KillDaemon). It is
858// the only caller that irreversibly ends the daemon: it closes move admission,
859// marks closing, reserves shutdown preservation for every live session, then
860// cancels the daemon-wide pane process context, preserves every live session as
861// stopped durable authority, and closes done so Serve returns.
862// KillAll never reaches this path. Setting closing under the same lock as the
863// snapshot guarantees no session can be inserted after the snapshot: route
864// rejects once closing is set, and both run under d.mu. killSession (which
865// relocks) runs after the lock is released.
866func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
867	return d.terminateAllForShutdown(reason, nil)
868}
869
870// terminateAllForShutdown performs the irreversible daemon teardown shared by
871// shutdownAll and Serve's exit path. It is separate from purgeAllSessions,
872// which leaves every daemon-wide shutdown fact untouched.
873func (d *Daemon) terminateAllForShutdown(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
874	// Stop background GC first so it releases the recovery fence before final
875	// checkpoints are published under the shutdown deadline.
876	d.cancelDurableMaintenance()
877	d.closeMoveLifecycles()
878	d.mu.Lock()
879	d.closing = true
880	// Wake any KillAll waiting on purge admission so it defers to this stop
881	// instead of holding the gate until its bounded drain deadline expires.
882	d.signalPurgeAdmissionChangedLocked()
883	d.purgeAllParkingLocked()
884	parkedRetirements := d.purgeAllParkedLocked()
885	d.purgeAllSuspendedLocked()
886	snapshot := d.sessionsSnapshotLocked()
887	d.mu.Unlock()
888	d.finishParkedAttachmentRetirements(parkedRetirements)
889	// Publish preservation policy for the complete registry snapshot before
890	// cancelling any PTY. Cancellation-driven EOF is allowed to own teardown,
891	// but its disposition must remain daemon shutdown rather than session purge.
892	// closeMoveLifecycles therefore leaves pane processes running; only this
893	// ordering, reserve then cancel, keeps a named session durable.
894	for _, s := range snapshot {
895		s.reserveShutdownTeardown()
896	}
897	d.cancelPaneProcesses()
898	d.log.Info("session termination begin", "reason", reason, "live_sessions", len(snapshot))
899	for _, s := range snapshot {
900		// Cancellation and PTY closure must not wait behind a teardown owner that
901		// is blocked in snapshot publication or purge work.
902		s.mu.Lock()
903		name := s.name
904		ephemeral := s.ephemeral
905		s.mu.Unlock()
906		s.stopInMemoryLifecycle()
907		if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline, nil); err != nil {
908			checkpointIncomplete = true
909			d.log.Error("closing session with unpersisted terminal state", "err", err)
910		}
911		if !ephemeral && deadline != nil {
912			select {
913			case <-deadline.Done():
914				checkpointIncomplete = true
915				d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded)
916			default:
917			}
918		}
919	}
920	// Signal Serve even when a concurrent role transition made this initial
921	// pass abort; Serve owns the bounded defensive teardown passes.
922	d.doneOnce.Do(func() { close(d.done) })
923	return checkpointIncomplete
924}
925
926// waitSessionWorkersWithSnapshotDeadline joins in-memory workers when possible,
927// but never extends Serve beyond the shared repository shutdown budget. By this
928// point connection handlers are joined and every session has been cancelled, so
929// no new session worker can be registered while the waiter is active.
930func (d *Daemon) waitSessionWorkersWithSnapshotDeadline(deadline *snapshotShutdownDeadline) {
931	done := make(chan struct{})
932	go func() {
933		d.sessWg.Wait()
934		close(done)
935	}()
936	if deadline == nil {
937		<-done
938		return
939	}
940	select {
941	case <-done:
942	case <-deadline.Done():
943	}
944}
945
946// snapshotShutdownDeadline is a single-use shutdown budget shared by forced
947// checkpoints and the final snapshot worker join. Its done channel is never
948// closed by callers, so a detached worker can safely retain session state until
949// an uncooperative repository call returns.
950type snapshotShutdownDeadline struct {
951	done     chan struct{}
952	stopCh   chan struct{}
953	finished chan struct{}
954	stopOnce sync.Once
955}
956
957func newSnapshotShutdownDeadline(clock ports.Clock) *snapshotShutdownDeadline {
958	deadline := &snapshotShutdownDeadline{done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{})}
959	timer := clock.NewTimer(snapshotFinalFlushTimeout)
960	go func() {
961		defer close(deadline.finished)
962		select {
963		case <-timer.C():
964			close(deadline.done)
965		case <-deadline.stopCh:
966			timer.Stop()
967		}
968	}()
969	return deadline
970}
971
972func (d *snapshotShutdownDeadline) stop() {
973	if d != nil {
974		d.stopOnce.Do(func() {
975			close(d.stopCh)
976			<-d.finished
977		})
978	}
979}
980
981func (d *snapshotShutdownDeadline) Done() <-chan struct{} {
982	if d == nil {
983		return nil
984	}
985	return d.done
986}
987
988func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, context.CancelFunc) {
989	ctx, cancel := context.WithCancel(context.Background())
990	if deadline == nil {
991		return ctx, cancel
992	}
993	select {
994	case <-deadline.Done():
995		cancel()
996		return ctx, cancel
997	default:
998	}
999	stop := make(chan struct{})
1000	go func() {
1001		select {
1002		case <-deadline.Done():
1003			cancel()
1004		case <-stop:
1005		}
1006	}()
1007	var stopOnce sync.Once
1008	return ctx, func() {
1009		stopOnce.Do(func() { close(stop) })
1010		cancel()
1011	}
1012}
1013
1014// registerSessionLocked publishes an exact attachment-session identity. Caller
1015// holds d.mu.
1016func (d *Daemon) registerSessionLocked(entry *session) bool {
1017	if entry == nil {
1018		return false
1019	}
1020	core := entry.core()
1021	if core == nil || core.id == "" || d.sessions[core.id] != nil {
1022		return false
1023	}
1024	d.sessions[core.id] = entry
1025	return true
1026}
1027
1028// unregisterSessionLocked removes only the exact registered identity. Caller
1029// holds d.mu.
1030func (d *Daemon) unregisterSessionLocked(entry *session) bool {
1031	if entry == nil {
1032		return false
1033	}
1034	core := entry.core()
1035	if core == nil || d.sessions[core.id] != entry {
1036		return false
1037	}
1038	delete(d.sessions, core.id)
1039	return true
1040}
1041
1042func (d *Daemon) sessionsSnapshotLocked() []*session {
1043	return sessionsSnapshot(d.sessions)
1044}
1045
1046// handleConn reads the first typed message off a fresh connection and routes it.
1047// A context watcher closes the connection on cancellation or handshake timeout.
1048func (d *Daemon) handleConn(tr ports.ServerConnection) {
1049	handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(d.hardCtx, acceptedHandshakeDeadline(tr))
1050	stopTransport := watchHandshakeTransport(handshakeCtx, tr)
1051	defer finishHandshake()
1052	defer stopTransport()
1053
1054	var first protocol.ClientMessage
1055	err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1056		var receiveErr error
1057		first, receiveErr = tr.ReceiveClient()
1058		return receiveErr
1059	})
1060	if err != nil {
1061		var failure *protocol.DecodeFailure
1062		if errors.As(err, &failure) {
1063			d.handleInitialDecodeFailure(handshakeCtx, tr, failure)
1064		} else {
1065			err = handshakeContextError(d.hardCtx, timedOut, err)
1066			d.log.Warn("connection closed before hello", "err", err)
1067		}
1068		_ = tr.Close()
1069		return
1070	}
1071	switch message := first.(type) {
1072	case protocol.List:
1073		stopTransport()
1074		finishHandshake()
1075		d.handleList(tr)
1076	case protocol.CommandRequest:
1077		stopTransport()
1078		finishHandshake()
1079		if err := d.handleCommand(tr, message); err != nil {
1080			d.log.Warn("command handler failed", "err", err)
1081		}
1082	case protocol.RemotePreviewWatch:
1083		stopTransport()
1084		finishHandshake()
1085		d.serveRemotePreviewWatch(tr, message)
1086	case protocol.NavigationInventoryRequest:
1087		stopTransport()
1088		finishHandshake()
1089		d.handleNavigationInventory(tr, message)
1090	case protocol.Kill:
1091		stopTransport()
1092		finishHandshake()
1093		d.handleKill(tr, message)
1094	case protocol.Hello:
1095		d.handleHelloWithContext(handshakeCtx, timedOut, stopTransport, finishHandshake, tr, message)
1096	default:
1097		d.log.Warn("hello rejected", "err", "expected hello")
1098		_ = boundedHandshakeOperation(handshakeCtx, tr, func() error {
1099			return tr.SendServer(serverError(protocol.ErrInternal, "expected hello"))
1100		})
1101		stopTransport()
1102		finishHandshake()
1103		_ = tr.Close()
1104	}
1105}
1106
1107func (d *Daemon) handleInitialDecodeFailure(ctx context.Context, tr ports.ServerConnection, failure *protocol.DecodeFailure) {
1108	send := func(message protocol.ServerMessage) {
1109		_ = boundedHandshakeOperation(ctx, tr, func() error { return tr.SendServer(message) })
1110		_ = tr.Close()
1111	}
1112	switch failure.Kind {
1113	case protocol.DecodeMessageHello:
1114		if failure.Version != 0 && failure.Version != protocol.Version {
1115			send(serverError(protocol.ErrVersionMismatch, "protocol version mismatch"))
1116			return
1117		}
1118		send(serverError(protocol.ErrInternal, "malformed hello"))
1119	case protocol.DecodeMessageCommand:
1120		if !failure.HasRequestID {
1121			return
1122		}
1123		code, text := protocol.ErrInternal, "malformed command request"
1124		if failure.Version != 0 && failure.Version != protocol.Version {
1125			code, text = protocol.ErrVersionMismatch, "protocol version mismatch"
1126		}
1127		send(protocol.CommandResult{RequestID: failure.RequestID, Outcome: protocol.CommandFailed, Code: code, Text: text})
1128	case protocol.DecodeMessageKill:
1129		send(serverError(protocol.ErrInternal, "malformed kill request"))
1130	case protocol.DecodeMessageRemotePreview:
1131		send(protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewMalformed})
1132	case protocol.DecodeMessageNavigationInventory:
1133		if !failure.HasRequestID {
1134			return
1135		}
1136		status := protocol.NavigationInventoryInvalid
1137		if failure.Version != 0 && failure.Version != protocol.Version {
1138			status = protocol.NavigationInventoryVersionMismatch
1139		}
1140		send(protocol.NavigationInventoryResponse{RequestID: failure.RequestID, Operation: protocol.NavigationInventorySnapshot, Status: status})
1141	default:
1142		send(serverError(protocol.ErrInternal, "expected hello"))
1143	}
1144}
1145
1146// handleList replies with the current session listing and closes the (one-shot
1147// control) connection.
1148func (d *Daemon) handleList(tr ports.ServerConnection) {
1149	defer func() { _ = tr.Close() }()
1150
1151	d.mu.Lock()
1152	infos := make([]protocol.SessionInfo, 0, len(d.sessions)+len(d.inactive))
1153	liveNames := make(map[string]struct{}, len(d.sessions))
1154	for _, s := range d.sessions {
1155		if s == nil {
1156			continue
1157		}
1158		s.mu.Lock()
1159		info := protocol.SessionInfo{
1160			SessionID: string(s.id),
1161			Name:      s.name,
1162			State:     protocol.SessionUp,
1163			Ephemeral: s.ephemeral,
1164			Tabs:      uint16(len(s.tabs)),
1165			// Attached means registered interactive presence, not mere transport
1166			// membership: a suspended-only session is headless to observers.
1167			Attached: sessionInteractivelyAttachedLocked(s),
1168		}
1169		liveNames[s.name] = struct{}{}
1170		s.mu.Unlock()
1171		infos = append(infos, info)
1172	}
1173	for name, inactive := range d.inactive {
1174		if _, live := liveNames[name]; live || !inactive.visible() {
1175			continue
1176		}
1177		state := protocol.SessionDown
1178		if inactive.broken() {
1179			state = protocol.SessionBroken
1180		}
1181		infos = append(infos, protocol.SessionInfo{Name: name, State: state})
1182	}
1183	d.mu.Unlock()
1184
1185	sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })
1186	_ = d.boundedControlSend(tr, serverSessions(infos))
1187}
1188
1189// handleKill terminates the requested live session or stopped named session,
1190// purges every session (KillAll), or accepts daemon shutdown (KillDaemon).
1191// Every normally decoded request receives exactly one correlated result before
1192// the control connection closes. KillAll leaves the daemon active.
1193func (d *Daemon) handleKill(tr ports.ServerConnection, request protocol.Kill) {
1194	defer func() { _ = tr.Close() }()
1195	send := func(result protocol.KillResult) {
1196		result.RequestID = request.RequestID
1197		d.logKillResultSendFailure(result, d.boundedControlSend(tr, result))
1198	}
1199
1200	switch request.Scope {
1201	case protocol.KillAll:
1202		result := d.purgeAllSessions(protocol.ReasonSessionKilled)
1203		if result.Superseded() {
1204			send(protocol.KillResult{Outcome: protocol.KillOutcomeUnknown, Code: protocol.ErrServerShutdown, Text: "daemon is shutting down"})
1205			return
1206		}
1207		if result.Failed() {
1208			failures := make([]protocol.KillFailure, 0, min(len(result.Failures), purgeSummaryFailureLimit))
1209			for _, failure := range result.Failures[:min(len(result.Failures), purgeSummaryFailureLimit)] {
1210				failures = append(failures, protocol.KillFailure{Class: failure.Class.String(), Name: failure.Name, Text: failure.Err.Error()})
1211			}
1212			send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: result.summary(), Failures: failures})
1213			return
1214		}
1215		send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1216		return
1217	case protocol.KillDaemon:
1218		send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1219		d.shutdownAll(protocol.ReasonServerShutdown)
1220		return
1221	case protocol.KillSession:
1222	default:
1223		send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "invalid kill scope"})
1224		return
1225	}
1226
1227	d.mu.Lock()
1228	target := d.findByNameLocked(request.Name)
1229	if target == nil {
1230		if _, ok := d.inactive[request.Name]; ok {
1231			d.mu.Unlock()
1232			// Stopped sessions use the same catalogue-first, incarnation-second
1233			// deletion order as live and offline purges.
1234			if err := d.retryStoppedPurge(request.Name); err != nil {
1235				d.log.Warn("deleting stopped session failed", "err", err, "session", request.Name)
1236				send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "deleting stopped session failed"})
1237				return
1238			}
1239			send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1240			return
1241		}
1242	}
1243	d.mu.Unlock()
1244
1245	if target == nil {
1246		send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: " + request.Name})
1247		return
1248	}
1249	if err := d.killSession(target, protocol.ReasonSessionKilled, true); err != nil {
1250		if errors.Is(err, errSessionKillParticipantsChanged) {
1251			// The exact session was not removed; a concurrent detach or token
1252			// resume invalidated the teardown snapshot. Report the retryable
1253			// outcome instead of a permanent delete failure.
1254			send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: sessionKillRetryMessage})
1255			return
1256		}
1257		send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "deleting persisted session failed"})
1258		return
1259	}
1260	send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1261}
1262
1263// handleHello runs the attach handshake for direct package callers. Accepted
1264// connections use handleConn, which resolves the optional accepted deadline so
1265// one budget also covers the first frame read; handleHello adopts the same seam
1266// when the connection exposes it. A connection without the seam keeps the
1267// ordinary fresh protocol.HandshakeTimeout budget.
1268func (d *Daemon) handleHello(tr ports.ServerConnection, hello protocol.Hello) {
1269	handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(context.Background(), acceptedHandshakeDeadline(tr))
1270	stopTransport := watchHandshakeTransport(handshakeCtx, tr)
1271	defer finishHandshake()
1272	defer stopTransport()
1273	d.handleHelloWithContext(handshakeCtx, timedOut, stopTransport, finishHandshake, tr, hello)
1274}
1275
1276// handleHelloWithContext runs the attach handshake: version check, intent
1277// routing, Welcome, guaranteed first paint, then the per-connection input loop.
1278func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <-chan struct{}, stopHandshakeTransport, finishHandshake func(), tr ports.ServerConnection, h protocol.Hello) {
1279	if err := handshakeContextError(d.hardCtx, timedOut, handshakeCtx.Err()); err != nil {
1280		_ = tr.Close()
1281		return
1282	}
1283	sendHandshake := func(message protocol.ServerMessage) error {
1284		err := boundedHandshakeOperation(handshakeCtx, tr, func() error { return tr.SendServer(message) })
1285		if err != nil {
1286			return handshakeContextError(d.hardCtx, timedOut, err)
1287		}
1288		return nil
1289	}
1290	if h.Version != protocol.Version {
1291		d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", h.Version, "expected", protocol.Version, "intent", h.Intent, "session", h.Name)
1292		_ = sendHandshake(serverError(protocol.ErrVersionMismatch, "protocol version mismatch"))
1293		_ = tr.Close()
1294		return
1295	}
1296
1297	sess, ac, rerr := d.routeWithContext(handshakeCtx, h, tr)
1298	if rerr != nil {
1299		d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
1300		if pe, ok := errors.AsType[*protoErr](rerr); ok {
1301			_ = sendHandshake(serverError(pe.code, pe.text))
1302		} else {
1303			_ = sendHandshake(serverError(protocol.ErrInternal, rerr.Error()))
1304		}
1305		_ = tr.Close()
1306		return
1307	}
1308
1309	welcomeSent := false
1310	//nolint:contextcheck // handshakeCtx is intentionally not propagated: cancellation teardown must complete its geometry and ownership cleanup.
1311	failAttachment := func() {
1312		d.failHandshakeAttachment(sess, ac, tr, welcomeSent)
1313	}
1314	if err := handshakeCtx.Err(); err != nil {
1315		failAttachment()
1316		return
1317	}
1318	expected := ac.transportSnapshot()
1319	welcomeToken := sess.captureAttachmentCapability(ac, tr)
1320	welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1321	if expected.transport != tr || !admitted || welcomeToken.ac == nil {
1322		if admitted {
1323			welcomeTicket.End()
1324		}
1325		failAttachment()
1326		return
1327	}
1328	if err := handshakeCtx.Err(); err != nil {
1329		welcomeTicket.End()
1330		failAttachment()
1331		return
1332	}
1333	welcomeDone, welcomeErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1334		frame, err := serverWelcome(sess, d.resumeTokenSnapshot(ac))
1335		if err != nil {
1336			return err
1337		}
1338		return ac.sendExpectedTransportForAttachment(expected, frame, welcomeTicket)
1339	})
1340	if welcomeErr != nil {
1341		<-welcomeDone
1342		welcomeTicket.End()
1343		failAttachment()
1344		return
1345	}
1346	welcomeSent = true
1347	// Release Welcome's effect before discovering post-handshake authority so a
1348	// replacement blocked behind the send can publish its generation and lease.
1349	welcomeTicket.End()
1350	postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffectContext(handshakeCtx, sess, tr)
1351	if !admitted {
1352		failAttachment()
1353		return
1354	}
1355	postWelcomeLease := postWelcomeToken.lease
1356	if postWelcomeToken.ac == nil {
1357		postWelcomeTicket.End()
1358		failAttachment()
1359		return
1360	}
1361	postWelcomeRC := sess.renderCoordinator()
1362	if postWelcomeLease != nil && (postWelcomeRC == nil || !postWelcomeRC.markAttachmentReady(postWelcomeLease)) {
1363		postWelcomeTicket.End()
1364		// The attachment was detached while Welcome was in flight; never let
1365		// this stale handshake emit an Output frame.
1366		failAttachment()
1367		return
1368	}
1369	paintToken := sess.captureAttachmentCapability(ac, tr)
1370	painted := make(chan bool, 1)
1371	paintDone, paintErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1372		//nolint:contextcheck // firstPaintForTransition is capability-bounded; the surrounding handshake operation owns cancellation.
1373		painted <- d.firstPaintForTransition(paintToken)
1374		return nil
1375	})
1376	if paintErr != nil {
1377		<-paintDone
1378		postWelcomeTicket.End()
1379		failAttachment()
1380		return
1381	}
1382	if !<-painted || handshakeCtx.Err() != nil {
1383		postWelcomeTicket.End()
1384		failAttachment()
1385		return
1386	}
1387	if !d.commitResumeClaim(ac) {
1388		postWelcomeTicket.End()
1389		failAttachment()
1390		return
1391	}
1392	postWelcomeTicket.End()
1393	stopHandshakeTransport()
1394	finishHandshake()
1395	d.runConnLoop(ac)
1396	_ = tr.Close()
1397}
1398
1399// protoErr is a session-level rejection carrying a wire ErrorMsg code.
1400type protoErr struct {
1401	code uint16
1402	text string
1403}
1404
1405func (e *protoErr) Error() string { return e.text }
1406
1407// finishAttach completes an attachment prepared while d.mu is held. Every
1408// caller must hold d.mu on entry; finishAttach transfers ownership by
1409// unlocking d.mu before returning, including every error path. It publishes
1410// terminal and role state before releasing d.mu, then defers coordinator
1411// cleanup so obsolete workers never delay the new handshake.
1412func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domain.Size, h protocol.Hello) (*attachedClient, error) {
1413	initialTabIndex := -1
1414	if h.SessionTarget != nil {
1415		var ok bool
1416		initialTabIndex, ok = remoteTargetTabIndexLocked(sess, *h.SessionTarget)
1417		if !ok {
1418			if tr != nil {
1419				_ = tr.Close()
1420			}
1421			d.mu.Unlock()
1422			return nil, &protoErr{protocol.ErrNoSuchTarget, "remote tab no longer exists"}
1423		}
1424	}
1425	if h.PreferredTabID != "" {
1426		// Route memory is a best-effort attachment cursor, not attach authority.
1427		// It overrides point-in-time picker tab metadata when still present and
1428		// otherwise falls back to the session's normal first-tab repair.
1429		initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)
1430	}
1431	terminalCapabilities := terminalcap.Resolve(h.Env, h.Color)
1432	// Kitty graphics are enabled only by the explicit direct-terminal
1433	// declaration in Hello. Environment values remain useful for color and
1434	// diagnostics, but cannot authorize terminal-global graphics side effects.
1435	terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
1436	opts := attachClientOptions{
1437		clientID:               h.ClientID,
1438		kittyKeyboard:          h.KittyKeyboard,
1439		resumeCapable:          true,
1440		maxOutputInFlight:      normalizeOutputWindow(h.MaxOutputInFlight),
1441		navigationCapabilities: h.NavigationCapabilities,
1442		terminalFocus:          h.TerminalFocus,
1443		clientPID:              h.ClientPID,
1444		clientEnv:              helloClientEnvironment(h),
1445		terminalCapabilities:   terminalCapabilities,
1446		capabilitiesSet:        true,
1447	}
1448	geometry := h.Geometry()
1449	if geometry.Size != sz {
1450		geometry = domain.Geometry{Size: sz}
1451	}
1452	ac := d.prepareAttachedClientLocked(sess, tr, geometry, opts)
1453	d.mu.Unlock()
1454	result, err := d.transitionAttachment(attachmentTransitionRequest{
1455		target:            sess,
1456		next:              ac,
1457		expectedTransport: ac.transportSnapshot(),
1458		activateTargetTab: initialTabIndex >= 0,
1459		targetTabIndex:    initialTabIndex,
1460		ready:             false,
1461	})
1462	if err != nil {
1463		if ac.output != nil && ac.output.graphicsOutput != nil {
1464			d.retireGraphicsOutput(ac, ac.output.graphicsOutput)
1465		}
1466		if tr != nil {
1467			_ = tr.Close()
1468		}
1469		return nil, err
1470	}
1471	d.finishAttachedClient(sess, ac, opts)
1472	d.deferAttachmentTransitionCleanups(result)
1473	return ac, nil
1474}
1475
1476func preferredTabIndex(sess *session, preferred domain.TabStableID) int {
1477	if sess == nil || preferred == "" {
1478		return -1
1479	}
1480	sess.mu.Lock()
1481	defer sess.mu.Unlock()
1482	for i, candidate := range sess.tabs {
1483		if candidate != nil && domain.TabStableID(candidate.stableID) == preferred {
1484			return i
1485		}
1486	}
1487	return -1
1488}
1489
1490func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1491	d.mu.Lock()
1492	var (
1493		done    chan struct{}
1494		stopped inactiveSession
1495		ok      bool
1496	)
1497	if sess := d.findByNameLocked(name); sess != nil {
1498		sess.mu.Lock()
1499		done = sess.restoreDone
1500		sess.mu.Unlock()
1501	} else {
1502		stopped, ok = d.inactive[name]
1503		if !ok || !stopped.visible() {
1504			d.mu.Unlock()
1505			return nil
1506		}
1507		if stopped.restorePending() {
1508			done = stopped.restoreDone
1509		}
1510	}
1511	d.mu.Unlock()
1512
1513	if done != nil {
1514		select {
1515		case <-done:
1516		case <-ctx.Done():
1517			return ctx.Err()
1518		}
1519	}
1520
1521	d.mu.Lock()
1522	defer d.mu.Unlock()
1523	if d.findByNameLocked(name) != nil {
1524		return nil
1525	}
1526	stopped, ok = d.inactive[name]
1527	if !ok || stopped.record.Name == "" {
1528		return nil
1529	}
1530	if stopped.broken() {
1531		return &protoErr{protocol.ErrInternal, "session durable state is broken: " + name}
1532	}
1533	if stopped.record.Committed == nil {
1534		return nil
1535	}
1536	return &protoErr{protocol.ErrInternal, "session was not restored into this daemon: " + name}
1537}
1538
1539// route resolves a Hello to a session and a freshly attached client, creating
1540// the session for ephemeral/new intents. Direct package callers retain the
1541// daemon lifetime context; inbound handshakes use routeWithContext.
1542func (d *Daemon) validateExactSessionTargetLocked(target protocol.ExactSessionTarget) error {
1543	if sess := d.findByNameLocked(target.SessionName); sess != nil {
1544		if sess.incarnation != target.LifecycleID {
1545			return &protoErr{protocol.ErrNoSuchSession, "session lifecycle has changed: " + target.SessionName}
1546		}
1547		return nil
1548	}
1549	stopped, ok := d.inactive[target.SessionName]
1550	if !ok || !stopped.visible() || stopped.incarnation != target.LifecycleID {
1551		return &protoErr{protocol.ErrNoSuchSession, "no such session lifecycle: " + target.SessionName}
1552	}
1553	return nil
1554}
1555
1556func (d *Daemon) validateExactSessionTarget(ctx context.Context, target protocol.ExactSessionTarget) error {
1557	if err := target.Validate(); err != nil {
1558		return &protoErr{protocol.ErrNoSuchSession, "invalid exact session target"}
1559	}
1560	if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil {
1561		return err
1562	}
1563
1564	d.mu.Lock()
1565	defer d.mu.Unlock()
1566	return d.validateExactSessionTargetLocked(target)
1567}
1568
1569func (d *Daemon) route(h protocol.Hello, tr ports.ServerConnection) (*session, *attachedClient, error) {
1570	ctx := d.serveCtx
1571	if ctx == nil {
1572		ctx = context.Background()
1573	}
1574	return d.routeWithContext(ctx, h, tr)
1575}
1576
1577func (d *Daemon) routeWithContext(ctx context.Context, h protocol.Hello, tr ports.ServerConnection) (*session, *attachedClient, error) {
1578	if ctx == nil {
1579		ctx = context.Background()
1580	}
1581	if err := ctx.Err(); err != nil {
1582		return nil, nil, err
1583	}
1584	sz := h.Size
1585	if !sz.Valid() {
1586		return nil, nil, &protoErr{protocol.ErrInternal, "invalid terminal size"}
1587	}
1588	// The accepting side's provisioned admission, when present, is the authority
1589	// this Hello is validated against before any restore, creation, resume
1590	// claim, ownership change, or environment mutation. A connection without
1591	// that seam receives the standard Hello validation and gains no admission
1592	// exception, so a forged Hello can never widen what it is allowed to do.
1593	admission, admitted := sessionHelloAdmission(tr)
1594	if admitted {
1595		if err := validateSessionHelloAdmission(h, admission); err != nil {
1596			return nil, nil, err
1597		}
1598		if admission.Admission == ports.BrokerAdmissionExact && h.ResumeToken != 0 {
1599			if err := d.validateResumeAdmittedTarget(h.ResumeToken, admission.Target); err != nil {
1600				return nil, nil, err
1601			}
1602		}
1603	}
1604	if h.Intent == protocol.IntentResume && h.ResumeToken == 0 {
1605		return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is required"}
1606	}
1607	if h.ExactTarget != nil && h.ResumeToken == 0 && h.Name != h.ExactTarget.SessionName {
1608		return nil, nil, &protoErr{protocol.ErrNoSuchSession, "exact session target name mismatch"}
1609	}
1610	// A remote exact-target handoff is an admitted transition like a local
1611	// create/restore: it reserves purge admission before dispatch, so a Hello
1612	// arriving during a KillAll waits on the gate exactly as a local route does
1613	// instead of being rejected outright.
1614	if h.SessionTarget != nil && h.ResumeToken == 0 {
1615		if err := d.acquirePurgeAdmission(ctx); err != nil {
1616			return nil, nil, err
1617		}
1618		defer d.releasePurgeAdmission()
1619		return d.routeRemoteTargetWithContext(ctx, h, tr)
1620	}
1621	if h.ExactTarget != nil && h.ResumeToken == 0 {
1622		if err := d.validateExactSessionTarget(ctx, *h.ExactTarget); err != nil {
1623			return nil, nil, err
1624		}
1625	}
1626
1627	// A non-zero token is an authoritative resume credential. If it is unknown,
1628	// expired, or raced with lifecycle teardown, fail closed instead of routing
1629	// the Hello as an ordinary attach that could create or replace ownership.
1630	// The one legitimate pre-park race is a still-active same-client credential
1631	// for the requested session; hand that into the park/resume lifecycle.
1632	if h.ResumeToken != 0 {
1633		d.mu.Lock()
1634		parkedAtStart := d.resume.parkedFor(h.ResumeToken)
1635		d.mu.Unlock()
1636		if parkedAtStart == nil {
1637			if sess, ac, ok, err := d.resumeLiveAttachment(h, tr, sz); err != nil {
1638				if errors.Is(err, errResumeTokenLifecycleRace) {
1639					// Live recovery parked then lost a competing resumeParked
1640					// race; keep the fail-closed wire response instead of
1641					// leaking the internal sentinel to handleHello as ErrInternal.
1642					return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1643				}
1644				return nil, nil, err
1645			} else if ok {
1646				return sess, ac, nil
1647			}
1648			return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1649		}
1650		if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {
1651			if ok {
1652				return sess, ac, nil
1653			}
1654			return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1655		} else if errors.Is(err, errResumeTokenLifecycleRace) {
1656			// The parked entry was replaced while this handshake waited for
1657			// its send lock. Fail closed on the original credential rather
1658			// than falling through to ordinary attach/create routing.
1659			return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1660		} else {
1661			return nil, nil, err
1662		}
1663	}
1664	if h.Intent == protocol.IntentResume || h.Intent == protocol.IntentAttach {
1665		if err := d.waitForTargetRestore(ctx, h.Name); err != nil {
1666			return nil, nil, err
1667		}
1668	}
1669	// A daemon-owned ephemeral Hello without an exact remote target is refused by the daemon. An authenticated remote admission that names the
1670	// creation is the one exception, and it is granted only under a validated
1671	// remote admission; a connection without one never gains it.
1672	if !admitted && h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned && h.SessionTarget == nil &&
1673		h.Intent == protocol.IntentEphemeral {
1674		return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "daemon-owned environment requires an exact remote target"}
1675	}
1676	// KillAll admission gate: a purge owns the exact lifecycle set it captured,
1677	// so a Hello arriving during it waits here and is then admitted as a create
1678	// or restore after the purge, never inserted into the set being removed.
1679	// This is transient and re-opened by the purge; it is not daemon closing.
1680	if err := d.acquirePurgeAdmission(ctx); err != nil {
1681		return nil, nil, err
1682	}
1683	defer d.releasePurgeAdmission()
1684	d.mu.Lock()
1685	// Shutdown/create interlock: once an explicit shutdown has begun
1686	// (shutdownAll started) no new session may be created and no attach may
1687	// proceed — the teardown snapshot has already been (or is being) taken, so
1688	// anything inserted now would leak its PTY and hang Serve. An empty registry
1689	// alone is not shutdown, and a KillAll purge is gated by admission above.
1690	if d.closing {
1691		d.mu.Unlock()
1692		return nil, nil, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}
1693	}
1694	// The first exact-target check happens before restore I/O. Recheck while
1695	// holding d.mu so a same-name lifecycle replacement cannot slip between
1696	// validation and attachment creation.
1697	if h.ExactTarget != nil && h.ResumeToken == 0 {
1698		if err := d.validateExactSessionTargetLocked(*h.ExactTarget); err != nil {
1699			d.mu.Unlock()
1700			return nil, nil, err
1701		}
1702	}
1703	switch h.Intent {
1704	case protocol.IntentEphemeral:
1705		name := d.allocEphemeralNameLocked()
1706		// A daemon-owned creation (an authenticated remote admission) uses the
1707		// daemon's own environment and home rather than any client value; a
1708		// client-owned creation (local) uses the admitted client environment and
1709		// working directory. The Hello fields are already validated to match the
1710		// admission, so this mirrors the named-creation ownership rule below.
1711		cwd, env := h.Cwd, h.Env
1712		if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned {
1713			cwd, env = d.dirOrHome(""), copyEnvironment(d.baseEnv)
1714		}
1715		sess, err := d.createSessionLockedWithMode(name, true, cwd, h.Geometry(), env)
1716		if err != nil {
1717			d.mu.Unlock()
1718			return nil, nil, err
1719		}
1720		ac, err := d.finishRouteAttach(sess, tr, sz, h, true, true)
1721		return sess, ac, err
1722
1723	case protocol.IntentNew:
1724		if h.Name == "" {
1725			d.mu.Unlock()
1726			return nil, nil, &protoErr{protocol.ErrInvalidSessionName, "empty session name"}
1727		}
1728		if err := domain.ValidateSessionName(h.Name); err != nil {
1729			d.mu.Unlock()
1730			return nil, nil, &protoErr{protocol.ErrInvalidSessionName, err.Error()}
1731		}
1732		if d.nameLiveOrStoppedLocked(h.Name) {
1733			d.mu.Unlock()
1734			return nil, nil, &protoErr{protocol.ErrNameTaken, "session name already in use: " + h.Name}
1735		}
1736		cwd, env := h.Cwd, h.Env
1737		if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned {
1738			// A remote CNS destination owns shell startup just like a picker
1739			// attach. Client paths may exist here but belong to another user.
1740			cwd, env = d.dirOrHome(""), copyEnvironment(d.baseEnv)
1741		}
1742		sess, err := d.createSessionLockedWithMode(h.Name, false, cwd, h.Geometry(), env)
1743		if err != nil {
1744			d.mu.Unlock()
1745			return nil, nil, err
1746		}
1747		ac, err := d.finishRouteAttach(sess, tr, sz, h, true, true)
1748		return sess, ac, err
1749
1750	case protocol.IntentAttach:
1751		sess := d.findByNameLocked(h.Name)
1752		created := false
1753		if sess == nil {
1754			stopped, ok := d.inactive[h.Name]
1755			if !ok || !stopped.canResume() {
1756				d.mu.Unlock()
1757				return nil, nil, &protoErr{protocol.ErrNoSuchSession, "no such resumable session: " + h.Name}
1758			}
1759			cwd := d.dirOrHome(stopped.cwd)
1760			env := h.Env
1761			daemonOwned := h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned
1762			if daemonOwned {
1763				env = copyEnvironment(d.baseEnv)
1764			}
1765			var err error
1766			sess, err = d.resumeInactiveSessionLocked(h.Name, cwd, h.Geometry(), env, stopped, stopped.tabNames)
1767			if err != nil {
1768				d.mu.Unlock()
1769				return nil, nil, err
1770			}
1771			if daemonOwned {
1772				// Like a snapshot restore, the resumed session starts from the
1773				// daemon's environment until a local client attaches.
1774				markEnvironmentProvisional(sess)
1775			}
1776			created = true
1777		}
1778		ac, err := d.finishRouteAttach(sess, tr, sz, h, created, false)
1779		return sess, ac, err
1780
1781	default:
1782		d.mu.Unlock()
1783		return nil, nil, &protoErr{protocol.ErrInternal, "unknown intent"}
1784	}
1785}

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