Skip to content

feat: declarative pane pinning via RunPane CLI#359

Merged
parsakhaz merged 1 commit into
mainfrom
issue-357-pane-pinning
Jul 22, 2026
Merged

feat: declarative pane pinning via RunPane CLI#359
parsakhaz merged 1 commit into
mainfrom
issue-357-pane-pinning

Conversation

@parsakhaz

@parsakhaz parsakhaz commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

What — RunPane can now declare a pane's pinned state: --pinned on panes create, new panes pin / panes unpin commands, and pinned in panes list JSON, across the daemon, the npm CLI, and the Python wrapper.

Why — an orchestrator (Pane Chat) could already create background panes but not arrange them: in the four-workstream dogfood run (#353), all four panes were created correctly in the background and the user still had to find and pin each one by hand in the UI. Now the orchestrator declares the layout (pinned, unfocused) at creation time and reconciles it later without stealing focus.

How — the persistence already existed (sessions.is_favorite / favorite_pinned_at; startPinned on session creation), so this is contract plumbing plus one new atomic mutation: a runpane:panes:pin daemon command whose written value comes only from the request (one SQL UPDATE with a CASE that preserves favorite_pinned_at on repeat pins — declarative set, never a toggle, so a retry after a transport failure can't flip state). Pinning never touches focus/activation. Everything is additive and contract-generated: contracts/runpane/contract.json → regenerated npm/shared/Python modules, fixture, and docs; both wrappers move together with parity assertions.

Done means: an orchestrator can run runpane panes create … --pinned --no-focus --json and runpane panes pin|unpin --pane <id> --yes --json (idempotently, with --dry-run preview), read pinned back from panes list --json, and the state matches the Pane UI's pin star including after app restart.

Visual overview

Before/after: pane pinning becomes a declarative CLI contract

Before: layout intent dies at the CLI boundary and the user hand-pins every pane. After: --pinned --no-focus declares the layout at create time, and a panes listpanes pin|unpin reconcile loop keeps it, as an idempotent set.

User journeys

Primary journey — orchestrator arranges a workspace: Pane Chat creates four worktree panes with --pinned --no-focus → each pane appears in the sidebar already starred/pinned, none steals focus → the user opens Pane to a pre-arranged workspace instead of hand-pinning four panes.

Reconciliation journey: the orchestrator reads panes list --json, diffs each pane's pinned against the desired layout, and issues panes pin/panes unpin for the drift — repeat requests are no-ops, so a retried command never un-pins what it just pinned.

Verification

Independently proven by a fresh verifier pass (quoted evidence in the run's verification reports; commands reproducible locally):

  • AC1panes create with pinned: true + noFocus: true returns pinned: true, focused: false and the session row has is_favorite set with non-null favorite_pinned_atpnpm --filter main exec vitest run src/ipc/runpane.test.ts (45 tests pass; assertions at the new pinned-create case).
  • AC2 — pin mutation on an unpinned pane returns pinned: true and updates the DB row → same suite.
  • AC3 — the same pin request twice: second succeeds, row (including favorite_pinned_at) byte-identical; unpin mirror case → same suite; the atomic setSessionFavorite UPDATE preserves the timestamp via COALESCE.
  • AC4panes list JSON carries pinned for both states → same suite.
  • AC5 — pin state survives restart: real temp-file SQLite DatabaseService is closed and a new instance re-initialized on the same file, pin state and timestamp read back → pnpm --filter main exec vitest run src/database/database.favorite-pinning.test.ts.
  • D3 paritynode scripts/generate-runpane-contract.js --check (generated files current) and node scripts/test-runpane-contract.js (npm and Python send identical {paneId, pinned} / {paneId, pinned, dryRun} payloads, --pinned create payloads, --yes gating, clean --json stdout).
  • Gatespnpm typecheck clean; pnpm lint 0 errors (165 pre-existing warnings); plan reviewed (1 Codex pass, 3 Must Fix findings folded in pre-implementation); verifier rubric blocker (missing functional --dry-run on the new mutating commands) found and fixed, re-verified pass.

Manual tests

All ACs are covered by the automated suites above; these human-runnable items exercise the live app + CLI end-to-end against a daemon running this build.

Important (user-facing behavior):

  • [J1] runpane panes create --repo <repo> --name pin-test --agent claude --pinned --no-focus --yes --json → response has "pinned": true, "focused": false; the pane appears in the Pane sidebar already pinned and the app window does not steal focus — left to human: needs a daemon running this build (installed daemon is 2.4.29, pre-feat: declarative pane pinning via RunPane CLI #359)
  • [J2] runpane panes pin --pane <id> --yes --json on an unpinned pane → UI pin star turns on immediately (no restart); panes unpin turns it off — left to human: needs a daemon running this build (installed daemon is 2.4.29, pre-feat: declarative pane pinning via RunPane CLI #359)
  • [J2] Run the same panes pin command twice → second run reports the same state, UI unchanged — left to human: needs a daemon running this build (installed daemon is 2.4.29, pre-feat: declarative pane pinning via RunPane CLI #359)
  • [J1] Restart the Pane app after a CLI pin → the pane is still pinned — left to human: needs a daemon running this build (installed daemon is 2.4.29, pre-feat: declarative pane pinning via RunPane CLI #359)
  • [J2] runpane panes pin --pane <id> --dry-run --json → previews without changing the UI star — left to human: needs a daemon running this build (installed daemon is 2.4.29, pre-feat: declarative pane pinning via RunPane CLI #359)

Nice (cosmetic):

Areas not affected: Pane UI code (unchanged — it already renders favorite/pinned state), panel-level pinning, archive semantics, existing RunPane commands/JSON fields, sessions:toggle-favorite (UI keeps using it).

QA results

Automated QA executed 0 of 6 Manual-test items (all six need a live daemon running this build; the installed daemon is 2.4.29, pre-#359) and instead re-proved the full command-shaped verification against the final commit: 46/46 vitest tests (AC1–AC5), generated-contract freshness, and npm/Python parity including the new pin/unpin/dry-run assertions — all pass, no bugs found. Post-PR code review: Approve, 0 Must Fix / 0 Should Fix (single Codex lane, zone 2). Evidence in the QA comment below.

Residual risks

  • A running daemon older than this build does not advertise runpane:panes:pin; the CLI fails fast with No Pane daemon command registered for channel "runpane:panes:pin" rather than misbehaving. CLI and app ship on the same release train.
  • Internal naming stays is_favorite/favorite_pinned_at while the public contract says pinned; the mapping is stated in the command help text (deliberate, per the item's capture gate).

Closes #357

@parsakhaz

Copy link
Copy Markdown
Member Author

QA evidence — automated pass (zone 2, command-shaped)

All acceptance criteria were proven by independent verifier runs during the pipeline and re-run once more against the final commit (git status clean at HEAD). No UI acceptance criteria exist for this change (the Pane UI is untouched), so no app-driving pass ran; screenshots are not applicable to this evidence set.

Passed automated

AC1–AC4 — handler integration tests (pnpm --filter main exec vitest run src/ipc/runpane.test.ts src/database/database.favorite-pinning.test.ts):

✓ src/database/database.favorite-pinning.test.ts (1 test)
✓ src/ipc/runpane.test.ts (45 tests)
Tests  46 passed (46)
  • AC1: create with pinned: true + noFocus: true asserts response { pinned: true, focused: false }, job startPinned: true, activateOnCreate: false, and DB row is_favorite === 1 with non-null favorite_pinned_at.
  • AC2: pin mutation on an unpinned pane asserts response pinned: true and the DB update.
  • AC3: the same pin request issued twice asserts expect(secondPin).toEqual(firstPin) and a byte-identical favorite_pinned_at; unpin mirror included. Dry-run case asserts setSessionFavorite not called and no session-updated emitted.
  • AC4: list case asserts panes: [{ paneId, pinned: true }] and the unpinned counterpart pinned: false.

AC5 — restart persistence (real temp-file SQLite): the test pins via setSessionFavorite, calls close(), constructs and initialize()s a new DatabaseService on the same file, and reads back is_favorite truthy + preserved timestamp; unpin across reopen also covered.

D1 — declarative, not toggle (code inspection, verifier-quoted): setSessionFavorite is one atomic UPDATEis_favorite = @pinned with favorite_pinned_at = CASE WHEN @pinned THEN COALESCE(favorite_pinned_at, CURRENT_TIMESTAMP) ELSE NULL END — the written value never derives from a read. sessions:toggle-favorite is unchanged (git diff --exit-code -- main/src/ipc/session.ts → 0).

D3 — contract parity:

runpane contract generated files are up to date
runpane CLI contract checks passed

The parity harness asserts npm and Python send exactly {paneId, pinned} for pin/unpin (and {paneId, pinned, dryRun: true} for --dry-run), include pinned: true in panes create --pinned payloads, refuse mutation without --yes in non-interactive shells, and keep --json stdout machine-clean — with assert.deepStrictEqual(python.calls, calls) proving the wrappers identical.

Repo gates: pnpm typecheck clean; pnpm lint 0 errors (165 pre-existing warnings); CLI rubric checks (exit codes, malformed input, help-matches-behavior, dry-run on mutating commands) pass — the dry-run gap the first verifier pass caught was fixed and re-verified.

Remaining for the human

The six Manual-test checkboxes in the body all require a daemon running this build (the installed daemon 2.4.29 predates runpane:panes:pin):

  1. Live panes create --pinned --no-focus → pane arrives pinned + unfocused in the UI
  2. Live panes pin/panes unpin → UI star flips immediately
  3. Repeat panes pin twice → no visual change
  4. App restart after CLI pin → star persists
  5. --dry-run → no UI change
  6. Text-mode panes list shows the pinned marker

Suggested setup: run the dev instance (PANE_DIR=~/.pane_test pnpm dev) so the production daemon socket stays untouched, then run the runpane CLI from packages/runpane/dist/cli.js with --pane-dir ~/.pane_test.

@parsakhaz

Copy link
Copy Markdown
Member Author

type: wrap-up-report
item: 357
pr: #359

Wrap-Up Report — Declarative pane pinning via RunPane CLI (#357)

What was built

RunPane now carries the Pane UI's pin/favorite state both ways: --pinned on
panes create (combined freely with --no-focus), new declarative
panes pin / panes unpin commands (idempotent set-not-toggle, with
--dry-run preview), and pinned in panes list --json — so an orchestrator
can create a pinned, unfocused workspace and reconcile drift without the user
hand-pinning panes (the #353 pain). One new daemon channel
(runpane:panes:pin) backed by a single atomic SQL UPDATE
(setSessionFavorite) whose written value comes only from the request;
contract-generated npm and Python wrappers move in lockstep; no schema
changes, no UI changes, sessions:toggle-favorite untouched.

Verification evidence

  • AC1–AC4pnpm --filter main exec vitest run src/ipc/runpane.test.ts:
    45 tests pass; assertions cover create --pinned --no-focus{pinned: true, focused: false} + DB row, pin mutation, double-request idempotency
    (timestamp preserved), dry-run non-mutation, and list serialization for
    both states.
  • AC5src/database/database.favorite-pinning.test.ts: real temp-file
    SQLite closed and re-initialized as a new DatabaseService; pin state and
    timestamp read back (unpin mirror included).
  • D3node scripts/generate-runpane-contract.js --check and
    node scripts/test-runpane-contract.js pass, with new stub-daemon parity
    assertions proving npm and Python send identical pin/unpin/create/dry-run
    payloads, gate on --yes, and keep --json stdout clean.
  • Gatespnpm typecheck clean; pnpm lint 0 errors. All of the above
    ran three times: implementer self-check, independent verifier, and a final
    QA run against the committed HEAD. Full quoted evidence in the PR's QA
    comment.

Review outcome

  • Plan review: 1/1 pass (zone-2 cap), verdict Request changes — 3 Must Fix
    (racy read-then-write → atomic UPDATE; missing parity-test coverage;
    mocked AC5 fixture → real-SQLite reopen test) and 2 Should Fix
    (agent-context catalog, required pinned in schemas), all folded into the
    plan before implementation.
  • Verify stage: first verifier pass failed one CLI rubric blocker (mutating
    panes pin/unpin had no functional --dry-run); fixed in an implementer
    resume round; scoped re-verification passed.
  • Post-PR review: Must Fix: 0 · Should Fix: 0 · passes used: 1/1 (single
    Codex lane) — verdict Approve, no surviving findings, so no inline comments.
  • QA pass (zone 2, trimmed): command-shaped items re-proven against HEAD;
    0 of 6 Manual-test items driven (all need a daemon running this build —
    installed daemon is 2.4.29, pre-feat: declarative pane pinning via RunPane CLI #359); 0 QA findings.

Human action required

  • ⛔ Blocks verification / QA (prerequisite): none.
  • ⛔ You must do (deploy / external): none — no schema, env, infra, or
    dependency changes. Normal release train ships it.
  • ✅ Done for you (applied in-run): all verification, review, QA
    evidence, PR body + diagram, and this report. The only human work is the
    6-item live-app Manual-tests checklist on the PR (needs a dev daemon:
    PANE_DIR=~/.pane_test pnpm dev, then packages/runpane/dist/cli.js --pane-dir ~/.pane_test).

Residual risks / follow-ups

  • Daemon/CLI version skew: an older running daemon lacks runpane:panes:pin;
    the CLI fails fast with a clear channel-not-registered error.
  • Public "pinned" ↔ internal "favorite" naming mapping is deliberate and
    stated in help text.
  • Pin-related lifecycle events deferred to epic feat: Event-driven RunPane orchestration #356 phase 2 (out of scope
    here by design).

Dial record

zone: 2
lanes: single-codex
passes: {plan: 1/1, post_pr: 1/1}
findings: {plan: {pass1: {codex: 5, claude: 0}, later: {codex: 0, claude: 0}},
           post_pr: {pass1: {codex: 0, claude: 0}, later: {codex: 0, claude: 0}}}
verifiers: {frontend: skipped, qa_pass: trimmed}
qa_findings: 0
wall_clock: "1:22"   # session transcript JSONL, first event 05:53Z → wrap-up
deviations: none
pr_size: {files_changed: 18, additions: 1466, deletions: 24}
tokens:
  codex: {total: 643916, by_role: {implementer: 356691, plan_reviewer: 60707,
          backend_verifier: 141445, code_reviewer: 85073}}
  claude_subagents: 0    # no Claude sub-agents dispatched (zone 2, single lane)
  overseer: 338751       # input+output+cache-write, cache reads excluded
  total: 982667
spend_ratio: 659.5
agents:
  - {role: plan-reviewer, model: gpt-5.6-sol, effort: low, dispatches: 1, wall_clock: "1:43", tokens: 60707}
  - {role: implementer, model: gpt-5.6-sol, effort: medium, dispatches: 2, wall_clock: "15:38", tokens: 356691}
  - {role: backend-verifier, model: gpt-5.6-sol, effort: low, dispatches: 2, wall_clock: "4:57", tokens: 141445}
  - {role: code-reviewer, model: gpt-5.6-sol, effort: low, dispatches: 1, wall_clock: "1:27", tokens: 85073}

Deltas vs plan

  • contracts/runpane/schema.json added to the change set (the contract
    manifest's closed help-topic schema needed the new command topics).
  • Pin/unpin --dry-run support added post-plan (verify-stage rubric finding).

@parsakhaz parsakhaz added the awaiting-human-review Run complete; awaiting human PR review label Jul 22, 2026
@parsakhaz

Copy link
Copy Markdown
Member Author

Postmortem — 357 (ops-only)


type: postmortem
item: 357
pr: #359
mode: ops-only

Postmortem — 357 pane pinning via RunPane CLI (ops-only)

Run operations

Wall clock: 1:22:26 (05:53:30Z → 07:15:56Z, single session, no compaction).
Agent-active vs human-idle: ~69% active / 31% idle — one idle block of
25:36 (06:46:48 → 07:12:23), ended by a human "continue". No
post-completion idle to carve out; the run was mid-Step-5 when it stalled.

Per-phase pacing (from transcript + dispatch marker mtimes):

Phase Span Notes
Step 0 preflight + load 05:53–05:58 (~5m) issue fetch, artifact harvest, branch ff, install refresh
Step 1 research + plan 05:58–06:10 (~12m) direct research (zone 2, no dossier); plan-reviewer dispatch 1:43
Plan fixes 06:10–06:15 (~5m) 3 MF + 2 SF folded in
Step 2 implement 06:15–06:24 (~9m dispatch) Codex implementer, 202k tokens
Step 3 verify + fix + re-verify 06:24–06:44 (~20m) verifier fail (dry-run rubric) → resume fix (6:42) → scoped re-verify pass
Step 4 commit + PR 06:44–06:47 (~3m) PR #359 opened, closing line verified
Step 5 review ∥ diagram 06:34–06:47 (overlapped) code-reviewer ran parallel to diagram authoring — good overlap
Stall 06:47–07:12 (25:36) turn ended after the QA-drive command returned; no ScheduleWakeup
Step 5 QA publish + Step 6 07:12–07:16+ body update, QA comment, wrap-up, label, notify

Ranked stalls:

  1. 25:36 human-idle at the QA-drive boundary. The QA command-shaped run
    (vitest + contract checks) returned green and the turn ended without
    publishing the results or scheduling a wakeup — exactly the "idle-waiting
    on a human nudge is a pipeline bug" case the /do charter names. The
    fix that removes it: treat the QA drive's own command results like a
    verifier report — the same turn must publish (body edit + evidence
    comment) before ending, and any turn that ends mid-run must carry a
    ScheduleWakeup.
  2. ~2:00 wasted on a hung compound Bash: a stray sed with no input file
    read stdin until the 2-minute tool timeout. Self-inflicted; the dispatch
    itself was unaffected.
  3. ~1:30 wasted on a code-reviewer launch whose role-instructions path
    (.claude/agents/code-reviewer.md) doesn't exist in this repo — the
    existence check and the launch ran in the same shell command, so the
    check couldn't prevent the launch. Killed and relaunched against
    ~/.claude/agents/code-reviewer.md.

Blocker inventory: no AskUserQuestion gates, no rate limits, no red-tier
gates. Legitimate background waits: 6 Codex dispatches totaling ~24 min,
all polled in-turn.

Dispatch health: 6/6 dispatches completed exit 0; one classified failure
loop (verify → fix → re-verify) worked as designed and caught a real
contract gap (mutating commands shipped without functional --dry-run) —
that loop's ~20 min bought a genuine fix, not churn.

Dial record (from wrapup.md)

zone: 2, lanes: single-codex, passes: {plan: 1/1, post_pr: 1/1}
findings: {plan_pass1_codex: 5 (3 MF), post_pr_pass1_codex: 0}
verifiers: {frontend: skipped, qa_pass: trimmed}, qa_findings: 0
wall_clock: "1:22", pr_size: {files: 18, +1466, -24}
tokens: {codex: 643916, overseer: 338751, claude_subagents: 0, total: 982667}
spend_ratio: 659.5

Judgment: right-sized. The plan pass was high-yield (3 Must Fix, one of
which — the atomic-write race — materially changed the design). The post-PR
pass found nothing, but at 85k tokens it was cheap assurance on a 1.5k-line
diff; no dial change is supported by one data point. The verify-stage rubric
(cli-script) earned its keep — it caught the only real defect. Zone 2's
trimmed QA (no app driving) was correct: no UI ACs existed, and the live-app
checks are honestly parked on the human's checklist.

Outcome

Deferred — ops-only run at wrap-up. On the automated evidence the run is
on-target (all 5 ACs proven, review Approve with zero findings); the outcome
half runs after the human's PR review and live-app manual pass.

What to change so it doesn't recur

  1. claude/skills/do/SKILL.md (dcouple/orchestra) — Step 5's publishing
    rule says "The turn in which a reviewer or verifier report arrives
    publishes its results (body edit, evidence comment) before ending."
    The 25-minute stall slipped through because the QA drive's results came
    from the Overseer's own command run, not a sub-agent report. Proposed
    edit — extend the sentence: "The turn in which a reviewer or verifier
    report arrives — or in which the QA drive's own checks complete
    publishes its results (body edit, evidence comment) before ending."
  2. claude/skills/codex/SKILL.md (dcouple/orchestra) — Step 1 says to
    confirm the instruction/format files exist before dispatching. The wasted
    launch happened because the check and the launch shared one shell command,
    so a failed check couldn't stop the launch. Proposed edit — add to the
    path-resolution note: "Run the existence check as its own command and read
    its result before writing the launcher; a check-and-launch combined in
    one shell invocation cannot prevent a bad dispatch."
  3. Consumer-repo sync (dcouple/Pane).claude/agents/ in this repo
    carries only 6 of the orchestra agent charters; code-reviewer.md (and
    others, e.g. discussant.md, frontend-verifier.md) are missing, forcing
    home-directory fallback paths. Not an orchestra edit — re-run the
    orchestra→Pane sync so repo-relative charter paths resolve.

System changes recorded here for /postmortem-loop; nothing applied.
Published as comments on issue #357 and PR #359 (URLs added post-publish).

@parsakhaz
parsakhaz merged commit 5894dc7 into main Jul 22, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-human-review Run complete; awaiting human PR review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Declarative pane pinning via RunPane CLI

1 participant