Skip to content

Persist terminal tabs and scrollback across restarts - #3

Merged
frenchie4111 merged 1 commit into
mainfrom
add-persistent-terminals
Apr 10, 2026
Merged

frenchie4111 merged 1 commit into
mainfrom
add-persistent-terminals

Conversation

@frenchie4111

Copy link
Copy Markdown
Collaborator

Summary

  • Tab metadata (claude + shell only — diff tabs are transient) is now saved in config.json per worktree path and restored on app launch.
  • xterm's SerializeAddon captures each terminal's buffer; history is written to userData/terminal-history/<id>.txt on a 30s interval and synchronously via beforeunload when the window closes.
  • On mount, XTerminal loads any saved history, writes it to the fresh xterm with a ── session restored ── separator, then spawns a new pty — so users see their previous output scrolled above a fresh shell.
  • Explicitly closing a tab or deleting a worktree marks the id as closing (suppressing pending saves) and deletes its history file. Orphaned history files are pruned on startup.

Caveats

  • Scrollback is visual only. The shell process is new on each launch — env, cwd, and $? do not survive. Claude sessions still rely on claude --continue (already the default).
  • xterm's default scrollback (1000 lines) caps per-terminal history file size.

Test plan

  • Open a shell tab, type some commands, quit with Cmd-Q, reopen — tab reappears with scrollback visible above a fresh prompt.
  • Same flow but close the tab explicitly before quitting — tab does not reappear and userData/terminal-history/ has no file for that id.
  • Delete a worktree with active terminals — its history files are removed.
  • Quit with multiple tabs across multiple worktrees — all restore correctly.

🤖 Generated with Claude Code

Tab metadata (claude + shell) is saved in config.json per worktree;
xterm serialize-addon captures each terminal's buffer to
userData/terminal-history/<id>.txt on an interval and synchronously on
window beforeunload. On mount, history is written to the fresh xterm
before spawning a new pty, so users see their previous output above a
new shell. Closing a tab or deleting a worktree drops the history file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@frenchie4111
frenchie4111 merged commit d760f3d into main Apr 10, 2026
frenchie4111 added a commit that referenced this pull request Oct 7, 2026
#316)

## The bug

"Always allow" in chat mode never persisted across worktrees. Two
independent reasons, both fatal.

**1. Claude writes the grant per-worktree.** It lands in
`<worktree>/.claude/settings.local.json`, which no sibling worktree
reads.

**2. Symlinking those files together — what Ness did — cannot work.**
From the bundled binary's settings writer:

```js
// allowSymlink: source === "userSettings" || c   (c is true only for ~/.claude)
if (d.isSymbolicLink())
  throw new Afe(`Refusing to write through symlink: ${e}. ...`)
```

`allowSymlink` is `true` only for `~/.claude/settings.json`. For
`localSettings` it's always `false`, and `checkParentDir` refuses a
symlinked `.claude` *directory* too. It's deliberate hardening — a
repo-controllable settings file must not be a symlink that escapes the
worktree.

So a symlinked worktree persisted **nothing at all**, which is strictly
worse than an unshared file. Every `claude-harness` worktree was in that
state; main's `settings.local.json` hadn't been written since Aug 31
despite grants being made since.

`symlinkClaudeSettings` also no-opped entirely when main had no
`settings.local.json` yet — which is how the repro repo (`flash-cards`,
no `.claude/` at all) ended up with seven worktrees and zero sharing.

There was a latent data-loss bug too: the old migration's shallow
`{...mainSettings, ...newSettings}` merge replaced main's whole
`permissions` object, since grants nest at `permissions.allow`.

## The fix

In json-mode Ness **is** the `--permission-prompt-tool`, so it already
sees every request before Claude would prompt. Own the allowlist there
instead of delegating to Claude's settings writer.

- **New `permissions` slice** — a global list of `{toolName,
ruleContent?}` grants, persisted to `config.json`. Global on purpose:
scoping was the bug.
- **`shared/permission-match.ts`** — matcher covering the rule shapes
`permission-patterns.ts` already generates: Bash prefixes (`git
status:*`, on token boundaries so `git s:*` can't authorize `git stash
drop`), path globs, Grep patterns, WebFetch `domain:` with subdomain
matching, bare tool, `*`. A scoped rule on an unmodelled tool refuses
rather than over-allows.
- **`ApprovalBridge`** consults it before the session-scoped set and the
Haiku auto-reviewer, and records which rule fired so the tool card shows
an `always` badge naming it. `AskUserQuestion` is still never
short-circuited.
- **Settings → Agent → Chat interface** lists saved grants with per-rule
and bulk removal, rendering each through `getToolDisplay` so
`mcp__ness-control__type_tab` reads as "Ness Control · Type tab" with
the server icon.

Because Ness matches the rules itself there's no destination root to
anchor paths against, so path rules now fire outside the session's
worktree. That removes the cross-cwd caveat that used to collapse those
suggestions to a bare-tool grant.

## Removals + migration

`symlinkClaudeSettings`, `applySharedClaudeSettings`, and the
`shareClaudeSettings` setting are gone. A one-shot boot migration (gated
on `claudeSettingsUnsymlinked`) converts existing symlinks back to real
files — so Claude's own persistence and terminal-tab grants start
working again — and seeds the new allowlist from any `permissions.allow`
entries it finds along the way. Broken links are dropped rather than
converted.

Verified against the real worktrees in a dev run: 16 worktrees
un-symlinked, 9 pre-existing grants recovered into the allowlist.

## Scope note

Terminal tabs spawn the user's PATH `claude`, which prompts in its own
TUI and persists to its own files. Those grants stay independent and
Ness never sees them — a chat-mode grant will not carry into a terminal
tab. That's why the panel lives in the Chat interface block rather than
Agent → General.

## Testing

`npm run typecheck`, `npx electron-vite build`, and `npx vitest run`
(1669 tests, 125 files) all pass. New coverage:

- `shared/permission-match.test.ts` — 31 tests over the matcher,
including the token-boundary case, `/dir/**` matching the directory
itself, regex metacharacters treated as literals, lookalike-host
rejection, and sibling-dir false positives.
- `shared/state/permissions.test.ts` — one per event variant, plus
reference-identity assertions on duplicate-grant and unknown-id-revoke
(CLAUDE.md anti-pattern #3).
- `main/approval-bridge-rules.test.ts` — socket-level, no `claude`
binary needed. Covers the cross-worktree case this PR exists for.
- `main/worktree.test.ts` — temp-dir coverage of the un-symlink
migration: idempotency, broken links, corrupt JSON, and an assertion
that writing to the converted file no longer touches main.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
frenchie4111 added a commit that referenced this pull request Oct 29, 2026
…336)

Step 1 of the agent-reviewer work: Review viewer comments move out of
\`ReviewPane\` \`useState\` and into a persisted \`reviews\` slice.
Nothing agent-facing is built here. The aim is a data model that
main-side code (MCP tools later) can write to directly.

## Data model (\`src/shared/state/reviews.ts\`)

\`reviews.byPath[worktreePath].comments: ReviewComment[]\`, where each
comment has:

- \`id\`: a UUID generated in main
- \`filePath\`, \`side: 'old' | 'new'\`, and \`line\`, the current line,
valid at \`anchoredSha\`
- \`anchor: { commitSha, line, snippet }\`: fixed at creation. The
commit and line the comment was written against, plus the trimmed text
of that line
- \`author\`: \`{kind: 'human'}\` or \`{kind: 'agent', tabId, label?}\`.
Replies use the same author shape
- \`status: 'open' | 'resolved' | 'outdated'\`, plus \`body\`,
\`createdAt\`, and optional \`replies[]\`

Events: \`commentAdded\`, \`commentDeleted\`, \`commentStatusChanged\`,
\`replyAdded\` (reducer only, no UI yet), \`anchorsUpdated\` (batched),
\`worktreeRemoved\`. Single-item patches use \`findIndex\` + \`slice\`
(anti-pattern #3). The renderer reads through a per-worktree
\`useReviewComments(path)\` hook (anti-pattern #4).

The slice persists to \`config.reviews\` the same way
\`pinned\`/\`aliases\` do, is sanitized on boot, and loses a worktree's
entry when that worktree leaves the list.

**One correction to the brief:** the review diff never shows
working-tree content. Whole-branch mode is merge-base..**HEAD**
(\`getFileDiffSides(…, 'branch')\`), and commit and range modes are
commit-to-commit. So a comment always anchors to a real SHA: HEAD or the
selected tip for the new side, the diff base for the old side. There's
no "working tree vs committed" flag to record.

**Main computes the snippet itself** (\`src/main/review-comments.ts\`).
\`addReviewComment\` resolves the SHA for that side of the current view
and reads the line with \`git show\`. A caller only supplies \`{file,
line, side, body, view?}\`, which is the shape step 2's
\`add_review_comment\` MCP tool needs.

## Outdated detection (\`src/shared/review-anchor.ts\`)

There's no poller and no subscriber.
\`reviews:refreshAnchors(worktreePath, file?)\` runs when the Review
pane opens and when a file is selected:

1. One \`rev-parse HEAD\`. Comments whose \`anchoredSha\` is already
HEAD are skipped, so in the steady state there are no git reads and no
dispatches.
2. For stale new-side comments it reads each file once at HEAD and looks
for the snippet: first at the current line, then at the original line,
then the nearest exact match anywhere in the file. Snippets shorter than
4 characters (\`}\`) only match in place, so a comment can't jump to a
random brace.
3. If the snippet is found, the comment moves to that line, and an
\`outdated\` comment goes back to \`open\`. If it isn't found, the
comment becomes \`outdated\`. Resolved comments are left alone.
4. All changes go out as one \`anchorsUpdated\` event. Statuses are
recomputed from a fresh snapshot after the git awaits, so resolving a
comment while a refresh is in flight isn't undone.

Old-side comments point at the diff base, which doesn't move as the
author commits, so they are never outdated.

When drawing, \`ReviewDiffPane\` places each comment by snippet against
the content on screen. That way a commit or range view (whose content
differs from HEAD) still shows it on the right code. A comment it can't
place goes into a collapsible "N comments not on a line in this diff"
list above the diff, greyed out, and is never deleted. That covers
outdated comments, old-side comments (inline diff mode has no click
target for removed lines yet) and lines that don't exist in the selected
revision.

## Reviewed checkmarks stay per-viewer renderer state

The "viewed" checkmarks are my progress through *this* file set. They
still reset when the commit selection changes, because the progress is
relative to the files in view. Sharing them properly would need
content-hash invalidation (GitHub resets "viewed" when a file changes).
A review agent has no use for them either, so I left them out of the
slice.

## UI

- Each comment shows its author ("You", or the agent label), a
Resolved/Outdated tag, Resolve/Reopen and delete. Resolved and outdated
comments are dimmed, and outdated ones are struck through. Inline styles
stay rem-based (§11) and icons use \`icon-*\`.
- The file tree badge shows the open count. If a file has only resolved
or outdated comments, it shows a muted total instead.
- The header count shows open comments, with a tooltip for open comments
on files outside the current selection. Changing the selection no longer
discards comments.
- Send to Agent and Copy send **open comments only**, as \`path:line
@sha7 — body\`, plus replies. Old-side comments are labeled as a removed
line. Routing to the active tab is unchanged.
- Fixed a latent bug: the view-zone effect didn't depend on the editor
instance, so after a file switch the zones were drawn into the disposed
editor. With persisted comments, nothing would have shown after a
reload.

## Testing

- Reducer tests cover every event, including reference identity on
no-ops. There are also tests for anchor relocation and outdating, and
for \`formatReviewComments\`.
- \`typecheck\`, \`electron-vite build\` and \`vitest\` all pass.
- Manual check against a scratch repo in headless mode with an isolated
\`HARNESS_DATA_DIR\`:
  - added a comment; it reached config.json
- committed two lines above it, reloaded, and the comment moved from L4
to L6
- committed a removal of the line; it showed as Outdated above the diff
and Send was disabled
- in the single-commit view of the original commit, it showed inline on
the original line
  - Resolve and Reopen round-trip

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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