Skip to content

fix(toolbar): move Resize button back to primary bar - #96

Merged
tstapler merged 2 commits into
mainfrom
fix/resize-primary-toolbar
May 31, 2026
Merged

fix(toolbar): move Resize button back to primary bar#96
tstapler merged 2 commits into
mainfrom
fix/resize-primary-toolbar

Conversation

@tstapler

Copy link
Copy Markdown
Owner

Resize triggers a terminal re-render and is needed frequently — hiding it inside the ⚙ Dev panel was too many taps away for a routine action.

Change: Move ↔️ Resize from the dev panel back into secondaryActions so it appears in the desktop secondary group and the mobile overflow row alongside Copy/Paste/Bottom/Clear/Mouse.

The dev panel now only contains true diagnostics: Debug, Log Stream, Record, Raw mode selector, and Handedness toggle.

🤖 Generated with Claude Code

Resize triggers a terminal re-render and is needed frequently enough
that hiding it in the Dev panel was too many taps away.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 31, 2026 04:47
Resize must be reachable on mobile without opening the overflow row
since it is needed frequently to re-fit the terminal after layout changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

Moves the terminal “Resize” control out of the developer-only panel and back into the standard toolbar’s secondaryActions, making it accessible alongside other frequent actions in both desktop and mobile overflow layouts.

Changes:

  • Added a resize entry to secondaryActions that tracks the click and triggers handleManualResize().
  • Removed the old “Resize” <button> from the dev tools panel so that panel is limited to diagnostic/dev controls.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Registry Validation

Registry Validation
===================

Building backend scanner...
Scanning backend features...
Wrote 94 feature files to /tmp/tmp.XCn2xQrG5p/backend
Wrote 14 feature files to /tmp/tmp.XCn2xQrG5p/backend
Wrote 22 feature files to /tmp/tmp.XCn2xQrG5p/backend
Wrote 6 feature files to /tmp/tmp.XCn2xQrG5p/backend

=== Backend Registry Diff ===
Committed: 128  Generated: 127  Divergence: 0.78%
⚠️  Removed RPCs:
  - upload:image
⚠️  87 feature(s) missing // +api: marker (markerFound: false)

✅ Registry validation passed. Divergence: 0.78%

Test Coverage: 93/128 features have testIds (72.7%)

Registry validation is in observation mode until 2026-05-02.
After that date, divergence > 2% will block merges.
Coverage reporting is advisory only.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Registry Validation

Registry Validation
===================

Building backend scanner...
Scanning backend features...
Wrote 94 feature files to /tmp/tmp.cMz5SXdjhq/backend
Wrote 14 feature files to /tmp/tmp.cMz5SXdjhq/backend
Wrote 22 feature files to /tmp/tmp.cMz5SXdjhq/backend
Wrote 6 feature files to /tmp/tmp.cMz5SXdjhq/backend

=== Backend Registry Diff ===
Committed: 128  Generated: 127  Divergence: 0.78%
⚠️  Removed RPCs:
  - upload:image
⚠️  87 feature(s) missing // +api: marker (markerFound: false)

✅ Registry validation passed. Divergence: 0.78%

Test Coverage: 93/128 features have testIds (72.7%)

Registry validation is in observation mode until 2026-05-02.
After that date, divergence > 2% will block merges.
Coverage reporting is advisory only.

@github-actions

Copy link
Copy Markdown
Contributor

Go Benchmarks (Tier 1)

goos: linux
goarch: amd64
pkg: github.com/tstapler/stapler-squad/session/detection/ratelimit
cpu: AMD EPYC 7763 64-Core Processor                
                              │ tier1-bench.txt │
                              │     sec/op      │
StripANSI_PlainText-4               6.848n ± 0%
StripANSI_WithEscapes-4             724.8n ± 0%
ProcessOutput_InactiveState-4       6.047n ± 0%
geomean                             31.08n

                              │ tier1-bench.txt │
                              │      B/op       │
StripANSI_PlainText-4              0.000 ± 0%
StripANSI_WithEscapes-4            136.0 ± 0%
ProcessOutput_InactiveState-4      0.000 ± 0%
geomean                                       ¹
¹ summaries must be >0 to compute geomean

                              │ tier1-bench.txt │
                              │    allocs/op    │
StripANSI_PlainText-4              0.000 ± 0%
StripANSI_WithEscapes-4            5.000 ± 0%
ProcessOutput_InactiveState-4      0.000 ± 0%
geomean                                       ¹
¹ summaries must be >0 to compute geomean

cpu: AMD EPYC 9V74 80-Core Processor                
                              │ benchmarks/go/tier1-baseline.txt │
                              │              sec/op              │
StripANSI_PlainText-4                                5.425n ± 5%
StripANSI_WithEscapes-4                              515.8n ± 0%
ProcessOutput_InactiveState-4                        5.122n ± 0%
geomean                                              24.29n

                              │ benchmarks/go/tier1-baseline.txt │
                              │               B/op               │
StripANSI_PlainText-4                               0.000 ± 0%
StripANSI_WithEscapes-4                             136.0 ± 0%
ProcessOutput_InactiveState-4                       0.000 ± 0%
geomean                                                        ¹
¹ summaries must be >0 to compute geomean

                              │ benchmarks/go/tier1-baseline.txt │
                              │            allocs/op             │
StripANSI_PlainText-4                               0.000 ± 0%
StripANSI_WithEscapes-4                             5.000 ± 0%
ProcessOutput_InactiveState-4                       0.000 ± 0%
geomean                                                        ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/tstapler/stapler-squad/session/queue
cpu: AMD EPYC 7763 64-Core Processor                
                              │ tier1-bench.txt │
                              │     sec/op      │
ReviewQueue_ConcurrentReads-4       84.78n ± 4%
ReviewQueue_Add-4                   411.1n ± 0%
geomean                             186.7n

                              │ tier1-bench.txt │
                              │      B/op       │
ReviewQueue_ConcurrentReads-4      0.000 ± 0%
ReviewQueue_Add-4                  640.0 ± 0%
geomean                                       ¹
¹ summaries must be >0 to compute geomean

                              │ tier1-bench.txt │
                              │    allocs/op    │
ReviewQueue_ConcurrentReads-4      0.000 ± 0%
ReviewQueue_Add-4                  4.000 ± 0%
geomean                                       ¹
¹ summaries must be >0 to compute geomean

cpu: AMD EPYC 9V74 80-Core Processor                
                              │ benchmarks/go/tier1-baseline.txt │
                              │              sec/op              │
ReviewQueue_ConcurrentReads-4                       63.67n ± 11%
ReviewQueue_Add-4                                   316.9n ±  1%
geomean                                             142.0n

                              │ benchmarks/go/tier1-baseline.txt │
                              │               B/op               │
ReviewQueue_ConcurrentReads-4                       0.000 ± 0%
ReviewQueue_Add-4                                   640.0 ± 0%
geomean                                                        ¹
¹ summaries must be >0 to compute geomean

                              │ benchmarks/go/tier1-baseline.txt │
                              │            allocs/op             │
ReviewQueue_ConcurrentReads-4                       0.000 ± 0%
ReviewQueue_Add-4                                   4.000 ± 0%
geomean                                                        ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/tstapler/stapler-squad/session/scrollback
cpu: AMD EPYC 7763 64-Core Processor                
                                      │ tier1-bench.txt │
                                      │     sec/op      │
CircularBuffer_ConcurrentReadWrite-4        3.437µ ± 1%
CircularBuffer_BurstAppend-4                102.9µ ± 0%
CircularBuffer_GetLastN_LargeBuffer-4       16.63µ ± 3%
CircularBuffer_GetRange_Sequential-4        10.24µ ± 3%
CircularBufferAppend-4                      99.58n ± 0%
CircularBufferGetLastN-4                    1.938µ ± 1%
CircularBufferConcurrentAppend-4            130.6n ± 0%
geomean                                     2.848µ

                                      │ tier1-bench.txt │
                                      │      B/op       │
CircularBuffer_ConcurrentReadWrite-4       6.062Ki ± 0%
CircularBuffer_BurstAppend-4               62.50Ki ± 0%
CircularBuffer_GetLastN_LargeBuffer-4      56.00Ki ± 0%
CircularBuffer_GetRange_Sequential-4       28.00Ki ± 0%
CircularBufferAppend-4                       24.00 ± 0%
CircularBufferGetLastN-4                   6.000Ki ± 0%
CircularBufferConcurrentAppend-4             32.00 ± 0%
geomean                                    3.077Ki

                                      │ tier1-bench.txt │
                                      │    allocs/op    │
CircularBuffer_ConcurrentReadWrite-4         2.000 ± 0%
CircularBuffer_BurstAppend-4                1.000k ± 0%
CircularBuffer_GetLastN_LargeBuffer-4        1.000 ± 0%
CircularBuffer_GetRange_Sequential-4         1.000 ± 0%
CircularBufferAppend-4                       1.000 ± 0%
CircularBufferGetLastN-4                     1.000 ± 0%
CircularBufferConcurrentAppend-4             1.000 ± 0%
geomean                                      2.962

                             │ tier1-bench.txt │
                             │       B/s       │
CircularBuffer_BurstAppend-4      592.9Mi ± 2%

cpu: AMD EPYC 9V74 80-Core Processor                
                                      │ benchmarks/go/tier1-baseline.txt │
                                      │              sec/op              │
CircularBuffer_ConcurrentReadWrite-4                         2.464µ ± 3%
CircularBuffer_BurstAppend-4                                 81.66µ ± 0%
CircularBuffer_GetLastN_LargeBuffer-4                        12.45µ ± 2%
CircularBuffer_GetRange_Sequential-4                         7.115µ ± 2%
CircularBufferAppend-4                                       80.08n ± 1%
CircularBufferGetLastN-4                                     1.347µ ± 2%
CircularBufferConcurrentAppend-4                             101.7n ± 1%
geomean                                                      2.125µ

                                      │ benchmarks/go/tier1-baseline.txt │
                                      │               B/op               │
CircularBuffer_ConcurrentReadWrite-4                        6.062Ki ± 0%
CircularBuffer_BurstAppend-4                                62.50Ki ± 0%
CircularBuffer_GetLastN_LargeBuffer-4                       56.00Ki ± 0%
CircularBuffer_GetRange_Sequential-4                        28.00Ki ± 0%
CircularBufferAppend-4                                        24.00 ± 0%
CircularBufferGetLastN-4                                    6.000Ki ± 0%
CircularBufferConcurrentAppend-4                              32.00 ± 0%
geomean                                                     3.077Ki

                                      │ benchmarks/go/tier1-baseline.txt │
                                      │            allocs/op             │
CircularBuffer_ConcurrentReadWrite-4                          2.000 ± 0%
CircularBuffer_BurstAppend-4                                 1.000k ± 0%
CircularBuffer_GetLastN_LargeBuffer-4                         1.000 ± 0%
CircularBuffer_GetRange_Sequential-4                          1.000 ± 0%
CircularBufferAppend-4                                        1.000 ± 0%
CircularBufferGetLastN-4                                      1.000 ± 0%
CircularBufferConcurrentAppend-4                              1.000 ± 0%
geomean                                                       2.962

                             │ benchmarks/go/tier1-baseline.txt │
                             │               B/s                │
CircularBuffer_BurstAppend-4                       747.4Mi ± 0%

pkg: github.com/tstapler/stapler-squad/session/tmux
cpu: AMD EPYC 7763 64-Core Processor                
                             │ tier1-bench.txt │
                             │     sec/op      │
StripANSICodes_PlainText-4         6.885n ± 1%
StripANSICodes_WithEscapes-4       641.6n ± 0%
IsBanner_PlainText-4               463.0n ± 1%
geomean                            126.9n

                             │ tier1-bench.txt │
                             │      B/op       │
StripANSICodes_PlainText-4        0.000 ± 0%
StripANSICodes_WithEscapes-4      56.00 ± 0%
IsBanner_PlainText-4              0.000 ± 0%
geomean                                      ¹
¹ summaries must be >0 to compute geomean

                             │ tier1-bench.txt │
                             │    allocs/op    │
StripANSICodes_PlainText-4        0.000 ± 0%
StripANSICodes_WithEscapes-4      4.000 ± 0%
IsBanner_PlainText-4              0.000 ± 0%
geomean                                      ¹
¹ summaries must be >0 to compute geomean

cpu: AMD EPYC 9V74 80-Core Processor                
                             │ benchmarks/go/tier1-baseline.txt │
                             │              sec/op              │
StripANSICodes_PlainText-4                          4.981n ± 3%
StripANSICodes_WithEscapes-4                        464.7n ± 1%
IsBanner_PlainText-4                                353.7n ± 2%
geomean                                             93.55n

                             │ benchmarks/go/tier1-baseline.txt │
                             │               B/op               │
StripANSICodes_PlainText-4                         0.000 ± 0%
StripANSICodes_WithEscapes-4                       56.00 ± 0%
IsBanner_PlainText-4                               0.000 ± 0%
geomean                                                       ¹
¹ summaries must be >0 to compute geomean

                             │ benchmarks/go/tier1-baseline.txt │
                             │            allocs/op             │
StripANSICodes_PlainText-4                         0.000 ± 0%
StripANSICodes_WithEscapes-4                       4.000 ± 0%
IsBanner_PlainText-4                               0.000 ± 0%
geomean                                                       ¹
¹ summaries must be >0 to compute geomean

@github-actions

github-actions Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

UX Analysis

Check Status Details
✅ Axe Core (WCAG 2.1 AA) success Critical/serious violations block merge
⚠️ Lighthouse Performance Score: unknown Warning if < 70 (non-blocking)
🤖 Claude UX Analysis Advisory See docs/qa/ for findings

Axe Core excludes terminal rendering areas (intentional design).
Lighthouse runs in desktop preset for this developer tool.

@github-actions

Copy link
Copy Markdown
Contributor

E2E RPC Latency

list-sessions-ttfb-mean: 7ms (▲ slower +131.2%; baseline: 3ms)
list-sessions-total-mean: 12ms (▲ slower +100.5%; baseline: 6ms)

@github-actions

github-actions Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

🎬 E2E Feature Demos

2 shard(s) recorded feature flows for this PR.

recordings shard 1
recordings shard 2

Demo preview opens directly in browser (single-file HTML). Raw WebM recordings in ZIP. Expires after 30 days.

@github-actions

Copy link
Copy Markdown
Contributor

Frontend Terminal Throughput

terminal-throughput-mean: 14 KB/s ▼ -12.3% (baseline: 16 KB/s)
terminal-throughput-p50: 16 KB/s ▼ -1.5% (baseline: 16 KB/s)

@tstapler
tstapler merged commit 9c4304a into main May 31, 2026
22 checks passed
@tstapler
tstapler deleted the fix/resize-primary-toolbar branch May 31, 2026 05:15
tstapler added a commit that referenced this pull request Jun 19, 2026
* chore(bench): update frontend throughput baseline [skip ci]

* feat(ux): re-order toolbar by usage, shared handedness hook, all-tab mobile reach (#94)

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* perf(session): lock-free shell registry + allocation hot-path fixes

Replace the per-Instance shell registry (deadlock.RWMutex + two plain maps)
with a lock-free ShellRegistry backed by puzpuzpuz/xsync/v4 CLHT map.
All mutations use Compute callbacks with copy-on-write shell structs so
callers never need to hold a lock, eliminating the heaviest contention
surface identified in pprof mutex profiles.

Also cherry-pick three allocation hot-path fixes found during profiling:
- ProcessOutput in ratelimit detector now does state/cooldown guards before
  allocating string(data), eliminating the alloc on the majority of calls
- stripANSI / stripANSICodes skip the regexp replace-all when the input
  contains no ESC byte, saving a string allocation per terminal output chunk
- UpdateReviewQueueState converted to a direct UPDATE WHERE query instead
  of SELECT + UpdateOne, removing a redundant round-trip per review cycle

Auth setup now creates the auth/ subdirectory before writing the token file
so first-run installs don't fail with ENOENT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(session): lock-free GetTimeSinceLastMeaningfulOutput via atomic shadow

GetTimeSinceLastMeaningfulOutput() was called on every review-queue poll
tick (every 2s × N sessions), each time acquiring a deadlock.RWMutex read
lock — 2.2B contended cycles, 5094 events in profiling.

Add an int64 atomic shadow field `lastMeaningfulOutputNs` to ReviewState
that mirrors LastMeaningfulOutput as UnixNano. UpdateTimestamps() writes
both fields (under the existing write lock). SyncAtomicTimestamps() seeds
the shadow after instance construction or restore from storage.

GetTimeSinceLastMeaningfulOutput() now reads the atomic directly (no lock)
once the shadow is initialised. When the atomic is zero (cold start or test
code that sets LastMeaningfulOutput directly), it falls back to the
lock-based read, preserving correctness without requiring test changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ux): re-order terminal toolbar by usage frequency + shared handedness hook

- Reorder primary toolbar: Copy→Paste→Bottom→Clear→Gallery→Files→Mouse (8 visible)
- Move dev tools (Debug, Log Stream, Record, Raw, Resize) behind ⚙ Dev toggle
- Instrument every toolbar button with track() for analytics going forward
- Extract shared useHandedness() hook; sets :root[data-left-handed] so any CSS
  can flip thumb-sensitive layouts without reading localStorage per-component
- mobileOverflowRow defaults to row-reverse (right-thumb reach); flips to row for
  left-handed users via :root[data-left-handed] selector
- Add handedness shortcut inside Dev panel for quick toggle without leaving terminal
- Refactor BottomNav to use shared useHandedness hook (removes inline duplication)
- Apply :root[data-left-handed] flex-direction overrides to Diff, VCS, Files, Logs
  toolbars across all session pane tabs
- Add Record button aria-label (a11y fix)
- Fix pre-existing ResizeObserver missing mock in BottomNav tests (7 tests now pass)
- 75 tests passing across 5 suites (25 new analytics/dev-panel tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): remove unused shellWgWait function

The shellWgWait helper was never called anywhere in the codebase,
causing a golangci-lint unused-function failure in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(toolbar): paste reachable on mobile, remove unused data-left-handed attr

Move Paste from inline secondaryGroup div (hidden on mobile) into the
secondaryActions array so it appears in the mobile overflow row.
Remove dead data-left-handed attribute from the overflow row div — the
CSS uses :root[data-left-handed] set by useHandedness, not the div's
own attribute.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add toolbar-reorder planning artifacts

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(toolbar): move Resize button back to primary bar (#96)

* fix(toolbar): move Resize button back to primary bar

Resize triggers a terminal re-render and is needed frequently enough
that hiding it in the Dev panel was too many taps away.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(toolbar): Resize as primary inline button, always visible

Resize must be reachable on mobile without opening the overflow row
since it is needed frequently to re-fit the terminal after layout changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(terminal): eliminate 60fps re-render storm on selection, add copy/select UX (#95)

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* perf(session): lock-free shell registry + allocation hot-path fixes

Replace the per-Instance shell registry (deadlock.RWMutex + two plain maps)
with a lock-free ShellRegistry backed by puzpuzpuz/xsync/v4 CLHT map.
All mutations use Compute callbacks with copy-on-write shell structs so
callers never need to hold a lock, eliminating the heaviest contention
surface identified in pprof mutex profiles.

Also cherry-pick three allocation hot-path fixes found during profiling:
- ProcessOutput in ratelimit detector now does state/cooldown guards before
  allocating string(data), eliminating the alloc on the majority of calls
- stripANSI / stripANSICodes skip the regexp replace-all when the input
  contains no ESC byte, saving a string allocation per terminal output chunk
- UpdateReviewQueueState converted to a direct UPDATE WHERE query instead
  of SELECT + UpdateOne, removing a redundant round-trip per review cycle

Auth setup now creates the auth/ subdirectory before writing the token file
so first-run installs don't fail with ENOENT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(session): lock-free GetTimeSinceLastMeaningfulOutput via atomic shadow

GetTimeSinceLastMeaningfulOutput() was called on every review-queue poll
tick (every 2s × N sessions), each time acquiring a deadlock.RWMutex read
lock — 2.2B contended cycles, 5094 events in profiling.

Add an int64 atomic shadow field `lastMeaningfulOutputNs` to ReviewState
that mirrors LastMeaningfulOutput as UnixNano. UpdateTimestamps() writes
both fields (under the existing write lock). SyncAtomicTimestamps() seeds
the shadow after instance construction or restore from storage.

GetTimeSinceLastMeaningfulOutput() now reads the atomic directly (no lock)
once the shadow is initialised. When the atomic is zero (cold start or test
code that sets LastMeaningfulOutput directly), it falls back to the
lock-based read, preserving correctness without requiring test changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(terminal): eliminate 60fps re-render storm on selection, add copy/select UX

The root cause was setCopyButtonPos (React useState) called inside
terminal.onSelectionChange, which fires at up to 60fps during mouse drag.
Each setState triggered a full React reconcile, clearing the selection in
progress and blocking right-click.

Fix: replace both useState calls with useRef + direct DOM mutation for the
floating Copy button and toast. Both elements are now portaled to document.body
via createPortal to comply with ADR-009 (fixed positioning inside CSS transform
ancestors silently mispositioning).

Additional improvements:
- Right-click context menu (Copy, Select All, Paste) via contextmenu listener
  on terminal.element; suppressed in mouse tracking mode (vim/tmux)
- Ctrl/Cmd+C with selection copies to clipboard and clears selection; without
  selection passes SIGINT to PTY as before
- Ctrl/Cmd+A calls terminal.selectAll()
- Extract isMouseTracking() to shared utility (web-app/src/lib/terminal/mouseTracking.ts)
  eliminating duplication between XtermTerminal and useTerminalGestures
- Replace hardcoded zIndex 9999 with named floatingTerminalUI slot (1085) in
  theme-contract.css.ts between toast (1080) and tooltip (1100)
- 37 new tests covering re-render prevention, keyboard shortcuts, and context menu

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(terminal): guard navigator.clipboard before calling writeText

Both the context menu Copy path and the Ctrl+C keyboard handler called
navigator.clipboard.writeText directly without checking availability.
In non-secure contexts (HTTP) or browsers without the Clipboard API,
this threw synchronously before the execCommand fallback could run.

Added navigator.clipboard?.writeText guard to both call sites; falls
through to execCommand('copy') when the API is absent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): remove unused shellWgWait accessor

shellWgWait was kept during merge conflict resolution but has no callers
on this branch. golangci-lint (unused linter) flagged it as dead code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* feat(notifications): refresh toast in-place on duplicate events, skip audio

Duplicate visible-toast events (same sessionId:notificationType within 10s)
now call addNotification silently to update stale content (e.g. fork-pressure
stats) rather than being dropped entirely. Audio and native notifications
still fire only once per dedup window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(notifications): native notification auto-close and close-before-open dedup

Implements FR-3 (auto-close via setTimeout) and FR-4 (close-before-open Map
dedup) in showBrowserNotification; adds types_pb mock to notifications.test.ts
so tests pass without generated proto files in the worktree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(vcs): cache go-git repos, per-repo mutex, bounded merge-base BFS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(tokens): reduce per-entry json.Unmarshal allocations in token parser

Define jsonlUserContent as a minimal content type for user message parsing
that omits the Input json.RawMessage field. User entries never contain
tool_use blocks (only tool_result), so the Input field was always empty on
user turns, yet its allocation accounted for ~237 KB per processUserEntry
call (PerfFix-4). Also adds a benchmark that exercises the hot path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(streaming): batch terminal output frames to reduce scheduler wakeups

Adds a batch cap of 32 to the greedy drain coalesce loop in streamViaControlMode.
Without the cap the loop drains frames until the channel is empty — unbounded when
bursts are large. The cap bounds worst-case batching latency to ~3 ms at 10 K fps
while still coalescing the typical `ls -la` burst into one WriteMessage call instead
of N, reducing goroutine scheduler wakeups and syscall count.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(go:optimize): Pattern F+G — fast-path guard + capacity hints

skill_detector.go: add !ContainsRune('/') guard before commandPattern
regexp so the common no-slash case is allocation-free (TestDetectCommandsInText_
ZeroAllocsWhenNoSlash asserts 0 allocs). Pre-allocate result slice with
make([]SkillActivation, 0, len(matches)).

gogit_vcs_reader.go: add capacity hints to reachableSet (64), findMergeBase
second-pass seen (mergeBaseBFSLimit), and countCommitsTo seen (32), avoiding
incremental rehashing on small-to-medium histories.

benchmark.yml: add BenchmarkDetectCommandsInText and BenchmarkTokenParser to
Tier 1 regex; add ./session/tokens to package list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(hibernation): auto-hibernate idle sessions after 20min, skip review-queue sessions; cap repoCache

- config: reduce default IdleTimeoutMinutes 120 → 20 so memory is reclaimed
  sooner on idle workstations; existing user config overrides remain respected.

- sweeper: gate both idle-timeout and resource-pressure hibernation on
  NeedsReview() — sessions waiting for approval or user input are never
  silently hibernated regardless of idle duration.

- gogit_vcs_reader: add TTL eviction to repoCache (repoCacheTTL=30min,
  repoCacheMaxEntries=100). Previously the cache grew unboundedly with each
  new repo scanned; pruneRepoCache() now evicts cold entries and is triggered
  when the cache exceeds the cap. accessedAtNs (atomic int64) stamps each hit
  so eviction is lock-free on the hot path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* fix(verify): resolve sdd:6-verify findings

Go:
- pruneRepoCache: implement LRU trim — after TTL eviction, sort remaining
  entries by accessedAtNs and delete coldest until count ≤ repoCacheMaxEntries.
  Previously the cap was advertised but not enforced (keys slice was dead code).
- openRepoEntry: wrap PlainOpenWithOptions error with path context; re-check
  cache after pruning to eliminate redundant PlainOpen under contention.
- findMergeBase: propagate non-ErrObjectNotFound CommitObject errors instead
  of silently continuing; skip only missing objects from shallow clones.

TypeScript:
- useSessionNotifications: extract approvalId before object literal, removing
  two non-null assertions on optional-chain result.
- useSessionNotifications: move dedup map prune before lastShown read so the
  captured value reflects post-prune state.
- useSessionNotifications.test: type makeEvent with TestNotificationEvent
  interface instead of any.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(vcs): move repo/diff caches onto GoGitVCSReader; release mutex before OS calls

Move repoCache, repoCacheSize, and diffStatCache from package-level globals to
GoGitVCSReader struct fields. GoGitVCSReader{} remains valid — sync.Map and
int64 are zero-value safe. This isolates cache state per reader instance,
preventing cross-test contamination when multiple GoGitVCSReader values exist
in the same process (e.g. concurrent test runs or benchmarks).

pruneRepoCache, openRepoEntry, and openWorktree are now methods on
*GoGitVCSReader; all callers within the file use g. prefix.

HasUncommitted: release the per-repo mutex before the working-tree stat loop
and hasUntrackedFiles directory walk. These phases call only os.Lstat and
os.ReadDir — no go-git access — so holding the packfile-reader mutex was
blocking concurrent VCS operations on the same repo unnecessarily. Index entry
fields are captured as a plain []trackedFile slice before the unlock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* feat(history): Story 1 — proto fields, git enrichment, fork dispatch

Task 1.1: Add 5 new fields to ClaudeHistoryEntry (branch, session_status,
git_status_summary, last_commit_message, diff_file_count) and 2 fork fields
to CreateSessionRequest (fork_source_id, fork_at_message).

Task 1.2: Cursor pagination was already implemented — no changes needed.

Task 1.3: Enrich ListClaudeHistory responses with branch (60s TTL git cache)
and session_status (cross-referenced against live instances via
GetConversationUUID). SearchService gains SetInstanceProvider(), cachedBranch(),
and liveSessionStatus(). Instance provider wired in NewSessionService.

Task 1.4: Fork dispatch in CreateSession — when fork_source_id is set,
FindConversationFilePath locates the source JSONL and ForkClaudeConversation
copies it, then resume_id is set to the new UUID so the normal start path
launches with --resume.

Also: export FindConversationFilePath from session/history.go, update
docs/tasks/TODO.md (Session Defaults → Completed, History Page Revamp → In Progress).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(history): Story 2 — fix broken resume (path validation + session type)

Root cause (Task 2.1): when resuming a deleted worktree session, the project
directory no longer exists. Without explicit validation the backend created a
Creating-status session and launched claude with --resume in a non-existent
directory. tmux silently fell back to $HOME; claude could not find the
conversation (keyed by original project path hash) and either exited
immediately or started a fresh conversation. User saw a session stuck in
Stopped state with no error message.

Fixes (Task 2.2):
- Backend: when resume_id is set and session_type is unspecified, force
  SESSION_TYPE_DIRECTORY so no new worktree is created (worktree path would
  produce a different project-dir hash, breaking --resume lookup).
- Backend: distinguish the resume error message — "cannot resume: project
  directory no longer exists: <path>" instead of generic "path does not exist".
- Frontend: pass sessionType: SessionType.DIRECTORY explicitly on resume to
  make the intent unambiguous and prevent future inference surprises.

The rename modal before launch was already implemented. No frontend modal
changes needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ci): resolve pre-existing lint-css-tokens and lint-custom failures

CSS tokens: add logError/logWarn/logInfo/logDebug/logTrace/logOnDark/
logOnAmber/logLive tokens to theme-contract.css.ts and all six themes.
Replace hardcoded hex in LevelFilterChips.css.ts, LogRow.css.ts,
LogViewerToolbar.css.ts (logs/ and shared/), and MemoryPressureCallout.css.ts
with vars.color.log* references. Also replace var(--background, #111) fallback
in LogRow with vars.color.background.

lint-custom: replace raw exec.CommandContext with safeexec.CommandContext in
search_service.go (norawexec rule); remove dead os/exec import.

Note: session/headless integration_test.go build failure (ClaudeRunner
interface mismatch) was already broken in origin/main before these commits
and is not addressed here.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* feat(history): Stories 3–5 — rich cards, virtual scroll, fork modal

Story 3 — Rich Session Cards + Inline Preview (Tasks 3.1, 3.2):
- HistoryEntryCard: status pill (running/paused/creating/stopped) with
  animated pulse dot for ACTIVE; branch shown on every card from Story 1
  enrichment (not just selected); shortened project path display.
- HistoryCardPreview: expand/collapse toggle on each card; lazy-fetches
  last 5 messages on first expand; caches per entryId in useRef; strips
  ANSI escape sequences (SGR + OSC hyperlinks) with a regex-based stripper
  (no npm dependency required).

Story 4 — Virtual Scrolling + Infinite Load (Tasks 4.1–4.3):
- VirtualHistoryList: replaces HistoryGroupView in the main list; uses
  @tanstack/react-virtual's useVirtualizer on a flattened array of group
  headers + card items; renders only visible rows (~8 overscan).
- Infinite scroll: IntersectionObserver sentinel row fires onLoadMore when
  the bottom of the list is reached, replacing the manual "Load more" button.
- Keyboard nav: ArrowDown/j and ArrowUp/k call virtualizerRef.scrollToIndex
  so the focused card is always visible.

Story 5 — Fork Modal + Split-Button + Unified Search (Tasks 5.1–5.3):
- ForkModal: <dialog>-based modal with title/path/sessionType/branch/
  fork-at-message slider; calls createSession with fork_source_id and
  fork_at_message from the Story 1 proto fields.
- HistoryDetailPanel: "Resume" button replaced with a split-button —
  primary "▶️ Resume" + "🍴" fork chevron, both open their respective modals.
- HistoryFilterBar + useHistoryFilters: added branchFilter (debounced text
  input, client-side); removed separate metadata/fulltext mode toggle from
  the filter bar (full-text search still accessible via the existing tab).
- page.tsx: wired fetchPreview callback, fork state/handlers, VirtualHistoryList,
  ForkModal, and all new filter props.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(history): virtual scroll layout + mark epic complete

- history.css.ts: add minHeight/overflow:hidden to entryList so
  VirtualHistoryList's 100% height resolves in the flex chain
- VirtualHistoryList: add maxHeight fallback matching detailPanel cap
- page.tsx: wrap VirtualHistoryList in flex:1/minHeight:0 div so it
  fills remaining height after the section title
- TODO.md: History Page Revamp moved to Completed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(headless): update argsCapturingRunner.Run to match ClaudeRunner interface

ClaudeRunner.Run now requires an io.Reader stdin parameter (added when the
prompt was moved off the command line to avoid /proc/pid/cmdline exposure).
The argsCapturingRunner mock in integration_test.go was not updated, causing
a build failure under -tags integration. Pass stdin through to the inner
ProcessRunner.Run call. Also rename local `copy` variable to `argsCopy` to
avoid shadowing the builtin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* fix(lint): replace inline layout style with existing entryList class

history/page.tsx:325 used style={{ flex: 1, minHeight: 0 }} which triggers
the no-restricted-syntax ESLint rule against inline layout properties.
The styles.entryList class already defines identical properties (flex:1,
minHeight:0) — use it directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* chore(fmt): gofmt search_service.go and other unformatted Go files

Fixes CI fmt-check failure. Files were unformatted in main; gofmt -w applied
with no logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* fix(ci): resolve Benchmarks race, Demo GIFs 403, and Release Please body parse

Benchmarks: wrap all four baseline push steps in a 3-attempt retry loop
(git pull --rebase --autostash && git push) so rapid consecutive pushes no
longer cause a permanent rejected-push failure.

Demo GIFs: add permissions: contents: write to the publish-demos job so
github-actions[bot] can commit the recorded GIF files to main.

Release Please: add commit-search-body: false to release-please-config.json
so the conventional commit parser does not attempt to parse commit message
bodies for breaking-change footers. Without this, commit bodies containing
Go source code (e.g. make([]T, 0, n)) cause an unexpected-token parse error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* feat(insights): time range filter, session detail, cost projections, perf (#98)

* fix(verify): resolve sdd:6-verify findings

Go:
- pruneRepoCache: implement LRU trim — after TTL eviction, sort remaining
  entries by accessedAtNs and delete coldest until count ≤ repoCacheMaxEntries.
  Previously the cap was advertised but not enforced (keys slice was dead code).
- openRepoEntry: wrap PlainOpenWithOptions error with path context; re-check
  cache after pruning to eliminate redundant PlainOpen under contention.
- findMergeBase: propagate non-ErrObjectNotFound CommitObject errors instead
  of silently continuing; skip only missing objects from shallow clones.

TypeScript:
- useSessionNotifications: extract approvalId before object literal, removing
  two non-null assertions on optional-chain result.
- useSessionNotifications: move dedup map prune before lastShown read so the
  captured value reflects post-prune state.
- useSessionNotifications.test: type makeEvent with TestNotificationEvent
  interface instead of any.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(vcs): move repo/diff caches onto GoGitVCSReader; release mutex before OS calls

Move repoCache, repoCacheSize, and diffStatCache from package-level globals to
GoGitVCSReader struct fields. GoGitVCSReader{} remains valid — sync.Map and
int64 are zero-value safe. This isolates cache state per reader instance,
preventing cross-test contamination when multiple GoGitVCSReader values exist
in the same process (e.g. concurrent test runs or benchmarks).

pruneRepoCache, openRepoEntry, and openWorktree are now methods on
*GoGitVCSReader; all callers within the file use g. prefix.

HasUncommitted: release the per-repo mutex before the working-tree stat loop
and hasUntrackedFiles directory walk. These phases call only os.Lstat and
os.ReadDir — no go-git access — so holding the packfile-reader mutex was
blocking concurrent VCS operations on the same repo unnecessarily. Index entry
fields are captured as a plain []trackedFile slice before the unlock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(insights): time range filter, session detail drawer, cost projections, perf

R1 — Time range filter: preset buttons (Today/7d/30d/90d/All/Custom) + custom
date picker persisted via URL search params; from/to wired through to
GetInsightsSummary and WatchInsights RPCs.

R2 — Per-session detail drawer: slide-over panel (createPortal) showing
session metadata, tools breakdown, and skill activations; click any session
row to open; Escape/backdrop closes.

R3 — Cost projections: useProjectedCost computes projected monthly spend from
daily buckets (requires ≥7 days of data); useBudgetThreshold persists a
localStorage budget threshold with SSR hydration guard; warning banner + card
styling when projected spend exceeds threshold.

R4 — Skeleton loaders: InsightsDashboardSkeleton shown immediately on mount
before first RPC response; chart dynamic() imports use Skeleton as loading
placeholder; Suspense boundary added for useSearchParams.

R5 — Virtual scrolling: TableVirtuoso (react-virtuoso) for >50 session rows;
scroll position preserved across live updates.

R6 — Smooth live updates: surgical per-session state patch on WatchInsights
events; exponential backoff reconnect (1s→30s cap) replaces silent drop;
chart data transforms wrapped in useMemo to prevent recharts re-renders.

R7 — Session table search/filter: Fuse.js path search + model family dropdown
+ clear button; filter state preserved across live update cycles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(insights): address review comments — date tz, UTC month bucketing, TableVirtuoso cells, test hydration assertion

- InsightsDashboard: serialize date to URL using local timezone (not UTC toISOString)
- TimeRangeFilter: parse URL date params as local T00:00:00/T23:59:59 (not UTC midnight)
- useProjectedCost: compare bucket dates using UTC methods to match server-side UTC midnight keys
- SessionsTable: return cells (not <tr>) from TableVirtuoso itemContent; use components.TableRow for row-level props
- useBudgetThreshold test: assert initial isHydrated is false (not just typeof boolean)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add planning artifacts for insights enhancement

* fix(insights): code quality, test coverage, and UX improvements

Code quality:
- Extract fmtCost/fmtTokens/fmtPct/fmtDate/shortId to insightsFormatters.ts;
  standardize fmtPct to toFixed(1) across all consumers
- Name retry delay magic numbers: INITIAL_RETRY_DELAY_MS / MAX_RETRY_DELAY_MS
- Log WatchInsights stream errors to console instead of silently swallowing

Correctness:
- Filter surgical stream update events against active from/to time range;
  sessions outside the current filter window no longer appear in live patches

Tests (+4):
- useProjectedCost: February non-leap (28 days), leap year (29 days),
  UTC month boundary exclusion
- useBudgetThreshold: invalid localStorage value (non-numeric) returns null

UX:
- ProjectedCostCard: show inline error when budget input is ≤ 0 or NaN
- TimeRangeFilter: show range-error message when from > to (custom range)
- InsightsDashboard: map raw gRPC error codes to user-friendly messages
- SessionDetailDrawer: add aria-describedby + srOnly description for a11y

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(insights): remove last duplicate fmtCost in ProjectedCostCard, import from insightsFormatters

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* revert: remove unnecessary commit-search-body flag

The release-please failure is caused by missing PR creation permission
(GitHub Actions not permitted to create PRs), not commit body parsing.
The parse errors in the logs are debug messages, not failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): move useMemo above early returns in insights charts; add displayName

React hooks must be called unconditionally. DailySpendChart, ModelBreakdownChart,
and ModelOverTimeChart all called useMemo after an early return on empty data.
Move the memo calls above the early return — they are cheap no-ops on empty
arrays and the behaviour is identical.

SessionsTable: add displayName to the React.forwardRef TableBody component
passed to Virtuoso so the react/display-name lint rule is satisfied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* fix(lint): eslint-disable-next-line for forwardRef inside useMemo object

The react/display-name rule cannot infer a display name for React.forwardRef
calls used as values in an object literal inside useMemo. Setting .displayName
after the fact is not visible to the lint rule at that location. Using
eslint-disable-next-line is the correct suppression for this inert pattern —
Virtuoso uses this TableBody ref purely for scroll container sizing, not for
React DevTools identification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* fix(ci): add push retry loop to demo GIFs workflow

Same race condition as benchmark baselines — the GIF commit push is
rejected when another commit lands on main while the recording job runs.
Retry up to 3 times with git pull --rebase --autostash before each attempt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(session): hydrate SquadSessionID from legacy conversation_id key

Fixes #38. Adds UnmarshalJSON fallback on ClaudeSessionData to hydrate SquadSessionID from the legacy conversation_id key for backward compatibility with pre-rename persisted state.

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* chore(demos): update E2E feature GIFs [skip ci]

* fix(bench): benchmark gate under 10 minutes — fix 3 root causes

Root cause 1: BenchmarkReactiveQueueManagerThroughput fails at b.N=518K
The benchmark used a per-iteration blocking drain (select + time.After). At
high b.N the event channel buffer (100) fills up, publishToClients silently
drops events, and the drain blocks forever. Fixed by:
  - Restructuring to add-all-then-drain (measures producer throughput, not
    round-trip latency)
  - Using b.TempDir() instead of a fixed path (isolates each calibration call)
  - Using a fast poll interval (10ms) via NewReviewQueuePollerWithConfig so
    events arrive in the benchmark without waiting for the default 2s cycle
  - Adding b.ReportMetric for delivery% to surface drop rate at high b.N

Root cause 2: BenchmarkSessionRestorePerformance spawns real tmux sessions
Takes ~176s × count iterations. Gated behind testing.Short() so it skips
with -short flag. Not a unit benchmark.

Root cause 3: CI command ran all tests AND benchmarks; count=10 too many
  - Added -run='^$' (benchmarks only, no tests)
  - Changed count=10 → count=5 (still statistically meaningful for ≥20%
    regression detection; halves the budget)
  - Added -short to skip integration benchmarks
  - Changed -timeout=30m → -timeout=10m per package

Expected CI benchmark gate: <10 minutes total.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* chore(demos): update E2E feature GIFs [skip ci]

* fix(bench): force-add gitignored baseline file; add retry + rebase on push

bench-baseline.txt matches the bench-*.txt gitignore pattern, causing
git add to fail silently and git push to find nothing to commit.
Use git add -f to force-track the intentional baseline data file.
Also add the pull --rebase retry loop (same pattern as other baseline
pushes) and match the commit message format used by tier1/tier2 baselines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(bench): make benchmark flows robust — 10 fragility fixes

Critical:
- Move benchmark-gate baseline to benchmarks/go/benchmark-gate-baseline.txt
  (tracked directory, not gitignored). Previously bench-baseline.txt matched
  bench-*.txt in .gitignore so it was never committed → regression check was
  dead on every run.
- Fix regression regex to catch ≥100% regressions: was [2-9][0-9]\ (missed
  +100%, +200%, etc.); now ([2-9][0-9]|[1-9][0-9]{2,})\.

High:
- All 5 push retry loops now fail loudly (exit 1) when all retries exhausted.
  Previously the loop exited 0 (last command was 'sleep 5') so silent baseline
  loss went undetected.
- Unify concurrency group: build.yml benchmark-gate now shares
  baseline-push-${{ github.ref }} with benchmark.yml so 5 concurrent baseline
  pushers are serialized instead of racing.
- Add timeout-minutes: 30 to benchmark-gate job (was defaulting to 6h).
- Add exponential backoff to retry loops (5s, 10s, 15s) since concurrent jobs
  need more than 5s spacing.

Medium:
- Pin benchstat to v0.0.0-20260312031701-16a31bc5fbd0 in build.yml (was
  @latest, inconsistent with benchmark.yml).
- Rename baseline commit message to "chore(bench): update benchmark-gate
  baseline [skip ci]" — distinct from tier2 message.
- Remove dead advisory benchmark step (BenchmarkNavigation doesn't exist;
  wasted 15 min per PR running nothing).
- Use github-actions[bot] identity for baseline commits (was ci@stapler-squad,
  non-standard and inconsistent with benchmark.yml).

Gitignore: replace bench-*.txt with specific scratch files (bench-current.txt,
bench-diff.txt) so committed baseline files in benchmarks/go/ are not blocked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sessions): fix InitialPrompt injection, add session goal tracking MCP + UI (#99)

* feat(sessions): fix InitialPrompt injection, add session goal tracking MCP + UI

Three improvements to session management:

1. **Fix InitialPrompt injection**: InitialPrompt was stored in DB but never
   delivered to the agent. Now StartSessionDriver is called in the CreateSession
   async goroutine, and the driver uses inst.InitialPrompt (sanitized) instead of
   the static fallback when non-empty. Prompt sanitization strips null bytes and
   collapses newlines before tmux injection.

2. **Session goal/task tracking**: New session_goal ent schema (1:1 per session)
   with goal text, task hierarchy (JSON, max 3 levels/50 tasks), and status enum.
   Three new MCP tools: set_session_goal, get_session_goal, update_session_task.
   Thread-safe SessionGoal cache on Instance via goalMu RWMutex. Goal state
   published via SessionUpdatedEvent for live UI updates.

3. **Goal visibility in UI**: GoalPanel component in session detail info tab shows
   goal text + recursive task tree with status badges. Session list cards show
   compact goal summary (60-char truncation + task completion fraction).

Also: steer_session MCP tool for mid-session steering; backlog tab confirmed
off by default (feature-flagged); Navigation tests for backlog visibility.

76 tests added (36 Go unit, 23 TS unit, 11 Go integration, 6 E2E planned).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sessions): address code review findings — upsert correctness, dedup, test coverage

- C1: Always set tasks column in SessionGoal upsert (UpdateNewValues → explicit Update)
- C2: Wrap UpdateSessionTaskStatus in transaction to prevent read-modify-write race
- M1: Export IsValidTaskStatus from session package; remove duplicate in tools_goal
- M2: Reuse resolved inst in setSessionGoal (eliminate second LoadInstances call)
- M3: Log warning when DecodeTasks fails in GetSessionGoal
- M4: Bulk-load session goals in LoadInstances (N+1 → 1 query)
- M5: Remove dead goroutineCount assertion in driver idempotency test
- M6: Remove vacuous default: branch in event publication assertions
- M7: Fix UTF-8 truncation in sanitizeInitialPromptForTmux; add boundary test
- M8: Fix ambiguous getSessionGoal test assertion
- M9: Add collapse direction to GoalPanel expand toggle test
- M10: Add happy-path test for steer_session SendKeys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sessions): address PR review comments

- truncateGoal: fix off-by-one (max+1 chars → exactly max chars)
- ValidateTaskDepth: correct misleading comment (count checked in validateTasks)
- GetSessionGoal: return shallow copy to prevent caller mutation of shared state
- proto types.proto: update tasks_json comment ("empty string" → "[]")
- update_session_task: distinguish not-found errors from internal errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(mcp): update integration test tool count from 20 to 24

4 new tools added in this PR: steer_session, set_session_goal,
get_session_goal, update_session_task.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* feat(approvals): dismiss notifications when user sends terminal input (#101)

* feat(approvals): dismiss notifications when user sends terminal input

When any keystroke is sent to a session, optimistically clear approval
notifications for that session and trigger a debounced re-fetch so the
UI stays in sync with actual backend state.

- ApprovalsContext: add clearForSession(sessionId) — optimistic filter
  backed by an in-flight ref to handle rapid Enter presses safely
- TerminalOutput: call clearForSession on "\r" (Enter) and trigger a
  300ms debounced refetch on any keystroke via useRef-based timer
- SessionCard: add suppressApprovalSubStatus prop scoped to NEEDS_APPROVAL
  only so the in-tab chip/badge also clears instantly
- SessionList: pass suppressApprovalSubStatus={clearedSessions.has(id)}
- Tests: 45 new/updated tests across ApprovalsContext, TerminalOutput,
  SessionCard, and mock fixtures; e2e spec scaffolded
- Feature registry: approval-enter-detection.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(approvals): address code review findings

- Fix double-refetch flicker: skip debounce on Enter since clearForSession
  already fires an eager refetch
- Guard debounced refetch behind pendingCount > 0 to avoid RPC noise
- Log refetch() rejections in clearForSession (no longer silently swallowed)
- Fix test silent-skip: wait for capturedOnData before switching to fake timers
- Remove dead ADR references from comments; replace with inline rationale
- Pass suppressApprovalSubStatus to SessionRow and suppress NEEDS_APPROVAL chip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(approvals): add feature marker and fix registry markerLine

Add // +feature: approval-enter-detection marker to ApprovalsContext.tsx
(line 2) and update the registry entry's markerLine from null to 2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(ci): add registry-diff to quick-check

Runs in ~0.6s — catches missing // +api: markers, markerLine: null,
and unregistered features locally before CI sees them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* chore(bench): update go tier1 baseline [skip ci]

* chore(demos): update E2E feature GIFs [skip ci]

* fix(bench+lint): gofmt PR#101 files; replace rebase with reset+recommit for baselines

Lint: gofmt five files introduced in feat(approvals) PR#101 that were not
formatted before merge (tools_goal.go, tools_goal_test.go, tools_terminal.go,
instance_serialization.go, storage.go).

Benchmarks: replace git pull --rebase with git fetch+reset+recommit in all
five baseline update steps (tier1, tier2, frontend, e2e in benchmark.yml;
benchmark-gate in build.yml).

Why: baseline files are "last writer wins" — they cannot be merged. Rebasing
a baseline commit fails when another job already pushed a newer version of
the same file. The reset approach: fetch latest origin/main, hard-reset to
it (discarding the stale commit), re-copy the freshly-computed result file,
and create a clean commit. On conflict (another push landed between our fetch
and push), the retry loop re-fetches and tries again.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* chore(demos): update E2E feature GIFs [skip ci]

* fix(nav): gate Backlog nav item behind feature flag

NAV_PAGES had Backlog hardcoded as bottomNavPrimary without any feature
flag check. Navigation.tsx correctly used useFeatureFlag("backlog") but
BottomNav and Header consumed NAV_PAGES directly, so Backlog appeared in
both the bottom bar and the hamburger menu regardless of the flag.

Add featureFlag?: string to NavPage interface. Set featureFlag: "backlog"
on the Backlog entry. BottomNav and Header now filter NAV_PAGES through
useFeatureFlags().flags before rendering, hiding any page whose featureFlag
is not enabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* chore(demos): update E2E feature GIFs [skip ci]

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* chore(demos): update E2E feature GIFs [skip ci]

* feat(rules): structured rule builder, template library, analytics→rule workflow (#103)

* feat(rules): structured rule builder, template library, analytics→rule workflow

Redesigns the rules page UX to expose the full power of the backend
CommandCriteria classifier through a guided visual interface:

**Backend**
- Extend ApprovalRuleProto with 9 structured fields (programs, subcommands,
  blocked_subcommands, required_flags, forbidden_flags, required_flag_prefixes,
  python_modes, safe_python_imports_only, tool_category) at field numbers 20-28
- Add manual_allow/manual_deny to SubcommandStatProto, ProgramStatProto,
  ToolStatProto for analytics decision-context display
- Extend ent schema with 8 JSON columns (empty-slice defaults, no migration)
- specsToRules() now builds CommandCriteria so user rules use structured matching
- ruleToSpec() exposes seed rule CommandCriteria so built-in rules display readably

**Frontend**
- RuleBuilderForm: Structured/Regex mode toggle, TagInput for programs/subcommands/
  flags, Python mode checkboxes, decision segmented control, priority auto-default
  by decision type (DENY→950, ESCALATE→450, ALLOW→100)
- RulePreview: live client-side preview of matching/non-matching commands
- TemplateLibrary: 13 curated templates (Python modes, git, npm, docker, MCP, etc.)
- MatchDescription: replaces raw regex chips with plain-language rule descriptions
- Analytics→rule prefill: "Add rule →" links now carry URL prefill payloads
  (programs, subcommands, toolName) so the builder opens pre-populated
- Manual outcome card: shows % of escalated reviews ultimately allowed vs denied
- Edit button on user rules for inline editing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(rules): address code review findings

- BLOCKER: move in-memory spec update to after DB write succeeds in
  rules_store.go Upsert(), eliminating stale in-memory state on DB failure
- CRITICAL: validate decodePrefill() fields individually instead of
  blind cast; guard suggestedDecision against out-of-range integers
- MAJOR: skip priority auto-default in handleDecisionChange when user
  has manually edited priority (compare against previous default first)
- MAJOR: remove Optional() from ent JSON fields that have Default([]),
  making columns NOT NULL and eliminating nil-slice fragility
- Regenerate ent code after schema change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: regenerate ent and proto after rebase, fix post-merge issues

- Removed criteria_programs/criteria_subcommands from ent schema (replaced by 8-field CommandCriteria)
- Regenerated ent code and proto stubs
- Fixed SubcommandDecisionCount missing from repository.go
- Fixed criteriaPrograms/criteriaSubcommands refs in ApprovalRulesPanel, ParsedRuleCard, useApprovalRules
- Removed stale form state and old form functions from ApprovalRulesPanel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(rules): update test to use Programs field after CriteriaPrograms rename

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* chore(demos): update E2E feature GIFs [skip ci]

* fix(rules): correct coverage false-positives and add three-state UX (#104)

* feat(sessions): Phase 1 programmatic control — \r fix + session_id capture for OneShot

Two improvements to OneShot (-p) session control:

1. **Fix initial prompt not submitting** (\r instead of \n): The session driver
   was sending initialPrompt + "\n" (LF) to the PTY. Claude Code's interactive
   readline needs \r (CR) to submit — the same signal a physical Enter key sends.
   Sessions received the typed text but never executed it, causing inactivity
   timeouts. Confirmed by log: "sent initial prompt" followed by 10-min idle.
   The startup dialog answers ("1\n") work because those menus handle both,
   but Claude's readline interface only responds to \r.

2. **Capture claude session_id from --output-format json output**: OneShot
   sessions now launch with -p --output-format json. When the session exits,
   the driver parses the JSON output for "session_id" and stores it as
   ConversationUUID on the instance. Subsequent restarts automatically use
   --resume <uuid>, sending Claude back into the same conversation with full
   context instead of re-running the task from scratch.

   Supporting infrastructure:
   - SetClaudeConversationUUID() — thread-safe setter that fires a save callback
   - SetClaudeSessionIDSavedCallback() — wired in service layer to flush to DB
   - wireClaudeSessionIDCallback() — registered on all creation paths including
     loadInstancesWithWiring (startup) and CreateDirectorySession (backlog)
   - Prompt is now appended even when claudeSessionID is set for OneShot
     sessions so continuation prompts are delivered after --resume

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sessions): Phase 2 — steer_session via --resume, --allowedTools, --permission-mode

Three programmatic control improvements over Phase 1:

1. **steer_session via subprocess**: When a OneShot session has completed
   (Stopped + ConversationUUID set), steer_session now runs
   'claude -p --resume <uuid> --output-format json <message>' instead of
   PTY send-keys. Returns structured result text in the MCP response.
   Send-keys path (interactive sessions) now uses \r correctly and
   reports method: "send_keys" vs "resume_subprocess".

2. **--allowedTools**: New AllowedTools field on Instance/InstanceOptions/
   proto CreateSessionRequest (field 21). When set, passed as
   --allowedTools to the claude CLI at launch. Allows callers to
   pre-approve specific tools (e.g. "Bash(git *),Read,Edit") without
   the all-or-nothing --dangerously-skip-permissions flag.

3. **--permission-mode**: New PermissionMode field (proto field 22).
   Passes --permission-mode to claude at launch. Supports values like
   "acceptEdits" (auto-approve file writes only) and "auto" (full
   autonomous classification).

Also:
- parseJSONField() generic helper in session_driver.go; parseClaudeSessionID()
  now delegates to it instead of duplicating the scan logic
- GetClaudeConversationUUID() thread-safe getter on Instance
- RunWithResume() on Instance: spawns subprocess, parses result, updates UUID

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(rules): correct coverage false-positives and add three-state UX

Three backend bugs in coveredSubcommands() caused items in the "Rule Coverage
Gaps" section to incorrectly show "✓ covered" in the drill-down panel:

1. ToolPattern filter leak: seed rules with ToolPattern=Read|Glob|Grep (no
   ToolName) bypassed the Bash-tool guard because the guard only checked
   spec.ToolName != "". Now also checks ToolPattern against "Bash".

2. ToolCategory false-positive: rules with ToolCategory="builtin-agent" or
   "mcp-read" had their category dropped by ruleToSpec() and leaked through.
   Added ToolCategory copy in ruleToSpec() and a ToolCategory guard.

3. CommandPattern="" only marked covered[""] not specific subcommands. A rule
   that covers all Bash now marks all knownSubcmds as covered.

Frontend: ProgramDetailPanel coverage column now shows three states — "covered"
(rule exists + no escalated events), "partial" (rule exists but still has gaps),
"gap" (no rule). Adds data-testid for each state.

Tests: 21 new Go tests (TestCoveredSubcommands × 11, TestReclassifyGaps × 5,
TestComputeSummary × 5) and 7 new Jest tests in ProgramDetailPanel.test.tsx.
All 28 new tests pass; no pre-existing tests broken.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(rules): add coverage tests for case-insensitive match and bare-program sentinel

Add TC-G-12 (CriteriaPrograms EqualFold regression guard) and TC-G-13
(CommandPattern bare-program match sets covered[""] sentinel). Document
the covered[""] sentinel convention in coveredSubcommands. Fix misleading
ManualReviewRate comment in TC-G-19.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(session): use proper JSON parsing in parseJSONField

Replace manual string-scanning with encoding/json unmarshaling plus
recursive tree search (searchJSONString). This correctly handles escaped
quotes in string values and nested fields like data.session_id in
stream-json output.

Fixes Copilot review comment on parseJSONField escaped-quote truncation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plans): add rules-page-robustness planning artifacts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* chore(demos): update E2E feature GIFs [skip ci]

* fix(notifications): auto-resolve stale toasts and Chrome notifications (#109)

* feat(sessions): Phase 1 programmatic control — \r fix + session_id capture for OneShot

Two improvements to OneShot (-p) session control:

1. **Fix initial prompt not submitting** (\r instead of \n): The session driver
   was sending initialPrompt + "\n" (LF) to the PTY. Claude Code's interactive
   readline needs \r (CR) to submit — the same signal a physical Enter key sends.
   Sessions received the typed text but never executed it, causing inactivity
   timeouts. Confirmed by log: "sent initial prompt" followed by 10-min idle.
   The startup dialog answers ("1\n") work because those menus handle both,
   but Claude's readline interface only responds to \r.

2. **Capture claude session_id from --output-format json output**: OneShot
   sessions now launch with -p --output-format json. When the session exits,
   the driver parses the JSON output for "session_id" and stores it as
   ConversationUUID on the instance. Subsequent restarts automatically use
   --resume <uuid>, sending Claude back into the same conversation with full
   context instead of re-running the task from scratch.

   Supporting infrastructure:
   - SetClaudeConversationUUID() — thread-safe setter that fires a save callback
   - SetClaudeSessionIDSavedCallback() — wired in service layer to flush to DB
   - wireClaudeSessionIDCallback() — registered on all creation paths including
     loadInstancesWithWiring (startup) and CreateDirectorySession (backlog)
   - Prompt is now appended even when claudeSessionID is set for OneShot
     sessions so continuation prompts are delivered after --resume

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(db): make approval_rule JSON fields Optional to fix SQLite migration

New JSON columns (programs, subcommands, etc.) were NOT NULL with no
SQL-level DEFAULT, causing the SQLite copy-table migration to fail for
existing rows. Adding Optional() makes the columns nullable so old rows
can be copied; new rows still get []string{} from the Go-level Default.

Also adds build backup + auto-rollback to install-service:
- make install-service saves stapler-squad.prev before building
- health check polls /health for 15s after service start
- on failure, auto-restores .prev and restarts the service
- make rollback provides a manual escape hatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(ent): stop tracking gitignored generated files

These files are auto-generated by ent and covered by .gitignore.
Force-added in the previous commit by mistake.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(notifications): auto-resolve stale toasts and Chrome notifications

Approved/timed-out approvals and cleaned-up stale sessions now clear
their toast and OS-level Chrome notification immediately instead of
lingering until the 6-minute stale timer fires.

Five root causes fixed:
- Approval timeout/cancel arms in ApprovalHandler now publish an
  EventApprovalResponse event so connected clients receive a real-time
  dismiss signal (RC-1).
- MarkRead is called on all three approval resolution paths (approve,
  deny, timeout, cancel), dropping the unread badge count as soon as
  the approval settles (RC-3).
- NotificationContext gains removeToastByApprovalId and
  removeToastBySessionId; the approval_response stream event handler
  calls the former before refreshHistory so the toast vanishes in the
  same render cycle (RC-2).
- useReviewQueueNotifications calls removeToastBySessionId for every
  session that leaves the review queue, clearing stale-process toasts
  when sessions are terminated (RC-4).
- closeNativeNotification() exported from notifications.ts; called on
  approval resolution and queue removal to programmatically close the
  Chrome OS-level notification (RC-5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(notifications): address code review — fix stale closure, extract helpers, guard empty session

- Fix onSessionDeleted stale closure in useSessionService (was not ref-stabilized unlike onApprovalResponse)
- Guard removeToastBySessionId against empty-string matching sessionId-less notifications
- Extract notificationTag helpers to notifications.ts to avoid duplicated tag format strings
- Extract stampResolved helper in ApprovalHandler to deduplicate timeout/cancel arms
- Consolidate approvalNotificationStamper + notificationMetadataStore into single interface
- Fix fragile nested-select drain loop in TestHandlePermissionRequest_TimeoutPublishesApprovalResponseEvent
- Add guard for empty approvalId in useSessionService approvalResponse handler
- Update useReviewQueueNotifications test mock to include notificationTag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* feat(workflows): Quick Workflows — @slug omnibar, management UI, cron scheduling (#106)

* feat(workflows): add Quick Workflows — @slug omnibar trigger, management UI, cron scheduling

Introduces a flexible workflow definition system that lets users save named
configurations (skill/command, target directory, input template, session type,
model) and invoke them instantly from the omnibar, a dedicated management panel,
or on a cron schedule.

Key additions:
- `@slug [arg]` omnibar syntax (WorkflowDetector, priority 25, collision-free)
- `/workflows` management page: create/edit/delete workflows via WorkflowForm + WorkflowsPanel
- WorkflowScheduler (robfig/cron/v3) fires one-off sessions on schedule; hot-reloads on CRUD
- 5 new proto RPCs: CreateWorkflow, UpdateWorkflow, DeleteWorkflow, ListWorkflows, RunWorkflow
- ent ORM Workflow entity (auto-migrated on startup, slug UNIQUE index)
- Circular import avoided: WorkflowSchedulerInterface in services/, SessionServiceInterface in workflows/, deferred SetWorkflowService injection
- 64 new tests (Go unit/integration + Jest + slug validation)
- Missed-fire policy: skipped cron runs during downtime are not backfilled (v1 non-goal)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(workflows): address code review findings — safety, validation, tests

BLOCKER/CRITICAL:
- Log Reload errors instead of discarding with _ (B1)
- Use errors.Is(err, session.ErrNotFound) instead of ent.IsNotFound after repo wrapping (B2)
- Add 8-second Stop() timeout to prevent hung server shutdown (C2)
- Rollback optimistic delete on RPC failure in useWorkflows (C3)
- Reject cron_enabled=true with empty cron_expression at validation layer (C4)
- Add ErrConflict sentinel; ent_workflow_repository converts ConstraintError (D3/D5)
- Validate target_directory is absolute and free of traversal components (S1/S2)

MAJOR:
- Add 5-minute timeout to cron callback context (M1)
- Add SessionTypeOneOff constant; scheduler uses it instead of magic string (M4)
- Replace window.confirm with inline confirmDeleteId state in WorkflowsPanel (M5)
- Add Limit(1000) safety cap to ListAll (A4)

Tests:
- mockScheduler struct; TestCreateWorkflow_WithCronEnabled_CallsReload (T1)
- TestRunWorkflow_HappyPath + TestRunWorkflow_NotFound (T2)
- Fix delegation test: create workflow first, assert Len==1 (T3)
- Add multi-field UpdateWorkflow test (T4)
- Add 64-char and 65-char slug boundary cases (T5)
- Add default registry @-input fallthrough test (T6)
- Add analytics.track assertion for run_workflow dispatch (T7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(workflows): address Copilot PR review — command injection, markers, registry format

- FireNow: include wf.Command in InitialPrompt (was silently dropped; sessions
  launched without the core skill/command entirely)
- CreateWorkflow: add Name == "" validation for explicit CodeInvalidArgument
- scheduler.Start: remove duplicate Stop() goroutine (server.go already registers
  Stop as a shutdown hook, creating a double-stop risk)
- session_service.go: add // +api: markers to all 5 workflow delegation methods
- WorkflowDetector.ts: add // +feature: marker for registry scanner
- registry JSON: fix filePath→path + add markerLine to match established schema
- WorkflowForm.tsx: tighten slug pattern ([a-z0-9]+(-[a-z0-9]+)*) + add
  minLength/maxLength to reject consecutive hyphens and enforce 2–64 length
- go.mod: go mod tidy to mark robfig/cron/v3 as direct dependency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(deps): update go.sum after ent regeneration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(bench): update go tier1 baseline [skip ci]

* chore(bench): update e2e latency baseline [skip ci]

* chore(bench): update frontend throughput baseline [skip ci]

* chore(bench): update go tier2 baseline [skip ci]

* fix(detection): detect indented spinners and CR-overwritten esc-to-interrupt (#108)

* feat(sessions): Phase 1 programmatic control — \r fix + session_id capture for OneShot

Two improvements to OneShot (-p) session control:

1. **Fix initial prompt not submitting** (\r instead of \n): The session driver
   was sending initialPrompt + "\n" (LF) to the PTY. Claude Code's interactive
   readline needs \r (CR) to submit — the same signal a physical Enter key sends.
   Sessions received the typed text but never executed it, causing inactivity
   timeouts. Confirmed by log: "sent initial prompt" followed by 10-min idle.
   The startup dialog answers ("1\n") work because those menus handle both,
   but Claude's readline interface only responds to \r.

2. **Capture claude session_id from --output-format json output**: OneShot
   sessions now launch with -p --output-format json. When the session exits,
   the driver parses the JSON output for "session_id" and stores it as
   ConversationUUID on the instance. Subsequent restarts automatically use
   --resume <uuid>, sending Claude back into the same conversation with full
   context instead of re-running the task from scratch.

   Supporting infrastructure:
   - SetClaudeConversationUUID() — thread-safe setter that fires a save callback
   - SetClaudeSessionIDSavedCallback() — wired in service layer to flush to DB
   - wireClaudeSessionIDCallback() — registered on all creation paths including
     loadInstancesWithWiring (startup) and CreateDirectorySession (backlog)
   - Prompt is now appended even when claudeSessionID is set for OneShot
     sessions so continuation prompts are delivered after --resume

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(db): make approval_rule JSON fields Optional to fix SQLite migration

New JSON columns (programs, subcommands, etc.) were NOT NULL with no
SQL-level DEFAULT, causing the SQLite copy-table migration to fail for
existing rows. Adding Optional() makes the columns nullable so old rows
can be copied; …
tstapler added a commit that referenced this pull request Jul 26, 2026
* feat(notifications): suppress alerts for auto-approved operations

Eliminates notification fatigue from classifier-handled tool approvals.
Previously, operations like Update/Edit on regular files fired
"APPROVAL NEEDED" desktop notifications even when the classifier would
immediately auto-allow them, because "Update" was missing from the
seed-allow-file-tools regex.

Changes:
- Add "Update" to seed-allow-file-tools, seed-deny-env-write, and
  seed-deny-git-internals-write patterns so Claude Code's Update tool
  is classified correctly (was Escalating → now AutoAllow/AutoDeny)
- Add NOTIFICATION_TYPE_AUTO_APPROVED = 13 proto enum value
- Add ClassificationResult.Source field (populated from rule.Source)
- ApprovalHandler now writes a silent pre-read audit record to
  NotificationHistoryStore for every auto-allow/deny via new
  AppendAutoApproved() method — bypasses event bus so no toast fires
- Wire SetAutoApprovalLogger in server.go
- NotificationPanel: auto_approved records are excluded from the main
  list and shown in a collapsible "Auto-handled (N)" section
- 4 new Go unit tests (T-UNIT-GO-01 through T-UNIT-GO-04)
- 2 new TS tests for AUTO_APPROVED mapping and filter exclusion

* fix(tmux): treat registry false as advisory to fix post-Start() race

DoesSessionExist() previously returned the registry result exclusively
when the registry was healthy. After Start() confirms session existence
via DoesSessionExistNoCache() (subprocess), the push-based registry may
not yet have received the %session-created event. Any DoesSessionExist()
call made immediately after Start() returns would hit the healthy registry,
get false (event pending), and return false despite the session existing.

Fix: when the registry returns false, fall through to cache/subprocess
instead of returning false unconditionally. The registry is now advisory
for the negative case — if it says YES we trust it, if it says NO we
verify via subprocess. This preserves the zero-fork happy path for running
sessions while eliminating the race on newly created sessions.

---------

Co-authored-by: Tyler Stapler <tystapler@gmail.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.

2 participants