main 4509ec7

session_portable.go RawHistoryFolder

1package core
2
3import (
4	"bufio"
5	"bytes"
6	"context"
7	"encoding/json"
8	"errors"
9	"fmt"
10	"io"
11	"os"
12	"path/filepath"
13	"sort"
14	"strings"
15	"time"
16
17	"github.com/bnema/zut/packages/provider"
18	"github.com/google/uuid"
19)
20
21// PortableExt is the filesystem extension used for exported sessions.
22// A ".zutsession" is just a zut JSONL session file with the meta
23// header rewritten so the importing user gets fresh ownership.
24const PortableExt = ".zutsession"
25
26// ExportSession writes the session at srcPath to dstPath as a
27// portable .zutsession file. If dstPath is an existing directory the
28// file is created inside it with a name derived from the session's
29// meta ("YYYYMMDD-HHMMSS-<first-prompt-excerpt>.zutsession"). The
30// destination's directory is created if needed. Returns the final
31// resolved path so the caller can tell the user where it landed.
32//
33// The on-disk format is unchanged from a live session; only the
34// meta.cwd is stripped of its per-machine prefix (the importing
35// user doesn't care what directory it came from). Everything else
36// round-trips byte-for-byte.
37func ExportSession(srcPath, dstPath string) (string, error) {
38	if srcPath == "" {
39		return "", errors.New("export: source path is empty")
40	}
41	if dstPath == "" {
42		return "", errors.New("export: destination path is empty")
43	}
44
45	// Read the effective snapshot so a later model/provider update is not
46	// replaced by the stale metadata in the first row.
47	snapshot, err := ReadSessionSnapshot(srcPath)
48	if err != nil {
49		return "", fmt.Errorf("export: read session snapshot: %w", err)
50	}
51
52	// Read the source meta up-front so we can name the output sensibly
53	// when dstPath is a directory, and so we can validate it's a real
54	// session before starting to write.
55	src, err := os.Open(srcPath)
56	if err != nil {
57		return "", fmt.Errorf("export: open source: %w", err)
58	}
59	defer src.Close()
60
61	sc := bufio.NewScanner(src)
62	sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
63	if !sc.Scan() {
64		return "", errors.New("export: session file is empty")
65	}
66	var head sessionLine
67	if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
68		return "", fmt.Errorf("export: parse meta: %w", err)
69	}
70	if head.Type != "meta" || head.Meta == nil {
71		return "", errors.New("export: first line is not a meta row")
72	}
73
74	// Scan the rest of the file for the first user message so we can
75	// build a humane filename. Only reads if dstPath doesn't already
76	// end in .zutsession.
77	firstPrompt := ""
78	if !strings.HasSuffix(strings.ToLower(dstPath), PortableExt) {
79		if fi, _ := os.Stat(dstPath); fi == nil || fi.IsDir() {
80			p, err := firstUserPrompt(src)
81			if err != nil {
82				return "", fmt.Errorf("export: read first prompt: %w", err)
83			}
84			firstPrompt = p
85		}
86	}
87
88	// Resolve dstPath: if it's a directory, build a name inside it.
89	outPath := dstPath
90	if fi, err := os.Stat(dstPath); err == nil && fi.IsDir() {
91		name := filenameFor(head.Meta.Started, head.Meta.ID, firstPrompt)
92		outPath = filepath.Join(dstPath, name)
93	} else if !strings.HasSuffix(strings.ToLower(outPath), PortableExt) {
94		outPath += PortableExt
95	}
96
97	// Re-open the source from the top since we advanced the scanner.
98	if _, err := src.Seek(0, io.SeekStart); err != nil {
99		return "", fmt.Errorf("export: rewind: %w", err)
100	}
101
102	if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
103		return "", fmt.Errorf("export: mkdir dst: %w", err)
104	}
105	dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
106	if err != nil {
107		return "", fmt.Errorf("export: create dst: %w", err)
108	}
109	defer dst.Close()
110	bw := bufio.NewWriter(dst)
111
112	// Rewrite the meta row: strip the cwd (the importing user has
113	// their own) and keep everything else identical. ID stays so the
114	// export is traceable; the importer will rotate to a fresh ID.
115	exportMeta := snapshot.Meta
116	exportMeta.CWD = ""
117	metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &exportMeta})
118	if err != nil {
119		return "", fmt.Errorf("export: marshal meta: %w", err)
120	}
121	if _, err := bw.Write(metaLine); err != nil {
122		return "", err
123	}
124	if err := bw.WriteByte('\n'); err != nil {
125		return "", err
126	}
127
128	// Stream every non-meta row verbatim. Use ReadBytes instead of
129	// bufio.Scanner: large sessions can contain very long JSONL rows
130	// (image blocks, big tool outputs, compacted history) that exceed
131	// Scanner's token limit and fail with "token too long".
132	r := bufio.NewReader(src)
133	for {
134		line, err := r.ReadBytes('\n')
135		if len(line) > 0 {
136			line = bytes.TrimRight(line, "\r\n")
137			var h sessionLineHead
138			if err := json.Unmarshal(line, &h); err == nil && h.Type != "meta" {
139				if _, werr := bw.Write(line); werr != nil {
140					return "", werr
141				}
142				if werr := bw.WriteByte('\n'); werr != nil {
143					return "", werr
144				}
145			}
146		}
147		if err == io.EOF {
148			break
149		}
150		if err != nil {
151			return "", fmt.Errorf("export: read source: %w", err)
152		}
153	}
154	if err := bw.Flush(); err != nil {
155		return "", err
156	}
157	return outPath, nil
158}
159
160// ImportSession copies the .zutsession file at srcPath into the
161// running user's session store under the given root+cwd, rewriting
162// the meta's id / cwd / started fields so the imported session is
163// owned by the current user / directory / clock. Returns the path
164// of the created session file, ready to pass to OpenSession.
165//
166// The imported session is a first-class zut session: it'll show up
167// in /sessions, /jump, and on-disk summaries just like any other.
168// Messages and usage rows are preserved verbatim.
169func ImportSession(srcPath, root, cwd, version string) (string, error) {
170	if srcPath == "" {
171		return "", errors.New("import: source path is empty")
172	}
173	src, err := os.Open(srcPath)
174	if err != nil {
175		return "", fmt.Errorf("import: open source: %w", err)
176	}
177	defer src.Close()
178
179	// Read the effective metadata before committing to a destination. Meta
180	// rows are append-only, so the first row may describe an older provider or
181	// model after a runtime switch.
182	snapshot, err := ReadSessionSnapshot(srcPath)
183	if err != nil {
184		return "", fmt.Errorf("import: read session snapshot: %w", err)
185	}
186
187	// Validate the file header before committing to a destination.
188	sc := bufio.NewScanner(src)
189	sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
190	if !sc.Scan() {
191		return "", errors.New("import: session file is empty")
192	}
193	var head sessionLine
194	if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
195		return "", fmt.Errorf("import: parse meta: %w", err)
196	}
197	if head.Type != "meta" || head.Meta == nil {
198		return "", errors.New("import: first line is not a meta row")
199	}
200
201	// Build the destination inside the current cwd's session dir
202	// with a fresh timestamped name.
203	dir := SessionsDir(root, cwd)
204	if err := os.MkdirAll(dir, 0o755); err != nil {
205		return "", err
206	}
207	newID := uuid.NewString()
208	name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
209	outPath := filepath.Join(dir, name)
210	dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
211	if err != nil {
212		return "", fmt.Errorf("import: create dst: %w", err)
213	}
214	defer dst.Close()
215	bw := bufio.NewWriter(dst)
216
217	// Write a fresh meta row claiming ownership.
218	now := time.Now()
219	timezone, timezoneOffset := localTimeMetadata(now)
220	importMeta := SessionMeta{
221		ID:             newID,
222		CWD:            cwd,
223		Model:          snapshot.Meta.Model,
224		Provider:       snapshot.Meta.Provider,
225		Started:        now.UTC(),
226		Version:        version,
227		Title:          snapshot.Title,
228		Timezone:       timezone,
229		TimezoneOffset: timezoneOffset,
230		CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),
231		Mission:        cloneSessionMission(snapshot.Meta.Mission),
232		Goal:           cloneSessionGoal(snapshot.Meta.Goal),
233		GoalHistory:    cloneGoalHistory(snapshot.Meta.GoalHistory),
234		Plan:           cloneSessionPlan(snapshot.Meta.Plan),
235	}
236	metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
237	if err != nil {
238		return "", fmt.Errorf("import: marshal meta: %w", err)
239	}
240	if _, err := bw.Write(metaLine); err != nil {
241		return "", err
242	}
243	if err := bw.WriteByte('\n'); err != nil {
244		return "", err
245	}
246
247	// Rewind the source and stream every non-meta row. Avoid
248	// bufio.Scanner so exported sessions with huge JSONL rows import
249	// cleanly.
250	if _, err := src.Seek(0, io.SeekStart); err != nil {
251		return "", fmt.Errorf("import: rewind: %w", err)
252	}
253	if err := forEachJSONLLine(src, func(line []byte) error {
254		var h sessionLineHead
255		if err := json.Unmarshal(line, &h); err != nil || h.Type == "meta" {
256			return nil
257		}
258		if _, err := bw.Write(line); err != nil {
259			return err
260		}
261		return bw.WriteByte('\n')
262	}); err != nil {
263		return "", fmt.Errorf("import: read source: %w", err)
264	}
265	if err := bw.Flush(); err != nil {
266		return "", err
267	}
268	return outPath, nil
269}
270
271// BranchSession creates a new session in root/cwd that contains the
272// parent's messages 0..upToMessageIdx-1 (i.e. the first N user+
273// assistant+tool rows). The new meta records Parent=<parent id> and
274// ForkPoint=N so /session tree can rebuild the branch topology
275// later. The effective repaired prefix is materialized, and all eligible
276// usage checkpoints at or before the cut are preserved so the running cost
277// tracker can reconstruct the final-turn delta. A boundary that ends on a
278// tool call is extended through its paired tool result.
279//
280// upToMessageIdx is a count over the flat message stream as
281// returned by OpenSession. To "branch at user turn 3" the caller
282// passes the index of that user message in msgs + 1 (so the
283// message itself is included). The caller figures that out; this
284// helper rejects a count outside the current effective snapshot.
285//
286// Returns the path of the new session file, ready for OpenSession.
287func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
288	return branchSession(context.Background(), parentPath, root, cwd, version, upToMessageIdx, false)
289}
290
291// BranchSessionHidden creates a branch that participates in /session tree but
292// is hidden from the flat /sessions picker. Used for in-place tree navigation.
293func BranchSessionHidden(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
294	return branchSession(context.Background(), parentPath, root, cwd, version, upToMessageIdx, true)
295}
296
297func branchSession(ctx context.Context, parentPath, root, cwd, version string, upToMessageIdx int, hideFromSessions bool) (string, error) {
298	if parentPath == "" {
299		return "", errors.New("branch: parent path is empty")
300	}
301	if upToMessageIdx < 0 {
302		return "", errors.New("branch: upToMessageIdx must be >= 0")
303	}
304
305	snapshot, err := readSessionSnapshot(ctx, parentPath)
306	if err != nil {
307		return "", fmt.Errorf("branch: read parent snapshot: %w", err)
308	}
309	if upToMessageIdx > len(snapshot.Messages) {
310		return "", fmt.Errorf("branch: upToMessageIdx %d exceeds effective message count %d", upToMessageIdx, len(snapshot.Messages))
311	}
312
313	// A tool-call row is not a safe provider boundary by itself. Accept the
314	// convenient row index used by existing callers, but extend it through the
315	// immediately paired tool result when necessary. Orphan calls already have
316	// a synthetic result in the shared snapshot.
317	limit := upToMessageIdx
318	if limit > 0 && limit < len(snapshot.Messages) && messageHasToolCalls(snapshot.Messages[limit-1]) && snapshot.Messages[limit].Role == provider.RoleTool {
319		limit++
320	}
321
322	extensionState, err := readExtensionStateAtFork(ctx, parentPath, limit)
323	if err != nil {
324		return "", err
325	}
326	compactHandoff := compactHandoffAtBranch(snapshot.Meta, snapshot.Messages, limit)
327	goal := goalAtBranch(snapshot.Meta, snapshot.Messages, limit)
328	return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff, goal)
329}
330
331// BranchSessionHiddenFromHistory creates a hidden tree branch from a
332// pre-compaction transcript segment. The segment is already repaired and is
333// kept separate from the effective snapshot so normal resume semantics remain
334// unchanged while older fork points stay usable.
335func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segment SessionHistorySegment, upToMessageIdx int) (string, error) {
336	if parentPath == "" {
337		return "", errors.New("branch: parent path is empty")
338	}
339	if upToMessageIdx < 0 {
340		return "", errors.New("branch: upToMessageIdx must be >= 0")
341	}
342	if upToMessageIdx > len(segment.Messages) {
343		return "", fmt.Errorf("branch: upToMessageIdx %d exceeds historical message count %d", upToMessageIdx, len(segment.Messages))
344	}
345
346	snapshot, err := ReadSessionSnapshot(parentPath)
347	if err != nil {
348		return "", fmt.Errorf("branch: read parent snapshot: %w", err)
349	}
350	limit := upToMessageIdx
351	if limit > 0 && limit < len(segment.Messages) && messageHasToolCalls(segment.Messages[limit-1]) && segment.Messages[limit].Role == provider.RoleTool {
352		limit++
353	}
354	extensionState, err := readExtensionStateAtFork(context.Background(), parentPath, limit)
355	if err != nil {
356		return "", err
357	}
358	return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil, nil)
359}
360
361// readExtensionStateAtFork reconstructs the latest extension snapshot that was
362// persisted at or before a branch boundary. Extension state is independent of
363// provider messages, but its timeline still follows the effective message
364// stream across compaction rows.
365func readExtensionStateAtFork(ctx context.Context, path string, limit int) (map[string]json.RawMessage, error) {
366	f, err := os.Open(path)
367	if err != nil {
368		return nil, fmt.Errorf("branch: open parent for extension state: %w", err)
369	}
370	defer f.Close()
371
372	state := make(map[string]json.RawMessage)
373	forkState := make(map[string]json.RawMessage)
374	cloneState := func(source map[string]json.RawMessage) map[string]json.RawMessage {
375		clone := make(map[string]json.RawMessage, len(source))
376		for name, raw := range source {
377			clone[name] = append(json.RawMessage(nil), raw...)
378		}
379		return clone
380	}
381	applyState := func(target map[string]json.RawMessage, row sessionLogRow) {
382		if len(row.state) == 0 || strings.TrimSpace(string(row.state)) == "null" {
383			delete(target, row.extension)
384			return
385		}
386		if json.Valid(row.state) {
387			target[row.extension] = append(json.RawMessage(nil), row.state...)
388		}
389	}
390	effectiveCount := 0
391	err = forEachSessionLogRowContext(ctx, f, func(row sessionLogRow) error {
392		switch row.typeName {
393		case "message":
394			effectiveCount++
395		case "compaction":
396			effectiveCount = len(row.messages)
397			// A compaction replaces the effective transcript. State that was
398			// recorded anywhere before this boundary belongs to every fork
399			// inside the replacement transcript, even when its old message
400			// index was after the requested fork point.
401			forkState = cloneState(state)
402		case "extension_state":
403			if row.extension == "" || len(row.state) > maxExtensionStateBytes {
404				return nil
405			}
406			applyState(state, row)
407			if effectiveCount <= limit {
408				applyState(forkState, row)
409			}
410		}
411		return nil
412	})
413	if err != nil {
414		return nil, fmt.Errorf("branch: read extension state: %w", err)
415	}
416	return forkState, nil
417}
418
419func compactHandoffAtBranch(parent SessionMeta, messages []provider.Message, limit int) json.RawMessage {
420	if len(parent.CompactHandoff) == 0 || limit != len(messages) {
421		return nil
422	}
423	return append(json.RawMessage(nil), parent.CompactHandoff...)
424}
425
426func goalAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionGoal {
427	if limit != len(messages) {
428		return nil
429	}
430	return cloneSessionGoal(parent.Goal)
431}
432
433func missionAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionMission {
434	if limit != len(messages) {
435		return nil
436	}
437	return cloneSessionMission(parent.Mission)
438}
439
440func cloneSessionGoal(goal *SessionGoal) *SessionGoal {
441	if goal == nil {
442		return nil
443	}
444	clone := *goal
445	if goal.TokenBudget != nil {
446		budget := *goal.TokenBudget
447		clone.TokenBudget = &budget
448	}
449	return &clone
450}
451
452func cloneGoalHistory(history []SessionGoal) []SessionGoal {
453	if len(history) == 0 {
454		return nil
455	}
456	clone := make([]SessionGoal, len(history))
457	for index := range history {
458		clone[index] = *cloneSessionGoal(&history[index])
459	}
460	return clone
461}
462
463func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit int) []SessionGoal {
464	if limit != len(messages) {
465		return nil
466	}
467	return cloneGoalHistory(parent.GoalHistory)
468}
469
470// cloneSessionPlan returns an independent copy of plan, or nil.
471func cloneSessionPlan(plan *SessionPlan) *SessionPlan {
472	if plan == nil {
473		return nil
474	}
475	clone := *plan
476	clone.Steps = clonePlanSteps(plan.Steps)
477	return &clone
478}
479
480// planAtBranch reconstructs the agent-owned plan as it stood at a fork point.
481//
482// A fork at the end of the effective transcript keeps the parent's persisted
483// plan: it is what the host recorded, and it is the only surviving record when
484// the parent was compacted and its plan calls are gone.
485//
486// A fork that cuts the transcript short replays the plan calls inside that prefix
487// and reaches back no further, so the branch never inherits work its own
488// transcript does not contain — the rule goals already follow. Replay skips calls
489// whose paired tool result failed, was denied, or is missing: those calls left a
490// transcript row but changed nothing.
491func planAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionPlan {
492	if limit > len(messages) {
493		limit = len(messages)
494	}
495	if limit == len(messages) {
496		return cloneSessionPlan(parent.Plan)
497	}
498	steps, _ := replayPlanOps(messages[:limit])
499	if len(steps) == 0 {
500		return nil
501	}
502	// Normalize the replay result so a hand-edited or legacy transcript cannot
503	// seed a branch with steps the live agent could never hold.
504	return normalizeSessionPlan(&SessionPlan{Steps: steps})
505}
506
507// PlanUpdateSnapshots reconstructs the per-call checklist snapshots the host
508// renders from a transcript. The transcript stores only per-call deltas, so the
509// materialized list shown for a historical plan call cannot be read back from
510// its arguments; it must be rebuilt by applying each accepted operation in
511// order. The result is keyed by tool-call id. Both the legacy update_plan
512// full-list shape and the plan action shape are accepted, and calls whose paired
513// tool result failed, was denied, or is missing are skipped, matching
514// planAtBranch.
515func PlanUpdateSnapshots(messages []provider.Message) map[string]PlanUpdate {
516	_, snapshots := replayPlanOps(messages)
517	return snapshots
518}
519
520// replayPlanOps applies every successful plan call in segment in order and
521// returns the final steps plus the materialized checklist after each applied
522// call, keyed by tool-call id. Read-only or invalid operations do not change
523// state and therefore produce no snapshot.
524func replayPlanOps(segment []provider.Message) ([]PlanStep, map[string]PlanUpdate) {
525	applied := planToolResults(segment)
526	var steps []PlanStep
527	var snapshots map[string]PlanUpdate
528	for idx := range segment {
529		for _, content := range segment[idx].Content {
530			call, ok := content.(provider.ToolCallBlock)
531			if !ok || !applied[call.ID] {
532				continue
533			}
534			op, ok := planOperationFromToolCall(call.Name, call.Arguments)
535			if !ok {
536				continue
537			}
538			next, changed := applyPlanOperation(steps, op)
539			if !changed {
540				continue
541			}
542			steps = next
543			if snapshots == nil {
544				snapshots = make(map[string]PlanUpdate)
545			}
546			snapshots[call.ID] = PlanUpdate{Explanation: op.Explanation, Plan: clonePlanSteps(steps)}
547		}
548	}
549	return steps, snapshots
550}
551
552// planToolResults maps the call ids in segment to whether their tool result
553// reports success. A call whose result is an error, or that has no result at all
554// because the turn ended first, never changed the plan.
555func planToolResults(segment []provider.Message) map[string]bool {
556	applied := make(map[string]bool)
557	for idx := range segment {
558		for _, content := range segment[idx].Content {
559			result, ok := content.(provider.ToolResultBlock)
560			if !ok || result.CallID == "" {
561				continue
562			}
563			applied[result.CallID] = !result.IsError
564		}
565	}
566	return applied
567}
568
569const (
570	// planToolName is the current plan tool name. Core cannot import
571	// packages/agent/tools, so the wire name is repeated here for transcript
572	// replay.
573	planToolName = "plan"
574	// legacyPlanToolName is the pre-rename checklist tool. Historical sessions
575	// still carry its calls.
576	legacyPlanToolName = "update_plan"
577)
578
579type planToolStep struct {
580	Step   *string `json:"step"`
581	Status *string `json:"status"`
582}
583
584// planOperationFromToolCall decodes one plan-affecting tool call. The legacy
585// update_plan shape carries a full list with replace semantics, so it maps to a
586// set operation; the new shape carries an explicit action.
587func planOperationFromToolCall(name string, args json.RawMessage) (PlanOperation, bool) {
588	switch name {
589	case legacyPlanToolName:
590		var legacy struct {
591			Explanation *string         `json:"explanation"`
592			Plan        *[]planToolStep `json:"plan"`
593		}
594		if err := json.Unmarshal(args, &legacy); err != nil || legacy.Plan == nil {
595			return PlanOperation{}, false
596		}
597		steps, ok := planToolSteps(*legacy.Plan)
598		if !ok {
599			return PlanOperation{}, false
600		}
601		return PlanOperation{Action: "set", Explanation: legacy.Explanation, Steps: steps}, true
602	case planToolName:
603		var raw struct {
604			Action      string         `json:"action"`
605			Explanation *string        `json:"explanation"`
606			Steps       []planToolStep `json:"steps"`
607			Index       int            `json:"index"`
608			Status      string         `json:"status"`
609			Step        *string        `json:"step"`
610		}
611		if err := json.Unmarshal(args, &raw); err != nil {
612			return PlanOperation{}, false
613		}
614		steps, ok := planToolSteps(raw.Steps)
615		if !ok {
616			return PlanOperation{}, false
617		}
618		return PlanOperation{
619			Action:      raw.Action,
620			Explanation: raw.Explanation,
621			Steps:       steps,
622			Index:       raw.Index,
623			Text:        raw.Step,
624			Status:      PlanStepStatus(raw.Status),
625		}, true
626	default:
627		return PlanOperation{}, false
628	}
629}
630
631func planToolSteps(raw []planToolStep) ([]PlanStep, bool) {
632	if len(raw) == 0 {
633		return nil, true
634	}
635	steps := make([]PlanStep, 0, len(raw))
636	for _, step := range raw {
637		if step.Step == nil {
638			return nil, false
639		}
640		steps = append(steps, PlanStep{Step: *step.Step, Status: planToolStatus(step.Status)})
641	}
642	return steps, true
643}
644
645// planToolStatus defaults an omitted status to pending, matching the schema's
646// optional status on new-shape steps.
647func planToolStatus(status *string) PlanStepStatus {
648	if status == nil {
649		return PlanPending
650	}
651	return PlanStepStatus(*status)
652}
653
654// applyPlanOperation replays op against steps and reports whether it changed
655// state. Invalid or read-only operations leave steps untouched.
656func applyPlanOperation(steps []PlanStep, op PlanOperation) ([]PlanStep, bool) {
657	switch op.Action {
658	case "set":
659		return clonePlanSteps(op.Steps), true
660	case "add":
661		if len(op.Steps) == 0 {
662			return steps, false
663		}
664		return append(clonePlanSteps(steps), clonePlanSteps(op.Steps)...), true
665	case "update":
666		if op.Index < 1 || op.Index > len(steps) {
667			return steps, false
668		}
669		if op.Text != nil {
670			steps[op.Index-1].Step = *op.Text
671		}
672		if op.Status != "" {
673			steps[op.Index-1].Status = op.Status
674		}
675		return steps, true
676	case "remove":
677		if op.Index < 1 || op.Index > len(steps) {
678			return steps, false
679		}
680		return append(steps[:op.Index-1:op.Index-1], steps[op.Index:]...), true
681	case "clear":
682		return nil, true
683	default:
684		return steps, false
685	}
686}
687
688func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage, goal *SessionGoal) (string, error) {
689	dir := SessionsDir(root, cwd)
690	if err := os.MkdirAll(dir, 0o755); err != nil {
691		return "", err
692	}
693	newID := uuid.NewString()
694	now := time.Now()
695	timezone, timezoneOffset := localTimeMetadata(now)
696	name := fmt.Sprintf("%s-%s.jsonl", now.UTC().Format("20060102-150405"), newID[:8])
697	outPath := filepath.Join(dir, name)
698	// Build in a same-directory temporary file. Rename is atomic only when
699	// the temporary and final paths share a directory; closing the file first
700	// also ensures no buffered writer or descriptor can expose a partial branch.
701	dst, err := os.CreateTemp(dir, ".zut-branch-*.tmp")
702	if err != nil {
703		return "", fmt.Errorf("branch: create temp: %w", err)
704	}
705	tmpPath := dst.Name()
706	if err := dst.Chmod(0o644); err != nil {
707		_ = dst.Close()
708		_ = os.Remove(tmpPath)
709		return "", fmt.Errorf("branch: chmod temp: %w", err)
710	}
711	committed := false
712	defer func() {
713		_ = dst.Close()
714		if !committed {
715			_ = os.Remove(tmpPath)
716			_ = os.Remove(outPath)
717		}
718	}()
719	bw := bufio.NewWriter(dst)
720
721	branchMeta := SessionMeta{
722		ID:               newID,
723		CWD:              cwd,
724		Model:            parent.Model,
725		Provider:         parent.Provider,
726		Started:          now.UTC(),
727		Version:          version,
728		Timezone:         timezone,
729		TimezoneOffset:   timezoneOffset,
730		Parent:           parent.ID,
731		ForkPoint:        limit,
732		HideFromSessions: hideFromSessions,
733		CompactHandoff:   append(json.RawMessage(nil), compactHandoff...),
734		Mission:          missionAtBranch(parent, messages, limit),
735		Goal:             cloneSessionGoal(goal),
736		GoalHistory:      goalHistoryAtBranch(parent, messages, limit),
737		Plan:             planAtBranch(parent, messages, limit),
738	}
739	metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
740	if err != nil {
741		return "", fmt.Errorf("branch: marshal meta: %w", err)
742	}
743	if _, err := bw.Write(metaLine); err != nil {
744		return "", err
745	}
746	if err := bw.WriteByte('\n'); err != nil {
747		return "", err
748	}
749
750	for idx := 0; idx < limit; idx++ {
751		msg := messages[idx]
752		line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})
753		if err != nil {
754			return "", fmt.Errorf("branch: marshal message %d: %w", idx, err)
755		}
756		if _, err := bw.Write(line); err != nil {
757			return "", err
758		}
759		if err := bw.WriteByte('\n'); err != nil {
760			return "", err
761		}
762	}
763
764	// Extension snapshots are branch state, not provider-visible transcript
765	// content. Write the latest snapshot that existed at the fork point so a
766	// child branch starts from the same extension state as its copied prefix.
767	extensionNames := make([]string, 0, len(extensionState))
768	for name := range extensionState {
769		extensionNames = append(extensionNames, name)
770	}
771	sort.Strings(extensionNames)
772	for _, name := range extensionNames {
773		line, err := json.Marshal(sessionLine{Type: "extension_state", Extension: name, State: extensionState[name]})
774		if err != nil {
775			return "", fmt.Errorf("branch: marshal extension state: %w", err)
776		}
777		if _, err := bw.Write(line); err != nil {
778			return "", fmt.Errorf("branch: write extension state: %w", err)
779		}
780		if err := bw.WriteByte('\n'); err != nil {
781			return "", fmt.Errorf("branch: terminate extension state row: %w", err)
782		}
783	}
784	// Preserve every cumulative checkpoint that belongs to this prefix. The
785	// final two rows are needed to reconstruct LastTurnUsage as a delta.
786	for _, checkpoint := range checkpoints {
787		if checkpoint.MessageCount > limit {
788			continue
789		}
790		usageLine, err := json.Marshal(sessionLine{
791			Type:       "usage",
792			Usage:      &checkpoint.Cumulative,
793			Cumulative: &checkpoint.Cumulative,
794		})
795		if err != nil {
796			return "", fmt.Errorf("branch: marshal usage: %w", err)
797		}
798		if _, err := bw.Write(usageLine); err != nil {
799			return "", err
800		}
801		if err := bw.WriteByte('\n'); err != nil {
802			return "", err
803		}
804	}
805
806	if err := bw.Flush(); err != nil {
807		return "", err
808	}
809	if err := dst.Sync(); err != nil {
810		return "", fmt.Errorf("branch: sync temp: %w", err)
811	}
812	if err := dst.Close(); err != nil {
813		return "", fmt.Errorf("branch: close temp: %w", err)
814	}
815	if err := os.Rename(tmpPath, outPath); err != nil {
816		return "", fmt.Errorf("branch: commit: %w", err)
817	}
818	committed = true
819	return outPath, nil
820}
821
822func lastUserText(msgs []provider.Message) string {
823	for i := len(msgs) - 1; i >= 0; i-- {
824		if msgs[i].Role != provider.RoleUser {
825			continue
826		}
827		if text := strings.TrimSpace(firstTextFromMessage(msgs[i])); text != "" {
828			return text
829		}
830	}
831	return ""
832}
833
834// TreeNode is one entry in the branch tree returned by
835// BuildSessionTree. Children are populated by linking on Parent ID.
836type TreeNode struct {
837	Summary  SessionSummary
838	Meta     SessionMeta
839	Children []*TreeNode
840}
841
842// BuildSessionTree loads every session in the cwd dir and returns
843// the forest rooted at parentless sessions, with each non-root
844// session placed under its parent. Used by /session tree to render
845// the branch hierarchy.
846func BuildSessionTree(root, cwd string) []*TreeNode {
847	dir := SessionsDir(root, cwd)
848	entries, err := os.ReadDir(dir)
849	if err != nil {
850		return nil
851	}
852	nodes := make(map[string]*TreeNode)
853	order := []string{}
854	for _, e := range entries {
855		if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
856			continue
857		}
858		path := filepath.Join(dir, e.Name())
859		summary := describeSession(path)
860		meta, _ := readSessionMeta(path)
861		if meta.ID == "" {
862			continue
863		}
864		nodes[meta.ID] = &TreeNode{Summary: summary, Meta: meta}
865		order = append(order, meta.ID)
866	}
867	return linkSessionTreeNodes(nodes, order)
868}
869
870// BuildSessionTreeStrict is the complete-read counterpart to the permissive
871// forest builder. It validates every session file before linking any node, so
872// callers that need a complete forest cannot silently omit malformed members.
873func BuildSessionTreeStrict(root, cwd string) ([]*TreeNode, error) {
874	dir := SessionsDir(root, cwd)
875	entries, err := os.ReadDir(dir)
876	if err != nil {
877		if errors.Is(err, os.ErrNotExist) {
878			return nil, nil
879		}
880		return nil, fmt.Errorf("build session tree: read directory: %w", err)
881	}
882	nodes := make(map[string]*TreeNode)
883	order := make([]string, 0, len(entries))
884	for _, e := range entries {
885		if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
886			continue
887		}
888		path := filepath.Join(dir, e.Name())
889		snapshot, err := ReadSessionSnapshot(path)
890		if err != nil {
891			return nil, fmt.Errorf("build session tree: read %q: %w", path, err)
892		}
893		if snapshot.Meta.ID == "" {
894			return nil, fmt.Errorf("build session tree: %q has no session id", path)
895		}
896		summary := SessionSummary{
897			Path:          path,
898			Started:       snapshot.Meta.Started,
899			Model:         snapshot.Meta.Model,
900			Provider:      snapshot.Meta.Provider,
901			MessageCount:  len(snapshot.Messages),
902			FirstUserText: firstUserTextFromMessages(snapshot.Messages),
903			Title:         snapshot.Meta.Title,
904		}
905		if len(snapshot.UsageCheckpoints) > 0 {
906			summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
907		}
908		if _, exists := nodes[snapshot.Meta.ID]; exists {
909			return nil, fmt.Errorf("build session tree: duplicate session id %q", snapshot.Meta.ID)
910		}
911		nodes[snapshot.Meta.ID] = &TreeNode{Summary: summary, Meta: snapshot.Meta}
912		order = append(order, snapshot.Meta.ID)
913	}
914	return linkSessionTreeNodes(nodes, order), nil
915}
916
917// BuildSessionTreeFamilyStrict locates the current session using lightweight
918// metadata headers, then strictly validates only that root and its descendants.
919// A corrupt unrelated root in the same cwd bucket must not hide an otherwise
920// readable current family, while a corrupt member of the selected family still
921// fails the whole preflight.
922func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, error) {
923	return BuildSessionTreeFamilyStrictContext(context.Background(), root, cwd, currentPath)
924}
925
926// BuildSessionTreeFamilyStrictContext is the cancellation-aware counterpart
927// to BuildSessionTreeFamilyStrict. It performs all filesystem reads on behalf
928// of callers that must remain responsive while a family is being discovered.
929func BuildSessionTreeFamilyStrictContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
930	roots, err := buildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
931	if err != nil || len(roots) == 0 {
932		return roots, err
933	}
934	var validate func(*TreeNode) error
935	validate = func(node *TreeNode) error {
936		if err := contextErr(ctx); err != nil {
937			return err
938		}
939		snapshot, err := readSessionSnapshot(ctx, node.Summary.Path)
940		if err != nil {
941			return fmt.Errorf("build session family: read %q: %w", node.Summary.Path, err)
942		}
943		if snapshot.Meta.ID != node.Meta.ID {
944			return fmt.Errorf("build session family: session id changed in %q", node.Summary.Path)
945		}
946		node.Meta = snapshot.Meta
947		node.Summary = SessionSummary{
948			Path:          node.Summary.Path,
949			Started:       snapshot.Meta.Started,
950			Model:         snapshot.Meta.Model,
951			Provider:      snapshot.Meta.Provider,
952			MessageCount:  len(snapshot.Messages),
953			FirstUserText: firstUserTextFromMessages(snapshot.Messages),
954			Title:         snapshot.Meta.Title,
955		}
956		if len(snapshot.UsageCheckpoints) > 0 {
957			node.Summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
958		}
959		for _, child := range node.Children {
960			if err := validate(child); err != nil {
961				return err
962			}
963		}
964		return nil
965	}
966	if err := validate(roots[0]); err != nil {
967		return nil, err
968	}
969	return roots, nil
970}
971
972// BuildSessionTreeFamilyContext discovers the current family from metadata
973// headers. Consumers that read every member themselves can start that work in
974// their preferred order without a redundant whole-family snapshot pass.
975func BuildSessionTreeFamilyContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
976	return buildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
977}
978
979func buildSessionTreeFamilyContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
980	if err := contextErr(ctx); err != nil {
981		return nil, err
982	}
983	if currentPath == "" {
984		return nil, errors.New("build session family: current path is empty")
985	}
986	dir := SessionsDir(root, cwd)
987	entries, err := os.ReadDir(dir)
988	if err != nil {
989		if errors.Is(err, os.ErrNotExist) {
990			return nil, nil
991		}
992		return nil, fmt.Errorf("build session family: read directory: %w", err)
993	}
994	cleanCurrent := filepath.Clean(currentPath)
995	nodes := make(map[string]*TreeNode)
996	order := make([]string, 0, len(entries))
997	for _, entry := range entries {
998		if err := contextErr(ctx); err != nil {
999			return nil, err
1000		}
1001		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
1002			continue
1003		}
1004		path := filepath.Join(dir, entry.Name())
1005		meta, err := readSessionMetaHeader(path)
1006		if err != nil {
1007			if filepath.Clean(path) == cleanCurrent {
1008				return nil, fmt.Errorf("build session family: read current metadata %q: %w", path, err)
1009			}
1010			// A file whose header is unreadable cannot be associated with the
1011			// selected family. Valid descendants are linked below and fully
1012			// preflighted; unrelated corrupt roots stay out of this family.
1013			continue
1014		}
1015		if _, exists := nodes[meta.ID]; exists {
1016			return nil, fmt.Errorf("build session family: duplicate session id %q", meta.ID)
1017		}
1018		nodes[meta.ID] = &TreeNode{
1019			Summary: SessionSummary{Path: path, Started: meta.Started, Model: meta.Model, Provider: meta.Provider},
1020			Meta:    meta,
1021		}
1022		order = append(order, meta.ID)
1023	}
1024	roots := linkSessionTreeNodes(nodes, order)
1025	var familyRoot *TreeNode
1026	for _, candidate := range roots {
1027		if treeNodeContainsPath(candidate, cleanCurrent) {
1028			familyRoot = candidate
1029			break
1030		}
1031	}
1032	if familyRoot == nil {
1033		return nil, fmt.Errorf("build session family: current path %q is not in the session forest", currentPath)
1034	}
1035	return []*TreeNode{familyRoot}, nil
1036}
1037
1038func readSessionMetaHeader(path string) (SessionMeta, error) {
1039	f, err := os.Open(path)
1040	if err != nil {
1041		return SessionMeta{}, err
1042	}
1043	defer f.Close()
1044	line, err := bufio.NewReader(f).ReadBytes('\n')
1045	if err != nil && len(line) == 0 {
1046		return SessionMeta{}, err
1047	}
1048	line = bytes.TrimSpace(line)
1049	var row sessionLine
1050	if err := json.Unmarshal(line, &row); err != nil {
1051		return SessionMeta{}, err
1052	}
1053	if row.Type != "meta" || row.Meta == nil || row.Meta.ID == "" {
1054		return SessionMeta{}, errors.New("first row is not a valid meta row")
1055	}
1056	return *row.Meta, nil
1057}
1058
1059func treeNodeContainsPath(node *TreeNode, path string) bool {
1060	if node == nil {
1061		return false
1062	}
1063	if filepath.Clean(node.Summary.Path) == filepath.Clean(path) {
1064		return true
1065	}
1066	for _, child := range node.Children {
1067		if treeNodeContainsPath(child, path) {
1068			return true
1069		}
1070	}
1071	return false
1072}
1073
1074func linkSessionTreeNodes(nodes map[string]*TreeNode, order []string) []*TreeNode {
1075	var roots []*TreeNode
1076	for _, id := range order {
1077		n := nodes[id]
1078		if n.Meta.Parent == "" {
1079			roots = append(roots, n)
1080			continue
1081		}
1082		if parent, ok := nodes[n.Meta.Parent]; ok {
1083			parent.Children = append(parent.Children, n)
1084		} else {
1085			// Parent file missing (was manually deleted). Treat as a root so it
1086			// still shows up in the tree.
1087			roots = append(roots, n)
1088		}
1089	}
1090	return roots
1091}
1092
1093// scanSessionMeta reads JSONL rows without hydrating transcript content and
1094// returns the latest valid metadata row. Listing and ID lookup only need this
1095// small projection; full snapshot validation remains the responsibility of
1096// resume, export, branching, and tree preflight callers.
1097func scanSessionMeta(path string) (meta SessionMeta, err error) {
1098	return scanSessionMetaContext(context.Background(), path)
1099}
1100
1101func scanSessionMetaContext(ctx context.Context, path string) (meta SessionMeta, err error) {
1102	if err := contextErr(ctx); err != nil {
1103		return SessionMeta{}, err
1104	}
1105	f, err := os.Open(path)
1106	if err != nil {
1107		return SessionMeta{}, err
1108	}
1109	defer f.Close()
1110
1111	var sawMeta bool
1112	err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
1113		var head sessionLineHead
1114		if err := json.Unmarshal(line, &head); err != nil {
1115			return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
1116		}
1117		if head.Type == "" {
1118			return fmt.Errorf("line %d: missing row type", lineNo)
1119		}
1120		if !sawMeta && head.Type != "meta" {
1121			return fmt.Errorf("line %d: first row is not meta", lineNo)
1122		}
1123		switch head.Type {
1124		case "meta":
1125			var row struct {
1126				Meta *SessionMeta `json:"meta"`
1127			}
1128			if err := json.Unmarshal(line, &row); err != nil {
1129				return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
1130			}
1131			if row.Meta == nil || row.Meta.ID == "" {
1132				return fmt.Errorf("line %d: meta row has no session id", lineNo)
1133			}
1134			meta = *row.Meta
1135			sawMeta = true
1136		case "message", "compaction", "usage", "rename", "extension_state":
1137			// These rows are intentionally not hydrated here.
1138		default:
1139			return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
1140		}
1141		return nil
1142	})
1143	if err != nil {
1144		return SessionMeta{}, fmt.Errorf("session metadata %q: %w", path, err)
1145	}
1146	if !sawMeta {
1147		return SessionMeta{}, fmt.Errorf("session metadata %q: file is empty", path)
1148	}
1149	return meta, nil
1150}
1151
1152// readSessionMeta returns the latest validated metadata for path without
1153// reconstructing its transcript. Full snapshot validation is performed by
1154// callers that need messages rather than listing metadata.
1155func readSessionMeta(path string) (SessionMeta, error) {
1156	return scanSessionMeta(path)
1157}
1158
1159// FindSessionByID looks up a session file in root/cwd whose meta id
1160// matches. Used by /session tree when the user picks an entry. O(n)
1161// over the files in the dir; the list is small in practice.
1162func FindSessionByID(root, cwd, id string) string {
1163	dir := SessionsDir(root, cwd)
1164	entries, err := os.ReadDir(dir)
1165	if err != nil {
1166		return ""
1167	}
1168	for _, e := range entries {
1169		if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
1170			continue
1171		}
1172		path := filepath.Join(dir, e.Name())
1173		meta, err := scanSessionMeta(path)
1174		if err != nil {
1175			continue
1176		}
1177		if meta.ID == id {
1178			return path
1179		}
1180	}
1181	return ""
1182}
1183
1184// FindManagedSessionByID looks up a session UUID across zut's managed
1185// session stores, independent of cwd. A nil error and empty path mean no
1186// matching session was found. Directory enumeration, cancellation, and
1187// duplicate-ID failures are returned to the caller. Candidate files that
1188// cannot be inspected or whose headers are unreadable or invalid are ignored
1189// because they cannot be associated with a requested UUID.
1190func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) {
1191	if strings.TrimSpace(root) == "" {
1192		return "", errors.New("find managed session: root is empty")
1193	}
1194	if strings.TrimSpace(id) == "" {
1195		return "", errors.New("find managed session: id is empty")
1196	}
1197
1198	stores := []string{root}
1199	agentsDir := filepath.Join(root, "sessions", "agents")
1200	agents, err := os.ReadDir(agentsDir)
1201	if err != nil && !errors.Is(err, os.ErrNotExist) {
1202		return "", fmt.Errorf("find managed session: read agent stores: %w", err)
1203	}
1204	if err == nil {
1205		for _, entry := range agents {
1206			if entry.IsDir() {
1207				stores = append(stores, filepath.Join(agentsDir, entry.Name()))
1208			}
1209		}
1210	}
1211
1212	var matches []string
1213	for _, store := range stores {
1214		if err := contextErr(ctx); err != nil {
1215			return "", err
1216		}
1217		if err := collectManagedSessionMatches(ctx, store, id, &matches); err != nil {
1218			return "", err
1219		}
1220	}
1221	if err := contextErr(ctx); err != nil {
1222		return "", err
1223	}
1224	if len(matches) == 0 {
1225		return "", nil
1226	}
1227	if len(matches) > 1 {
1228		return "", fmt.Errorf("find managed session: UUID %q is ambiguous", id)
1229	}
1230	return matches[0], nil
1231}
1232
1233func collectManagedSessionMatches(ctx context.Context, root, id string, matches *[]string) error {
1234	if err := contextErr(ctx); err != nil {
1235		return err
1236	}
1237	dir := filepath.Join(root, "sessions")
1238	entries, err := os.ReadDir(dir)
1239	if errors.Is(err, os.ErrNotExist) {
1240		return nil
1241	}
1242	if err != nil {
1243		return fmt.Errorf("find managed session: read %q: %w", dir, err)
1244	}
1245	for _, bucket := range entries {
1246		if err := contextErr(ctx); err != nil {
1247			return err
1248		}
1249		if bucket.Type()&os.ModeSymlink != 0 || !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {
1250			continue
1251		}
1252		bucketPath := filepath.Join(dir, bucket.Name())
1253		files, err := os.ReadDir(bucketPath)
1254		if err != nil {
1255			return fmt.Errorf("find managed session: read %q: %w", bucketPath, err)
1256		}
1257		for _, file := range files {
1258			if err := contextErr(ctx); err != nil {
1259				return err
1260			}
1261			if file.Type()&os.ModeSymlink != 0 || file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {
1262				continue
1263			}
1264			path := filepath.Join(bucketPath, file.Name())
1265			info, err := os.Lstat(path)
1266			if err != nil || !info.Mode().IsRegular() {
1267				continue
1268			}
1269			meta, err := readSessionMetaHeader(path)
1270			if err != nil || meta.CWD == "" || filepath.Clean(SessionsDir(root, meta.CWD)) != filepath.Clean(bucketPath) {
1271				continue
1272			}
1273			if meta.ID == id {
1274				*matches = append(*matches, path)
1275			}
1276		}
1277	}
1278	return nil
1279}
1280
1281func isSessionBucketName(name string) bool {
1282	if len(name) != 16 {
1283		return false
1284	}
1285	for _, r := range name {
1286		if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
1287			return false
1288		}
1289	}
1290	return true
1291}
1292
1293// firstUserPrompt scans forward from the current source position
1294// looking for the first user-role message and returns its text.
1295// Used to build a humane export filename. Uses Reader instead of
1296// Scanner so a very large JSONL row before the first user prompt
1297// cannot trip Scanner's token limit.
1298func firstUserPrompt(src io.Reader) (string, error) {
1299	r := bufio.NewReader(src)
1300	for {
1301		lineBytes, err := r.ReadBytes('\n')
1302		if len(lineBytes) > 0 {
1303			lineBytes = bytes.TrimRight(lineBytes, "\r\n")
1304			var line sessionLine
1305			if err := json.Unmarshal(lineBytes, &line); err == nil {
1306				if line.Type == "message" && line.Message != nil && line.Message.Role == "user" {
1307					b, _ := json.Marshal(line.Message)
1308					var m struct {
1309						Content []struct {
1310							Text string `json:"text"`
1311						} `json:"content"`
1312					}
1313					_ = json.Unmarshal(b, &m)
1314					for _, c := range m.Content {
1315						if c.Text != "" {
1316							return c.Text, nil
1317						}
1318					}
1319				}
1320			}
1321		}
1322		if err == io.EOF {
1323			return "", nil
1324		}
1325		if err != nil {
1326			return "", err
1327		}
1328	}
1329}
1330
1331// filenameFor builds a descriptive .zutsession filename from the
1332// session's start time and, when available, an excerpt of the
1333// first user prompt.
1334func filenameFor(started time.Time, id, firstPrompt string) string {
1335	base := started.UTC().Format("20060102-150405")
1336	if id != "" && len(id) >= 8 {
1337		base += "-" + id[:8]
1338	}
1339	slug := slugify(firstPrompt, 40)
1340	if slug != "" {
1341		base += "-" + slug
1342	}
1343	return base + PortableExt
1344}
1345
1346// slugify lowercases, strips punctuation, collapses whitespace to
1347// hyphens, and truncates to max runes so it's safe as a filename.
1348func slugify(s string, max int) string {
1349	s = strings.TrimSpace(strings.ToLower(s))
1350	if s == "" {
1351		return ""
1352	}
1353	var out strings.Builder
1354	prevDash := false
1355	for _, r := range s {
1356		switch {
1357		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
1358			out.WriteRune(r)
1359			prevDash = false
1360		default:
1361			if !prevDash && out.Len() > 0 {
1362				out.WriteByte('-')
1363				prevDash = true
1364			}
1365		}
1366		if out.Len() >= max {
1367			break
1368		}
1369	}
1370	return strings.TrimRight(out.String(), "-")
1371}

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 1

Tags 476

↑↓ moveenter openesc close