From 7e1377274e361f32ce4538cfada3ce73d7556af7 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:08:06 -0700 Subject: [PATCH 01/83] docs(plans): add the name reliability fix plan Investigation, load-bearing validation (2 rounds) and task plan for pane/tab names failing to set and reverting. Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-10-21-name-reliability.md | 2162 +++++++++++++++++++++ 1 file changed, 2162 insertions(+) create mode 100644 docs/plans/2026-10-21-name-reliability.md diff --git a/docs/plans/2026-10-21-name-reliability.md b/docs/plans/2026-10-21-name-reliability.md new file mode 100644 index 000000000..c16115a7b --- /dev/null +++ b/docs/plans/2026-10-21-name-reliability.md @@ -0,0 +1,2162 @@ +# Name Reliability Implementation Plan + +> **For agentic workers:** Execute this plan task by task with a fresh +> implementer and a specification-plus-quality review after every task. Track +> progress with the checkbox steps below. Every task is red → green → +> refactor; a task is not done until its red receipt, green receipt and +> refactor note are recorded. A step labelled **pinning test** is expected to +> pass before the change; it guards behavior the change must keep and is not +> a red step. + +## User Request + +### Requested result +Dan (product manager): "We are having serious issues with pane and tab names +not getting set properly, and reverting after they're set. Investigate and +write a plan to fix." Also: "this is a regression; … seemed to get much worse +when we did the 'durable souls' work." + +The investigation is complete +(`/tmp/claude-1000/-home-dan-code-freshell/b0e858cc-c789-4cc8-aba4-46c81f21d8b8/scratchpad/investigation-report.md`, +root causes RC1–RC11). This plan fixes all eleven plus one more found while +revising it (RC12, below: 171 saved names are a tool's wrapper text such as +``), lets existing junk names heal on their own as +sessions are seen (the self-heal rule, below), and proves each fix in the +browser. + +Charter change (Dan, 2026-10-22; see "Decisions (resolved)"): no repair tool; +no dedicated naming proxy (Dan entered a Gemini key in Settings); no +conversation-switch carry-forward; one PR at the end, which Dan approved. + +### Explicit constraints +- One plan, one branch (`fix/name-reliability` in `.worktrees/name-reliability`, + based on `origin/main`, currently 1c7e4dff7), tasks sized for independent + review, delivered as ONE PR at the end. +- Ship order: import shutdown first (it stops new damage), then ranking, + self-heal and fallback, then client data models, then server binding, then + store performance, then logging. With one branch, commit order is not + delivery order: every fix reaches Dan's live server only when the PR is + merged and Dan approves the deploy. +- Coordinator decisions D1–D11 (encoded below) are the design, as amended by + Dan's charter change; D12 (the repair tool) is withdrawn. +- Delivery (Dan's words): "Build, code review, check, and if it was gnarly + recheck a few times (max 5). Then land on main via PR." Dan approved + creating and merging that PR after the code review and the gate (Task 13). + No deploy and no restart of the live server without Dan's explicit + "APPROVED"; no writes to live data. The live server on port 3001 and + `~/.freshell` are never touched by this work; tests use owned scratch + servers, fixtures, or the copies under the scratchpad. +- Repo rules: Red/Green/Refactor TDD, unit AND e2e coverage, coordinated test + commands, NodeNext `.js` imports, structured JSONL logs with severity, a11y + rules, files over 1000 lines get new logic in new modules, + `docs/development/rename-scope-contract.md` updated, `docs/index.html` only + for a major UI change (none is planned). +- Every test exercises real behavior and fails when that behavior breaks. No + test checks prose, prompts, docs or config text. + +### Accepted tradeoffs and residuals +See "Residuals and out of scope" at the end. The main ones: per-record name +storage (one file per record) is a follow-up; the reconstructed managed shell +tab can only recover a custom name when the view intent carries one; the +hosted fresh-agent proxy naming gap is fixed only if it is small (Task 10 +decision rule); the server-side default-label check cannot recognize a folder +name where the server has no folder for the record (Task 4 pins that limit); +existing names that are not recognizable junk (for example the imported +"ms-people-summit") stay as they are, because nothing repairs them; 11 +tool-text names whose sessions are no longer indexed stay; a name given +before the first message is not shown on a reconstructed managed tab until +the session links; Claude sessions made only of slash commands plus one +prompt now count as non-interactive; a keyed request goes straight to +Google when the ambient proxy is OneCLI's, so a host that can reach Google +only through OneCLI pauses AI naming; an older binary sharing `~/.freshell` +could fight the self-heal; managed rigs can flake unless run alone; the key's +generation quota is confirmed only by the first AI name after deploy. A +pre-existing managed reattach bug found during validation is reported to +Dan, not fixed here. + +**Goal:** A Claude, Codex or OpenCode session gets a sensible name (first +message, then Freshell's AI name) and keeps it across reloads, new windows, +reconnects and server restarts, and a conversation switch shows the new +conversation's name; shells, browsers and editors keep their user names in +new windows and across managed-runtime refreshes; a saved name that is +recognizable junk ("Tab 23", "OpenCode agent", tool wrapper text, a provider +placeholder) is replaced by the next real automatic name the session offers, +so existing damage heals as sessions are seen, while names a person chose are +never touched. + +**Architecture:** (1) The browser stops treating its live layout as "legacy" +name evidence: capture runs once per device, only from genuine pre-migration +keys, and once the one-time consolidation is complete the server acknowledges +browser imports without writing anything. (2) One shared default-label +classifier (TypeScript + Rust, one shared fixture) keeps "Tab N", folder +names, provider labels, OpenCode placeholders and tool wrapper text out of +every name-evidence path. (3) The naming store gets four ranking fixes: +provider-native titles that actually change may replace an older automatic +provider-level name, automation renames report applied/not-applied, the +first-message fallback is offered on every newly observed first message, and +the self-heal rule: an unprotected record whose current name the classifier +recognizes as default/derived ranks below `directory` for every automatic +offer, so the next real name replaces it. (4) A configured Gemini key always +wins and is used directly; only with no key does AI naming fall back to the +inherited OneCLI proxy; credential and quota failures pause naming without +using up attempts or losing the naming input. (5) On the client, a +session-owned tab's stored title becomes a display mirror of the saved name, +written only when a saved record is known; the managed-runtime merge never +writes names; the new-window rebuild carries pane titles and user flags. +(6) On the server, every terminal row binds its name through one helper that +both doors (WebSocket and REST) can call, and a conversation switch moves the +row's binding to the new session; the name store serves reads without the +lock; every accepted change emits one structured event. + +**Tech Stack:** TypeScript client (React 18, Redux Toolkit, Vitest jsdom), +Rust server crates (`freshell-server`, `freshell-ws`, `freshell-terminal`, +`freshell-sessions`, `freshell-protocol`, `freshell-freshagent`, +`freshell-supervisor`, `freshell-codex`), Playwright e2e with the owned +`RustServer` fixture. + +## Global Constraints + +- **Worktree:** `/home/dan/code/freshell/.worktrees/name-reliability`, branch + `fix/name-reliability` from `origin/main` (1c7e4dff7 at revision time; the + plan's line numbers were read at 302cee989 and re-checked: #851 between them + touched only `crates/freshell-codex`, its tests and one codex e2e spec, so + every naming line number below holds; `git fetch origin` and re-check + before creating the worktree). Before creating the worktree run the + green-base check through `GCLOUD_ROBOT_REQUIRE=1 scripts/base-gate.sh test`. +- **Known baseline failures (Dan waived them on 2026-10-22; the branch must + not add failures):** on + `origin/main` the coordinated suite has exactly 7 failing tests in 3 files, + all Docker managed-runtime host timeouts unrelated to naming: + `test/integration/server/managed-provider-parity.test.ts` (7 cases), + `test/integration/server/fresh-agent-parity.test.ts` and + `test/integration/server/provider-parity-contract.test.ts` (failing as + wrappers of the same timeouts; receipt + `/tmp/claude-1000/-home-dan-code-freshell/b0e858cc-c789-4cc8-aba4-46c81f21d8b8/scratchpad/base-gate-rerun.log` + lines 777–1053). Every gate in this plan treats that exact set as the + baseline: the run passes when the failing set is identical or smaller, and + fails on any new failure. Record the failing-test list in each gate receipt. +- **Focused client tests:** + `pnpm run test:vitest run --config config/vitest/vitest.config.ts`. + Never raw `pnpm exec vitest`/`npx vitest`. +- **Focused Rust tests:** for `freshell-server`, use the coordinator's + delegated narrowed lane `pnpm run test:server ` (it runs + `cargo test -p freshell-server --locked `; see + `scripts/testing/coordinator-command-matrix.ts:177-180`). The coordinator + has no narrowed lane for other crates, so those use + `~/.local/bin/mise exec rust@1.96 -- cargo test -p --locked `. + Cargo accepts ONE name filter per run: two filters mean two commands. + Broad Rust lane: `pnpm run test:server` (coordinated). +- **E2e:** `GCLOUD_ROBOT_REQUIRE=1 FRESHELL_E2E_BACKEND=cloud pnpm run test:e2e --project=chromium ` + (no `--` separator). Specs that need the host Docker supervisor are added to + `LOCAL_ONLY_SPECS` in `test/e2e-browser/playwright.config.ts`; every + other new spec must be cloud-legal and must NOT be added to + `CLOUD_SKIP_SPECS`. A spec that matches zero tests is not coverage. +- **Local-only (Docker supervisor) runs (V3).** Every local-only command has + this exact shape, one case at a time: + `env -u FRESHELL_MANAGED_OPENCODE_ONECLI_AUTH_FILE -u FRESHELL_MANAGED_OPENCODE_ONECLI_CA_FILE -u FRESHELL_MANAGED_OPENCODE_ONECLI_ENV_FILE pnpm run test:e2e:local --project=chromium --grep ''` + (three separate `-u` flags: a brace expansion would hand `env` the second + and third names as the command). The shell exports those OneCLI grant + files, and with them a managed OpenCode case can spend real model turns on + Dan's credential. Run `--list` with the same arguments first and confirm + exactly the intended case is selected. Before the first run, make sure + Playwright 1.58.2's `chromium-headless-shell` revision 1208 is installed + (`~/.cache/ms-playwright/chromium_headless_shell-1208`; if it is missing, + install it into a scratch `PLAYWRIGHT_BROWSERS_PATH` with + `pnpm exec playwright install chromium-headless-shell` rather than changing + the shared cache). Managed-runtime rigs (these specs and the + `test/integration/server` managed parity files) run alone, never in + parallel with another managed rig: run alone they passed, run together + they timed out (V3; the cause is inferred, Residual 22). Never run `runtime-terminal-continuity-rust.spec.ts` for + this plan: its P2-G01 is red on `main` (Residuals, out-of-scope finding) + and its P2-G04 makes paid model calls. +- **Cloud env (non-login shells):** export `FRESHELL_VITEST_BACKEND=cloud`, + `FRESHELL_E2E_BACKEND=cloud`, `GCLOUD_ROBOT_REQUIRE=1` when unset. +- **Full gate:** `FRESHELL_TEST_SUMMARY="name-reliability task N gate" GCLOUD_ROBOT_REQUIRE=1 pnpm run check` + (typecheck then coordinated suite). Check `pnpm run test:status` first; + never kill a foreign holder. +- **Lint/format:** `pnpm run lint` (jsx-a11y), `pnpm run typecheck`, + `~/.local/bin/mise exec rust@1.96 -- cargo fmt --all --check`, + `~/.local/bin/mise exec rust@1.96 -- cargo clippy --workspace --exclude freshell-tauri --all-targets -- -D warnings`. + The native Windows `freshell-server.exe` build must keep compiling: any + platform-specific code is behind `#[cfg(unix)]`/`#[cfg(windows)]` and uses + only stable std APIs (Task 11). +- **Destructive tests:** none of the planned tests kill processes outside an + owned fixture or corrupt host config. Server restarts happen only on owned + `RustServer` instances on ephemeral ports (the existing restart-spec + pattern). If an implementer finds a test that must kill an unowned process + or storm restarts, it moves into `scripts/sandbox-test.sh ""`. +- **1000-line rule:** these files are over the cap and get wiring-only edits; + new logic goes in the new modules named in each task: + `crates/freshell-server/src/session_names.rs` (4104), + `crates/freshell-server/src/main.rs` (7812), + `crates/freshell-server/src/ai_title.rs` (1191), + `crates/freshell-server/src/auto_title_sweep.rs` (2439), + `crates/freshell-server/src/recovery_inventory.rs` (1772), + `crates/freshell-server/src/managed_runtime.rs` (1814), + `crates/freshell-server/src/fresh_agent_proxy.rs` (1431), + `crates/freshell-server/src/terminals.rs` (1957), + `crates/freshell-server/src/session_name_migration.rs` (1006), + `crates/freshell-ws/src/terminal.rs` (15408), + `crates/freshell-ws/src/identity.rs` (1619), + `crates/freshell-terminal/src/registry.rs` (15751), + `crates/freshell-freshagent/src/naming.rs` (1226), + `crates/freshell-freshagent/src/terminal_tabs.rs`, + `crates/freshell-freshagent/src/lib.rs`, + `crates/freshell-codex/src/remote_proxy.rs`, + `src/App.tsx` (2571), `src/store/tabsSlice.ts` (1944), + `src/store/panesSlice.ts` (3050), `src/components/TerminalView.tsx` (7806). +- **Logging:** every new decision path logs structured JSONL with severity + through `tracing` (server) or `createLogger` (client; errors forward to + `/api/logs/client`). Never log credentials, tokens, prompts, proxy URLs or + OneCLI manage URLs; log error codes and statuses only. The JSONL writer's + scrubber recognizes only the old `AIza…` Gemini key form, and Dan's key is + the newer `AQ.` form, so Task 6 extends it (G48). +- **Rename contract:** user-intent renames always win; nothing automatic ever + replaces `manual`, `legacy_protected` or `freshell_ai`. Shell/browser/editor + names stay in the legacy four-scope model. +- **Commits:** one focused commit per task (conventional message, ends with + the attribution line from the session reminder). Push the branch at major + checkpoints. The single PR is opened only in Task 13, after the code review + and the gate (Dan approved creating and merging it then). +- **Load-bearing validation:** round 1 validated five assumptions (V1–V5, + section near the end) and every result is applied in the tasks below. + Steps that rest on one cite it; the task receipt records that the + implementation matches. + +## Verified ground truth (origin/main; lines at 302cee989, unchanged at 1c7e4dff7) + +Each fact below was re-read in the tree while writing or revising this plan. +Corrections to the investigation report are marked **(corrected)**. + +| # | Fact | Location | +|---|---|---| +| G1 | `isLegacyNameCaptureSourceKey` accepts the live per-window key `freshell.layout.v3.` and its `.bak` / fresh-agent-backup channels | `src/lib/session-name-migration.ts:111-136` | +| G2 | Capture runs at module import, before the store | `src/lib/session-name-migration.ts:889-893`, imported first by `src/main.tsx:13` | +| G3 | Capture also runs in machine-workspace restore and on every sibling-window layout write | `src/lib/machine-workspace.ts:147-155`; `src/store/crossTabSync.ts:443-453` (`captureLegacyLayoutEnvelope`, `src/lib/session-name-migration.ts:299-326`) | +| G4 | Submission fires on every connection-ready edge; nothing checks for a finished migration | `src/store/store.ts:162-196`; `submitPendingLegacyNameImports` `src/lib/session-name-migration.ts:771-821` | +| G5 | Tab titles of session-owned tabs become `source_tab` candidates ranked `provider_ai`; pane titles likewise; a legacy tab whose `titleSetByUser` is true and whose first leaf is a scoped pane becomes `legacy_protected` **(new finding)** | `extractEnvelopeCandidates` `src/lib/session-name-migration.ts:482-630` (tab at 568-593, panes at 596-627) | +| G6 | Acknowledgment only adds an `.ack.` marker; envelopes are never deleted | `markAcknowledged` `src/lib/session-name-migration.ts:169-178` | +| G7 | A failing capture defers the whole layout flush | `src/store/persistMiddleware.ts:658-671` via `ensureLegacyNameCapturesForFlush` `src/lib/session-name-migration.ts:346-352` | +| G8 | The import route never checks `migration.completed`; every import writes a create-once backup file keyed by import id BEFORE acknowledging, and every newly seen candidate id is pushed into `acknowledged_candidate_ids`, which forces a document write | route `crates/freshell-server/src/session_name_routes.rs:317-390`; decision `crates/freshell-server/src/session_names.rs:4003-4104` (backup `:4029`, ack push `:4053-4057`, write forced `:4094-4103`); backup writer `:3734-3770` | +| G9 | Rank order `directory 0 < first_message 1 < provider_ai 2 < freshell_ai 3 < legacy_protected 4 < manual 5`; untrusted manual/protected claims without evidence degrade to `provider_ai` | `source_rank` `session_names.rs:2034-2043`; `classify_legacy_candidate` `:3623-3673` | +| G10 | Equal-rank ties among migration-owned records end on evidence key (contains the window id) then candidate id | `compare_classified_candidates` `session_names.rs:3682-3712`; applied at `:3836-3851` | +| G11 | A missing record is installed by any import; pending targets (including derived `legacy-pending` handles) are installed too, which mints orphan records | `apply_legacy_candidate` `session_names.rs:3815-3881`; handle derivation `src/lib/session-name-migration.ts:461-475` | +| G12 | Bind collision: equal automatic ranks favor the durable record, so an import that landed on the session key first keeps its label | `bind_pending_decision` `session_names.rs:2489-2625` (collision at 2537-2553) | +| G13 | Automatic offers only strictly raise rank; automation renames map to `provider_ai` and return the unchanged record as success | `offer_mut` `session_names.rs:2147-2204`; `rename_decision` `:2427-2464` | +| G14 | Native observations offer at `provider_ai` with no placeholder filter; the only placeholder predicate is used by the directory index; every call site discards the result (`let _ =`) and awaits it INLINE in an event relay loop | `observe_native_decision` `session_names.rs:2802-2930`; `is_opencode_placeholder_title` `crates/freshell-sessions/src/parse/opencode.rs:35`; OpenCode event `crates/freshell-opencode/src/events.rs:198-210`; inline awaits: freshopencode SSE bridge `crates/freshell-freshagent/src/opencode_ws.rs:7787-7805`, codex proxy route `crates/freshell-ws/src/codex_proxy_route.rs:112-131`; other lanes `crates/freshell-server/src/main.rs:1955-1985`, `crates/freshell-freshagent/src/codex.rs:7622` | +| G15 | First-message fallback is offered only for `AcceptedUserMessage`/`IndexUserMessage`; the sweep sends `Opened` whenever a live terminal is bound | `plan_activity` `session_names.rs:2651-2727` (gate at 2668-2681); sweep `crates/freshell-server/src/auto_title_sweep.rs:470-523` | +| G16 | The Gemini route is `OneCliProxy` whenever the effective proxy URL carries a OneCLI marker; `enabled()` is then true without any key; on that route the first request carries NO key even when one is configured (`:332-334`), so with Dan's Settings key and the inherited proxy, naming still fails on `access_restricted` (confirmed live, G49); a 403 drops the body; only `credential_not_found`/`app_not_connected` permit a direct-key retry (through the same proxy-honoring client); 407 never does. Both naming and summary clients are `reqwest::Client::new()`, which honors the ambient proxy | `crates/freshell-server/src/ai_title.rs:256-288`, `:325-391` (error at 385), `:437-450`; clients `main.rs:974`, `:985` | +| G17 | Each failure consumes one of `MAX_GENERATION_STARTS = 3`. The CLAIM (not the fold) exhausts the series at the cap and clears the excerpt; the fold only sees `(series_id, fingerprint, outcome)`; the failure reason is never persisted; boot recovery of an interrupted start already exhausts and clears at the cap | `session_names.rs:100`; claim `:3030-3061` (exhaust + `excerpt = None` at 3038-3043); fold `:3063-3152`; recovery `recover_interrupted_generation_decision` `:3154-3185`; generator `crates/freshell-server/src/session_name_generation.rs:233-351` | +| G18 | **Open question 1 answered:** the server's Gemini key is env `GOOGLE_GENERATIVE_AI_API_KEY` (wins at boot) else `settings.ai.geminiApiKey` in `/config.json`; every successful Settings save force-applies the Settings key into the same key cell. The live config copy (taken before 2026-10-22) had `settings.ai = {}`; **Dan has since entered a key in Settings** (whether the live process also has an env key is part of V1). The probe answered `{"error":"access_restricted",…}` from the OneCLI gateway. The proxy (and so the OneCLI agent identity) is whatever `HTTPS_PROXY` the launching shell exported; the server's `.env` cannot override it because environment variables win over `.env` | `crates/freshell-server/src/main.rs:953-964`; `crates/freshell-server/src/settings_store.rs:2139-2143`; `scratchpad/live-evidence/config.json`; `scratchpad/live-evidence/gemini-probe.json`; `.env` precedence per `AGENTS.md` | +| G19 | The managed merge writes `title: view.title` for existing tabs unless `titleSetByUser`, takes tab-level fields from whichever pane matched, and bumps `updatedAt` | `buildManagedRuntimeMergePlan` `src/lib/recovery/managed-runtime-recovery.ts:291-372` (title 323, reconstructed title 359); `updateTab` `src/store/tabsSlice.ts:404-410` | +| G20 | View-intent titles are fixed provider labels | `crates/freshell-server/src/managed_runtime.rs:776`; `crates/freshell-server/src/fresh_agent_proxy.rs:723`; fallback "Recovered X agent" `crates/freshell-supervisor/src/view_intents.rs:103-106`, `:683-689` | +| G21 | **Open question 6 answered (code):** "Shell agent" IS visible on a single-pane shell tab: `getTabDisplayTitle` shows a stored non-default `tab.title` that differs from the derived name whenever no non-derived pane title exists | `src/lib/tab-title.ts:19-38`. Task 8's first test pins this. | +| G22 | Nothing writes the saved name back into a session-owned tab's `title`; the display falls back to it when no record is cached | `src/App.tsx:1472-1477`; `selectTabDisplayName` `src/store/selectors/sessionNameSelectors.ts:217-230`; the comment in `stripSessionOwnedTabFreezeFlags` `src/lib/session-name-migration.ts:870-887` is false today | +| G23 | Legacy tab-title writers: OSC title `src/components/TerminalView.tsx:3189-3192` (**corrected: no session-owned gate here**, unlike `:6160-6176`), exit suffix `:6136-6143`, `openSessionTab` existing-tab retitles `src/store/tabsSlice.ts:1821`, `:1871`, create defaults `:358`, `:1836`, `:1912`, `:1930`, `src/store/codingAgentNaming.ts:36-42`, managed merge (G19), machine restore `src/lib/machine-workspace.ts:197-205`, UI command `tab.create` (`src/lib/ui-commands.ts:181-196`, sets `titleSetByUser:true` for any caller name, including on an agent tab). UI command `tab.rename` (`:222-226`) is already harmless for session-owned tabs: `consumeScopedRenameReceipt` (`:38-62`) returns before `applyTabRename` whenever the tab has a naming source | +| G24 | The new-window rebuild drops pane titles and user flags: plan hard-codes `paneTitles: {}`; inventory forwards only `tabKey/tabName/nameSource/panes`; the registry pane snapshot carries `title` but **no pane user flag (corrected)** | `src/lib/recovery/build-recovery-plan.ts:430-437`; `crates/freshell-server/src/recovery_inventory.rs:1010-1033`; `src/lib/tab-registry-snapshot.ts:89-113`; `shared/tab-registry-types.ts:54-59`; `restoreMachineWorkspace` `src/lib/machine-workspace.ts:197-213` | +| G25 | `register_managed` titles every managed row "Shell" with no naming; adoption paths never bind naming. **Production** callers **(corrected)**: WS managed create `crates/freshell-ws/src/terminal.rs:6876`, REST managed create `crates/freshell-freshagent/src/terminal_tabs.rs:2906`, compat adopt `terminal.rs:3459`, reconcile adopt `terminal.rs:8481`. The calls at `terminal.rs:3837, 3943, 3994, 4076` are inside `mod managed_runtime_id_tests` (`:3590-4090`) and `:13408+` inside the test module starting at `:10894` | `crates/freshell-terminal/src/registry.rs:4719-4776` (title 4752); `adopt_managed_for_reconcile` `:4646-4676` | +| G26 | `admit_create_naming` turns a read error into "no record" (`.ok()` at `:4798`), so "read failed" and "no record" arrive as the same `None`; a lock error on a resume create that carries a persisted `namingHandle` falls into the fresh-pending path (`:4799-4828`); a failed `ensure_pending` returns `None` (`.ok()?` at `:4855`), leaving the row with NO naming binding. REST create does the same | `crates/freshell-ws/src/terminal.rs:4751-4870`; REST CLI naming admission `crates/freshell-freshagent/src/terminal_tabs.rs:3457-3545` | +| G27 | Conversation switch retargets only the identity table (a durable `Session` ref moves to the new session; pending refs never move); respawn titles " (auto-resumed)" without naming | `crates/freshell-ws/src/identity.rs:314-330`, `bind_pending_naming` `:613-655`; `respawn_agent_terminal` `crates/freshell-ws/src/terminal.rs:7680` (title `:8099-8107`) | +| G28 | Every name read takes the exclusive flock and re-parses the whole document; the read route runs `refresh_current` first; the 2 s tick runs `refresh_current` | `run_txn_sync` `session_names.rs:1395-1484`; `get` `:1164-1180`; `refresh_current` `:655-668`; lock `:94-96`, `:1706-1730`; route `session_name_routes.rs:233-248`; tick `main.rs:1352-1398` | +| G29 | **Corrected bloat breakdown** (current copy, 5,050 records): exhausted series already carry no excerpt; the excerpt bytes are Idle (boot-absorbed, never attempted) series, ~1.93 MB, and they are still needed by the explicit-open arming path; acknowledged candidate ids 0.38 MB; winning evidence 0.18 MB | `scratchpad/live-evidence/session-names.json`; arming dependency `bind_pending_decision` `session_names.rs:2603-2617` | +| G30 | Import-owned records (winning evidence still at its installed revision, unprotected) on the live copy: 188 `first_message`, 1 `directory`, 114 `provider_ai`. Of the `provider_ai` ones, 29 are default labels recognizable without a folder ("Tab N", "OpenCode agent"-style; 27 came from per-window layout keys), and 85 are other text — among them folder names ("machines", "shapiroserver2", "podcast") and labels that look typed by a person ("ms-people-summit", "DirectorDeck"). None has a generation excerpt, so first messages must come from the session index | same copy (count script: `/tmp/claude-1000/-home-dan-code-freshell/b0e858cc-c789-4cc8-aba4-46c81f21d8b8/scratchpad/plan/revision-1-notes.md`) | +| G31 | Log spam: 15,145 of 15,833 lines are `codex_proxy_repair_trigger` WARNs, all the generic `ProxyError { "Codex remote proxy client/upstream connection errored." }`, ~580 per minute spread across 6 terminals (~1.6/s each: a reconnect loop, not one burst). #851 (merged after the live build) added a per-proxy `EmissionGate` and teardown-pair suppression only for CLASSIFIED capacity errors; the generic errored trigger is still emitted unthrottled at `crates/freshell-codex/src/remote_proxy.rs:1118-1130` | `crates/freshell-ws/src/codex_proxy_route.rs:92-95`; `scratchpad/live-evidence/rust-server.jsonl`; `EmissionGate` `remote_proxy.rs:826-860` | +| G32 | Existing e2e helpers to reuse: `bootUnifiedNamesServer`, `startFakeGemini`, `startFakeOneCliGeminiProxy`, `modeFixture`, `sendFirstMessage`, `readRecord`; the Claude terminal fake is `test/e2e-browser/fixtures/providers/fake-claude.mjs` (wired as `CLAUDE_CMD` at `helpers/unified-agent-names.ts:236`) | `test/e2e-browser/helpers/unified-agent-names.ts`, `test/e2e-browser/helpers/unified-agent-names-modes.ts`; server log via `RustServer.capturedOutput()` `test/e2e-browser/helpers/rust-server.ts:571` | +| G33 | `selectScopedTabDisplayTitles` calls `selectPaneDisplayName`, which returns a FALLBACK label (`selectPaneFallbackTitle`) when no saved record is cached; the tabs registry already overlays these values onto session-owned tab records | `src/store/selectors/sessionNameSelectors.ts:165-177` (fallback at 176), `:317-338`; `selectLiveLocalTabRecords` `src/store/selectors/tabsRegistrySelectors.ts:163-186` | +| G34 | The client name cache is fed by `/api/session-names/read` bootstrap AND by record projections riding on other frames and responses (terminal/session-directory projections, rename responses) | `src/App.tsx:1253`, `:1465`, `:1475`, `:1488`, `:1715`; `src/store/sessionNamesSlice.ts:179-222` | +| G35 | Crate edge: `freshell-ws` depends on `freshell-freshagent` (`crates/freshell-ws/Cargo.toml:55`), never the reverse; `freshell-freshagent` depends on `freshell-terminal` and already bridges outward through traits (`identity_sink.rs` `PaneIdentitySink`) | `crates/freshell-freshagent/Cargo.toml:54`; `crates/freshell-freshagent/src/identity_sink.rs:1-5, 343` | +| G36 | No folder on the server side of imports and renames: `LegacyNameCandidate` and `RenameNameInput` carry no cwd, and the name document stores none. Folders are known only to `PendingNameInput.cwd` (at create), `IndexedNameInput.cwd`/`NameActivity.cwd` (session index), and the terminal row | `crates/freshell-protocol/src/session_names.rs:395-405`; `crates/freshell-freshagent/src/naming.rs:78-96`; `crates/freshell-server/src/session_name_generation.rs:116-122` | +| G37 | The sweep fetches an OpenCode first message only while `needs_generation_input` is true, which is false once a series carries a fingerprint or is exhausted — so an OpenCode session stuck on a folder name never gets the first-message fallback | `crates/freshell-server/src/auto_title_sweep.rs:441-465`; `session_names.rs:850-873` | +| G38 | `hydrate_indexed_decision` returns early whenever any record exists; the sweep pre-check `hydrate_indexed_pending` mirrors that; the `activity_pending` pre-check's comment relies on "a record's source rank never decreases" | `session_names.rs:3200-3212`, `:933-940`, `:951-960` | +| G39 | **RC12 (new):** 171 saved names on the live copy are tool wrapper text taken as the "first message": 153 ``, 9 ``, 4 ``, 5 `…` (172 on the later V2 copy; 161 of them have an index row, 11 do not). The three Codex tags are Codex's own model-context fragments; in real Claude transcripts the caveat record is `isMeta` and is followed by ``, `` and sometimes `` records before the real prompt (V5). The first-message extraction goes through `extract_user_authored_text`, whose system-context tag list (`USER_CONTEXT_TAGS`/`SYSTEM_CONTEXT_LEAD_TAGS`) does not list these tags | `crates/freshell-sessions/src/text.rs:110-140`, `:311-335`; fallback `session_names.rs:2103-2110` | +| G40 | Rename convenience routes nest the update differently: the freshagent pane/tab routes return `data.sessionName` (the full `SessionNameUpdate`) | `crates/freshell-freshagent/src/lib.rs:6064-6079` | +| G41 | Which naming inputs carry a folder: `NameActivity.cwd` (today unused by `activity_decision`, bound as `cwd: _cwd`) and `IndexedNameInput.cwd` do; `NativeNameObservation` does not. The activity pre-check runs the SAME `plan_activity` as the transaction; the hydrate pre-check is a bare "no record" test; the sweep's settled memo lasts one pass only, and every non-boot pass feeds `IndexUserMessage` for every indexed session with a first message | `crates/freshell-freshagent/src/naming.rs:117-124`, `:141-147`; `crates/freshell-server/src/session_name_generation.rs:116`; `session_names.rs:933-940`, `:963-976`, `:2729-2741`; `crates/freshell-server/src/auto_title_sweep.rs:432-436`, `:495-535` | +| G42 | The index keeps a persistent parse cache (`rust-session-cache.json`) keyed by file `(mtime, size)` that stores `first_user_message`; an unchanged file is never re-parsed; `CACHE_SCHEMA_VERSION = 3` gates whole-cache invalidation, and `load_cache_file` already drops individual stale entries with a targeted filter (`has_legacy_claude_identity`). So a tag-list change alone does not re-derive cached first messages (V2: 164 live rows start with a new tag, 161 of them in files that will not change by themselves; a schema bump would re-parse all 17.6 GB of transcripts at boot) | `crates/freshell-sessions/src/directory_index.rs:1128`, `:2448-2450`, `:2688`, `:2738-2779` | +| G43 | Tests that pin "the inherited OneCLI proxy wins over a configured key": `session_name_onecli_route_omits_direct_key`, `onecli_missing_credential_retries_with_direct_key` and neighbors; the worker test `onecli_missing_credential_worker_fallback_saves_ai_name`; the e2e OneCLI journey, whose `bootJourney` sets `GOOGLE_GENERATIVE_AI_API_KEY` to a sentinel or a key next to the proxy and asserts `hasApiKey:false` | `crates/freshell-server/src/ai_title.rs:1034-1200`; `crates/freshell-server/src/session_name_generation_tests.rs:718`; `test/e2e-browser/helpers/unified-agent-names-modes.ts:75-83`, `:251-287` (used by `unified-agent-names-freshcodex.spec.ts:24`) | +| G44 | The current client rewrites the per-window sidecar `freshell.layout.pre-migration-raw.v1.` during its own storage migration, so it is not genuine pre-migration evidence (on the live copy it produced 5 winning "OpenCode agent" records); the bare `freshell.layout.pre-migration-raw.v1` key is not written by the current client | `src/store/storage-migration.ts:318-331`; `src/store/window-layout-keys.ts:53`, `:75` | +| G45 | Contract: "switching conversations adopts the new session's name". The identity upsert moves a durable `Session` ref onto the new session but never touches the registry's naming binding, so the row keeps showing the old session's name | `docs/plans/2026-09-16-unified-agent-names.md:19`; `crates/freshell-ws/src/identity.rs:314-330`; registry binding set only in `bind_pending_naming` `:640-646` | +| G46 | `observe_native_live` stamps each observation with the store's CURRENT verified location revision at call time, so a retry that calls it again gets a new stamp | `crates/freshell-freshagent/src/naming.rs:608-639` | +| G47 | A Rust-only, env-read-once test seam for e2e already exists (`FRESHELL_GEMINI_BASE_URL`) | `crates/freshell-server/src/main.rs:965-972` | +| G48 | The JSONL writer's scrubber redacts the exact `AUTH_TOKEN` plus a common-secret regex whose Gemini arm is `AIza[0-9A-Za-z_-]{20,}` only; Dan's Settings key is 53 characters in the `AQ.` form (V1), which it would not redact | `crates/freshell-runtime-observability/src/lib.rs:61-69`, `:92-125`; tests `crates/freshell-server/src/logging.rs:495-535` | +| G49 | **V1 (live, read-only GETs):** with Dan's key, Gemini answers 200 both directly and through the inherited OneCLI gateway (identical bodies; the gateway decrypts TLS with its own CA, which the live process trusts through `SSL_CERT_FILE`). Through the gateway: no key → 403 `access_restricted`; a bad key → **400** with the same OneCLI `access_restricted` body (Google's `API_KEY_INVALID` is hidden); a valid key and an unknown model → 404 with Google's own body. Without `SSL_CERT_FILE` the proxied request fails `is_connect()` with an `UnknownIssuer` certificate error in its source chain. The live process has no `GOOGLE_GENERATIVE_AI_API_KEY`; since its start the live log shows 25 `generation_failed: gemini http 403` and 0 successes | `scratchpad/lb-validate/report-0.md`; `scratchpad/lb-validate/V1/` | +| G50 | The activity apply re-checks the strict raise: `activity_decision` applies `plan.offer_fallback` through `offer_mut`, which accepts an automatic offer only when `source_rank(source) > source_rank(record.source)`; so a pre-check that plans a self-heal would be refused at apply time, every pass. The bind's explicit-open hook flips an Idle, never-attempted series to Eligible with whatever excerpt it stored | `crates/freshell-server/src/session_names.rs:2756-2765`, `:2183`; bind hook `:2600-2614` | +| G51 | Reconstructed managed panes: `managedTerminalContent` sets a `sessionRef` only when the soul has `nativeSessionId`, and never sets `nameRef`/`namingHandle`; the inventory has no naming handle. Managed runtime covers shell and OpenCode only; OpenCode learns its session id later, and `terminal.session.associated` writes that `sessionRef` into every pane with the matching `terminalId`, reconstructed ones included. A tab added with no title stores `Tab N`, which `getTabDisplayTitle` treats as a default | `src/lib/recovery/managed-runtime-recovery.ts:119-145`; `crates/freshell-agent-runtime/src/lib.rs:83-175`; `crates/freshell-ws/src/opencode_association.rs:314`; `src/App.tsx:1608-1625`; `src/lib/terminal-session-association.ts:162`; `src/store/tabsSlice.ts:358`; `src/lib/tab-title.ts:19-38` | +| G52 | `extract_user_authored_text` also feeds Claude's `user_message_count`, which drives `is_non_interactive` (at most 1 counted message) and the sidebar's default hide-non-interactive filter; V5 found 1 of 32 Claude sessions on this host would flip once the command tags are skipped | `crates/freshell-sessions/src/parse/claude.rs:353-358`, `:532`; `src/store/selectors/sidebarSelectors.ts:729-732` | +| G53 | **V3:** the host's Docker supervisor lane works when a managed case runs alone (a managed OpenCode parity case passed alone; the baseline's 7 failures ran several managed rigs at once). `runtime-terminal-continuity-rust.spec.ts` is `test.describe.serial`; its P2-G01 fails on `main` (after a graceful web restart the managed shell's `terminal.attach` gets `INVALID_TERMINAL_ID` and the client starts a new terminal), so later cases are skipped; its P2-G04 needs Dan's OneCLI grant files and makes paid model calls. Playwright 1.58.2 needs `chromium_headless_shell-1208` | `test/e2e-browser/specs/runtime-terminal-continuity-rust.spec.ts:164-165`, `:323-329`; `test/e2e-browser/playwright.config.ts:24-36`; `src/components/TerminalView.tsx:6695-6710`; `scratchpad/lb-validate/report-3.md`, `scratchpad/lb-validate/v3/` | +| G54 | Rust seams for a restart-level managed naming test: `TerminalRegistry::set_managed_controller`; the in-crate lookup-only fake controller pattern; the reconcile adopt path; the integration tests' `NamingProbeSink`; the in-process WebSocket harness of `pane_reconcile.rs` | `crates/freshell-terminal/src/registry.rs:4608`, `:4646-4676`; `crates/freshell-ws/src/terminal.rs:3635-3660`, `:8460-8490`; `crates/freshell-ws/tests/common/mod.rs:385`; `crates/freshell-ws/tests/pane_reconcile.rs:102-118` | + +## Decisions and exact cross-task interfaces + +### Name evidence (Task 1) +- `shared/name-evidence.ts`: + `isDefaultOrDerivedName(name: string, ctx?: { cwd?: string; provider?: string; mode?: string }): boolean` + and `isProviderPlaceholderTitle(provider: string, title: string): boolean`. +- `crates/freshell-sessions/src/name_evidence.rs` with the same two functions + (`is_default_or_derived_name(name, &NameEvidenceContext)`, + `is_provider_placeholder_title(provider, title)`), re-exported from the crate + root. It calls the existing `is_opencode_placeholder_title` + (`parse/opencode.rs:35`). +- A name is default/derived when, after trimming: it matches `^Tab \d+$`; it + equals the basename of `ctx.cwd` (only when a cwd is supplied); it is a + provider or mode label (`Claude`, `Claude CLI`, `Codex`, `Codex CLI`, + `OpenCode`, `Shell`, `Freshclaude`, `Freshcodex`, `Freshopencode`, + `Kilroy`, `Gemini`, `Kimi`, `Amplifier`, with optional `Recovered ` prefix + and/or ` agent` suffix, case-insensitive); it is `Recovered`, `Untitled` or + `Tab`; it is a provider placeholder (OpenCode `New session - `, + `Child session - `, `New Session - `); or it begins with a tool + wrapper tag (`` or `` or its + channels; never the per-window sidecar + `freshell.layout.pre-migration-raw.v1.`, which the current client + rewrites during its own storage migration (G44); never from `crossTabSync` + or machine-workspace restore. +- **Server, once `migration.completed` is true, for every non-boot import: + acknowledge every candidate id and write nothing.** No record is created or + changed, no candidate id is stored, no backup file is written, and the + document generation does not move. One INFO + `session_names.import_ignored_after_completion {import_id, candidates}` per + import id per process (an in-memory set bounds it). D1 permits this ("may *at + most* create"); the report's RC1 fix 1.b recommends it. +- **Why not create at the lowest rank (the earlier draft).** A created + `directory` record keeps nothing: the next sweep pass replaces it (the + first-message fallback, or the self-heal hydrate re-offer for a folder + name), and creating costs a 4.5 MB document write per import. Old browser + tabs that stay open after the deploy keep sending imports until reloaded; + acknowledge-only makes those sends free. +- Bind collision: an import-owned record (winning evidence still at its + installed revision) whose source is `directory`, `first_message` or + `provider_ai` loses to the pending record being bound ONLY when its name is + default/derived (`is_default_or_derived_name`, `cwd: None`: bind inputs + carry no folder, G36); the loser goes to recovery as today. Every other + import-owned record (a real-looking label such as "ms-people-summit", a + real first message) and every protected one keeps today's rank rule. +- Tie-break (pre-completion boot path only): `compare_classified_candidates` + stops at scope; equal candidates compare `Equal`, and + `apply_legacy_candidate` replaces only on a strict win, so the incumbent + stays. `order_boot_legacy_candidates` keeps a candidate-id sort only to keep + chunk membership stable (it decides batching, not the winner). + +### Ranking and self-heal (Tasks 4–5) +| Source of the offer | Applies when the current record is | Result when refused | +|---|---|---| +| User rename (`nameIntent:"user"`) | anything | — (unchanged rule) | +| Freshell AI answer | below `freshell_ai` (every unprotected record, so the self-heal rule changes nothing here) | (unchanged rule) | +| Automation rename (omitted/`automatic` intent), recorded at `provider_ai` | `directory`, `first_message`, `provider_ai` (equal rank allowed) and text differs and is not a default or derived label, classified with the best-known folder for the target (terminal row cwd for pane/tab/terminal routes, session index row cwd for session-key routes; LB-B8), so a rename to the session's own folder name is refused, never applied and then healed | `outcome:{applied:false, reason:"protected_name"|"default_label"}`; same text → `{applied:true, reason:"unchanged"}` with `changed:false` | +| Provider-native observation (current location; not a placeholder or other default/derived label, folder-free patterns) | title differs from `last_native_title` (C4) AND the record is below `provider_ai` (raise), a self-heal record, or `provider_ai` when a baseline exists (`last_native_title` set) and the observed title differs from both the current name and that baseline. The FIRST observation of a record (`last_native_title == None`) only records the baseline at equal rank | provenance only (the baseline is still recorded) | +| First-message fallback (wrapper and other default/derived text excluded, RC12) | below `first_message`, or a self-heal record, for ANY activity reason that carries a first user message | no-op | +| Hydrate from the session index (sweep) | no record (today's insert); or a self-heal record (new; which rung may apply is below) | no-op | +| Post-migration browser import | never applies | acknowledged, nothing written | + +**Self-heal rule (new; replaces the withdrawn repair tool).** +- A **self-heal record** is an unprotected record (`directory`, + `first_message`, `provider_ai`) whose current name the Task 1 classifier + recognizes: `is_default_or_derived_name(name, ctx)` with `ctx.provider` from + the record's ref and `ctx.cwd` from the offer's own input — `NameActivity.cwd` + (the sweep's index row, or the lane that reported the accepted input + through `report_accepted_input`, `crates/freshell-freshagent/src/naming.rs:422-450`) + and the hydrate input's index-row cwd; `None` (folder-free patterns only) + for native observations, imports and pending→durable binds, whose inputs + carry no folder (G36, G41). +- A self-heal record ranks below `directory` for every automatic offer + (first-message fallback, native observation, hydrate from the index, AI + generation answer). Concretely: + - an offer whose own text is a real name (not default/derived under the + same context) replaces it, whatever the offer's rank; + - a default/derived offer never replaces a name, with one exception: + hydrate's last rung (`directory_fallback_name`: the session's folder + name, else the provider label) may replace a self-heal record whose source + is above `directory`. After that the record is an ordinary `directory` + record, so two default labels can never alternate; + - the same text is always a no-op (no revision, no publish). +- `manual`, `legacy_protected` and `freshell_ai` records are never self-heal + records. Automation renames are not affected by the rule (their own row + above already allows equal rank). +- One pure predicate `self_heal_offer_applies(record, offer_name, + offer_source, ctx) -> bool` in `crates/freshell-server/src/session_name_policy.rs` + decides it. V2 confirmed the rule works with the sweep pre-checks only + under five conditions, each with its own Task 4/5 test: + - **C1 — apply what was planned.** `activity_pending` already runs + `plan_activity` (G41); `plan_activity` gains the activity's `cwd` and + routes its fallback rank check through the predicate, AND + `activity_decision` applies `plan.offer_fallback` as decided instead of + re-offering it through `offer_mut`'s strict raise (G50). Otherwise the + pre-check says "pending" while the apply refuses, the name never heals, + and every 5 s pass runs a locked full-document transaction per record. + - **C2 — one hydrate plan.** `hydrate_indexed_pending` + (`session_names.rs:933-940`, today a bare "no record" test) and the early + return in `hydrate_indexed_decision` (`:3200-3212`) both call one new pure + `plan_hydrate(document, &input) -> Option<(String, NameSource)>`: no + record → today's ladder (insert, absorb unchanged); a self-heal record → + the ladder's rung when the predicate allows it (replace; no series is + created or absorbed on this path); the same text, or anything else → + `None` (Read, and the pre-check reports no work). + - **C3 — stored rank can decrease.** Healing moves `provider_ai` to + `first_message` and `first_message` to `directory`. The two doc + comments that argue from "a record's source rank never decreases" + (`:911-932`, `:950-962`) are rewritten to say so and to state the + remaining safety direction: a stale adopted view only delays work (a + foreign writer — in practice only an older binary — storing a junk name + is seen after the next refresh, the 2 s tick) and never loses it. + - **C4 — native echoes never re-raise.** An observed native title equal + to `last_native_title` is provenance only, also when it would raise + rank. Without this, a native title equal to the folder name (not + recognizable without a folder) could raise over a healed name on every + echo while the sweep healed it back (0 live cases, but the loop is + possible). + - **C5 — the OpenCode first-message lookup sees the folder.** + `needs_first_message_input(target, cwd)` takes the index row's folder, + so a folder-named record counts as needing input, and the sweep's "no + user message" memo persists across passes (keyed by session id + index + `updated_at`), not only within one pass (Task 5). +- Where healing happens: every non-boot sweep pass feeds `IndexUserMessage` + for every indexed session with a first message and re-checks hydrate + (G41); live native observations; accepted user messages. Existing junk + names therefore heal within the first sweep passes after deploy, or when + a session is next seen, with no separate tool. Names that are not + recognizable junk stay (Residuals). V2's estimate for the live data: + about 190 one-time record rewrites in the first passes (141 Codex wrapper + names → real first message, 16 → folder name, 4 Claude wrapper names, 19 + OpenCode records → provider title, 11 `provider_ai` folder/label names → + first message), plus the re-absorb writes for the re-derived Codex + sessions and the native baselines. The wrapper names heal only because + Task 5 invalidates their stale parse-cache entries (G42), and the + re-derived first messages do not start background AI naming (Task 5, + re-absorb at rest). +- **Batched sweep writes (LB-B1).** Every transaction re-reads and rewrites + the whole 4.5 MB document under a lock that waiters poll for at most 1 s + (`session_names.rs:93-96`, `:1703-1727`), so ~190 back-to-back + single-record commits could starve user renames and binds right after + deploy. Instead, the sweep pass collects its maintenance work — self-heal + replacements (activity fallback or hydrate rung), re-absorb-at-rest + updates, and staged native baselines — and applies it in ONE store + transaction per pass covering at most `SWEEP_MAINTENANCE_BATCH = 25` + records (`sweep_maintenance_decision`, re-planning each item on the + freshly read document with the same plan functions, publishing each + changed record). Work beyond 25 waits for the next pass (≥ 5 s later), so + the post-deploy burst is about eight writes spread over a minute, and + renames and binds take the lock between passes. Inserts of new records + and activities that arm generation keep today's per-row transactions. + The pre-checks report which kind of work a row has, so the pass can sort + it without transacting. +- Logging: the Task 12 `name_changed` event carries `self_heal: true` when + the replaced record was a self-heal record. + +**Native observation details.** +- New internal field `NativeWriteState.last_native_title: Option` + (serde default; never on the wire). It is persisted whenever a valid + current-location, non-placeholder title differs from it, so an echo of the + same native title can never undo an automation or user rename, and never + raises rank either (C4). +- **First observation sets the baseline.** Every existing record starts with + `last_native_title == None`, and pre-deploy automation renames + (`provider_ai`) were never written back to the provider (only `manual` and + `freshell_ai` are writable), so the provider still reports its older title. + The first observation therefore records the baseline and replaces at + equal rank only when the record is a self-heal record; a raise from below + `provider_ai` still applies as today. A baseline that changes nothing + else never makes its own commit: the store stages it in memory and the + next sweep maintenance batch (or the next real commit touching that + record) persists it. Losing a staged baseline at a restart only means the + next observation records it again (LB-B1). +- **Native observations never block an event relay (G14).** `observe_native_live` + builds the `NativeNameObservation` once, stamping `location_revision` at + observation time (G46), and makes one attempt inline; on + `LockUnavailable`/`Persistence` it hands THAT observation to a per-sink + background retrier (200 ms, then 1 s) that keeps only the LATEST + observation per target, tagged with a per-target sequence. A retry + re-submits the captured observation unchanged (never re-stamped), so if + the provider location moved meanwhile it folds as stale (provenance only); + a retry applies only if its sequence is still the latest, so an older + title can never land after a newer one. The relay loop returns + immediately. + +**Automation rename outcome.** +- Wire (additive, optional): `SessionNameUpdate.outcome?: { applied: boolean; reason?: 'protected_name' | 'default_label' | 'unchanged' }` + in `crates/freshell-protocol/src/session_names.rs:191-198` and + `shared/session-names.ts:184-190`. Only rename responses set it; broadcasts + never do. Rename convenience routes pass it through unchanged; the CLI and + MCP read it at each route's actual nesting (freshagent routes: + `data.sessionName.outcome`, G40; the implementer records each route's path + in the Task 4 receipt). + +### Generation capability (Task 6) +- **Route by credential (D5, as amended by Dan on 2026-10-22),** decided per + attempt from the live key cell: + 1. **A key is configured** (`GOOGLE_GENERATIVE_AI_API_KEY`, else + `settings.ai.geminiApiKey`, through the existing `AiKeyCell`, so a + Settings save takes effect at the next attempt, G18) → **key route**: + the key is always sent (`x-goog-api-key`) and wins over any OneCLI + marker on the ambient proxy. The old "OneCLI first, key only on + `credential_not_found`/`app_not_connected`" order (G16) is removed. + 2. else the ambient proxy carries a OneCLI marker → **inherited OneCLI + route** (today's behavior: no key sent), with a boot WARN + `session_names.generation_route_inherited_identity` (once) saying the + server is borrowing the launching shell's OneCLI identity. + 3. else unavailable (no claims). + Every route makes one request per attempt; there is no fallback between + routes. +- **How the key route reaches Google (V1 + LB-B5/B6, resolved).** A + request carrying a configured key goes DIRECT (a `reqwest` client built + with `.no_proxy()`) when the ambient proxy for the Gemini URL carries the + OneCLI marker (`has_onecli_authorization`, `ai_title.rs:471`): OneCLI is + the launching agent's credential broker, its token may not outlive that + agent, and a client-supplied key through it was only ever proven for GETs, + while direct was proven on this host (G49). Otherwise the key route uses + the normal client that honors the ambient proxy environment + (`HTTPS_PROXY`/`NO_PROXY`, `reqwest::Client::new()` semantics), so hosts + whose only way out is an ordinary proxy keep working. Requests with no + key (the inherited OneCLI route) use the normal client as today. On Dan's + host the key therefore never passes through the gateway. The boot log + field `key_route_transport` (`direct` | `ambient_proxy`) says which. +- Failure classes (G49), checked in this order: + - HTTP: **429 is `Throttled` first** (LB-B10: a quota answer through the + gateway must not latch as a 15-minute `Capability`); then any non-2xx + whose body is OneCLI-style JSON (a top-level string `error`, e.g. + `access_restricted`, `credential_not_found`, `app_not_connected`) is + `Capability{code: }`, checked before the remaining status + rules because the gateway reports a bad key as **400** + `access_restricted`; then 401/403/407 and Google `API_KEY_INVALID` are + `Capability`; any other status is `Transient`/`Invalid` (unchanged + consumption). + - Transport: whether the request went through a proxy is computed for the + request URL with the existing `GeminiProxyEnvironment` + (`effective_proxy`/`bypasses_proxy`, `ai_title.rs:152-230`), and is + always false on the direct key route. A rustls invalid-certificate + error anywhere in the source chain is `Capability{proxy_tls}` through a + proxy and `Capability{tls_untrusted}` otherwise; it is found by + downcasting to `rustls::Error::InvalidCertificate` (walking `source()` + and looking inside `std::io::Error::get_ref()`), never by matching + message text, because `is_connect()` is also true for a refused + connection. `freshell-server` adds `rustls` as a direct dependency at + the version already in `Cargo.lock` (0.23.41, via reqwest) so the + downcast type is the same one. Any other `is_connect()` error is + `Capability{proxy_unreachable}` through a proxy (a CONNECT refused with + 407/403, a proxy that is down) and `Capability{direct_unreachable}` + otherwise (latched, nothing consumed: a host that reaches Google only + through its OneCLI proxy pauses naming with a WARN, Residual 19). + Timeouts and everything else are `Transient`. +- **Log scrubbing (G48, LB-B13).** The JSONL writer redacts the currently + configured key value itself (exact match) in addition to the patterns: + the writer gains a shared live-secret set next to its `AUTH_TOKEN` + secret, `main.rs` puts the key cell's value in it after `AiKeyCell::init`, + and the Settings PATCH path refreshes it right after + `apply_settings_key_forced` (`settings_store.rs:2139-2143`). The + common-secret regex in `crates/freshell-runtime-observability/src/lib.rs:61-69` + also gains the `AQ.` key form (`AQ\.[0-9A-Za-z_-]{20,}`) next to `AIza…`. + Naming code still logs only statuses and codes. +- **Exhaustion moves from the claim to the fold (G17).** The claim increments + `consumed` and sets `InFlight`, and never clears the excerpt. The fold + decides: `Capability`/`Throttled` refund the start (consumed −1, attempt + id/start removed, status Eligible, excerpt kept); `Empty`/`Failed` at the + cap exhaust and clear the excerpt (today's end state). Boot recovery of an + interrupted start already exhausts at the cap (`:3168-3171`), so a crash + mid-attempt still ends exhausted. +- **Latches (in memory, with code).** `Capability` latches naming as + unavailable until a re-probe time (default 15 min; Rust-only test seam + `FRESHELL_NAMING_CAPABILITY_REPROBE_MS`) or a key change. `Throttled` + latches the same way with a shorter, growing re-probe: 60 s, doubling per + consecutive throttled probe, capped at 15 min, reset by any success or a + key change (Rust-only test seam `FRESHELL_NAMING_THROTTLE_REPROBE_MS` sets + the base). One probe attempt runs per window. While latched the worker + claims nothing, so a refunded series cannot loop (load-bearing review + LB-A14). +- Logs: WARN `session_names.generation_capability_unavailable {code, status, route}` + once per transition (a 429 logs `code=throttled`); INFO + `session_names.generation_capability_restored`; INFO at boot + `session_names.generation_route_selected {route, onecli_marker, key_configured, key_route_transport}` + (booleans and names only; never a key or URL); `session_names.generation_failed` + gains `status` and `provider_code` fields. + +### Client tab titles (Tasks 7–9) +- **D2 choice: mirror, not "no stored title".** A session-owned tab + (`nameSource.kind === 'session'`) keeps `tab.title`, written only by a new + middleware from the canonical record, without bumping `updatedAt` and + without `titleSetByUser`. Justification: `title` is a required field in + persisted layouts, the server layout mirror, the tabs registry, closed-tab + snapshots and REST projections; removing it would touch every one of those + schemas and old-client compatibility, while a mirror fixes all of them at + once (they already copy `tab.title`) and keeps the last-known saved name + for offline display. It is never name evidence because Task 2 stops reading + live layouts. +- **The mirror writes only when a saved record is known (G33).** For each + session-owned tab it resolves the source pane's ref + (`resolvePaneNameRef(content)`) and reads `selectSessionNameRecord(state, ref)`. + No record → no write; the tab keeps its last-known title. It never uses + `selectScopedTabDisplayTitles`/`selectPaneDisplayName`, which fall back to + provider labels. New selector `selectSessionTabSavedNames(state): Record` + (records only) in `src/store/selectors/sessionNameSelectors.ts` next to the + existing one; the registry overlay switches to it too (so the registry no + longer advertises a fallback label for a session-owned tab whose record is + not loaded). +- Module name: `src/store/savedNameTabTitleMirror.ts` + (`savedNameTabTitleMirrorMiddleware`). It differs from the existing + `src/store/sessionTitleMirror.ts`, which copies session-directory titles + into NON-scoped pane titles; the contract doc's pane-title section says so. +- One gate `canWriteLegacyTabTitle(state, tabId)` in new + `src/lib/tab-title-ownership.ts`: false when the tab is session-owned. Every + legacy writer in G23 calls it; the `tab.create` UI command does not set + `titleSetByUser` when the created pane content is scoped (its title is a + provisional display until the record arrives). +- `updateTab` payload gains optional `touchUpdatedAt?: boolean` (default + true); display-only refreshes pass `false`. + +### Terminal naming bind (Task 10) +The core lives in `freshell-freshagent` so both doors can call it (G35): +```rust +// crates/freshell-freshagent/src/terminal_name_binding.rs +pub enum NamingBindOutcome { Bound(SessionNameRecord), BoundNoRecord, NotScoped, Deferred(String) } +pub trait TerminalNameBinder: Send + Sync { + /// Record the terminal's naming ref in the door's identity table. + fn set_name_ref(&self, terminal_id: &str, name_ref: &SessionNameRef); + /// The durable (provider, session id) the door knows for this terminal, if any. + fn durable_session_of(&self, terminal_id: &str) -> Option<(String, String)>; +} +pub struct NamingBindHint { pub mode: String, pub cwd: Option, pub resume_session_id: Option, pub naming_handle: Option } +pub async fn bind_terminal_naming_from_identity( + sink: &NameSink, registry: &TerminalRegistry, binder: &dyn TerminalNameBinder, + terminal_id: &str, hint: NamingBindHint, +) -> NamingBindOutcome +``` +- `freshell-ws` implements `TerminalNameBinder` over its identity registry + (thin wrapper in new `crates/freshell-ws/src/terminal_naming.rs`); the REST + door implements it over whatever `terminal_tabs.rs:3491-3545` uses today to + record the binding. +- It resolves the target (durable scoped session if known, else the pending + handle), sets the identity and registry bindings together, then reads the + record. "Read failed" and "no record" are separate arms (today both are + `None`, G26): read failure retries on `LockUnavailable`/`Persistence` + (50 ms, 250 ms, 1 s) and then returns `Deferred` with the binding kept; + "no record" with a persisted handle goes to the pending path; `ensure_pending` + gets the same bounded retry, and on persistent failure the row is still + bound to the pending ref and marked for the tick (it is never left + unbound). It fills `registry.update_session_name` and sets the row title to + the saved name or `mode_label(mode)` (never "Shell" for an agent mode). +- The 2 s tick reconciles: rows whose identity ref differs from the registry + ref; bound rows whose name cache is empty; and scoped-mode rows with NO ref + (it runs `ensure_pending` for them). +- **Conversation switch adopts the new session's name (existing contract, + G45).** When the identity upsert retargets a terminal from session A to + session B, the registry binding moves to B together with the identity ref + (the helper sets both; the tick's "identity ref ≠ registry ref" reconcile + is the backstop), and the row adopts B's record: B's saved name if B has a + record; otherwise the row title is `mode_label(mode)` and the pane shows + its no-saved-name fallback label until B's record is created by the + normal paths (the sweep's hydrate ladder: provider title → first message + → folder; then the first-message fallback and AI naming). On this path + the helper never takes the "no record → pending" arm: the terminal's + pending handle was already bound to A, so falling back to it would show + A's name again. The retarget itself creates or changes no record, and A's + record is untouched. + +### Store reads (Task 11) +- `Adopted` gains `stamp: DocumentStamp { len: u64, mtime_ns: i128, file_id: Option<(u64, u64)> }`. + `file_id` is `(dev, ino)` from `std::os::unix::fs::MetadataExt` under + `#[cfg(unix)]` and `None` elsewhere (the Windows file-index APIs in std are + unstable; no new crate). On Windows the stamp is `len` + 100 ns `mtime`. +- `SessionNames::adopt_external_if_changed() -> NameFuture`: stat the + document; if the stamp equals the adopted one AND the last full check is + younger than 30 s, return `false` without a lock; otherwise run the existing + locked full-read adoption. The 30 s full check bounds staleness for the + rare case a replace keeps length and mtime (coarse filesystems). +- `get` serves from the adopted view after `adopt_external_if_changed`; only + an unestablished store (generation 0) falls back to the locked path. + +### Name-change event (Task 12) +INFO `session_names.name_changed` with fields `name_ref`, `old_name`, +`old_source`, `old_revision`, `new_name`, `new_source`, `new_revision`, +`cause` (`import`|`native_observation`|`first_message_fallback`|`generation`|`manual_rename`|`automation_rename`|`bind`|`bind_collision`|`ensure_pending`|`hydrate`|`adopted_external`), +`self_heal` (true when the replaced record was a self-heal record), plus +`import_id`/`evidence_key`/`series` when applicable. Emitted once per +accepted visible change, after the durable commit. A conversation switch +changes no record, so it emits no `name_changed`; Task 10 logs the binding +move itself. + +### Task order and dependencies +| Phase | Task | Depends on | Validation results it applies | +|---|---|---|---| +| Stop new damage | 1 Name-evidence classifier | — | V5 (leading-tag check only) | +| | 2 Client import shutdown + envelope cleanup + flush never blocked (D1 client, D10) | 1 | — | +| | 3 Server: acknowledge-only imports after completion; default-label bind collisions (D1 server) | 1 | — | +| Ranking, self-heal & fallback | 4 Native placeholders, native replacement, automation outcome, self-heal rule (D3) | 1 | V2 (C1–C4); LB-B1 (batched sweep maintenance, staged baselines), LB-B8 (folder-aware automation renames) | +| | 5 First-message fallback on every first message; wrapper text excluded (D4, RC12) | 1, 4 | V2 (cache invalidation, re-absorb at rest, prompt-input guard, C5), V5 (tag set, fixture shape); LB-B7 (interactive pin), LB-B11 (per-entry rules stamp) | +| | 6 Generation routing, capability classification and pause (D5 as amended) | — | V1; LB-B5/B6 (direct key route when the proxy is OneCLI's), LB-B10 (429 first), LB-B13 (live-key scrubbing) | +| Client models | 7 Saved-name tab title mirror (D2) | 2 | — | +| | 8 Managed-runtime merge never writes names (D6) | 1, 7 (the mirror must read records only before reconstructed agent tabs rely on it) | V3 (new local-only spec), V4 (scoped-only session ownership); LB-B3 (stored-state assertions, red receipts), LB-B4/B14 (fallbacks) | +| | 9 New-window rebuild keeps pane titles and flags (D7) | 7 | — | +| Server binding | 10 Bind naming on every terminal row path; a conversation switch adopts the new session's name (D8) | — | V3 (restart naming at Rust integration level); LB-B2 (every adopt/create entry) | +| Store perf | 11 Lock-free reads and a cheap cross-process peek (D9) | — | — | +| Observability | 12 Name-change events; codex proxy `RepairTrigger` log spam diagnosed and bounded (D11) | 3–6, 10 | — | +| Finish | 13 Contract doc, e2e roll-up, build, code review, gate, PR and merge | all | V3 (local-only run rules), all results recorded | + +--- + +### Task 1: Shared default/derived name classifier + +**Intent:** one definition of "this text is a default or derived label, not a +name", used everywhere name evidence is accepted and by the self-heal +rule (D1, D3). + +**Files:** +- Create: `shared/name-evidence.ts` +- Create: `crates/freshell-sessions/src/name_evidence.rs` (export from `crates/freshell-sessions/src/lib.rs`) +- Create: `test/fixtures/name-evidence/cases.json` +- Create: `test/unit/shared/name-evidence.test.ts` +- Create: `crates/freshell-sessions/tests/name_evidence.rs` +- Modify: `crates/freshell-sessions/src/text.rs:135-154` (extract the leading + `\s]` branch of `is_system_context` into + `pub fn starts_with_context_tag(text: &str) -> bool`; `is_system_context` + calls it, so its behavior is unchanged) + +- [ ] **Step 1: Write the failing tests.** + - `cases.json`: ≥40 rows `{name, ctx, expected}` covering `Tab 7`, ` Tab 12 `, + `Tab` , folder basename with `ctx.cwd:"/home/dan/code/freshell"` → + `freshell`, the same `freshell` with no `ctx.cwd` → NOT default (pins the + folder-free limit), `OpenCode agent`, `Shell agent`, `Recovered Claude agent`, + `Claude CLI`, `codex cli` (case), `New session - 2026-10-20T00:09:25.350Z`, + `Child session - …`, `New Session - …` (legacy capital S), `Untitled`, + and true names that must NOT match: `Tab bar fix`, `freshell refactor`, + `Fix the OpenCode agent crash`, `New session - planning` (not ISO), + `main cleanup`, `LotL`, `ms-people-summit`, and the two V5 guards for the + leading-tag-only rule: `$ ls -la` and `1, 2, 3` → NOT default (the rest + of `is_system_context` would flag them). `…` → + default (an existing tag). (The RC12 wrapper-tag rows are added in + Task 5, when the tags exist.) + - `test/unit/shared/name-evidence.test.ts`: + `it('classifies every shared fixture case exactly as expected')` (loops the + table, reports the failing row) and + `it('treats provider placeholders per provider only')` (`opencode` + + placeholder → true; `claude` + same text → false). + - `crates/freshell-sessions/tests/name_evidence.rs`: + `shared_fixture_cases_match` (reads + `concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/name-evidence/cases.json")`) + and `opencode_placeholder_reuses_directory_index_predicate`. +- [ ] **Step 2: Run red.** `pnpm run test:vitest run test/unit/shared/name-evidence.test.ts --config config/vitest/vitest.config.ts` + and `~/.local/bin/mise exec rust@1.96 -- cargo test -p freshell-sessions --locked --test name_evidence`. + Expected: fail to compile/import (module missing). +- [ ] **Step 3: Implement** both modules per "Name evidence" above. TS imports + use `.js` extensions where NodeNext requires them. +- [ ] **Step 4: Run green** (same commands). +- [ ] **Step 5: Refactor.** Make the directory index's placeholder use + (`crates/freshell-sessions/src/parse/opencode.rs:1014`) and the client + `tab-title.ts` `^Tab \d+$` check use the shared functions so there is one + definition per language; re-run the existing tests of those files + (`~/.local/bin/mise exec rust@1.96 -- cargo test -p freshell-sessions --locked opencode`, + and the existing `getTabDisplayTitle` cases, located with + `grep -rln getTabDisplayTitle test/unit`, through + `pnpm run test:vitest run --config config/vitest/vitest.config.ts`). +- [ ] **Step 6: Commit** `feat(names): shared default-label classifier`. + +### Task 2: Client import shutdown, envelope cleanup, flush never blocked (D1 client, D10) + +**Intent:** the browser stops sending its current tab and pane titles as name +evidence, deletes the migration copies it no longer needs, and can never +block a layout save on evidence capture. + +**Files:** +- Create: `src/lib/legacy-name-capture-policy.ts` (`isGenuinePreMigrationSource(key, raw)`, `runOneTimeLegacyCapture(storage)`, `purgeObsoleteEnvelopes(storage)`, `markCaptureDone(storage)`, `isCaptureDone(storage)`) +- Modify: `src/lib/session-name-migration.ts` (module-load call at 889-893 → `runOneTimeLegacyCapture`; delete `captureLegacyLayoutEnvelope` 299-326 and `ensureLegacyNameCapturesForFlush`/`captureWriteFailed` 77-84, 346-357; `markAcknowledged` deletes the envelope; `submitPendingLegacyNameImports` marks done when nothing is pending; `extractEnvelopeCandidates` 482-630 skips default/derived labels with `ctx.cwd` from pane content `initialCwd`, and skips session-owned tabs' `title` entirely) +- Modify: `src/store/crossTabSync.ts:443-453` (remove capture) +- Modify: `src/lib/machine-workspace.ts:147-155` (remove capture) +- Modify: `src/store/persistMiddleware.ts:658-671` (remove the deferral gate) +- Modify: `src/store/store.ts:162-196` (submission only while capture is not done) +- Tests: rewrite `test/unit/client/lib/session-name-migration.test.ts` cases at :148, :209, :243, :269 and the taxonomy case at :686 (they pin the removed behavior); rewrite `test/unit/client/store/persistFlushCaptureGate.test.ts:97`; update the capture assertion near `test/unit/client/store/crossTabSync.test.ts:3060`; create `test/unit/client/lib/legacy-name-capture-policy.test.ts`. +- E2e: create `test/e2e-browser/specs/name-import-shutdown-rust.spec.ts` (scenarios A–C below; scenario D lands in Task 5) and `test/e2e-browser/specs/name-storage-quota-rust.spec.ts` (RC10, see the e2e matrix). + +- [ ] **Step 1: Write the failing unit tests** (`legacy-name-capture-policy.test.ts` + and the rewritten cases): + - `it('never captures a live per-window layout key, its .bak or its fresh-agent backup')`: + seed `freshell.layout.v3.layout-window-a`, `….bak`, + `….backup-before-fresh-agent-centralization`; run; assert zero + `freshell.session-names.migration.v1.*` keys and no import request + (mock `api.post`). + - `it('never captures the per-window pre-migration sidecar')`: seed + `freshell.layout.pre-migration-raw.v1.layout-window-a` holding a + pre-names raw whose scoped tab is titled `OpenCode agent` (the shape the + current client writes, G44); run; zero envelopes, zero requests. The + bare `freshell.layout.pre-migration-raw.v1` key is still captured (covered + by the next case's source list). + - `it('captures a genuine bare legacy layout once, then marks the device done after acknowledgment')`: + seed `freshell.layout.v3` with a pre-names raw (no `nameSource`); run, submit + (mock 200 with all candidate ids), assert envelope deleted, done marker + present; run again with a changed bare key → no new envelope, no request. + - `it('ignores a bare legacy key whose raw already carries unified-names fields')`. + - `it('a device with prior envelopes or ack markers becomes done without resubmitting live-layout envelopes')`: + seed an acked envelope + an unacked envelope whose `storageKey` is + `freshell.layout.v3.layout-window-b`; run; assert both envelopes and the + ack marker are deleted, done marker set, zero requests. + - `it('a sibling window layout write never captures or submits')` + (crossTabSync storage event with a changed per-window raw; assert no + envelope, no `api.post`). + - `it('machine-workspace restore performs no legacy capture')` (in + `test/unit/client/lib/machine-workspace.test.ts`). + - `it('extracts no candidate from default or derived labels')`: genuine + legacy raw where a scoped tab is titled `Tab 7`, `freshell` (cwd basename), + `OpenCode agent`, `Claude CLI`; assert `prepareLegacyNameImports` yields + zero candidates; a real label `Billing bug` still yields one. + - `persistFlushCaptureGate.test.ts`: `it('a quota-failing capture never blocks the layout flush')` + (make `setItem` throw for `freshell.session-names.*` keys only; dispatch a + tab rename; assert the layout key was written and a structured + `legacy_name_capture_failed` error was logged). +- [ ] **Step 2: Write the failing e2e (scenarios A–C)** in + `name-import-shutdown-rust.spec.ts`, driven by `bootUnifiedNamesServer({ mode: 'claude', gemini: })` + (extend `startFakeGemini` with `opts.status?: number` and + `opts.body?: unknown`), real pane-picker create, `sendFirstMessage`, + `page.on('request')` counting `POST /api/session-names/import`, and + `readRecord` for the server truth: + - A `reload keeps the saved name and sends no import`: after the first message + settles, read the record (name N, revision R); `page.reload()`; assert zero + import POSTs, record still N at R, tab label N. + - B `a second window keeps the saved name`: `context.newPage()` (new + sessionStorage → new window id, shared localStorage); same assertions in + both pages. + - C `two windows with different stale tab titles never change the name`: + `addInitScript` seeds two per-window keys whose session-owned tab titles + are `Tab 23` and `ms-people-summit` for the same session; open both, + reload each three times; record name and revision never change. +- [ ] **Step 3: Run red.** `pnpm run test:vitest run test/unit/client/lib/legacy-name-capture-policy.test.ts test/unit/client/lib/session-name-migration.test.ts test/unit/client/store/persistFlushCaptureGate.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/lib/machine-workspace.test.ts --config config/vitest/vitest.config.ts` + and `GCLOUD_ROBOT_REQUIRE=1 FRESHELL_E2E_BACKEND=cloud pnpm run test:e2e --project=chromium test/e2e-browser/specs/name-import-shutdown-rust.spec.ts test/e2e-browser/specs/name-storage-quota-rust.spec.ts`. + Expected red: envelopes captured from per-window keys, flush deferred, + e2e A sees ≥1 import POST and (as reproduced) the name becomes `Tab N`. +- [ ] **Step 4: Implement.** Policy module per "Import policy after + migration". `runOneTimeLegacyCapture`: done → purge acked/obsolete + envelopes and return; else purge envelopes whose source is not genuine + (never submit them), delete acked envelopes and markers; if a prior capture + ever happened (ack markers or envelopes existed) and nothing genuine is + pending → mark done; else capture genuine sources once (no source → mark + done). Capture write failure logs `log.error('legacy_name_capture_failed', { storageKey, reason })` + and never sets any flush gate. Submission marks done after the last + acknowledgment. Remove the three capture call sites and the flush gate. +- [ ] **Step 5: Run green** (both commands). +- [ ] **Step 6: Refactor.** Remove now-dead exports (`captureLegacyLayoutEnvelope`, + `registerLegacyNameSubmitGate`, `resetLegacyNameCaptureFailureForTests`) and + their imports; `session-name-migration.ts` must end smaller than 893 lines. + Fix the `stripSessionOwnedTabFreezeFlags` comment once Task 7 lands (note + it in Task 7). Re-run Step 3 commands plus `pnpm run typecheck`. +- [ ] **Step 7: Commit** `fix(names): capture legacy names once per device and never block layout saves`. + +### Task 3: Server acknowledges browser imports after completion without writing; default-label bind collisions (D1 server) + +**Intent:** once the one-time consolidation is complete, a browser import +changes nothing and costs nothing; an import-owned default label ("Tab 4", +"OpenCode agent") never wins a bind collision, while other imported names +keep today's rank rule; window ids never decide a name. + +**Files:** +- Create: `crates/freshell-server/src/session_name_import_policy.rs` (`post_completion_acknowledge(input) -> LegacyImportResult`, `is_import_owned(document, key) -> bool`, `import_owned_label_loses_bind(document, key) -> bool` = import-owned AND unprotected AND `is_default_or_derived_name(name, cwd: None)` from Task 1, the per-process logged-import-id set) +- Create: `crates/freshell-server/src/session_name_import_policy_tests.rs` +- Modify (wiring): `crates/freshell-server/src/session_names.rs` — `import_legacy_decision` 4003-4104: when `document.migration.completed && !trusted_boot`, return `Decision::Read(post_completion_acknowledge(&input))` BEFORE the backup write (`:4029`) and the ack push (`:4053-4057`); `compare_classified_candidates` 3682-3712 ends at scope; `apply_legacy_candidate` 3836-3851 keeps strict-win; `bind_pending_decision` 2537-2553 consults `import_owned_label_loses_bind` +- Modify: `crates/freshell-server/src/session_names_tests.rs`, `crates/freshell-server/src/session_name_routes_tests.rs` (cases that pinned tie-break order or post-completion application) + +- [ ] **Step 1: Write the failing tests** (`session_name_import_policy_tests.rs`, real `SessionNames` on a temp data dir, migration completed through `complete_legacy_migration`): + - `post_completion_import_writes_nothing`: one existing session record + (`directory` "work") and one session with no record; import candidates + `provider_ai` "Tab 4" (existing session), "Billing bug" (no-record + session), a canonical pending target and a derived `legacy-pending` + handle → every candidate id acknowledged; existing record unchanged + (name, source, revision); no record created for either session or + either pending target; document generation unchanged; the import backup + directory gained no file. + - `post_completion_reimport_stores_no_candidate_ids`: the same import sent + three times → generation unchanged every time and + `acknowledged_candidate_ids` did not grow (the 4.5 MB rewrite stops). + - `post_completion_import_never_raises_rank`: existing `first_message` + record; candidate claims `legacy_protected` (flag evidence) → unchanged. + - `post_completion_import_logs_once_per_import_id` (capture `tracing` + output with the crate's existing log-capture helper; same import id + twice → one INFO `session_names.import_ignored_after_completion`). + - `import_owned_default_label_loses_a_bind_collision`: before completion a + boot import installs "Tab 4" at `provider_ai` on `session:claude:X`; + complete; pending handle P has directory "work"; bind P→X → record + "work", loser in recovery with reason `bind_collision_loser`. + - **Pinning test** `import_owned_real_label_keeps_the_rank_rule`: same + setup with "ms-people-summit" → the durable record keeps + "ms-people-summit" (`provider_ai` outranks the pending `directory` + record), exactly as today. + - **Pinning test** (pre-completion boot path; short) + `equal_rank_boot_candidates_keep_the_incumbent`: migration-owned record + installed from window `pii…`; an equal-rank, equal-scope candidate from + window `mn0…` → incumbent kept; swapped spelling → incumbent kept. Red + today because the evidence key decides. + - Route test in `session_name_routes_tests.rs`: + `import_route_after_completion_acknowledges_without_writing` (HTTP POST → + 200, `acknowledged` lists ids, generation unchanged, no backup file). +- [ ] **Step 2: Run red.** `pnpm run test:server session_name_import_policy` + and `pnpm run test:server session_name_routes`. Expected: creates, + replacements, stored ids and backup files happen today; tie test flips + with key order. +- [ ] **Step 3: Implement** per "Import policy after migration". +- [ ] **Step 4: Run green**, then the whole naming suite: + `pnpm run test:server session_name`. +- [ ] **Step 5: Refactor.** Update the doc comments that promise "a later + previously-offline import compares against this retained evidence with the + same deterministic total order" (`session_names.rs:286-296`) and the + tie-break comments; the pre-completion path is unchanged apart from the + tie-break. +- [ ] **Step 6: E2e.** Add scenario `server ignores a forced stale import` to + `name-import-shutdown-rust.spec.ts`: seed + `freshell.session-names.migration.v1.` holding a per-window raw with + "Tab 23", clear the done marker (forcing an old-client-style send); assert + the import POST returns 200, the record and its revision are unchanged, and + the server log has one `import_ignored_after_completion` line. +- [ ] **Step 7: Commit** `fix(names): acknowledge post-migration imports without writing`. + +### Task 4: Native placeholders, native replacement, automation outcome, self-heal (D3) + +**Intent:** OpenCode's temporary "New session -