Skip to content

feat(agentsessions): import other coding agents' local sessions and continue them in Zero - #878

Draft
gnanam1990 wants to merge 3 commits into
mainfrom
feat/import-agent-sessions
Draft

feat(agentsessions): import other coding agents' local sessions and continue them in Zero#878
gnanam1990 wants to merge 3 commits into
mainfrom
feat/import-agent-sessions

Conversation

@gnanam1990

@gnanam1990 gnanam1990 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Zero can now read the sessions other coding agents leave on the local disk — Claude Code, Codex, Factory Droid and Pi — list them, and continue that work in Zero.

zero sessions discover              # sessions from other agents, this workspace
zero sessions import <agent>:<id>   # copy one into Zero
zero exec --resume <zero-id> "…"    # continue it

In the TUI, /resume gains a tab strip (All · zero · claude-code · codex · factory · pi) and lists un-imported sessions directly — choosing one imports and resumes in a single step.

Draft, and deliberately so. There is no parent issue yet. Opening this to make the design concrete before asking for one, because the neighbourhood is sensitive — see Scope below.

Scope: how this differs from #399

#399 (internal/agentcli) was closed on a deliberate design line: Zero talks to model APIs directly, does not wrap other vendors' CLIs, and does not reuse another product's subscription login. That closure invited "a narrow, self-contained slice… with no subprocess harness and no borrowed-identity tokens".

This is that slice:

Rejected in #399 Here
Reads other agents' auth tokens Reads only transcripts. Never opens an auth file.
Shells out to claude / codex binaries No subprocess. Parses files at rest.
Runs turns on a borrowed subscription Runs on Zero's own provider, the user's own key.

Import is strictly one-way: nothing is written to, moved in, or locked in another agent's store.

Why it is small

sessions.FormatExecPrompt — behind both zero exec --resume and the TUI's /resume — renders the event log to a text digest rather than rehydrating a provider-native conversation. So an importer never has to reconstruct tool_use/tool_result pairs into Anthropic- or OpenAI-shaped messages. It only has to emit Zero Event records, after which resume, fork, rewind, compaction, lineage and the picker all work unchanged.

Four agents cost two parsers: Claude Code, Factory Droid and Pi independently converged on the same layout, so one family-1 parser serves all three. Codex needs its own (date-partitioned, payload-wrapped).

Credential safety

Every one of the surveyed agents keeps live credentials in the same tree as its transcripts — ~/.codex/auth.json (OPENAI_API_KEY + OAuth), ~/.gemini/oauth_creds.json, ~/.claude/.credentials.json, ~/.grok/auth.json, ~/.factory/auth.v2.key, and ~/.pi/agent/auth.json, which is the direct sibling of ~/.pi/agent/sessions/.

So discovery is fixed-depth globs pinned to one extension, never filepath.WalkDir; symlinks are rejected by Lstat (a link named x.jsonl pointing at auth.json otherwise passes the extension check); and a session id is resolved by comparing glob results, never by joining the id onto a root, so ../../auth matches nothing.

Imported text is untrusted input and passes through internal/redaction at a single chokepoint.

Both properties are mutation-tested: swapping the glob for a walk, or gutting the redaction call, each fail a test.

Tool work reaching the model

sessions.promptContextEvents passes messages but not EventToolCall/EventToolResult. Without help, a 22-event import gave the continuing model 2 messages and ~1,155 characters — no knowledge that any file had been touched.

Zero's own compaction cannot substitute: toolPayloadPreview allow-lists id/name/toolName/status and drops arguments and output, so a summariser learns that a Read failed but never which file or why. Those values are still in hand at translation time.

So the translator emits an activity summary as EventCompaction — a type the filter already passes — one event per category, each under the digest's 500-character per-event budget. promptContextEvents is untouched; native resumes are unaffected.

A call whose result failed withdraws its claim, so a Read of a path that does not exist is never reported as a file that was read.

Behaviour changes to existing code

  • internal/tui/model_test.go: the session-picker assertion moves from Meta == "" to "Meta must not contain the session id, and must name the source agent". That check has always been about keeping the raw id out of the row; empty-string was a proxy for it.
  • applyQuery gains a tab filter that is a no-op for every picker without a tab strip (covered by a test).

Verification

  • make fmt-check, go vet ./..., go build ./..., git diff HEAD --check — clean
  • go test ./... — all packages pass except two pre-existing failures on main: TestRunDoctorFormatsRedactedProviderDiagnostics and TestRunDoctorConnectivityProbesProvider. Both reproduce on a pristine origin/main worktree with no changes from this branch.
  • go test -race ./internal/agentsessions/ — clean
  • Advisory golangci-lint (unused,ineffassign,staticcheck) — no findings in the new code
  • Exercised against a real local corpus: 302 sessions across four agents; 260/269 Claude Code transcripts indexed (the 9 excluded are single-record bridge-session stubs), 14/14 Codex rollouts. Import → --resume verified end to end.
  • Mutation-checked: glob→walk, dropped symlink guard, gutted redaction, Codex union-type regression, tab filter in the wrong branch of applyQuery, removed failed-call withdrawal, oversized summary events, summaries emitted before the conversation — each fails its test.

Not included

Cursor, Cline, Roo, Windsurf, Continue, Aider, Grok, opencode and Gemini. Cursor and the VS Code family store chats in undocumented state.vscdb blobs with no stability guarantee, and none were installed on the machine this was built against — there is no fixture to test them against, so shipping them would be guesswork.

Known limits

Resume continues the work, not the process: the conversation, tool activity, cwd, branch and last state in flight are recoverable; the other agent's in-memory context, prompt cache and half-executed tool call are not. The activity summary is an activity log, not comprehension — it says what was done, never why.

Every one of these formats is a private, undocumented implementation detail of another product and will drift. That recurring maintenance, not the initial build, is the real cost — hence one small adapter per agent, each independently skippable, each pinned to checked-in fixtures so a format change fails a test rather than a user's import.

Summary by CodeRabbit

  • New Features

    • Discover and import sessions from Claude Code, Codex, Factory Droid, and Pi.
    • Resume imported sessions directly from the session picker, with source-agent labels and filter tabs.
    • Added discover and import commands with workspace, agent, output, event-limit, and reasoning options.
    • Imported sessions include readable activity summaries and continuation guidance.
  • Bug Fixes

    • Improved handling of incomplete transcripts, failed tool calls, duplicate activity, sensitive values, and oversized session data.
    • Session discovery is faster through short-lived caching.

Adds internal/agentsessions, which reads the sessions Claude Code, Codex,
Factory Droid and Pi leave on the local disk and translates them into Zero
session events.

Four agents, two parsers. Claude Code, Factory Droid and Pi independently
arrived at the same layout — one JSONL file per session under a directory
named after the working directory, with text/thinking/tool_use/tool_result
content blocks — so one family-1 parser serves all three. Codex differs
enough to need its own: date-partitioned directories and every record
wrapped in a "payload" object.

Three rules hold throughout, each enforced by a test rather than left to
care:

  1. Read-only. Nothing here writes to, moves or locks another agent's
     store.

  2. Path-exact globs, never a directory walk. Every one of these agents
     keeps live credentials in the same tree as its transcripts —
     ~/.codex/auth.json, ~/.grok/auth.json, ~/.factory/auth.v2.key, and
     most pointedly ~/.pi/agent/auth.json, the direct sibling of
     ~/.pi/agent/sessions/. Discovery uses fixed-depth globs pinned to one
     extension, rejects symlinks by Lstat, and resolves session ids by
     comparing glob results rather than joining an id onto a root.

  3. Imported text is untrusted input and passes through
     internal/redaction at a single chokepoint before reaching the event
     log.

Discovery is a bounded head read (64 lines / 2 MiB), never a full parse:
the corpus this was built against is 439 MB across 1,266 files with a
single 73 MB transcript in it. The byte budget is sized from measurement —
three real sessions open with a ~334 KB record, and a 256 KiB budget was
spent before reaching the record carrying cwd, dropping those sessions from
discovery with no error anywhere.

The slugged directory name is treated as a hint only. It is lossy —
"-Users-x-dev-zero" is what both /Users/x/dev/zero and /Users/x/dev-zero
produce — so it narrows the search and the cwd recorded inside the
transcript decides.

Also emits an activity summary as EventCompaction events, because
sessions.promptContextEvents passes messages but not tool events: without
this the model continuing the work sees none of the files read, commands
run or errors hit. Zero's own compaction cannot substitute, since
toolPayloadPreview allow-lists id/name/toolName/status and drops the
arguments and output this needs. Each summary event stays under the
digest's 500-character per-event budget, and a call whose result failed
withdraws its claim so a Read of a nonexistent path is never reported as a
file that was read.
Two subcommands on the existing `zero sessions` dispatcher:

  zero sessions discover              sessions from other agents, this workspace
  zero sessions import <agent>:<id>   copy one into Zero, then --resume it

discover scopes to the current workspace by default (--all widens it,
--agent filters), because an unscoped list on a machine holding a thousand
transcripts is not a list anyone can use.

import creates an ordinary Zero session, after which every existing verb —
resume, fork, rewind, compact, the picker — works on it with no further
change. The provenance tag records both the agent and the source session
id, which is what lets a caller tell an already-imported session from one
still only on the other agent's disk.

An adapter that fails is reported as a warning and does not fail the
command: these are undocumented formats belonging to other products, and
one vendor shipping a new layout must not deny the user the other three.

Nothing here reads another agent's credentials, launches its binary, or
uses its subscription. The imported session runs on Zero's own provider
with the user's own key.
The picker gains a tab strip — All, then one tab per agent that actually
has sessions, busiest first. All lists everything with the source agent on
each row; Tab narrows to one agent and wraps back to All. Typing to search
still works and the query survives a tab change, since switching agents is
a narrowing rather than a reset.

Tabs are built from what is present, so an agent never used gets no tab and
a single-source store gets no strip at all — "All | zero" is chrome that
says nothing.

The picker also lists sessions from other agents that have NOT been
imported yet; choosing one imports it and resumes in a single step. Without
this the strip was honest but nearly empty: in a workspace with 137
discoverable Claude Code sessions it offered the one that had been imported
by hand.

`/resume <agent>:<id>` accepts the same reference from the command line.
Zero session ids cannot contain a colon (sessions.ValidSessionID), so the
form is unambiguous.

Discovery is memoised per workspace for ten seconds and invalidated on
import. Opening the picker costs a bounded read of every transcript across
four stores; paying that on each keypress made /resume hitch every time it
was opened and dismissed.

The tab filter is applied before the query ranks results, so an empty
search box shows one agent's sessions rather than every agent's — the exact
moment the strip has to be trusted.

model_test.go's session-picker assertion moves from "Meta must be empty" to
"Meta must not contain the session id, and must name the source agent".
That check has always been about keeping the raw id out of the row, which
consumed half the picker and truncated the title; empty-string was a proxy
for it.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This change adds cross-agent session discovery and import support. It reads supported transcript formats, translates and redacts events, exposes CLI commands, caches discovery results, and adds agent tabs and import handling to /resume.

Changes

Foreign session storage and adapters

Layer / File(s) Summary
Session roots, bounded reads, and adapters
internal/agentsessions/types.go, internal/agentsessions/paths.go, internal/agentsessions/jsonl.go, internal/agentsessions/family1.go, internal/agentsessions/codex.go, internal/agentsessions/*_test.go
The package discovers safe JSONL transcript files, indexes bounded metadata, and reads Claude Code, Factory Droid, Pi, and Codex sessions. Tests cover path handling, malformed records, lookup validation, ordering, and tool payload variants.

Translation and import flow

Layer / File(s) Summary
Translation, activity summaries, and import state
internal/agentsessions/translate.go, internal/agentsessions/activity.go, internal/agentsessions/registry.go, internal/agentsessions/cache.go, internal/agentsessions/*_test.go
Foreign transcripts become redacted Zero events with optional reasoning, tool pairing, event limits, activity summaries, provenance tags, and cached discovery results.
CLI discovery and import commands
internal/cli/sessions.go, internal/cli/sessions_import.go
The CLI adds discover and import, with workspace, agent, event-limit, reasoning, JSON, filtering, validation, and cross-workspace output handling.
Cross-agent resume picker
internal/tui/session.go, internal/tui/picker.go, internal/tui/model.go, internal/tui/view.go, internal/tui/*test.go
/resume includes undiscovered foreign sessions, imports agent-qualified references, and displays source-agent tabs with cyclic navigation and preserved search text.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant Adapter
  participant ForeignStore
  participant ZeroStore
  CLI->>Adapter: discover or read session reference
  Adapter->>ForeignStore: scan or stream transcript
  ForeignStore-->>Adapter: session metadata and transcript records
  Adapter-->>CLI: redacted translated events
  CLI->>ZeroStore: create imported session and append events
  ZeroStore-->>CLI: imported session result
Loading

Possibly related PRs

  • Gitlawb/zero#855: Strengthens shared secret-redaction behavior used by imported session and tool content.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes importing local sessions from other coding agents and continuing them in Zero.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/import-agent-sessions

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

🧹 Nitpick comments (15)
internal/agentsessions/registry.go (2)

134-140: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The comment states an import tag format that the code no longer produces.

Line 138 says the tag is "imported:claude-code". ImportTag at line 91 produces "imported:claude-code:<foreign session id>". Update the comment.

As per coding guidelines: "Ensure PR descriptions, help text, and comments match shipped behavior".

📝 Proposed comment fix
-// Provenance lives in the tag ("imported:claude-code") and in the title.
+// Provenance lives in the tag ("imported:claude-code:<foreign session id>")
+// and in the title.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/registry.go` around lines 134 - 140, Update the
provenance comment near ImportTag to describe the shipped tag format, including
the foreign session ID suffix (for example, “imported:claude-code:<foreign
session id>”), without changing the import behavior.

Source: Coding guidelines


141-152: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Import indexes the whole foreign store twice for one session.

describe calls adapter.Discover(""), which head-reads every transcript in the store. The file comments report 1,266 files and 439 MB on one real machine. adapter.Read then globs the same store again to resolve the id. A single import therefore pays a full index plus a second directory scan, only to obtain the title, cwd, and model.

This is acceptable for a one-shot CLI import. It is worth reconsidering if the TUI picker imports on selection. Consider adding a Describe(id string) (ForeignSession, bool) method to Adapter so both the lookup and the read resolve the path once.

Also applies to: 175-187

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/registry.go` around lines 141 - 152, The Import flow
currently scans the foreign store twice by calling describe and then
adapter.Read. Add an Adapter-level Describe(id string) (ForeignSession, bool)
lookup that resolves the session path once, update Import to use it for metadata
and pass the resolved path or session to the read operation, and preserve the
existing missing-session and read-error behavior.
internal/agentsessions/family1_test.go (1)

248-257: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The 85% ratio assertion depends on a developer's private corpus.

TestTheRealCorpusStillParses fails when a contributor's real store contains a higher share of stubs than the store this threshold was measured on. The failure is not caused by the change under test. Consider reporting the ratio with t.Logf and keeping only a lower, clearly-broken bound, for example ratio == 0.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/family1_test.go` around lines 248 - 257, The ratio
assertion in TestTheRealCorpusStillParses is tied to a private corpus and should
not require 85% coverage. Replace the 0.85 failure threshold with only a clearly
broken zero-result check, while retaining the existing ratio reporting via
t.Logf and diagnostic context.
internal/agentsessions/translate_test.go (2)

51-59: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The doc comment for TestPayloadKeysMatchWhatTheTUIReads is attached to conversationEvents.

Lines 51-55 describe the test. Lines 56-58 describe conversationEvents. The whole block sits above conversationEvents, so godoc reports the TUI-tripwire explanation as documentation for the helper. Move lines 51-55 above the test at line 70.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/translate_test.go` around lines 51 - 59, Move the TUI
payload-key tripwire documentation so it directly precedes
TestPayloadKeysMatchWhatTheTUIReads, and leave the conversationEvents-specific
explanation immediately above conversationEvents. Ensure each comment block
documents only its corresponding symbol.

259-266: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the exact counts in the trim note.

The test checks only that the summary contains "not imported". The reported number is therefore unverified, and it is currently wrong by one. Add assertions for both numbers, and add a case for MaxEvents: 1, which yields a note and zero conversation events.

As per coding guidelines: "Every behavior or security-boundary change requires a regression test, including failure paths."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/translate_test.go` around lines 259 - 266, The
trim-note test around the existing event-type and summary assertions only checks
wording; assert both reported event counts and correct the expected count. Add a
separate case covering MaxEvents: 1, verifying it emits the trim note followed
by zero conversation events, so the boundary behavior is regression-tested.

Source: Coding guidelines

internal/agentsessions/cache_test.go (1)

81-107: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Cover the problems slice too.

The test asserts the aliasing property for sessions only. DiscoverAllCached copies sessions but returns entry.problems by reference at internal/agentsessions/cache.go Line 45. A caller that appends to or sorts that slice reaches the next caller's results. Either copy problems in cache.go and extend this test, or state in the comment that only sessions is protected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/cache_test.go` around lines 81 - 107, Extend
TestCallersCannotReorderEachOthersResults to mutate the returned problems slice
and verify a subsequent DiscoverAllCached call is unaffected; also update the
cache implementation to return a copied problems slice alongside the existing
sessions copy, using the relevant entry.problems handling in DiscoverAllCached.
internal/agentsessions/paths_test.go (1)

78-147: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add a case for a symlinked project directory.

This test plants decoys at the wrong depth and credential files at three levels. It does not cover an intermediate component that is a symlink. globTranscripts only Lstats the final match, so a symlinked project directory under the sessions root escapes the store and the test still passes. Add a case where sessions/<slug> is a symlink to a directory outside the store, and assert that no transcript under it is returned.

The coding guidelines state: "Every behavior or security-boundary change requires a regression test, including failure paths."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/paths_test.go` around lines 78 - 147, The test
TestDiscoveryGlobsNeverMatchACredentialFile must cover symlink traversal through
the project-directory component. Create an external directory containing a
transcript, add a sessions/<slug> symlink pointing to it, invoke
globTranscripts, and assert the external transcript is not returned while
preserving the existing valid-transcript assertion.

Source: Coding guidelines

internal/agentsessions/cache.go (2)

42-49: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Key the memo by the normalized workspace path.

The map key is the raw cwd string. paths.go defines normalizeDir for exactly this problem: /tmp/proj, /tmp/proj/, and /private/tmp/proj are the same workspace, and sameDir treats them as equal. Here they produce three separate entries and three separate 300ms discoveries, and InvalidateDiscovery is the only thing that ever bounds the map size. Normalize the key once at entry.

♻️ Proposed fix
 func DiscoverAllCached(env Env, cwd string) ([]ForeignSession, []error) {
+	key := normalizeDir(cwd)
 	discoveryMu.Lock()
 	defer discoveryMu.Unlock()
 
-	if entry, ok := discoveryCache[cwd]; ok && discoveryNow().Sub(entry.at) < discoveryTTL {
+	if entry, ok := discoveryCache[key]; ok && discoveryNow().Sub(entry.at) < discoveryTTL {
 		// Copy: callers sort and filter the slice they are handed, and a shared
 		// backing array would let one caller reorder another's results.
 		return append([]ForeignSession{}, entry.sessions...), entry.problems
 	}
 
 	found, problems := DiscoverAll(env, cwd)
-	discoveryCache[cwd] = discoveryEntry{sessions: found, problems: problems, at: discoveryNow()}
+	discoveryCache[key] = discoveryEntry{sessions: found, problems: problems, at: discoveryNow()}
 	return append([]ForeignSession{}, found...), problems
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/cache.go` around lines 42 - 49, Normalize cwd once at
the entry point using normalizeDir, then use that normalized workspace path
consistently as the discoveryCache key for lookup and storage in the surrounding
discovery function. Preserve the existing cache-copy, discovery, and
problem-handling behavior, and ensure InvalidateDiscovery receives or matches
the same normalized key.

27-33: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

discoveryNow is mutated by tests outside the mutex.

withFakeClock in internal/agentsessions/cache_test.go assigns discoveryNow while DiscoverAllCached reads it under discoveryMu. No test in this package calls t.Parallel, so the race detector stays quiet today. The moment one does, go test -race reports a data race on a package-level variable. Move the clock into the guarded state, or read and write it under discoveryMu.

The coding guidelines state: "run affected concurrent code under the race detector."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/cache.go` around lines 27 - 33, Protect discoveryNow
consistently with discoveryMu: update withFakeClock’s test assignment and
restoration to hold the mutex, and ensure DiscoverAllCached reads the clock
while holding the same lock. Prefer moving the clock into the mutex-guarded
discovery state if that fits the existing design, while preserving
test-controlled TTL behavior.

Source: Coding guidelines

internal/agentsessions/jsonl_test.go (2)

142-173: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a streamLines case for an over-long record.

TestALineTooLongToKeepIsSkippedNotFatal covers scanHead only. streamLines is the function used for the full import read, so an over-long record there decides whether an imported transcript loses a message or fails outright. Add a case that feeds streamLines a record longer than its limit and assert the following records are still visited.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/jsonl_test.go` around lines 142 - 173, Add a focused
test for streamLines where one record exceeds the configured size limit,
asserting streamLines returns no error and still invokes the callback for
subsequent records. Reuse the existing temporary-file and callback-counting
patterns from TestStreamLinesReadsEverything and
TestStreamLinesToleratesAMissingTrailingNewline.

16-46: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Shrink the 40 MB fixture.

The loop writes 200 lines of 200 KiB each, so this test creates roughly 40 MB on disk on every run, including race-detector runs. The property under test is a ratio: bytes read must stay under defaultHeadLimit.MaxBytes and well under the file size. Size the fixture from defaultHeadLimit.MaxBytes instead of a fixed 32 MB floor. A file of a few megabytes proves the same property and keeps the suite fast.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/jsonl_test.go` around lines 16 - 46, Reduce the
fixture size in TestScanHeadReadsFarLessThanTheWholeFile by deriving the bulk
content or number of lines from defaultHeadLimit.MaxBytes rather than writing
200 fixed 200 KiB lines. Keep the file several times larger than the head budget
so the existing read-limit and file-size ratio assertions still verify the
intended behavior without creating a roughly 40 MB fixture.
internal/cli/sessions_import.go (2)

138-142: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Take now as a parameter instead of calling time.Now() in the loop.

describeAge already accepts a clock. formatDiscoveredSessions defeats that seam by calling time.Now() per session, so a table test cannot pin the "today" / "Jan _2" / date branches. The redundant IsZero check also disappears, because describeAge already returns "" for a zero time.

♻️ Proposed change
-func formatDiscoveredSessions(found []agentsessions.ForeignSession, cwd string) string {
+func formatDiscoveredSessions(found []agentsessions.ForeignSession, cwd string, now time.Time) string {
 	if len(found) == 0 {
 	for _, session := range found {
-		age := ""
-		if !session.UpdatedAt.IsZero() {
-			age = describeAge(session.UpdatedAt, time.Now())
-		}
+		age := describeAge(session.UpdatedAt, now)
 		header := session.Agent + ":" + session.ID

Then update the call site on line 42 to pass time.Now().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/sessions_import.go` around lines 138 - 142, Update
formatDiscoveredSessions to accept a now time parameter and pass that value to
describeAge for every session, removing the per-session time.Now() call and
redundant UpdatedAt.IsZero() check. Update its caller to provide time.Now().

33-34: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Validate --agent against the known adapter names.

A misspelled agent name silently yields an empty result. agentsessions.ParseRef rejects an unknown agent for import, so discover behaves differently for the same input. The empty-state text does list the readable agents, so this is a polish item, not a bug.

♻️ Optional: reject an unknown agent name up front
 	found, problems := agentsessions.DiscoverAll(agentsessions.OSEnv(), cwd)
+	if wanted := strings.TrimSpace(options.agent); wanted != "" {
+		known := agentsessions.AdapterNames(agentsessions.OSEnv())
+		if !containsFold(known, wanted) {
+			return writeExecUsageError(stderr, "unknown agent "+wanted+"; known agents: "+strings.Join(known, ", "))
+		}
+	}
 	found = filterDiscoveredByAgent(found, options.agent)

containsFold would be a small helper using strings.EqualFold.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/sessions_import.go` around lines 33 - 34, Validate options.agent
against the known adapter names before calling filterDiscoveredByAgent in the
discover flow, using case-insensitive matching consistent with
agentsessions.ParseRef and the existing readable-agent list. Reject unknown
non-empty agent names up front instead of allowing them to produce an empty
result, while preserving discovery for valid names and omitted filters.
internal/tui/model.go (1)

1806-1812: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Wire Shift+Tab to cycleTab(-1), or drop the backward path.

cycleTab accepts a negative delta, and TestCyclingBackwardsWraps exercises it, but no key binding reaches it. The Shift+Tab branch at line 1659 has no tabbed-picker case, so it falls to m.noBlockingModal(), which an open picker makes false. Shift+Tab therefore does nothing while the /resume strip is up.

Forward-only cycling works with three tabs. It stops being reasonable if a user has sessions from all four supported agents plus Zero, where reaching the previous tab costs four presses.

♻️ Proposed addition in the Shift+Tab branch
 		case keyIs(msg, tea.KeyTab) && keyShift(msg):
 			if m.transcriptDetailed {
 				return m, nil
 			}
 			if m.pendingPermission != nil {
 				return m.movePermissionCursor(-1), nil
 			}
 			if m.pendingAskUser != nil {
 				return m.moveAskUserTab(-1), nil
 			}
+			if m.picker != nil && m.picker.hasTabs() {
+				m.picker.cycleTab(-1)
+				return m, nil
+			}

If you keep forward-only cycling, remove TestCyclingBackwardsWraps or restate it as a unit test of cycleTab rather than of user-reachable behavior.

As per coding guidelines: "wire advertised entry points or narrow the claim".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/model.go` around lines 1806 - 1812, Update the Shift+Tab
handling branch in the model’s key-processing logic to detect an open tabbed
picker, call m.picker.cycleTab(-1), and return before the noBlockingModal
fallback. Alternatively, remove or narrow TestCyclingBackwardsWraps so it only
verifies the cycleTab method rather than user-reachable behavior; preserve the
existing forward Tab handling.

Source: Coding guidelines

internal/tui/session_picker_tabs_test.go (1)

69-76: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the imported-session dedup rule; this test cannot fail.

Two points.

TestAnAgentWithNoSessionsGetsNoTab builds a picker from zero and codex rows, then asserts that no tab is named factory or pi. sessionPickerTabs derives every tab from the items it receives, so the assertion holds by construction. The test documents intent but detects no regression.

More important is what is missing. foreignSessionItems skips any discovered session whose <agent>:<id> already appears as an import tag on a local session. That rule is what stops /resume from listing the same conversation twice — once as itself and once as its copy. No test in this file covers it, because every test here constructs pickerItem values directly and never exercises foreignSessionItems.

A table test over ParseImportTag inputs plus a fake discovery result would cover it. That needs the injectable agentsessions.Env discussed on internal/tui/model_test.go, so the two are worth doing together.

As per coding guidelines: "Every behavior or security-boundary change requires a regression test, including failure paths".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/session_picker_tabs_test.go` around lines 69 - 76, Replace the
construction-only assertions in TestAnAgentWithNoSessionsGetsNoTab with
regression coverage for foreignSessionItems: use an injectable agentsessions.Env
and fake discovery results to verify sessions whose <agent>:<id> matches a local
session’s ParseImportTag are excluded, while non-matching imported sessions
remain. Add table cases covering matching, non-matching, and malformed import
tags, reusing the test injection pattern from model_test.go.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/agentsessions/activity.go`:
- Around line 258-312: Update activityLog.summaryEvents to apply
maxSummaryEventChars to the fully assembled headline after adding the
toolBreakdown text, rather than relying on toolBreakdown’s independent
truncation. Preserve the existing count and breakdown content while ensuring the
emitted headline stays within the event budget, and extend the relevant summary
test with many unrecognised tool names to cover this case.
- Around line 89-118: Change activityLog deduplication to track claim counts
rather than booleans: update newActivityLog to initialize seen as
map[string]int, increment the bucket/value key in add, and decrement it in
withdraw. Remove the list entry and delete the key only when its count reaches
zero, preserving entries still referenced by other calls.

In `@internal/agentsessions/cache.go`:
- Around line 38-51: Update DiscoverAllCached so the discoveryMu lock is held
only while checking the cache and storing results, not while calling the slow
DiscoverAll operation. Unlock before discovery, allow concurrent misses
(including different workspaces) to proceed independently, then re-acquire the
lock to write the discovered entry and return the copied sessions and problems.

In `@internal/agentsessions/codex_test.go`:
- Around line 150-189: Gate TestTheRealCodexCorpusStillParses behind an explicit
opt-in environment variable, returning via t.Skip before accessing codexRoot,
OSEnv, or the developer’s transcripts when the variable is unset. Preserve the
existing live-corpus assertions for opted-in runs, and keep path-sensitive
behavior covered through a hermetic or non-Linux test rather than relying on
this live test.

In `@internal/agentsessions/paths.go`:
- Around line 97-117: Update globTranscripts in internal/agentsessions/paths.go
(lines 97-117) to reject matches with symlinked parent components and enforce
containment at open time using a rooted or handle-relative no-follow API,
including platform reparse-point protections; final-component Lstat alone is
insufficient. In internal/agentsessions/paths_test.go (lines 78-147), add
coverage where sessions/<slug> symlinks to a directory outside the store and
assert no transcript beneath it is returned.
- Around line 48-60: Update claudeCodeRoot and codexRoot so configured
CLAUDE_CONFIG_DIR or CODEX_HOME values are used only when absolute; treat
relative values like unset configuration and fall back to env.underHome with the
existing default subpaths.
- Around line 195-203: Update sameDir to compare normalized paths
case-insensitively when runtime.GOOS is Windows, while preserving the existing
case-sensitive comparison on other platforms. Add the runtime dependency to the
import block and keep the current empty-path rejection unchanged.

In `@internal/agentsessions/registry.go`:
- Around line 154-167: Update the import flow around store.Create and
store.AppendEvents to delete the newly created session via the sessions store’s
existing delete/remove operation when AppendEvents fails. Preserve the original
append error, but return a combined error if cleanup also fails; never delete
pre-existing sessions or report success after unsuccessful cleanup.

In `@internal/agentsessions/translate.go`:
- Around line 91-97: Full-read translators silently discard records truncated by
the 64 KiB stream limit; make truncation observable and emit a noteEvent for
each skipped truncated record. In internal/agentsessions/translate.go lines
91-97, update streamLines/readBoundedLine signaling and translateFamily1 to
distinguish truncation from ordinary unmarshal failures. Apply the same handling
in internal/agentsessions/codex.go lines 195-199 within translateCodex, while
preserving silent skipping for unrecognised or non-response records.
- Around line 189-201: Update capEvents so the omitted-event count includes
kept[0], using len(events)-(max-1) or the equivalent count, and pass that
corrected value to plural. Adjust the note text to use singular/plural verb
agreement, producing “was not imported” for one omitted event and “were not
imported” otherwise.

In `@internal/cli/sessions_import.go`:
- Around line 230-236: Replace the lexical filepath.Clean comparison in the
sessions import workspace check with the shared sessionMatchesWorkspace
predicate. Promote sessionMatchesWorkspace from internal/tui/session.go to an
appropriate shared package, update both callers to use it, and preserve the
existing empty-string behavior when the workspaces match or the current
directory cannot be determined.
- Around line 1-14: Add regression tests for the sessions discover and import
command flows, covering agent filtering, JSON output, failure exit codes, and
importWorkspaceWarning behavior. Include a non-Linux case that verifies
workspace path normalization, and use the command handlers and existing
session-test helpers to assert results and errors without changing production
behavior.

In `@internal/tui/model_test.go`:
- Around line 908-917: Thread an agentsessions.Env through the model and
session-picker construction so newSessionPicker and foreignSessionItems use the
injected environment instead of agentsessions.OSEnv(). In
internal/tui/model_test.go lines 908-917, build the model with a t.TempDir()
home to isolate discovery. In internal/tui/session_picker_tabs_test.go lines
69-76, use the same injected Env, add coverage for imported-session
deduplication in foreignSessionItems, and strengthen
TestAnAgentWithNoSessionsGetsNoTab so it genuinely verifies the no-tab behavior.

In `@internal/tui/session.go`:
- Around line 434-444: Update newSessionPicker to retain each session’s raw
update time on pickerItem, including items from both local assembly and
foreignSessionItems, then sort the merged items by recency before building the
picker. Add or reuse sortPickerItemsByRecency so sorting uses time.Time rather
than the formatted Label, while preserving per-agent item behavior.
- Around line 515-518: Guard session.UpdatedAt.IsZero() before formatting it, so
zero timestamps do not reach sessionWhen or sessionPickerLabel and produce a
year-1 date. Update the surrounding label logic in the session row path,
preferably by reusing or adding a typed time.Time variant of sessionWhen to
avoid converting the timestamp through RFC3339 text while preserving existing
behavior for populated timestamps.
- Around line 473-479: Move the synchronous agentsessions.Import call out of the
Bubble Tea Update path into a tea.Cmd that performs the import asynchronously
and returns a result message containing the session or error, then handle that
message in the Update flow while preserving agentsessions.InvalidateDiscovery
before rebuilding the picker. Review whether the import should set an explicit
MaxEvents limit instead of using uncapped ReadOptions{}.

---

Nitpick comments:
In `@internal/agentsessions/cache_test.go`:
- Around line 81-107: Extend TestCallersCannotReorderEachOthersResults to mutate
the returned problems slice and verify a subsequent DiscoverAllCached call is
unaffected; also update the cache implementation to return a copied problems
slice alongside the existing sessions copy, using the relevant entry.problems
handling in DiscoverAllCached.

In `@internal/agentsessions/cache.go`:
- Around line 42-49: Normalize cwd once at the entry point using normalizeDir,
then use that normalized workspace path consistently as the discoveryCache key
for lookup and storage in the surrounding discovery function. Preserve the
existing cache-copy, discovery, and problem-handling behavior, and ensure
InvalidateDiscovery receives or matches the same normalized key.
- Around line 27-33: Protect discoveryNow consistently with discoveryMu: update
withFakeClock’s test assignment and restoration to hold the mutex, and ensure
DiscoverAllCached reads the clock while holding the same lock. Prefer moving the
clock into the mutex-guarded discovery state if that fits the existing design,
while preserving test-controlled TTL behavior.

In `@internal/agentsessions/family1_test.go`:
- Around line 248-257: The ratio assertion in TestTheRealCorpusStillParses is
tied to a private corpus and should not require 85% coverage. Replace the 0.85
failure threshold with only a clearly broken zero-result check, while retaining
the existing ratio reporting via t.Logf and diagnostic context.

In `@internal/agentsessions/jsonl_test.go`:
- Around line 142-173: Add a focused test for streamLines where one record
exceeds the configured size limit, asserting streamLines returns no error and
still invokes the callback for subsequent records. Reuse the existing
temporary-file and callback-counting patterns from
TestStreamLinesReadsEverything and
TestStreamLinesToleratesAMissingTrailingNewline.
- Around line 16-46: Reduce the fixture size in
TestScanHeadReadsFarLessThanTheWholeFile by deriving the bulk content or number
of lines from defaultHeadLimit.MaxBytes rather than writing 200 fixed 200 KiB
lines. Keep the file several times larger than the head budget so the existing
read-limit and file-size ratio assertions still verify the intended behavior
without creating a roughly 40 MB fixture.

In `@internal/agentsessions/paths_test.go`:
- Around line 78-147: The test TestDiscoveryGlobsNeverMatchACredentialFile must
cover symlink traversal through the project-directory component. Create an
external directory containing a transcript, add a sessions/<slug> symlink
pointing to it, invoke globTranscripts, and assert the external transcript is
not returned while preserving the existing valid-transcript assertion.

In `@internal/agentsessions/registry.go`:
- Around line 134-140: Update the provenance comment near ImportTag to describe
the shipped tag format, including the foreign session ID suffix (for example,
“imported:claude-code:<foreign session id>”), without changing the import
behavior.
- Around line 141-152: The Import flow currently scans the foreign store twice
by calling describe and then adapter.Read. Add an Adapter-level Describe(id
string) (ForeignSession, bool) lookup that resolves the session path once,
update Import to use it for metadata and pass the resolved path or session to
the read operation, and preserve the existing missing-session and read-error
behavior.

In `@internal/agentsessions/translate_test.go`:
- Around line 51-59: Move the TUI payload-key tripwire documentation so it
directly precedes TestPayloadKeysMatchWhatTheTUIReads, and leave the
conversationEvents-specific explanation immediately above conversationEvents.
Ensure each comment block documents only its corresponding symbol.
- Around line 259-266: The trim-note test around the existing event-type and
summary assertions only checks wording; assert both reported event counts and
correct the expected count. Add a separate case covering MaxEvents: 1, verifying
it emits the trim note followed by zero conversation events, so the boundary
behavior is regression-tested.

In `@internal/cli/sessions_import.go`:
- Around line 138-142: Update formatDiscoveredSessions to accept a now time
parameter and pass that value to describeAge for every session, removing the
per-session time.Now() call and redundant UpdatedAt.IsZero() check. Update its
caller to provide time.Now().
- Around line 33-34: Validate options.agent against the known adapter names
before calling filterDiscoveredByAgent in the discover flow, using
case-insensitive matching consistent with agentsessions.ParseRef and the
existing readable-agent list. Reject unknown non-empty agent names up front
instead of allowing them to produce an empty result, while preserving discovery
for valid names and omitted filters.

In `@internal/tui/model.go`:
- Around line 1806-1812: Update the Shift+Tab handling branch in the model’s
key-processing logic to detect an open tabbed picker, call
m.picker.cycleTab(-1), and return before the noBlockingModal fallback.
Alternatively, remove or narrow TestCyclingBackwardsWraps so it only verifies
the cycleTab method rather than user-reachable behavior; preserve the existing
forward Tab handling.

In `@internal/tui/session_picker_tabs_test.go`:
- Around line 69-76: Replace the construction-only assertions in
TestAnAgentWithNoSessionsGetsNoTab with regression coverage for
foreignSessionItems: use an injectable agentsessions.Env and fake discovery
results to verify sessions whose <agent>:<id> matches a local session’s
ParseImportTag are excluded, while non-matching imported sessions remain. Add
table cases covering matching, non-matching, and malformed import tags, reusing
the test injection pattern from model_test.go.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7c1df6e0-d321-4254-bc75-bca5c98723d3

📥 Commits

Reviewing files that changed from the base of the PR and between ff608c7 and a957369.

📒 Files selected for processing (25)
  • internal/agentsessions/activity.go
  • internal/agentsessions/activity_test.go
  • internal/agentsessions/cache.go
  • internal/agentsessions/cache_test.go
  • internal/agentsessions/codex.go
  • internal/agentsessions/codex_test.go
  • internal/agentsessions/family1.go
  • internal/agentsessions/family1_test.go
  • internal/agentsessions/import_resume_test.go
  • internal/agentsessions/jsonl.go
  • internal/agentsessions/jsonl_test.go
  • internal/agentsessions/paths.go
  • internal/agentsessions/paths_test.go
  • internal/agentsessions/registry.go
  • internal/agentsessions/translate.go
  • internal/agentsessions/translate_test.go
  • internal/agentsessions/types.go
  • internal/cli/sessions.go
  • internal/cli/sessions_import.go
  • internal/tui/model.go
  • internal/tui/model_test.go
  • internal/tui/picker.go
  • internal/tui/session.go
  • internal/tui/session_picker_tabs_test.go
  • internal/tui/view.go

Comment on lines +89 to +118
// withdraw removes a value a failed call had contributed.
func (log *activityLog) withdraw(claim pathClaim) {
list := &log.read
if claim.bucket == "changed" {
list = &log.changed
}
for index, value := range *list {
if value == claim.value {
*list = append((*list)[:index], (*list)[index+1:]...)
break
}
}
delete(log.seen, claim.bucket+"\x00"+claim.value)
}

// add appends value to list unless an equal value is already recorded under
// bucket. Deduplicated because agents re-read the same file repeatedly and a
// list of forty identical paths tells the reader nothing.
func (log *activityLog) add(bucket string, list *[]string, value string) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return
}
key := bucket + "\x00" + trimmed
if log.seen[key] {
return
}
log.seen[key] = true
*list = append(*list, trimmed)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A failed call can withdraw a value another call recorded successfully.

add deduplicates by bucket + value, so two calls that touch the same path produce one entry. withdraw removes that single entry unconditionally. If call t1 reads parser.go and succeeds, and call t2 reads parser.go and fails, the successful read disappears from "Files read". The summary then understates the prior work, which is the failure mode this file exists to prevent.

Count the claims per key and withdraw only when the count reaches zero.

🐛 Proposed fix
-	seen map[string]bool
+	// seen counts how many calls contributed each bucket/value pair, so a
+	// failed call cannot withdraw a value another call recorded successfully.
+	seen map[string]int
 }
 func (log *activityLog) withdraw(claim pathClaim) {
+	key := claim.bucket + "\x00" + claim.value
+	if log.seen[key] > 1 {
+		log.seen[key]--
+		return
+	}
 	list := &log.read
 	if claim.bucket == "changed" {
 		list = &log.changed
 	}
 	for index, value := range *list {
 		if value == claim.value {
 			*list = append((*list)[:index], (*list)[index+1:]...)
 			break
 		}
 	}
-	delete(log.seen, claim.bucket+"\x00"+claim.value)
+	delete(log.seen, key)
 }
 
 func (log *activityLog) add(bucket string, list *[]string, value string) {
 	trimmed := strings.TrimSpace(value)
 	if trimmed == "" {
 		return
 	}
 	key := bucket + "\x00" + trimmed
-	if log.seen[key] {
+	if log.seen[key] > 0 {
+		log.seen[key]++
 		return
 	}
-	log.seen[key] = true
+	log.seen[key] = 1
 	*list = append(*list, trimmed)
 }

Update newActivityLog to build seen: map[string]int{}.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/activity.go` around lines 89 - 118, Change activityLog
deduplication to track claim counts rather than booleans: update newActivityLog
to initialize seen as map[string]int, increment the bucket/value key in add, and
decrement it in withdraw. Remove the list entry and delete the key only when its
count reaches zero, preserving entries still referenced by other calls.

Comment on lines +258 to +312
func (log *activityLog) summaryEvents() []sessions.AppendEventInput {
if log == nil || log.calls == 0 {
return nil
}

events := []sessions.AppendEventInput{}
headline := "Prior session activity: " + countPhrase(log.calls, "tool call")
if log.failed > 0 {
headline += ", " + countPhrase(log.failed, "failure")
}
headline += "."
if extra := log.toolBreakdown(); extra != "" {
headline += " " + extra
}
events = append(events, noteEvent(headline))

for _, section := range []struct {
label string
items []string
}{
{"Files read", log.read},
{"Files changed", log.changed},
{"Commands run", log.commands},
{"Searched for", log.searches},
{"Failures", log.failures},
} {
if line := summaryLine(section.label, section.items); line != "" {
events = append(events, noteEvent(line))
}
}
return events
}

// toolBreakdown names tools whose arguments yielded nothing, so an unrecognised
// schema degrades to "Also: exec x4" rather than to silence.
func (log *activityLog) toolBreakdown() string {
if len(log.toolCounts) == 0 {
return ""
}
names := make([]string, 0, len(log.toolCounts))
for name := range log.toolCounts {
names = append(names, name)
}
sort.Strings(names)
parts := make([]string, 0, len(names))
for _, name := range names {
count := log.toolCounts[name]
if count > 1 {
parts = append(parts, name+" x"+itoaEvents(count))
continue
}
parts = append(parts, name)
}
return truncateToBudget("Also: "+strings.Join(parts, ", "), maxSummaryEventChars)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The headline event can exceed its own character budget.

toolBreakdown truncates to maxSummaryEventChars (460). The result is then appended to a headline that already holds "Prior session activity: N tool calls, M failures." So the combined string reaches about 510 characters. That is over the 460 budget and over the 500-character digest limit this file exists to respect, so the headline is cut mid-sentence by sessions.summarizePayload.

TestEverySummaryEventSurvivesTheDigestIntact does not catch this, because its fixture uses only Read calls and leaves toolCounts empty. Truncate the assembled headline, and add a case with many unrecognised tool names.

🐛 Proposed fix
 	if extra := log.toolBreakdown(); extra != "" {
 		headline += " " + extra
 	}
-	events = append(events, noteEvent(headline))
+	events = append(events, noteEvent(truncateToBudget(headline, maxSummaryEventChars)))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (log *activityLog) summaryEvents() []sessions.AppendEventInput {
if log == nil || log.calls == 0 {
return nil
}
events := []sessions.AppendEventInput{}
headline := "Prior session activity: " + countPhrase(log.calls, "tool call")
if log.failed > 0 {
headline += ", " + countPhrase(log.failed, "failure")
}
headline += "."
if extra := log.toolBreakdown(); extra != "" {
headline += " " + extra
}
events = append(events, noteEvent(headline))
for _, section := range []struct {
label string
items []string
}{
{"Files read", log.read},
{"Files changed", log.changed},
{"Commands run", log.commands},
{"Searched for", log.searches},
{"Failures", log.failures},
} {
if line := summaryLine(section.label, section.items); line != "" {
events = append(events, noteEvent(line))
}
}
return events
}
// toolBreakdown names tools whose arguments yielded nothing, so an unrecognised
// schema degrades to "Also: exec x4" rather than to silence.
func (log *activityLog) toolBreakdown() string {
if len(log.toolCounts) == 0 {
return ""
}
names := make([]string, 0, len(log.toolCounts))
for name := range log.toolCounts {
names = append(names, name)
}
sort.Strings(names)
parts := make([]string, 0, len(names))
for _, name := range names {
count := log.toolCounts[name]
if count > 1 {
parts = append(parts, name+" x"+itoaEvents(count))
continue
}
parts = append(parts, name)
}
return truncateToBudget("Also: "+strings.Join(parts, ", "), maxSummaryEventChars)
}
func (log *activityLog) summaryEvents() []sessions.AppendEventInput {
if log == nil || log.calls == 0 {
return nil
}
events := []sessions.AppendEventInput{}
headline := "Prior session activity: " + countPhrase(log.calls, "tool call")
if log.failed > 0 {
headline += ", " + countPhrase(log.failed, "failure")
}
headline += "."
if extra := log.toolBreakdown(); extra != "" {
headline += " " + extra
}
events = append(events, noteEvent(truncateToBudget(headline, maxSummaryEventChars)))
for _, section := range []struct {
label string
items []string
}{
{"Files read", log.read},
{"Files changed", log.changed},
{"Commands run", log.commands},
{"Searched for", log.searches},
{"Failures", log.failures},
} {
if line := summaryLine(section.label, section.items); line != "" {
events = append(events, noteEvent(line))
}
}
return events
}
// toolBreakdown names tools whose arguments yielded nothing, so an unrecognised
// schema degrades to "Also: exec x4" rather than to silence.
func (log *activityLog) toolBreakdown() string {
if len(log.toolCounts) == 0 {
return ""
}
names := make([]string, 0, len(log.toolCounts))
for name := range log.toolCounts {
names = append(names, name)
}
sort.Strings(names)
parts := make([]string, 0, len(names))
for _, name := range names {
count := log.toolCounts[name]
if count > 1 {
parts = append(parts, name+" x"+itoaEvents(count))
continue
}
parts = append(parts, name)
}
return truncateToBudget("Also: "+strings.Join(parts, ", "), maxSummaryEventChars)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/activity.go` around lines 258 - 312, Update
activityLog.summaryEvents to apply maxSummaryEventChars to the fully assembled
headline after adding the toolBreakdown text, rather than relying on
toolBreakdown’s independent truncation. Preserve the existing count and
breakdown content while ensuring the emitted headline stays within the event
budget, and extend the relevant summary test with many unrecognised tool names
to cover this case.

Comment on lines +38 to +51
func DiscoverAllCached(env Env, cwd string) ([]ForeignSession, []error) {
discoveryMu.Lock()
defer discoveryMu.Unlock()

if entry, ok := discoveryCache[cwd]; ok && discoveryNow().Sub(entry.at) < discoveryTTL {
// Copy: callers sort and filter the slice they are handed, and a shared
// backing array would let one caller reorder another's results.
return append([]ForeignSession{}, entry.sessions...), entry.problems
}

found, problems := DiscoverAll(env, cwd)
discoveryCache[cwd] = discoveryEntry{sessions: found, problems: problems, at: discoveryNow()}
return append([]ForeignSession{}, found...), problems
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Discovery runs while the global lock is held.

DiscoverAll reads four transcript stores and costs 50-300ms per the comment at Lines 10-13. That call happens at Line 48 with discoveryMu held. Every other caller of DiscoverAllCached blocks for the full duration, including callers for a different workspace that already have a valid memo. The comment states the goal is to stop the TUI hitching; a global lock around the slow path reintroduces the hitch on a concurrent open.

Release the lock while discovering, then re-acquire it to store the entry. Accept that two concurrent misses may both discover; that is cheaper than serializing every caller.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/cache.go` around lines 38 - 51, Update
DiscoverAllCached so the discoveryMu lock is held only while checking the cache
and storing results, not while calling the slow DiscoverAll operation. Unlock
before discovery, allow concurrent misses (including different workspaces) to
proceed independently, then re-acquire the lock to write the discovered entry
and return the copied sessions and problems.

Comment on lines +150 to +189
func TestTheRealCodexCorpusStillParses(t *testing.T) {
env := OSEnv()
root := codexRoot(env)
if root == "" {
t.Skip("no home directory")
}
if _, err := os.Stat(root); err != nil {
t.Skip("no Codex store on this machine")
}
adapter := Codex(env)
found, err := adapter.Discover("")
if err != nil {
t.Fatal(err)
}
total := len(adapter.(codex).transcripts())
if total == 0 {
t.Skip("store exists but holds no rollouts")
}
titled, modelled := 0, 0
for _, session := range found {
if session.Title != "" && session.Title != "untitled" && !isCodexContextInjection(session.Title) {
titled++
}
if session.ModelID != "" {
modelled++
}
}
t.Logf("indexed %d of %d rollouts; %d titled, %d with a model", len(found), total, titled, modelled)
if len(found) == 0 {
t.Fatal("no Codex sessions indexed from a non-empty store")
}
// Both of these were zero before the fixes above; a regression takes them
// back to zero rather than to some slightly-lower number.
if titled == 0 {
t.Error("no session got a real title — the context-injection filter has stopped working")
}
if modelled == 0 {
t.Error("no session got a model — turn_context is being discarded again")
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Gate the live-corpus test behind an explicit opt-in.

This test reads the developer's real ~/.codex store during a normal go test ./.... Two consequences follow. First, the result depends on private local data, so the same commit passes on one machine and fails on another; the t.Error calls at Lines 183-188 report a defect that no change in this PR caused. Second, the test opens the contributor's real transcripts, and t.Logf reports counts derived from them.

Require an opt-in environment variable, so the default suite stays hermetic.

The coding guidelines state: "path-sensitive logic must include a non-Linux case or a hermetic equivalent exercising the same normalization."

♻️ Proposed fix
 func TestTheRealCodexCorpusStillParses(t *testing.T) {
+	if os.Getenv("ZERO_TEST_LIVE_CORPUS") == "" {
+		t.Skip("set ZERO_TEST_LIVE_CORPUS=1 to run against the local Codex store")
+	}
 	env := OSEnv()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestTheRealCodexCorpusStillParses(t *testing.T) {
env := OSEnv()
root := codexRoot(env)
if root == "" {
t.Skip("no home directory")
}
if _, err := os.Stat(root); err != nil {
t.Skip("no Codex store on this machine")
}
adapter := Codex(env)
found, err := adapter.Discover("")
if err != nil {
t.Fatal(err)
}
total := len(adapter.(codex).transcripts())
if total == 0 {
t.Skip("store exists but holds no rollouts")
}
titled, modelled := 0, 0
for _, session := range found {
if session.Title != "" && session.Title != "untitled" && !isCodexContextInjection(session.Title) {
titled++
}
if session.ModelID != "" {
modelled++
}
}
t.Logf("indexed %d of %d rollouts; %d titled, %d with a model", len(found), total, titled, modelled)
if len(found) == 0 {
t.Fatal("no Codex sessions indexed from a non-empty store")
}
// Both of these were zero before the fixes above; a regression takes them
// back to zero rather than to some slightly-lower number.
if titled == 0 {
t.Error("no session got a real title — the context-injection filter has stopped working")
}
if modelled == 0 {
t.Error("no session got a model — turn_context is being discarded again")
}
}
func TestTheRealCodexCorpusStillParses(t *testing.T) {
if os.Getenv("ZERO_TEST_LIVE_CORPUS") == "" {
t.Skip("set ZERO_TEST_LIVE_CORPUS=1 to run against the local Codex store")
}
env := OSEnv()
root := codexRoot(env)
if root == "" {
t.Skip("no home directory")
}
if _, err := os.Stat(root); err != nil {
t.Skip("no Codex store on this machine")
}
adapter := Codex(env)
found, err := adapter.Discover("")
if err != nil {
t.Fatal(err)
}
total := len(adapter.(codex).transcripts())
if total == 0 {
t.Skip("store exists but holds no rollouts")
}
titled, modelled := 0, 0
for _, session := range found {
if session.Title != "" && session.Title != "untitled" && !isCodexContextInjection(session.Title) {
titled++
}
if session.ModelID != "" {
modelled++
}
}
t.Logf("indexed %d of %d rollouts; %d titled, %d with a model", len(found), total, titled, modelled)
if len(found) == 0 {
t.Fatal("no Codex sessions indexed from a non-empty store")
}
// Both of these were zero before the fixes above; a regression takes them
// back to zero rather than to some slightly-lower number.
if titled == 0 {
t.Error("no session got a real title — the context-injection filter has stopped working")
}
if modelled == 0 {
t.Error("no session got a model — turn_context is being discarded again")
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/codex_test.go` around lines 150 - 189, Gate
TestTheRealCodexCorpusStillParses behind an explicit opt-in environment
variable, returning via t.Skip before accessing codexRoot, OSEnv, or the
developer’s transcripts when the variable is unset. Preserve the existing
live-corpus assertions for opted-in runs, and keep path-sensitive behavior
covered through a hermetic or non-Linux test rather than relying on this live
test.

Source: Coding guidelines

Comment on lines +48 to +60
func claudeCodeRoot(env Env) string {
if dir := env.lookup("CLAUDE_CONFIG_DIR"); dir != "" {
return filepath.Join(dir, "projects")
}
return env.underHome(".claude", "projects")
}

func codexRoot(env Env) string {
if dir := env.lookup("CODEX_HOME"); dir != "" {
return filepath.Join(dir, "sessions")
}
return env.underHome(".codex", "sessions")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Reject a relative redirect value instead of probing the working directory.

env.underHome returns "" when home is unknown, so discovery never probes a relative path. The redirect branches do not apply the same rule. If CLAUDE_CONFIG_DIR=.config/claude is set, claudeCodeRoot returns .config/claude/projects and discovery reads relative to the process working directory. Require an absolute path in both branches.

🛡️ Proposed fix
 func claudeCodeRoot(env Env) string {
-	if dir := env.lookup("CLAUDE_CONFIG_DIR"); dir != "" {
+	if dir := env.lookup("CLAUDE_CONFIG_DIR"); filepath.IsAbs(dir) {
 		return filepath.Join(dir, "projects")
 	}
 	return env.underHome(".claude", "projects")
 }
 
 func codexRoot(env Env) string {
-	if dir := env.lookup("CODEX_HOME"); dir != "" {
+	if dir := env.lookup("CODEX_HOME"); filepath.IsAbs(dir) {
 		return filepath.Join(dir, "sessions")
 	}
 	return env.underHome(".codex", "sessions")
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func claudeCodeRoot(env Env) string {
if dir := env.lookup("CLAUDE_CONFIG_DIR"); dir != "" {
return filepath.Join(dir, "projects")
}
return env.underHome(".claude", "projects")
}
func codexRoot(env Env) string {
if dir := env.lookup("CODEX_HOME"); dir != "" {
return filepath.Join(dir, "sessions")
}
return env.underHome(".codex", "sessions")
}
func claudeCodeRoot(env Env) string {
if dir := env.lookup("CLAUDE_CONFIG_DIR"); filepath.IsAbs(dir) {
return filepath.Join(dir, "projects")
}
return env.underHome(".claude", "projects")
}
func codexRoot(env Env) string {
if dir := env.lookup("CODEX_HOME"); filepath.IsAbs(dir) {
return filepath.Join(dir, "sessions")
}
return env.underHome(".codex", "sessions")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agentsessions/paths.go` around lines 48 - 60, Update claudeCodeRoot
and codexRoot so configured CLAUDE_CONFIG_DIR or CODEX_HOME values are used only
when absolute; treat relative values like unset configuration and fall back to
env.underHome with the existing default subpaths.

Comment on lines +230 to +236
working, err := os.Getwd()
if err != nil {
return ""
}
if filepath.Clean(working) == filepath.Clean(recorded) {
return ""
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

filepath.Clean is not enough to compare two workspace paths.

Clean only performs lexical tidying. It does not resolve symlinks, and it does not fold case. Two consequences:

  • On macOS, os.Getwd frequently returns a /private/var/... path while the recorded cwd holds /var/... for the same directory. The paths differ lexically, so the warning fires for a session that ran right here.
  • On Windows, the comparison is case-sensitive against a case-insensitive filesystem. C:\Work\repo and C:\work\repo compare unequal and produce the same spurious warning.

The failure mode is a misleading advisory line, not data loss. It is still worth fixing, because the note exists precisely to prevent confusion.

internal/tui/session.go line 483 answers this same question with sessionMatchesWorkspace. Reuse that predicate here rather than maintaining a second, weaker comparison.

🐛 Proposed fix
 	working, err := os.Getwd()
 	if err != nil {
 		return ""
 	}
-	if filepath.Clean(working) == filepath.Clean(recorded) {
+	if sessionMatchesWorkspace(recorded, working) {
 		return ""
 	}

sessionMatchesWorkspace currently lives in internal/tui. Promote it to a shared package so both callers use one implementation.

As per coding guidelines: "Code and tests must pass on Linux, macOS, and Windows; canonicalize paths before comparison and avoid asserting raw temporary-directory spellings."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/sessions_import.go` around lines 230 - 236, Replace the lexical
filepath.Clean comparison in the sessions import workspace check with the shared
sessionMatchesWorkspace predicate. Promote sessionMatchesWorkspace from
internal/tui/session.go to an appropriate shared package, update both callers to
use it, and preserve the existing empty-string behavior when the workspaces
match or the current directory cannot be determined.

Source: Coding guidelines

Comment on lines +908 to 917
// Meta now carries the source agent ("zero", "codex", …) so the picker's
// All tab says where each session came from. What it must never carry is
// the raw session id, which is what this check has always been about:
// rendering the id consumed half the picker and truncated the title.
if strings.Contains(item.Meta, want.id) {
t.Fatalf("picker %q exposes the raw session id in metadata: %q", want.title, item.Meta)
}
if item.Meta != "zero" {
t.Fatalf("picker %q metadata = %q, want the source agent", want.title, item.Meta)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

The session picker resolves its agent environment at the leaf, which makes TUI tests host-dependent and blocks the missing dedup test. newSessionPickerforeignSessionItems calls agentsessions.DiscoverAllCached(agentsessions.OSEnv(), m.cwd), and OSEnv reads the real os.UserHomeDir. One root cause produces both problems below: existing picker tests observe whatever transcripts the host happens to have, and no test can supply a controlled discovery result. Thread an agentsessions.Env through the model so both are fixable.

  • internal/tui/model_test.go#L908-L917: this test passes only on a machine with no Claude Code, Codex, Factory Droid, or Pi transcripts. Extra discovered rows break the "1 / 2" assertion on line 921. Build the model with an injected Env pointing at a t.TempDir() home.
  • internal/tui/session_picker_tabs_test.go#L69-L76: add a test for the imported-session dedup rule in foreignSessionItems, which currently has no coverage, and strengthen TestAnAgentWithNoSessionsGetsNoTab, which cannot fail as written. Both need the same injected Env.

As per coding guidelines: "Every behavior or security-boundary change requires a regression test, including failure paths".

📍 Affects 2 files
  • internal/tui/model_test.go#L908-L917 (this comment)
  • internal/tui/session_picker_tabs_test.go#L69-L76
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/model_test.go` around lines 908 - 917, Thread an
agentsessions.Env through the model and session-picker construction so
newSessionPicker and foreignSessionItems use the injected environment instead of
agentsessions.OSEnv(). In internal/tui/model_test.go lines 908-917, build the
model with a t.TempDir() home to isolate discovery. In
internal/tui/session_picker_tabs_test.go lines 69-76, use the same injected Env,
add coverage for imported-session deduplication in foreignSessionItems, and
strengthen TestAnAgentWithNoSessionsGetsNoTab so it genuinely verifies the
no-tab behavior.

Source: Coding guidelines

Comment thread internal/tui/session.go
Comment on lines +434 to +444
agent := sessionAgentName(meta.Tag)
items = append(items, pickerItem{
Label: label,
Value: meta.SessionID,
// Shown on the right of the row, so the "All" tab says at a glance
// which agent each session came from.
Meta: agent,
Tab: agent,
})
}
items = append(items, m.foreignSessionItems(metas, now)...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The "All" tab is not sorted by recency once foreign sessions are appended.

Local items arrive in ListResumable order. foreignSessionItems returns its own recency-ordered list. Line 444 concatenates the second after the first, so the combined list is two sorted runs, not one. Each row leads with a timestamp, which makes the discontinuity visible: a Codex session from ten minutes ago sits below a Zero session from last March.

The per-agent tabs are unaffected, because each tab shows one run. Only "All" — the default tab, and the one this feature exists for — reads as unordered.

Sort the merged slice before building the picker. That needs a sortable timestamp on pickerItem, since Label is already formatted for display.

🐛 Sketch of the fix

Carry the update time alongside each item while assembling, then sort once:

-	items = append(items, m.foreignSessionItems(metas, now)...)
+	items = append(items, m.foreignSessionItems(metas, now)...)
+	sortPickerItemsByRecency(items)

sortPickerItemsByRecency needs the raw time.Time per item. Add an unexported field to pickerItem, or build a parallel []struct{ item pickerItem; at time.Time } inside newSessionPicker and emit the sorted items from it.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
agent := sessionAgentName(meta.Tag)
items = append(items, pickerItem{
Label: label,
Value: meta.SessionID,
// Shown on the right of the row, so the "All" tab says at a glance
// which agent each session came from.
Meta: agent,
Tab: agent,
})
}
items = append(items, m.foreignSessionItems(metas, now)...)
items = append(items, m.foreignSessionItems(metas, now)...)
sortPickerItemsByRecency(items)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/session.go` around lines 434 - 444, Update newSessionPicker to
retain each session’s raw update time on pickerItem, including items from both
local assembly and foreignSessionItems, then sort the merged items by recency
before building the picker. Add or reuse sortPickerItemsByRecency so sorting
uses time.Time rather than the formatted Label, while preserving per-agent item
behavior.

Comment thread internal/tui/session.go
Comment on lines +473 to +479
result, err := agentsessions.Import(m.sessionStore, adapter, id, agentsessions.ReadOptions{})
if err != nil {
return "", "", err
}
// This session is no longer un-imported, so the memo that says otherwise
// must go before the picker is rebuilt.
agentsessions.InvalidateDiscovery()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check transcript read bounds and the model's async command pattern.
set -euo pipefail

echo "=== MaxEvents handling in the read path ==="
rg -n -C4 'MaxEvents' internal/agentsessions --glob '!*_test.go'

echo "=== whole-file vs bounded reads ==="
rg -n -C3 'io.ReadAll|bufio.Scanner|os.Open' internal/agentsessions --glob '!*_test.go'

echo "=== existing tea.Cmd dispatch for slow work ==="
rg -n -C3 'func .*Cmd\(\) tea\.Cmd' internal/tui | head -60

Repository: Gitlawb/zero

Length of output: 7803


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== importForeignSession and its callers ==="
sed -n '430,510p' internal/tui/session.go
rg -n -C8 'handleResumeCommand|importForeignSession|type .*Msg|Import\(' internal/tui internal/agentsessions --glob '*.go'

echo "=== Import implementation ==="
rg -n -C10 'func Import|func \(.*\) AppendEvents|func .*Read\(' internal/agentsessions --glob '*.go'

echo "=== Update return paths around resume handling ==="
rg -n -C12 'handleResumeCommand|resume' internal/tui/model.go internal/tui/session.go --glob '*.go' | head -240

Repository: Gitlawb/zero

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== handleResumeCommand and command dispatch ==="
sed -n '205,270p' internal/tui/session.go
rg -n -C10 'handle.*Command|resumeText\(\)|command.*resume|/resume' internal/tui/model.go internal/tui/*.go --glob '*.go' | grep -E 'handleResumeCommand|resumeText|/resume|command' | head -180

echo "=== Import call graph ==="
sed -n '120,205p' internal/agentsessions/registry.go
rg -n -C8 'func \(.*\) Read|func .*Read\(id string|capEvents|streamLines|scanHead' internal/agentsessions --glob '*.go' | head -240

echo "=== relevant async command shape ==="
sed -n '1,100p' internal/tui/command_bash.go
sed -n '40,105p' internal/tui/files_git_sweep.go

Repository: Gitlawb/zero

Length of output: 15983


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Import implementation ==="
sed -n '135,190p' internal/agentsessions/registry.go
sed -n '50,180p' internal/agentsessions/jsonl.go

echo "=== adapter Read implementations ==="
rg -n '^func .*Read\(.*ReadOptions|^func \(.*\) Read\(' internal/agentsessions --glob '*.go'
for f in $(rg -l '^func .*Read\(.*ReadOptions|^func \(.*\) Read\(' internal/agentsessions --glob '*.go'); do
  echo "--- $f"
  grep -n -A55 -B5 'func .*Read(' "$f" | head -80
done

echo "=== command dispatch references ==="
rg -n -C6 'handleResumeCommand\(' internal/tui --glob '*.go'
rg -n -C6 'Update\(.*tea.Msg|func \(m model\) Update|parseCommand' internal/tui/model.go internal/tui/*.go --glob '*.go' | head -220

Repository: Gitlawb/zero

Length of output: 30533


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

session = Path("internal/tui/session.go").read_text()
registry = Path("internal/agentsessions/registry.go").read_text()
jsonl = Path("internal/agentsessions/jsonl.go").read_text()
types = Path("internal/agentsessions/types.go").read_text()

checks = {
    "TUI passes zero-value ReadOptions": "agentsessions.Import(m.sessionStore, adapter, id, agentsessions.ReadOptions{})" in session,
    "zero MaxEvents means no cap": "Zero means no cap." in types,
    "Import reads before store append": registry.index("events, err := adapter.Read(id, options)") < registry.index("store.AppendEvents(created.SessionID, events)"),
    "full-read path is explicitly unbounded": "streamLines calls visit with every line of path, without bounding the total." in jsonl,
    "full-read loop continues until EOF": "for {" in jsonl[jsonl.index("func streamLines"):jsonl.index("func readBoundedLine")],
}
for name, result in checks.items():
    print(f"{name}: {result}")
if not all(checks.values()):
    raise SystemExit(1)
PY

echo "=== exact Update call sites ==="
sed -n '4238,4262p' internal/tui/model.go
sed -n '4538,4565p' internal/tui/model.go

Repository: Gitlawb/zero

Length of output: 2749


Move foreign-session import off the Bubble Tea Update loop.

agentsessions.Import synchronously reads the transcript to EOF, translates all events, creates the session, and appends the events. ReadOptions{} sets no MaxEvents cap. A large transcript can therefore block rendering and input handling, including Ctrl+C, until the import completes.

Return a tea.Cmd that performs the import and sends a result message. Consider an explicit MaxEvents cap if full imports are not required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/session.go` around lines 473 - 479, Move the synchronous
agentsessions.Import call out of the Bubble Tea Update path into a tea.Cmd that
performs the import asynchronously and returns a result message containing the
session or error, then handle that message in the Update flow while preserving
agentsessions.InvalidateDiscovery before rebuilding the picker. Review whether
the import should set an explicit MaxEvents limit instead of using uncapped
ReadOptions{}.

Comment thread internal/tui/session.go
Comment on lines +515 to +518
label := displayValue(session.Title, "untitled")
if when := sessionWhen(session.UpdatedAt.Format(time.RFC3339), now); when != "" {
label = sessionPickerLabel(when, label)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard the zero UpdatedAt before formatting it.

session.UpdatedAt.Format(time.RFC3339) on a zero time.Time yields "0001-01-01T00:00:00Z". sessionWhen then parses a valid timestamp and returns a stamp for the year 1, which sessionPickerLabel prints. The CLI path guards this case explicitly — formatDiscoveredTime returns "" for a zero time.

ForeignSession.UpdatedAt falls back to the file modification time, so it is normally populated. An adapter that cannot stat the file leaves it zero, and that row then renders a January year-1 date.

The round trip through a string is also avoidable if sessionWhen gains a time.Time variant, since the value here is already typed.

🐛 Minimal fix
 		label := displayValue(session.Title, "untitled")
-		if when := sessionWhen(session.UpdatedAt.Format(time.RFC3339), now); when != "" {
-			label = sessionPickerLabel(when, label)
+		if !session.UpdatedAt.IsZero() {
+			if when := sessionWhen(session.UpdatedAt.Format(time.RFC3339), now); when != "" {
+				label = sessionPickerLabel(when, label)
+			}
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
label := displayValue(session.Title, "untitled")
if when := sessionWhen(session.UpdatedAt.Format(time.RFC3339), now); when != "" {
label = sessionPickerLabel(when, label)
}
label := displayValue(session.Title, "untitled")
if !session.UpdatedAt.IsZero() {
if when := sessionWhen(session.UpdatedAt.Format(time.RFC3339), now); when != "" {
label = sessionPickerLabel(when, label)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/session.go` around lines 515 - 518, Guard
session.UpdatedAt.IsZero() before formatting it, so zero timestamps do not reach
sessionWhen or sessionPickerLabel and produce a year-1 date. Update the
surrounding label logic in the session row path, preferably by reusing or adding
a typed time.Time variant of sessionWhen to avoid converting the timestamp
through RFC3339 text while preserving existing behavior for populated
timestamps.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed as a draft, so this is findings rather than a verdict. The design question you actually asked about is above my pay grade and needs @kevincodex1; what follows is whether the code does what it says.

The credential-safety work is the strongest part and it mostly holds. I checked the claims rather than taking them: the extension pin is case-insensitive, globTranscripts rejects symlinks because IsRegular() is false for them, and I confirmed by probe that a junction is rejected too. Two adversarial passes tried to turn the reparse-point gap into an escape and could not: creating a link under ~/.codex/sessions already requires write access to ~/.codex/sessions, and writing a transcript there directly reaches the same outcome with no link at all. The .jsonl pin plus the rollout-* pin plus Discover gating Import close the residual.

Two blocking, though.

The activity summary is emitted as EventCompaction, whose payload contract it does not satisfy. RehydrateEvents (replay.go:240) scans backwards for the last EventCompaction and restructures the transcript around it. A real CompactionPayload carries PreserveLast, CompactableEvents, PreservedEvents and CompactedThroughSequence — the bookkeeping saying which events the summary replaces. noteEvent writes {"summary": ...} and nothing else, so every one of those is zero, and rehydration reorders the imported transcript around a boundary that describes nothing. It decodes cleanly because the only validated field is Summary. Verified end to end through ImportReadRehydratedEventsPrepareExec. You picked the type because promptContextEvents already passes it; the same type has a second contract on the replay side.

Imported text carries control bytes into the terminal. The redaction chokepoint scrubs secrets, not control characters. Probed directly: "innocent title\x1b[2J\x1b[1;1H FORGED ROW \x00 tail" comes back byte-identical, ESC and NUL intact, and that string becomes a picker row and a transcript line. We have shipped this exact class twice in a fortnight: #835, where an MCP failure reason forged a row, and #876, where a copied NUL panicked the whole TUI. An imported title is strictly more attacker-influenced than either. sanitizeCardText already exists.

Two worth fixing before it leaves draft.

TestTheRealCodexCorpusStillParses and TestTheRealCorpusStillParses discover against the real ~/.codex and ~/.claude of whoever runs go test, and assert on what they find. The first fails at your head on this machine (indexed 2 of 2 rollouts; 2 titled, 0 with a model) because these rollouts carry turn_context past the 64-line head budget, which no change to the adapter can fix. CI passes only because the runner has no store to find. That inverts the usual bargain: green on CI, red for contributors. Worth a fixture.

The activity summary collapses successful and failed calls into one bucket per path. A successful Write /p/config.yaml followed by a failed Edit of the same path withdraws the claim entirely, so the summary reports no files changed although the file was rewritten. The withdraw logic is right in principle; it is keyed too coarsely.

Smaller: the family-1 slug fast path skips globSessionDirs, so the picker can list a session Import then refuses; name, toolCallId and role skip redact() while content and arguments get it, so the chokepoint comment is not literally true; capEvents understates the drop by one, and the note is the only thing telling the reader the import is partial; a tool call with no matching result keeps its claim, so an interrupted write reports as a file changed.

Two things I checked and am NOT raising, so you do not chase them. The Title field skipping redaction is real but pre-existing: createSessionTitle on main writes a raw prompt into metadata.json for native sessions too, and zero sessions list redacts at display. Your translate.go redaction is above baseline, not below it. And the reparse-point discovery gap is a documentation inaccuracy rather than a boundary crossing, for the reason above.

The engineering standard here is high: mutation-testing the glob and the redaction, exercising against 302 real sessions, and documenting the two pre-existing main failures instead of claiming a clean run. The two blocking items are both "this type/string has a second contract elsewhere", which is the hardest class to see from inside the change.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

One correction to the blocking item above, since the sentence ran together: a real CompactionPayload carries PreserveLast, CompactableEvents, PreservedEvents and CompactedThroughSequence, which together record which events the summary stands in for. noteEvent sets only Summary, so all of them are zero, and rehydrateEventsWithCompaction restructures the imported transcript around a boundary that describes nothing.

The cheapest fix is probably a distinct event type rather than filling in the payload, since the import is not a compaction and pretending otherwise will keep colliding with replay, rewind and lineage. If promptContextEvents needs to pass it, adding the new type to that filter is a one-line change at exec_session.go:197.

@anandh8x

anandh8x commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Tested the latest head with real local session data. Discovery, CLI import, source tabs, and importing/resuming a selected session all work on the normal path.

I found three blockers:

  1. On a fresh Zero install with no local Zero sessions, /resume shows none even when foreign sessions are available. loadResumeSessions returns early when ListResumable() is empty, before adding foreign sessions. After creating one local session, those same foreign sessions appear.
  2. Imported activity summaries are emitted as compaction events without full compaction metadata. Rehydration treats them as structural compactions and can reorder the transcript; in my import, the final raw event was moved to the beginning after rehydration.
  3. Imported titles/content are not sanitized for terminal control bytes before picker rendering. A synthetic session title containing ESC and NUL bytes reached the /resume UI and was terminal-interpreted.

There is also a smaller UX concern: importing a 2,692-event session synchronously blocked the UI for about 0.86s on this machine.

Please fix at least the first three before merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants