Skip to content

feat(resume): auto-resume an agent session after a host restart - #2

Closed
kiwidream wants to merge 11 commits into
kiwidream/resume-handle-durabilityfrom
kiwidream/auto-resume-sessions
Closed

kiwidream wants to merge 11 commits into
kiwidream/resume-handle-durabilityfrom
kiwidream/auto-resume-sessions

Conversation

@kiwidream

@kiwidream kiwidream commented Sep 22, 2026 •

Copy link
Copy Markdown

ELI5

Stacked on #1. Once a terminal remembers which conversation it had, this makes it come back on its own. If the machine can't tell which of several conversations belonged to that terminal, it asks you instead of guessing — because picking wrong forks the conversation permanently.

What Changed

Before: a pane whose resume handle was lost (restarts before #1 landed, or an agent started outside Orca) reopens as a bare shell forever, even though the host still holds the transcript.

After: when you open such a pane, Orca asks its execution host which sessions exist for that workspace, and either resumes the right one or shows a small chooser.

The mechanism, in layers, each independently tested:

  • agent-resume-candidate-source — asks one host (aiVault.listSessions, scoped to the workspace) and validates every field at that boundary. An unreachable host answers with no candidates rather than throwing.
  • agent-resume-candidate-resolver — a pure function that refuses rather than guesses. Filters: agent match, exact cwd (never a prefix — that would pull in a sibling worktree), a message-count floor, and exclusion of any session a live pane already holds. Then rungs: sole candidate → resume; the tab's own last session id → resume; exactly one inside the tab's lifetime window → resume; otherwise offer a choice.
  • agent-resume-pane-decision — three gates before anything destructive: the pane already owns a record, the pane is hidden, or the user has already typed.
  • AgentResumeChooser — the one visible surface, keyboard-first, shown only when the resolver refused to decide.

Timing. The recovery runs after the pane's plain shell has spawned, and a recovered session replaces it by respawning. Holding every spawn behind the host query would tax every terminal you open: the answer comes from a transcript scan measured at ~2.0s on a host holding 1200 transcripts (547 MB). The cost is a brief flicker on panes that actually recover. A resume still only ever rides a spawn — it is never typed into a live shell (docs/reference/omp-resume-transcript-locator.md).

Why

The alternative — auto-picking the newest transcript — is unsafe, and that is not theoretical. During development the newest transcript in a worktree was a 39-line diagnostic session, ahead of the real 758-line one. A resolver that ranked by recency would have resumed the wrong conversation and forked it. Hence the substance floor and the refuse-don't-guess rungs.

Agent-agnostic throughout: resume argv comes from getAgentResumeArgv for all of RESUMABLE_TUI_AGENTS; no claude special case and no hardcoded transcript path.

Linked Issue

Fixes #

Visual Proof

Pending. The chooser is new UI and this needs a before/after capture before merge. Everything below it is tested headlessly; the chooser surface itself has not been visually reviewed yet.

Testing

96 tests across the new modules, plus the existing pane suite for the wiring:

  • pnpm test src/renderer/src/lib/agent-resume* and pending-agent* — 96 passed
  • pnpm test src/renderer/src/components/terminal-pane — 4563 passed, 1 skipped (475 files), confirming the pane wiring breaks nothing
  • pnpm tc — exit 0
  • pnpm run check:code-quality:changed — 0 findings across 27 changed files (including the design-system gate)

Notable coverage: the gates are checked before the host is asked (a hidden pane never pays the scan) and again after, so a pane the user starts typing into while the host is answering is never replaced.

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

Not yet exercised against a real host restart end to end; that is the remaining validation.

AI Disclosure

Authored with Claude (Opus 5), across parallel workers with human-directed review and integration.

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Known limitations, stated rather than hidden:

  • Agents that resume by transcript path (Pi, OMP) cannot be served from a remote host, because redactForTransport withholds filePath from paired clients. They remain record-only.
  • A recovered session has no captured launchConfig — the handle that held it is what was lost — so the pane returns with the agent's configured defaults, not the original turn's flags.
  • listSessions scans rather than reading an index. searchSessions is index-backed and would be cheaper, but its empty-query behaviour is unverified and no index was present on the test hosts.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • Before/after screenshots or videos attached for UI changes — outstanding, see Visual Proof
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered
  • pnpm lint, pnpm typecheck, pnpm test pass

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@tenki-reviewer

tenki-reviewer Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Review complete. 🟠 1 high · 🟡 2 medium

💬 Inline comments (3)

Reviewed commit: 61c72d4

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-23T17:54:57.820888Z 5a5f0a1 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 61c72d4770

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 61c72d4770

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@tenki-reviewer tenki-reviewer 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.

Expand to see previous review

Adds an agent session auto-resume chooser with candidate resolution, decision gates, and portal UI; three race/lifecycle defects remain in the recovery and dispose paths.

@tenki-reviewer

tenki-reviewer Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 3 medium

💬 Inline comments (2)

📍 Outside the diff (1)

  • 🟡 Clear tracker misses rows written by full-snapshot persists (workspace-session-host-persistence.ts:262–276) — nonLocalPartitionPatches only learns which non-local hosts hold rows from the debounced patch path (src/renderer/src/lib/workspace-session-partition-field-clearing.ts:32); persistWorkspaceSessionByHost fills ssh/runtime partitions via api.set without recording them in the hostsHoldingRowsByWriter tracker (src/renderer/src/lib/workspace-session-host-persistence.ts:262-276).

Reviewed commit: f3389ed

@kiwidream
kiwidream force-pushed the kiwidream/auto-resume-sessions branch from 61c72d4 to f3389ed Compare September 22, 2026 17:53

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f3389ed0ec

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@tenki-reviewer tenki-reviewer 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.

Expand to see previous review

Adds agent-resume candidate resolution, chooser UI, PTY resume recovery, and workspace-session host persistence with partition field clearing; three medium-severity issues found.

@kiwidream
kiwidream force-pushed the kiwidream/auto-resume-sessions branch from f3389ed to 29baf66 Compare September 22, 2026 21:01
@tenki-reviewer

tenki-reviewer Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 2 medium

💬 Inline comments (2)

Reviewed commit: 29baf66

@tenki-reviewer tenki-reviewer 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.

Expand to see previous review

Adds an agent-resume chooser with session reservations, candidate resolution, and PTY recovery install across renderer terminal panes; two medium bugs found around latch spending on visibility loss and keyboard/focus selection divergence.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 29baf66c7f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@kiwidream
kiwidream force-pushed the kiwidream/auto-resume-sessions branch from 29baf66 to 603e3f0 Compare September 23, 2026 14:48
@tenki-reviewer

tenki-reviewer Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 1 medium

💬 Inline comments (1)

  • 🟡 Chooser strings missing from five locales — en.json:17862
🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 Startup-build failure misreported as session-claimed (agent-resume-recovery.ts:79) — In recoverAgentSessionForPane, any false from args.resume becomes session-claimed (src/renderer/src/lib/agent-resume-recovery.ts:80), but resumeWithCandidate returns false for three unrelated causes: lost pane binding, buildCandidateResumeStartup returning null, and an actual reserve loss.

Reviewed commit: 603e3f0

@tenki-reviewer tenki-reviewer 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.

Expand to see previous review

Adds an agent auto-resume recovery flow for orphaned agent sessions, with a modal chooser, candidate scanning/resolution, and reservation lifecycle; two minor issues: chooser strings missing from non-English locales and a startup-build failure misreported as session-claimed.

Comment thread src/renderer/src/i18n/locales/en.json Resolved

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 603e3f085b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@kiwidream
kiwidream force-pushed the kiwidream/auto-resume-sessions branch from 603e3f0 to f63e4e6 Compare September 23, 2026 16:56
@tenki-reviewer

tenki-reviewer Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Review complete. 🟠 2 high · 🟡 1 medium

💬 Inline comments (3)

🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 Korean title leaves "terminal" untranslated (ko.json:14997) — The Korean title for the blocking chooser mixes an untranslated English word into the sentence: "이 terminal에서 어떤 세션을 재개할까요?" (src/renderer/src/i18n/locales/ko.json:14997).

Reviewed commit: f63e4e6

@kiwidream
kiwidream force-pushed the kiwidream/auto-resume-sessions branch from f63e4e6 to 603e3f0 Compare September 23, 2026 17:00
@kiwidream
kiwidream force-pushed the kiwidream/resume-handle-durability branch from 5427f80 to 2efa685 Compare September 23, 2026 17:00
@kiwidream
kiwidream force-pushed the kiwidream/auto-resume-sessions branch from 603e3f0 to f63e4e6 Compare September 23, 2026 17:01

@tenki-reviewer tenki-reviewer 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.

Expand to see previous review

Adds an agent auto-resume recovery feature (host session scan → candidate resolution → blocking chooser → respawn) across renderer libs, PTY connection lifecycle, React UI, and locale files.

Comment on lines +95 to +99
session.agentResumeRecoveryUnregister = registerAgentResumePaneHandler(
session.cacheKey,
session.transport,
resumeWithCandidate
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 bug · high

Resume handler registered before session.transport exists

installAgentResumeRecovery registers the pane handler with session.transport as owner, but in connectPanePty it runs before installPtyInputRecovery assigns session.transport, so the handler is registered with owner undefined (src/renderer/src/components/terminal-pane/pty-connection/agent-resume-recovery-install.ts:97). Choices are later published with the real transport as owner, so getAgentResumePaneHandler always mismatches and returns undefined. Every chooser interaction — Enter, number key, click — silently does nothing; only the automatic sole-candidate resume path works.

📋 Prompt for AI Agents

In src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts, installAgentResumeRecovery(session) (line 172) runs before installPtyInputRecovery(session) (line 180) assigns session.transport, so agent-resume-recovery-install.ts lines 95-99 register the resume pane handler with owner undefined while pending choices are published with the real transport as owner — getAgentResumePaneHandler in AgentResumeChooserPortals.tsx line 31 always returns undefined and every chooser selection is a silent no-op. Move the registerAgentResumePaneHandler call to after the transport assignment (e.g. invoke it from the end of installPtyInputRecovery, or change installAgentResumeRecovery to accept an owner getter), keeping the unregister wired to dispose.

Comment on lines +125 to +128
const failures = (result.issues ?? []).filter((issue) => issue.kind !== 'notice')
if (failures.length > 0) {
return 'scan-issues'
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 bug · high

Notice rows hide dropped failure issues from completeness check

scanIncompleteReason treats a notice-only issue list as a complete scan (src/renderer/src/lib/agent-resume-candidate-source.ts:125), but a notice row is emitted precisely when the issue array overflowed SCAN_ISSUE_LIMIT and further failure rows were dropped (src/main/ai-vault/session-scan-issues.ts:20-25). A stalled WSL distro or unreachable remote root records one refusal per discovered path, so a 500+ refusal scan ships only the notice; the renderer filters zero failures, answers complete, and the surviving subset can be auto-resumed as a sole candidate — forking the wrong transcript.

📋 Prompt for AI Agents

In src/renderer/src/lib/agent-resume-candidate-source.ts around lines 125-128, scanIncompleteReason filters issues to issue.kind !== 'notice', but a kind: 'notice' row is also how the main scanner signals that additional failure rows were dropped at SCAN_ISSUE_LIMIT (src/main/ai-vault/session-scan-issues.ts). Change the function so the 'Additional scan issues were omitted.' notice makes the scan unverifiable with reason 'scan-issues', while genuine per-transcript commentary notices still count as complete — distinguish them by path/message. Update agent-resume-candidate-source.test.ts accordingly.

// while a newer one survives, and the resolver reads that subset as the whole. An answer sitting
// exactly at the cap is indistinguishable from a truncated one, so it is `unverifiable`: a
// subset must never be read as the whole (docs/reference/ssh-execution-boundary.md).
return (result.sessions ?? []).length >= DEFAULT_AI_VAULT_SCAN_LIMIT ? 'scan-truncated' : null

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 bug · medium

Truncation heuristic misjudges both capped and complete scans

scanIncompleteReason flags any answer with sessions.length >= DEFAULT_AI_VAULT_SCAN_LIMIT as scan-truncated (src/renderer/src/lib/agent-resume-candidate-source.ts:135), but the request is unlimited: true and an honouring host returns its uncapped session list — so a host holding 1000+ sessions total (the PR's own motivating example) answers completely, gets labelled truncated, and every recovery attempt returns unverifiable, permanently disabling auto-resume on exactly the machines that accumulate the most transcripts. Conversely, the heuristic cannot detect an older host that caps below 1000, so a genuinely truncated subset can pass as complete.

📋 Prompt for AI Agents

In src/renderer/src/lib/agent-resume-candidate-source.ts line 135, the sessions.length >= DEFAULT_AI_VAULT_SCAN_LIMIT heuristic misclassifies a complete uncapped answer as truncated (disabling auto-resume on hosts with 1000+ total sessions) and cannot detect a host that caps below the constant. Replace it with an explicit host signal: extend the listSessions result consumed here (respecting remote-wire-compatibility for the optional field) so the host reports whether it applied a cap, return 'scan-truncated' only when a cap was actually applied, and keep the length heuristic only as a fallback for hosts older than the new field.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f63e4e6cb0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +61 to +62
if (resolveAgentResumePaneLiveness(state, rowPaneKey) !== 'exited') {
claimed.add(entry.providerSession.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope claims to the complete provider identity

When two execution hosts or providers expose the same raw session ID, this global claim set treats them as one conversation even though only one belongs to the recovering pane's host. The repository's agentProviderSessionsEqual also establishes that Pi and Prime Agent identities include transcriptPath, but claims and reservations retain only .id; a live session on host A, or a distinct Pi transcript with the same ID, can therefore suppress a valid recovery on host B indefinitely. Key claims by execution host, agent, and complete provider metadata rather than the raw ID.

AGENTS.md reference: AGENTS.md:L89-L91

Useful? React with 👍 / 👎.

Comment on lines +50 to +51
{panes.map((pane) => (
<AgentResumeChooserPortal key={pane.id} paneKey={makePaneKey(tabId, pane.leafId)} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Render only one split-pane chooser at a time

When two visible split panes both produce ambiguous recovery results, this maps every pane to an open app-modal Dialog, creating stacked focus traps and unlabeled decisions whose selected session applies to whichever hidden portal owns the top layer. Fresh evidence in this revision is that TerminalPaneSurface gates only on the tab-level isActive, while this loop still mounts a chooser for every split pane rather than the active pane. Filter to the active pane or serialize the pending choices.

Useful? React with 👍 / 👎.

kiwidream and others added 6 commits September 23, 2026 13:14
Refuses rather than guesses: filters by pane agent, exact cwd scope, a
substance floor and live-pane claims, then resolves by sole survivor, tab
session id, or a unique host-clock lifetime-window match; otherwise asks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The source asks one execution host which sessions it holds for a workspace and
validates every field the resolver will act on at that boundary; an unreachable
host answers with no candidates rather than throwing, so the pane falls back to a
plain shell and never a wrong resume.

The chooser is the one visible surface of auto-resume. It renders only when the
resolver refused to decide, and it is wired to replace the spawn rather than sit
over a live shell, so no resume command is ever typed into a running terminal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… session

Runs after the plain shell has spawned rather than holding every fresh spawn
behind a host round trip, so a terminal in a workspace that never ran an agent
pays nothing. A recovered session replaces the shell by respawning, which keeps
a resume riding a spawn instead of being typed into a live terminal.

Three gates refuse: the pane already owns a record (a second resume would fork
one transcript across two panes), the pane is hidden (a host restart must not
respawn a workspace in bulk), or the user has already typed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kept out of the persisted session state on purpose: a pending choice is transient
UI state, and persisting it would let a stale list be revived against sessions
the host no longer has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ties the host query, the resolver and the pane gates together for a pane whose
resume handle was lost. The gates are read live rather than captured: asking the
host means a transcript scan measured at ~2s on a host holding 1200 of them, which
is long enough for the user to start typing, and replacing a shell someone is
using is the one outcome this path must never produce.

The chooser reaches its pane through a handler registry rather than React props,
matching how the pty layer already keys per-pane state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Registers each pane's resume handler on connect, clears it on dispose, and
attempts recovery when the pane first becomes visible rather than on mount, so a
host restart never respawns a workspace behind the user's back.

A recovered session has no captured launchConfig — the handle that held it is
what was lost — so the pane's configured defaults for that agent apply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kiwidream and others added 5 commits September 23, 2026 13:14
Recovery only ran on a hidden -> visible flip, so the main case (a tab already
active when Orca opens) never recovered; it now also runs when a visible pane's
fresh shell spawns, behind the same attempt-once latch. A pane must now show
positive evidence it hosted an agent before anything is offered, two panes can
no longer resolve the same conversation because the final claim is a synchronous
reservation taken after the claim set is re-read, candidate metadata is built per
agent and unusable candidates are dropped, a failed attempt no longer disables
recovery for the connection, offers and disposal are guarded against a successor,
and the chooser ages rows against the newest candidate's host clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round two of review feedback on the pane recovery path.

The recovery guard read `resolveExpectedLaunchTuiAgent`, which accepts the
tab-wide `launchAgent` — a value that describes the tab's ORIGINAL pty only, so
a plain-shell split in an agent-launched tab passed it and would have had its
shell replaced by an unrelated conversation. Recovery now requires evidence read
under this pane's own key: its startup, its registered launch config, its status
row, or its own foreground inference.

`aiVault.listSessions` can resolve `cancelled` or carry scan issues instead of
rejecting. Those answers said nothing about the host, yet a subset could be read
as the sole candidate and resumed, and an empty one spent the pane's single
attempt. The scan now reports completeness, an incomplete scan refuses to
resolve, and it leaves the attempt unspent.

Dispose released the pane's resume reservation without checking this binding
still owns the pane, so a retired binding could free a successor's reservation
and reopen the window where two panes resume one transcript. It now sits behind
the same successor guard as the pending-choice clear.

The tab-session-match and lifetime-window rungs were unreachable: the caller
never passed their evidence, and it cannot. A tab carries no last provider
session id — that handle is exactly what was lost — and `tab.createdAt` is a
renderer-clock value the resolver may never compare to a host-clock `updatedAt`.
Both rungs are removed rather than left as code the tests pretended covered
something; the chooser asks the user instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. Untruncated scan. `listSessions` sliced a recency-sorted list at 1,000, and the
   in-scope bypass that survives that slice covers Claude files only — so for the
   other 17 resumable agents an older matching session vanished while a newer one
   survived, and the resolver read that subset as the whole and auto-resumed the
   survivor. The scan now asks for an uncapped answer, and an answer that still
   arrives at the default cap is `unverifiable`: a host too old to honour the flag
   is indistinguishable from one with exactly that many sessions, and a subset must
   never be read as the whole.

2. Revalidate the claim at selection. Selecting a chooser row checked only the
   renderer-local reservation map, but another pane's cold restore and an Agent
   Session History launch reserve nothing there. The host-owned status store is now
   re-read in the same synchronous step as the claim, immediately before the spawn.

3. Binding identity on a published choice. Pending choices carried none, so a
   chooser published for a retired connection stayed on screen and reached the
   SUCCESSOR's handler under the same stable pane key — replacing a live shell with
   a session scanned for a connection that no longer owns the pane. A choice now
   names the binding that scanned it, only that binding's handler can be reached
   through it, and dispose retires its own choice and leaves a successor's alone.

4. Mid-scan visibility loss no longer spends the latch. The contract is that a
   hidden pane keeps its attempt and retries when revealed; the post-scan gate
   answers `pane-hidden` for a pane the user switched away from inside the ~2s scan,
   and that answer was burning the attempt.

5/6. The chooser is now the repo's `Dialog` primitive, and its rows are no longer
   native buttons. As an inline overlay it trapped no focus — Tab reached the xterm
   underneath, so the user could type into the very shell a row was about to replace.
   The same Tab moved real focus between row buttons without moving `selected`, and
   Enter then resumed `rows[selected]`: a different session than the focused one,
   silently forking the wrong transcript. One focusable listbox naming its active row
   through `aria-activedescendant` makes focus and selection one value, so fix 6
   subsumes fix 5 rather than both being fixed twice. The Dialog is app-modal, so it
   renders for the active tab only.

7. Selected rows use the documented jump-palette selection surface and inset ring;
   flat `bg-accent` is near-invisible on a light dialog, and the cursor is what
   decides which transcript Enter resumes.

8. Folder workspaces. Recovery gated on `session.worktree?.path`, so agent panes in
   a folder workspace — whose root is `folderWorkspace.folderPath` — could never
   recover. The scope is derived from either representation.

9. Equivalent path spellings. Scope matching compared raw `cwd` strings, which
   false-negatives on `C:\repo` vs `C:/repo`, drive-letter casing, a trailing
   separator, and macOS NFD against an agent's NFC cwd. Both sides now go through
   `normalizeRuntimePathForComparison`, the normalizer the vault's own scope
   machinery uses. Still equality, not containment: a sibling worktree and the repo
   root keep missing, which is what scopes the resume at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oser

Three round-four review findings on the auto-resume path.

THE PANE-IDENTITY CLAIM RULE replaces `entry.state !== 'done'`, which was wrong
in both directions. Too broad: after a host restart the hook server republishes
the recovering pane's OWN persisted row as `restoredUnconfirmed`, provider
session included, so the pane treated its own conversation as claimed, the sole
candidate was filtered out, and a restart during working/blocked/waiting left a
plain shell — the feature's primary scenario. Too narrow: `done` means "task
complete but pane live", so excluding those rows let a live idle sibling's
transcript be resumed into a second pane. Ownership now comes from pane identity
and liveness from the pane's own terminal binding, in the live/unverifiable/
exited vocabulary: the pane's own row never claims against it whatever its
state, another pane's row claims whenever that pane is live or unverifiable
whatever its state, and only a pane this renderer no longer holds releases its
session.

A WSL workspace is stored as its UNC spelling while the transcripts inside the
distro report a Linux cwd, so the single normalized scope key matched nothing and
recovery never ran on WSL at all. The resolver now admits both spellings of the
one directory, the same representation pair session-scanner-scope-discovery.ts
already expands for the scan. Still equality, not containment: a sibling worktree
and the repo root keep missing.

The four chooser strings existed only in en.json, so a surface that decides which
conversation resumes rendered in English for every other locale. Added under the
identical path in es, fr, ja, ko and zh, matching each catalog's existing tone
and its convention of keeping Enter/Esc as literal key names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects that all end the same way — a pane that silently does the wrong
thing, or nothing.

**The chooser has never worked.** `installAgentResumeRecovery` ran before
`installPtyInputRecovery`, and the latter is what assigns `session.transport`.
So the pane handler was registered under owner `undefined` while choices were
published under the real transport, and every lookup mismatched: Enter, number
key and click all did nothing. Only the automatic sole-candidate path worked.

No unit test could see it, because every test handed the installer a session
bag that already had a transport. The new test binds through the real
`connectPanePty` sequence, so a future reorder fails the suite. The registry
now also admits an absent owner in its type and refuses it loudly — the
session bag is an index-signature type, so nothing else would have caught it —
and an absent owner no longer matches an absent registration, which was a
crash and a hole in the identity guard.

**A `notice` row meant the opposite of what was assumed.** Round three read a
notice-only issue list as a complete scan. But a list AT `SCAN_ISSUE_LIMIT` is
one `recordSessionScanIssue` stopped appending to: the rows that did not fit
were dropped. A stalled WSL distro records one refusal per discovered path, so
filtering notices out of that list answers about the handful that survived,
not about the scan. The limit moves to shared and the reader treats a list at
the cap as unverifiable. A short notice-only list is still what it looks like —
per-transcript commentary — and stays complete.

**The truncation heuristic was wrong in both directions.** It called any answer
with 1000+ sessions `scan-truncated`, but the request now sends
`unlimited: true`, so a host that honours it and holds many transcripts was
permanently refused — exactly the machines this feature exists for. It also
could not see a host capping BELOW 1000 at all.

There was no completeness signal in the API, so this adds one:
`AiVaultListResult.appliedSessionDepth`, the bound the answering scan actually
applied. Optional, because hosts and clients update independently, and absent
means "coverage cannot be proved" — never "complete". That fails safe:
refusing costs a bare shell, which is the pre-feature behaviour, while reading
a slice as the whole lets a candidate be auto-resumed that is only sole
because the rest were cut.

It is stamped where a bound is applied (local scan, truncate, merge), folded
across legs so one unprovable leg makes the merge unprovable, and carried
through `restampAiVaultListResult` and the relay parser — both rebuild the
result field by field and would otherwise have dropped it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kiwidream pushed a commit that referenced this pull request Sep 23, 2026
… manifest (OTA phase C follow-up) (stablyai#22376)

* perf(mobile): keep four bundle chunk reads in flight across the whole manifest

The fetch ran one worker per asset and paged inside an asset sequentially, so
the largest script's 71 chunks were 71 serial round trips while the other
readers idled. One window of four chunk reads now covers every (asset, offset)
on the host's chunk grid, largest asset first. A read_limited refusal narrows
the window and retries the read; eof is still read from the reply.

Synthetic manifest (one 71-chunk asset, five small): 72 round trips -> 19.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the bundle-fetch family under pipelined reads

Baseline moves to a1ee317, the pipelined fetch.
781 goldens change only their `baseline` header line. Six bodies move:
mobile-web-bundle-fetch-paged, mobile-web-bundle-build-changed, and the four
matrix-mobileweb.bundle-fetch-* goldens.

The two bundle-fetch scenarios now bind requests in pipelined order, largest
asset first, with every chunk sent before any reply: index.html@0 (#1),
index.html@16 (#2), assets/app.js@0 (#3).
- fetch-paged: the request set is identical, only reordered. The chunk
  sender names/ordinals and the scenarioSha256 moved; the replies and the
  fetched bytes did not.
- build-changed: the same reorder, plus one request that is new because
  pipelining puts it in flight before the refusal lands (index.html@16).
  The refusal and the checkpoint are unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): the bundle chunk comment no longer says a reply picks the next offset

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the recording corpus after the bundle comment fix

Baseline moves from a1ee317 to e94bde3,
the comment-only commit on a fenced path. All 787 goldens and the scenarios file
change only their `baseline` line. No golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): pool four workers over planned bundle chunks, report progress per chunk

Design-review fix round, sketch C: four workers take reads from one planned
chunk queue, largest asset first. They replace the central pump and the
read_limited narrowing. A host frees its read slot before it replies, so a lone
fetch capped at four cannot trip the limit. A refusal now fails the fetch, as
it did on base, and stops the other reads.

- Each asset's buffer is allocated when the plan is built. That removes the
  nullable buffer and its guard. The per-asset byte count is gone, and the
  hash is the oracle (S1, S2).
- The caller's signal is checked before each read and once after the pool
  drains, so an abort during the final window rejects with fetch-stopped
  (S3). The stopped check now covers only the caller's abort. The internal
  stop only makes late replies skip checks, hashing and progress (S4).
- Progress is reported per accepted chunk. completedAssets still counts on
  completion (S6).
- Renames: MAX_CONCURRENT_CHUNK_READS, and `reply` for the RPC reply (N1).
- The slot check states exact-slot acceptance once, then classifies the
  refusal (N3).
- Stale test titles and comments are renamed (N4).

The synthetic manifest still takes 19 round trips.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say why bundle reads settle at on-settle under pipelined chunks

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the bundle-fetch family under per-chunk progress

Baseline moves to 34fda6f. 782 goldens and
the scenarios file change only their `baseline` line. Five bodies move:
mobile-web-bundle-fetch-paged and the four matrix-mobileweb.bundle-fetch-*
goldens. The only change is bundle-progress effects. One report now lands
after the first accepted chunk of index.html (0 assets, 16 bytes), and the
later progress ordinals shift by one. Requests, replies and fetched bytes
are identical. mobile-web-bundle-build-changed keeps its body, because its
one accepted chunk is the whole of assets/app.js.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drain the fake host after the fetch settles so the sibling-stop bounds can fail

The wave host stopped releasing replies once the fetch settled. Reads that
should have been stopped were never answered, so the read_limited bound (7)
and the chunk-failure bound (5) held even with no sibling stop at all. It now
drains until nothing waits. With the worker's stopped.abort() removed, both
bounds fail at 76 requests. assertChunkDescribesAsset's parameter is renamed
to `reply`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the recording corpus after the sibling-stop test fix

Baseline moves from 34fda6f to 97b13ec.
All 787 goldens and the scenarios file change only their `baseline` line. No
golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
kiwidream pushed a commit that referenced this pull request Sep 23, 2026
…otiated mobileWeb.bundle.range (OTA phase C follow-up) (stablyai#22381)

* feat(mobile-web): serve gzipped 384 KiB bundle ranges behind a capability

Adds mobileWeb.bundle.range with its own strict params and result, so
shipped chunk readers see no reply change. The host gzips each range at
level 6 and sends identity when gzip does not shrink it, sharing the chunk
method's read-slot budget and per-asset verification. status.get
advertises mobileWeb.bundle.range.v1 beside mobileWeb.bundle.v1, and the
method is allowlisted for paired phones.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): read the bundle range capability and range replies

Adds the range reply reader and operation, and picks range or chunk from
the status.get capabilities the connection already proved, so an older
desktop keeps being paged in chunks with no probe round trip.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* perf(mobile): keep four bundle chunk reads in flight across the whole manifest

The fetch ran one worker per asset and paged inside an asset sequentially, so
the largest script's 71 chunks were 71 serial round trips while the other
readers idled. One window of four chunk reads now covers every (asset, offset)
on the host's chunk grid, largest asset first. A read_limited refusal narrows
the window and retries the read; eof is still read from the reply.

Synthetic manifest (one 71-chunk asset, five small): 72 round trips -> 19.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): add fflate 0.8.2 for gzip bundle ranges

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): decode gzip bundle ranges into a bounded buffer

Inflates each range into a buffer one byte past its window, so a gzip
bomb costs at most that allocation and an overlong body is visible. A
corrupt, truncated or unknown-encoding body refuses as range-undecodable;
a body of the wrong decoded length refuses as range-length-mismatch.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): pass the bundle read method from the session to the fetch

The download reads the capabilities of the gates the reducer decided
under and hands the fetch range or chunk. The fetch does not act on it
yet; the range read lands on the pipelined window.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the bundle-fetch family under pipelined reads

Baseline moves to a1ee317, the pipelined fetch.
781 goldens change only their `baseline` header line. Six bodies move:
mobile-web-bundle-fetch-paged, mobile-web-bundle-build-changed, and the four
matrix-mobileweb.bundle-fetch-* goldens.

The two bundle-fetch scenarios now bind requests in pipelined order, largest
asset first, with every chunk sent before any reply: index.html@0 (#1),
index.html@16 (#2), assets/app.js@0 (#3).
- fetch-paged: the request set is identical, only reordered. The chunk
  sender names/ordinals and the scenarioSha256 moved; the replies and the
  fetched bytes did not.
- build-changed: the same reorder, plus one request that is new because
  pipelining puts it in flight before the refusal lands (index.html@16).
  The refusal and the checkpoint are unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): the bundle chunk comment no longer says a reply picks the next offset

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): read gzipped bundle ranges on the pipelined window

A host that advertised mobileWeb.bundle.range.v1 is paged in 384 KiB
ranges on the range grid, through the same four-read window and queue as
chunks; any other host keeps the chunk grid. Each range is decoded to its
exact window length before the fill checks and the asset hash.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the recording corpus after the bundle comment fix

Baseline moves from a1ee317 to e94bde3,
the comment-only commit on a fenced path. All 787 goldens and the scenarios file
change only their `baseline` line. No golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the corpus at the range-read pin

Repins baseline to the range-read commit and re-records every golden.
Only the baseline and lockfileSha256 headers move: the lockfile gained
fflate, and the bundle-fetch adapter pages the chunk path, whose
requests and replies are unchanged, so no golden body moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state the on-settle reason that holds for pipelined bundle reads

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the recording corpus after the on-settle comment fix

Baseline moves from 252c592 to fe41226,
the comment-only commit on a fenced path. Re-recorded: all 787 goldens and
the scenarios file change only their baseline line. No golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): keep the lockfile's patch block in main's form

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the recording corpus after the lockfile patch-block restore

Baseline moves from fe41226 to 84d6fca,
which restores main's patchedDependencies form in mobile/pnpm-lock.yaml.
Re-recorded: all 787 goldens change only baseline and lockfileSha256, and
the scenarios file only baseline. No golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): pool four workers over planned bundle chunks, report progress per chunk

Design-review fix round, sketch C: four workers take reads from one planned
chunk queue, largest asset first. They replace the central pump and the
read_limited narrowing. A host frees its read slot before it replies, so a lone
fetch capped at four cannot trip the limit. A refusal now fails the fetch, as
it did on base, and stops the other reads.

- Each asset's buffer is allocated when the plan is built. That removes the
  nullable buffer and its guard. The per-asset byte count is gone, and the
  hash is the oracle (S1, S2).
- The caller's signal is checked before each read and once after the pool
  drains, so an abort during the final window rejects with fetch-stopped
  (S3). The stopped check now covers only the caller's abort. The internal
  stop only makes late replies skip checks, hashing and progress (S4).
- Progress is reported per accepted chunk. completedAssets still counts on
  completion (S6).
- Renames: MAX_CONCURRENT_CHUNK_READS, and `reply` for the RPC reply (N1).
- The slot check states exact-slot acceptance once, then classifies the
  refusal (N3).
- Stale test titles and comments are renamed (N4).

The synthetic manifest still takes 19 round trips.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say why bundle reads settle at on-settle under pipelined chunks

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-web): announce bundle ranges on the manifest reply

The manifest reply now names the range grid in an optional rangeBytes,
beside chunkBytes, and the status capability is gone. The range method
takes exactly the chunk params on that grid instead of a caller length.
Both methods share one verified read that returns the six-field header,
and the range handler checks the connection again before deflating.
Range schemas move into the bundle RPC contract; SHA256_PATTERN is shared
from the manifest contract. Shared refusals are tested once over both
methods.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the range capability read and its session threading

The phone will read rangeBytes off the loose manifest reply instead, so
the read-method module goes and the session effects and hook return to
the pipeline branch's version. Range imports move to the bundle RPC
contract, the reply reader reuses the shared SHA256_PATTERN, and a new
test pins that node's level-6 gzip from the host encoder inflates with
fflate to the same bytes. The fetch and window-read modules still import
the deleted names until part 2.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the bundle-fetch family under per-chunk progress

Baseline moves to 34fda6f. 782 goldens and
the scenarios file change only their `baseline` line. Five bodies move:
mobile-web-bundle-fetch-paged and the four matrix-mobileweb.bundle-fetch-*
goldens. The only change is bundle-progress effects. One report now lands
after the first accepted chunk of index.html (0 assets, 16 bytes), and the
later progress ordinals shift by one. Requests, replies and fetched bytes
are identical. mobile-web-bundle-build-changed keeps its body, because its
one accepted chunk is the whole of assets/app.js.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): page bundle ranges through one window reader chosen by the manifest

The fetch keeps the pipeline's four-worker pool and builds one window
reader from the manifest reply: ranges on rangeBytes when the host names
it, chunks on chunkBytes otherwise. The reader returns the six-field
header and a lazy bytes() so the stop and misroute checks run before any
decode. A range that inflates to the wrong length now falls to the slot
checks, with the one-byte-over buffer as the memory bound, so
range-length-mismatch is gone. A rangeBytes this build cannot page reads
as absent. Fetch names say window, not chunk.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drain the fake host after the fetch settles so the sibling-stop bounds can fail

The wave host stopped releasing replies once the fetch settled. Reads that
should have been stopped were never answered, so the read_limited bound (7)
and the chunk-failure bound (5) held even with no sibling stop at all. It now
drains until nothing waits. With the worker's stopped.abort() removed, both
bounds fail at 76 requests. assertChunkDescribesAsset's parameter is renamed
to `reply`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the recording corpus at the window-reader commit

Baseline moves to a25a355. Re-recorded:
every golden and the scenarios file move only baseline, and the five
bundle-fetch goldens also move lockfileSha256 to this branch's lockfile.
Every golden body is byte-identical to the pipeline branch's. The
recording adapter's scripted host names no rangeBytes, so the bundle
family still records the chunk path.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the recording corpus after the sibling-stop test fix

Baseline moves from 34fda6f to 97b13ec.
All 787 goldens and the scenarios file change only their `baseline` line. No
golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the recording corpus at the second pipeline merge

Baseline moves from a25a355 to 4d2cab3,
the merge of the pipeline's sibling-stop test fix. Re-recorded: every golden
and the scenarios file move only baseline. Against the pipeline branch, only
baseline and lockfileSha256 differ; every golden body is identical.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the bomb inflation without depending on call order

With the fetch's sibling stop removed, a read left over from the previous
test inflated into the bomb test's record first, and indexOf(601) picked
it. The test now asserts some inflation stopped at 601 and none exceeded
its buffer, whatever else ran.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the recording corpus at the bomb-test fix

Baseline moves from 4d2cab3 to 73fde15,
the test-only commit on a fenced path. All 787 goldens and the scenarios
file change only their baseline line. No golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-web): tighten the bundle window contract and pin the range sibling stop

The range bomb test reads only its own host's inflations, keyed by the
gzip bodies that host sent, and plans twenty reads so a missing sibling
stop is visible: with stopped.abort() removed it sends all twenty.
The range params are an alias of the chunk params, and the chunk data
bound is the exact base64 length of a full chunk. The phone's chunk and
range replies share one header shape. The window reader closes over the
client and bytes() takes the slot length the fetch computes. The host's
positional read is readMobileWebBundleAssetWindow.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the recording corpus at the window-contract commit

Baseline moves from 73fde15 to 7346e00.
All 787 goldens and the scenarios file change only their baseline line.
No golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the recording corpus at the main merge

Baseline moves from 7346e00 to 9c0fe1a,
the merge of main at 98a6a53. Recorded with --record: all 787 goldens
and the scenarios file change only their baseline line. No golden body
moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-web): drop a test cast and shape-named field maps

The bomb test's inflation log is typed by its hoisted factory's return
instead of an assertion, and the zod field maps shared by the bundle
window schemas are windowParamsFields and windowHeaderFields.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the recording corpus at the lint fix

Baseline moves from 9c0fe1a to f4f0915.
Recorded with --record: all 787 goldens and the scenarios file change only
their baseline line. No golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): page the range fetch fixture on a small advertised grid

The fake host names a 4 KiB range grid and a 1 KiB chunk grid on its
manifest reply, which the phone pages as it would the real ones, so the
fixtures shrink to a few KiB with the same shapes and each asset is
hashed once. The file runs in about 360 ms instead of 3.5 s, which a
loaded CI runner pushed past the 5 s test timeout. The desktop range
suite still pins that the real host names MOBILE_WEB_BUNDLE_RANGE_BYTES.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-pin the recording corpus at the range fixture fix

Baseline moves from f4f0915 to d4d6aad.
Recorded with --record: all 787 goldens and the scenarios file change only
their baseline line. No golden body moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
@kiwidream
kiwidream force-pushed the kiwidream/auto-resume-sessions branch from f63e4e6 to 5a5f0a1 Compare September 23, 2026 17:45
@tenki-reviewer

tenki-reviewer Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Review complete. 🟠 1 high · 🟡 1 medium

💬 Inline comments (1)

📍 Outside the diff (1)

  • 🟠 Runtime scan leg drops appliedSessionDepth (runtime-session-scanner.ts:158–162) — The PR teaches every trust-boundary rebuilder to carry the new appliedSessionDepth coverage signal (restampAiVaultListResult at src/main/ai-vault/session-list-results.ts:56 does), but withRuntimeExecutionHost rebuilds the runtime-host leg with only sessions/issues/scannedAt, discarding the depth parseAiVaultListResult just preserved (src/main/ai-vault/runtime-session-scanner.ts:158).
🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 truncateAiVaultListResult stamps untruthful depth (ai-vault-session-depth.ts:63) — truncateAiVaultListResult unconditionally sets appliedSessionDepth: depth (src/shared/ai-vault-session-depth.ts:63), but the field's contract is that absence means "coverage unprovable" and a value records the bound actually applied.

Reviewed commit: 5a5f0a1

@tenki-reviewer tenki-reviewer 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.

This PR adds an agent auto-resume feature (pane-scoped chooser, reservations, claims, recovery) and threads a new appliedSessionDepth scan-coverage signal through the AI vault scan/cache/merge paths.

) {
return false
}
void session.startFreshColdRestoreAgentResume(startup)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 bug · medium

Reservation leaks when resume spawn fails

The reservation taken in resumeWithCandidate is never released if the subsequent spawn fails: void session.startFreshColdRestoreAgentResume(startup) swallows rejection and no release runs on that path (src/renderer/src/components/terminal-pane/pty-connection/agent-resume-recovery-install.ts:91). The entry stays in paneKeyBySessionId for the pane's lifetime, and the dispose-time release is additionally skipped when a successor binding already occupies the pane slot. Impact: a session whose respawn failed stays reserved for other panes, so agentResumeSessionsReservedElsewhere filters it out of every future chooser and the transcript can never be resumed elsewhere.

📋 Prompt for AI Agents

In src/renderer/src/components/terminal-pane/pty-connection/agent-resume-recovery-install.ts around lines 87-92, after reserveAgentResumeSession(candidate.providerSession.id, session.cacheKey) succeeds, attach a .catch to the promise from session.startFreshColdRestoreAgentResume(startup) that releases this pane's reservation for candidate.providerSession.id (add a single-entry release such as releaseAgentResumeSession(sessionId, paneKey) that deletes the map entry only when the holder equals paneKey, to src/renderer/src/lib/agent-resume-session-reservations.ts) so a failed spawn does not leave the session permanently reserved and unresumable in other panes.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5a5f0a168c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +155 to +157
const depth = result.appliedSessionDepth
if (depth === undefined) {
return 'scan-truncated'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stamp uncapped SSH filesystem fallback scans

When an SSH host lacks the relay aiVault.listSessions method and scanSshAiVaultSessions falls back to scanRemoteAiVaultSessions, the returned result has no appliedSessionDepth (remote-session-scanner.ts:129-133). Therefore this branch classifies every otherwise successful filesystem fallback as scan-truncated, even though the caller requested unlimited: true, so automatic recovery can never work through the supported legacy SSH fallback. Stamp that fallback result with the depth it actually applied.

AGENTS.md reference: AGENTS.md:L89-L91

Useful? React with 👍 / 👎.

kiwidream added a commit that referenced this pull request Sep 23, 2026
Found on PR #2 against the same code, never raised here.

The plain shell this connection just spawned still owns the pane's stable key,
so `startFreshColdRestoreAgentResume` reached `beginPtyIpcSpawn`, found that
owner, and REATTACHED it instead of running the resume command. Recovery and
every chooser selection reported success and closed the chooser over the same
untouched shell. `reattach-result-handler` retires an adopted shell only for a
passive sleeping-record restore, and this startup deliberately carries no
record, so nothing retired it.

The binding is now disconnected and cleared first, the way that passive path
does it, before the replacement spawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kiwidream

Copy link
Copy Markdown
Author

This PR is superseded by #6, and its base branch is merged nowhere — so merging it would land nothing. Here is where each open finding actually stands, and my recommendation.

Why it cannot merge as-is

Its base, kiwidream/resume-handle-durability (d6988270c5), is not an ancestor of swaplabs/main, of main, or of anything else. It was the base for PR #1, which merged into the fork's old main — and that main has since been force-pushed back to a pure mirror of stablyai/orca, because the fork's main is a mirror and must never carry our commits (orca-fork-maintenance). The local changes now live on the integration branch swaplabs/main.

Retargeting does not fix it. Against swaplabs/main the diff re-proposes PR #1's content, which is already there as a squash with different SHAs — I checked with git merge-tree and it conflicts. Against #6's branch it is the same content with a different history.

Where each open finding stands

Finding Status
Resume handler registered before session.transport exists (high) Fixed — round five, carried into #6
Notice rows hide dropped failure issues (high) Fixed — round five, carried into #6
Truncation heuristic misjudges capped and complete scans Fixed — round five, plus 02c0d49a46
Retire the bound shell before starting the resume (P1) Fixed in #6 as 5ca75519e3 — see below
Preserve the execution-host platform for remote resumes (P1) Fixed in #6 as d6ed29df36
Stamp uncapped scans Fixed in #6 as 02c0d49a46
Scope claims to host and agent (P2) Open, deferred
Render only one split-pane chooser (P2) Open, deferred
Reservation leaks when resume spawn fails Open, deferred

The bound-shell P1 was the valuable one. It was raised here and never on #6, but #6 carries the same code, so it was live there too: the plain shell still owned the pane's stable key, startFreshColdRestoreAgentResume reattached it instead of running the resume command, and recovery reported success over an untouched shell. Same shape as the inert-chooser defect. Now fixed, with a test that asserts the disconnect precedes the spawn and fails without it.

Recommendation

Close this once #6 lands, rather than retarget. #6 covers every file this PR touches and carries seven further fix commits. The eleven-commit history and five rounds of discussion stay on kiwidream/auto-resume-sessions and in this PR's record either way — closing destroys nothing.

Not closing it myself: it is @sawfish-f6's, and the upstream PR they will cut from upstream/main is the real destination for this work.

@kiwidream

Copy link
Copy Markdown
Author

Closing as superseded: #6 is merged into swaplabs/main as 8ee1fa764f, and #5 before it as 4cff4d9077.

This is the disposition set out above, now that its condition has been met. To restate why closing rather than retargeting:

Nothing is lost. The eleven-commit history stays on kiwidream/auto-resume-sessions, and the five rounds of review discussion stay in this PR's record. Closing removes neither.

The remaining work is the upstream PR into stablyai/orca, cut fresh from upstream/main per orca-fork-ops §3 — not from this branch and not from the integration branch, either of which would drag the fork's local commits into the diff. That is @sawfish-f6's, and awaits the repository owner's go-ahead.

Deferred findings from this feature are recorded on #6 rather than dropped: the Windows SSH shell family (needs a new host-reported field across the remote boundary), claim identity scoping by host and agent, split-pane chooser stacking, the evidence watcher not re-arming, a reservation leak on a failed spawn, and unparseable-timestamp rows vanishing instead of forcing ambiguity.

One caveat worth carrying upstream: no test exercises a real host restart, which is this feature's entire premise.

@kiwidream kiwidream closed this Sep 23, 2026
@kiwidream
kiwidream deleted the kiwidream/auto-resume-sessions branch September 24, 2026 13:58
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.

1 participant