Commit Graph

1098 Commits

Author SHA1 Message Date
Michael Ramos 3ce7f6abf2 feat(annotate): fade the primary send while the note panel is open
Maintainer-directed disambiguation from live review: the moment the
caret opens the note panel, the header's Send Feedback fades to 40%
and goes inert, so the panel's own action is unmistakably the submit;
closing the panel restores it. The primary is disabled while faded — a
faded-but-clickable send would still plain-send and silently drop the
typed note on a muscle-memory click.

The panel's action now always renders full-strength: it started
disabled-gray while the field was empty, which put two dimmed buttons
on screen and made the live one look dead. An empty-note click is a
no-op that refocuses the field.
2026-09-01 16:16:08 -07:00
Michael Ramos 60e2fe226e feat(annotate): redesign the send control as a joined split button
Maintainer design review of the first cut ruled the detached one-line
strip inelegant and the second cut's embedded-button overlap wrong.
Final contract, approved in live review:

- One joined split pill [Send Feedback | v] built from the shared
  outline Button: hairline divider, single visual unit.
- Send Feedback never changes meaning: always the incumbent plain send.
- The caret opens a panel below with a multi-line auto-growing textarea
  and its OWN action, 'Send with additional feedback', styled exactly
  like the send button. The two actions never share a button.
- Enter inserts a newline (the field is multi-line now), Mod+Enter
  submits with feedback, Esc closes and keeps the half-typed text.

Tests updated to the multi-line contract: bare Enter must NOT submit,
Mod+Enter and Ctrl+Enter both do.
2026-09-01 15:54:57 -07:00
Michael Ramos a23631b4b6 feat(annotate): one-step submit with a quick note
Reading an agent's message and wanting to reply "that's fine, but watch
the migration" took four interactions: open the global-comment composer,
type, save, then Send. Every annotate surface now has a split Send
control whose caret opens a one-line "Add a note..." field. Enter sends
the note together with any annotations already queued, in one action.

With nothing queued the primary Send button opens that field instead of
staying hidden, which is what the header did before (submitting an empty
review was never useful). With feedback present the primary button is
the incumbent Send Feedback, unchanged. Escape closes the field without
submitting and keeps the typed text for the rest of the session.

The note is created as a GLOBAL_COMMENT at submit time and committed
into the annotations state, so it rides exportAnnotations and the
/api/feedback annotations array exactly like a composer-made global
comment. Both runtimes' /api/feedback handlers take a pre-rendered
feedback string plus an opaque unknown[], so there are no server
changes. Committing into state rather than threading the note through
the payload builders is what makes annotate-last's multi-message export
pick it up, since those entries are rebuilt from the live linked-doc
session snapshot; the submit therefore waits one render for the commit.

The note is not recorded in the annotation undo/redo history: it exists
for the duration of one submit. On HTML and live-app surfaces the
comment-only clamp does not apply, because that clamp sits on the
iframe's postMessage ingest and this note is created in the parent.
Plan mode is untouched. The compact touch shell has no header Send
control, so the field lives in its "Review and finish" surface.

Covers: file, folder, annotate-last, URL and live-app sessions.
2026-08-31 13:32:24 -07:00
Michael Ramos 82a8f236ec feat(opencode): restore the slash commands on OpenCode 2 (#1434)
* feat(opencode): restore the slash commands on OpenCode 2

OpenCode's V2 plugin API gained native command execution upstream
(anomalyco/opencode issue #2185, PR #44765): ctx.command.transform lets a
plugin add a command whose execute callback fully owns the invocation. That
shape currently ships on the beta and dev dist-tags of @opencode-ai/plugin
while next and latest still carry the older context, so the capability is
duck-typed at runtime and never imported. On a host that exposes it the V2
adapter registers /plannotator-review, /plannotator-annotate and
/plannotator-last and runs the same handleCliCommand machinery OpenCode 1
uses, passing the raw argument tail straight through to the CLI. On a host
without it nothing new is registered and behavior is byte-identical to before.

Also wires ctx.session.switchAgent (same API generation, same probe) so an
agent switch chosen in the review UI is applied instead of only warned about,
and accepts both agent.list() response shapes: the HTTP client types it as a
{ location, data } envelope while the in-process plugin domain answers with a
bare array, where reading .data threw and silently emptied the agent list.

The shared command stubs get model-mediated fallback bodies for OpenCode 2
hosts on the stale channels. They carry no shell interpolation on purpose:
OpenCode 1 evaluates a template's !`...` before the V1 plugin's
command.execute.before hook can clear the parts, so a bang template there
would launch a second Plannotator session on every OC1 invocation. A source
level test pins that.

AI-assisted (Claude) under maintainer direction.

* fix(opencode): probe the command draft and reclaim the names from the stubs

Review found the capability probe was wrong in the direction that matters.
ctx.command.transform exists on pre-#44765 hosts too: our own pinned
@opencode-ai/plugin@0.0.0-next-16775 declares CommandDraft as
{ list, get, update, remove } with no add. The probe therefore returned true on
next and latest, draft.add was undefined, and because transforms are stored and
replayed the TypeError landed in the batched reload flush and aborted it before
commit, plausibly taking every command registration on the host down with it.
Capability is now read from the draft handed to the callback, which is the only
witness, and the registration call is wrapped so no transform rejection can fail
plugin setup.

The stubs also shadowed the native definitions on new hosts. Command definitions
land in a name-keyed map where add is Map.set, transforms replay in registration
order, and OpenCode's own ConfigCommandPlugin activates in the post group after
package plugins while scanning the exact directory the installer writes the
three stubs to. A setup-time registration is therefore always overwritten on a
normal install. The plugin now re-registers the same transform once activation
settles, so its definitions are last in the replay order, and calls
ctx.command.reload() explicitly because a late registration only adds its reload
to the already-flushed boot batch. Ownership is read back from
ctx.command.list() by description, which is why the native descriptions and the
stub frontmatter are deliberately distinct. If the reclaim cannot run the stubs
keep the names and the commands still work through their fallback bodies.

Also: a failing switchAgent no longer costs the reviewer their feedback on the
command path, feedback is delivered as "queue" rather than replaying the
invocation's admission mode minutes later when a steer would land mid-turn, and
the agent-list comment no longer asserts a bare-array response that could not be
reproduced upstream (accepting both shapes is still right, since reading .data
blindly throws into a catch that degrades silently).

Tests: the real old-host draft shape registers nothing and throws nothing, the
shadowing contest is modelled against upstream's replay semantics, the OpenCode 1
parts-clearing invariant is pinned for all three commands in both plan-agent and
manual mode now that the stubs carry real instructions, and the V2 smoke asserts
the plugin did not activate as failed and that all three commands resolve. The
smoke now also installs the stubs into its sandbox config dir so the contest
actually happens there. scripts/opencode2-native-commands-smoke.sh runs the same
smoke against a dev-channel build with native commands required; CI cannot,
because it pins a next build.

AI-assisted (Claude) under maintainer direction.

* fix(opencode): keep the reclaim ticking and stop an unbuilt checkout failing setup

The reclaim ended the loop when the draft-probe flag read false, but that flag
only flips when the transform replays, which under boot batching is the flush
after every plugin has loaded. Plannotator loads before the post-group config
plugins, so the first tick legitimately reads false and the loop exited for
good: the reclaim was inert in exactly the shape production has. The tick is
skipped now instead, with a test that flips the flag between ticks.

The V1 entry called resolveBundledHtmlPath synchronously during plugin
construction, outside the .catch that was there to absorb a missing asset, so an
unbuilt checkout threw out of construction before any code path that needs the
HTML. The Test workflow runs bun test with no build step, so the new OpenCode 1
interception tests failed there. Both preloads are guarded; the lazy getters
still raise a clear error if something actually needs the file.

The smoke's failed-plugin guard read entry.state.status, but Plugin.Info carries
status and error at the top level, so a failed activation slipped through.
Reads the top level first and keeps the nested one as a fallback.

Comment corrections: State.batch clears its active flag before flushing, so a
late transform registration materializes on its own; the explicit reload() is
redundant-but-defensive rather than required. The reclaim schedule is a list of
deltas the loop awaits in turn, so the ticks land near 0.3s, 1.5s, 5.5s and
15.5s, not at the raw numbers.

AI-assisted (Claude) under maintainer direction.
2026-08-31 10:42:26 -07:00
Michael Ramos 1cab9dd9a8 feat(review): mark files viewed as you scroll past them (#1430)
* feat(review): mark files viewed as you scroll past them

Reviewers reading the all-files diff top to bottom had to check every file
off by hand. Now a file marks itself viewed when the reviewer MOVES ON from
it, after its content was actually on screen long enough to have been read.
Arriving at a file never marks it; leaving it downward does.

- All-files surface: a file marks when the reader scrolls past it (its
  successor has reached the viewport top, so it genuinely scrolled out above)
  and has accumulated at least 1000ms as the reported reading file. Dwell is
  cumulative per diff snapshot, so bouncing between two files still accrues,
  while a momentum flick to the bottom marks nothing. The last file, which can
  never scroll out above, marks on reaching the end of the diff.
- Single-file panel: opening a file never marks it; navigating away after the
  same dwell floor does. Keyboard file navigation drives the same panel
  switches, so keyboard-only parity is automatic.
- Collapsed cards never mark. Generated files seed collapsed, so nobody
  reviews a lockfile by scrolling past its folded header.
- Un-viewing a file suppresses auto-view for it until it is marked viewed by
  hand again. That set rides the review draft as an additive optional field.
- Inert inside the Guided Review takeover and on a commit detour, where the
  files on screen are not the change under review.
- A viewed file whose patch changes under a refresh loses its checkmark, but
  only while auto-view is on, so the off state stays byte-identical to today.
- PR sessions batch the marks into one /api/pr-viewed request rather than one
  per file.

The setting is reviewAutoViewed, cookie-only and on by default, with two off
switches: Settings > Git and a row in the file-list gear popover. The first
time auto-view actually fires, a toast says so and offers Turn off; using
either switch consumes that one-time notice.

The decision core is pure and clock-injected (utils/autoViewed.ts), the
binding is a hook (hooks/useAutoViewed.ts), and AllFilesCodeView only gains
one optional emission callback on the rAF path it already runs. No server
changes in either runtime.

AI-assisted (Claude) under maintainer direction.

* fix(review): scope auto-mark-viewed to the transitions it was meant for

Four review findings on the auto-mark-viewed branch.

Rule 5 fired on EVERY applied diff switch, not just the staleness refresh.
The review app funnels every transition through one apply path, so entering
the Commits detour (the rail auto-opens HEAD), switching base branch, and
toggling hide-whitespace all un-viewed files whose per-path patch text
legitimately differs, which contradicts both Rule 4's "a commit detour is
inert" and Rule 5's own rationale. The apply path now goes through
resolveDiffSwitchUnviews, which requires the caller to opt in
(`contentRefresh`) and re-checks the identity of the diff on top of that:
same selection, same base, and never a commit-family type on either side.
Only the staleness refresh and the post-fetch base refresh opt in. The pure
delta resolver is unchanged. A source-level test pins which call sites may
opt in, since that is where the guarantee actually lives.

The at-bottom branch fired on the mount tick. A diff shorter than the
viewport is at-bottom from the very first report, and that report is the
mount seed, so the file on screen marked itself about a second later with
zero interaction and fired the first-time toast at a motionless page. It now
requires a real scroll event on the current file set.

Staging a file marked it viewed without clearing auto-view suppression,
unlike v, the header button and the tree row, so a file the reviewer
un-viewed and later staged stayed permanently off-limits to auto-view.

Dwell accrued while the setting was off, so enabling mid-read could mark the
current file instantly on time the reviewer spent with the feature
deliberately disabled. Disabled is now fully inert: the clock does not
accrue, and enabling starts a fresh one rather than replaying the gap.

AI-assisted (Claude) under maintainer direction.

* chore: refresh pinned guide viewer manifest after merging main
2026-08-31 09:13:49 -07:00
Michael Ramos 4465950f0c feat: add bounded annotation undo and redo (#1426)
* feat: add bounded undo and redo history

* fix: address undo redo review feedback

* chore: refresh guide viewer manifest

* fix undo history review regressions

* chore: refresh guide viewer manifest
2026-08-31 09:12:13 -07:00
Michael Ramos 42978fe847 fix(share): invalidate stale short links (#1425)
* fix(share): invalidate stale short links

* fix(share): address short-link review feedback

* test(ci): isolate short-link lifecycle coverage

* test(ci): isolate registered DOM suites
2026-08-31 09:11:35 -07:00
Michael Ramos 79cb016828 fix(agent-terminal): approve node-pty install scripts for npm 12 (#1411)
npm 12 blocks dependency lifecycle scripts unless the installing project
names the package in allowScripts. node-pty ships prebuilds for macOS and
Windows only, so on Linux its install script is what compiles
build/Release/pty.node. The generated managed-runtime package.json declared
no allowScripts, so npm installed the tree, exited 0, and the Agent tab
later failed with "Agent terminal runtime could not load WebTUI".

Generate the runtime manifest with a name-only node-pty approval, and verify
after install that pty.node actually exists (build/Release, build/Debug, or
prebuilds/<platform>-<arch>, which is node-pty's own resolution order). A
missing binary now triggers one targeted npm rebuild and, if that does not
repair it, fails provisioning with the blocked-scripts diagnostic and the
manual remedy instead of leaving a runtime that looks installed.

Closes #1409
2026-08-29 20:02:25 -07:00
Michael Ramos d48b3a9332 feat(marketing): Herdr Annotate landing page at /tui-annotate
Self-contained page under apps/marketing/public/tui-annotate/ with images; demo videos served from media.plannotator.ai (R2, versioned, immutable).
2026-08-29 20:02:10 -07:00
Michael Ramos ce0e1e99ea feat: announce Herdr Annotate on the README and landing page
README gains a Herdr Annotate section after Annotate HTML Artifacts: the
banner SVG and a TUI screenshot side by side, install one-liner, standalone
Plannotator TUI pointer, and a header link row entry anchoring to it.

Landing page: slim announce bar under the nav linking to the plugin repo, a
"watch the demo" strip above the capabilities section linking to the X demo
post, and the Workspaces waitlist pill redrawn as a blueprint chip (plan-grid
fill, corner registration marks) replacing the rounded dot pill.

Also fixes the hero shimmer under Firefox forced colors: Firefox drops the
author gradient but leaves color: transparent standing, so the shimmer word
and the command list rendered invisible when "Override the colors specified
by the page: Always" (or OS high contrast) was active. The gradient is now
declared once for both call sites behind @supports, forced-colors mode gets
CanvasText with the animation stopped, and prefers-reduced-motion freezes
the shimmer at a legible mid-palette slice.
2026-08-28 20:46:11 -07:00
Michael Ramos 9e3af49f84 chore: bump version to 0.27.9 v0.27.9 2026-08-27 16:05:28 -07:00
Michael Ramos c2950e709f fix: pre-release QA findings for 0.27.9 (#1405)
Fixes from the 0.27.9 pre-release review. Servers: an unreadable rendered-HTML root falls back to the startup snapshot on both runtimes with a once-per-process warning instead of hanging (Pi) or answering 500 (Bun); the version diff is recomputed against current bytes on reload and carried through the in-app Refresh instead of being dropped, with no history write on a GET. Client: a Refresh action on the compact touch shell; HtmlSurfaceControls renders Refresh independently of the eye; the dead HtmlSurfaceActions removed. Threading: one linear, cycle-safe reply resolution shared by the annotations panel, its sort, and the export (5,000-chain tests), PATCH ingest on both runtimes rejects self-references and cycles, nothing is ever dropped from feedback. WebMCP and viewer hygiene: bounded tombstone and request memories, per-instance minted ids, nudge id caps, waiter cleanup on unmount, a shared retry epoch for diagram blocks. Docs: HTML Refresh documented, the WebMCP design pointer fixed, marketing pages updated.

AI-assisted (Claude) under maintainer direction.
2026-08-27 15:23:28 -07:00
Michael Ramos 469046f4e9 fix(uninstall): edit the Windows user PATH through the registry with a best-effort change broadcast (#1403)
The Windows uninstaller removed its PATH entry through .NET's SetEnvironmentVariable, whose synchronous settings-change broadcast can stall behind a hung window past the 15 second command timeout and make the uninstaller refuse to proceed (seen three times on one CI runner). The edit now goes through the registry directly (reading unexpanded, preserving the value kind), echoes the original value as proof of the write, and broadcasts the change best-effort with an abort-if-hung timeout that never affects the exit code; the restore path gets the same treatment with a sentinel. A completed write is trusted regardless of how PowerShell ended, while an unproven write still fails closed and preserves the CLI. Tests include a real PowerShell parse check of both scripts.

AI-assisted (Claude) under maintainer direction.
2026-08-27 15:21:56 -07:00
Michael Ramos 58358cca83 chore(ui): keep the atomic-editor range at ^0.8.0 so the tree matches the published 0.34.0; the lockfile still resolves 0.8.1 2026-08-27 15:03:04 -07:00
Michael Ramos 2b9dbc9d1a chore(ui): update @plannotator/atomic-editor to 0.8.1 for the editor entry fix (#1401) 2026-08-27 13:51:11 -07:00
Michael Ramos 8e0c51f5ff fix(ui): 0.33.0 adoption feedback and bump @plannotator/ui to 0.34.0 (#1402)
Follow-ups from the Workspaces adoption of 0.33.0: a math-slot module hosts can redirect Mermaid's own katex import to (importer-scoped resolveId recipe in HANDOFF) so a math document fetches one KaTeX chunk owned by the host; HtmlViewer bridgeErrorDisplay ('banner' default, 'none' lets a host own the failure banner while onBridgeUnavailable still fires); the documented bridge alias narrowed to relative sibling imports; resetMathRenderer keeps a registered loader and discards stale in-flight loads via an epoch, with setMathRendererLoader(null) and getMathRendererLoader added. Plannotator's own bundles unchanged (markers and sizes within noise of main). Bumps @plannotator/ui to 0.34.0; core stays 0.25.0.

AI-assisted (Claude) under maintainer direction.
2026-08-27 12:08:00 -07:00
Michael Ramos 9a0cf3b3e0 chore(packages): bump @plannotator/ui to 0.33.0 (#1400)
Version bump, lockfile refresh, and docs pass for the ui 0.33.0 release carrying the bridge-as-asset seam (#1398) and the 0.32.0 adoption feedback fixes (#1399). Core stays 0.25.0.

AI-assisted (Claude) under maintainer direction.
2026-08-27 09:16:55 -07:00
Michael Ramos e807e2b89c fix(ui): 0.32.0 adoption feedback from hosts (#1399)
Follow-ups from the Workspaces adoption of 0.32.0: the default KaTeX loader moves to its own module so a host that registers a mathRendererLoader can alias it away and drop the unused KaTeX chunk (a registered loader is never backfilled by the default); docs state when onUnanchoredChange first delivers, what projectHostThreads loses on markdown surfaces (export ordering, line labels, repeated-text disambiguation, no-flash restore) while still re-anchoring by text search, and that cap-dropped handling is a backstop once maxAdditionalTargets is enforced upstream; HANDOFF.md now ships in the tarball so the README references resolve. Plannotator's own behavior is unchanged (the eager entry fills the slot before first render; built-bundle markers and sizes match main).

AI-assisted (Claude) under maintainer direction.
2026-08-27 08:54:32 -07:00
Michael Ramos 7d6dd29c08 perf(ui): load the HTML viewer bridge by URL for hosts, with a protocol version and ready timeout (#1398)
Opt-in bridgeScriptUrl on HtmlViewer so multi-chunk hosts can serve the 185 KB bridge as a hashed asset instead of an inlined string; the inline bridge stays the default and Plannotator's own builds, the Pi and OpenCode copies, and the live-app proxy are unchanged apart from a protocolVersion field on the bridge's ready message. The parent checks the version (one warning naming both versions; on the URL path a dismissible banner plus onBridgeUnavailable while the old bridge keeps working), arms a ready timeout on the URL path only, and resolves the URL against the parent document before it reaches the frame so a page's own base href cannot redirect the load. A prepack-generated bridge-script.asset.js (byte-for-byte the inline string) and a bridge-script.lite.ts alias target ship in the tarball. CSP and CORP requirements for hosts are documented.

AI-assisted (Claude) under maintainer direction.
2026-08-27 08:45:22 -07:00
Michael Ramos bafd1f5f5f chore(packages): bump @plannotator/core to 0.25.0, @plannotator/ui to 0.32.0 (#1396)
Version bumps, lockfile refresh, and docs for the lockstep npm release carrying the HTML annotation seams (#1395), the lazy renderers with eager entries (#1394), and WebMCP phase 1 (#1393). Core publishes first because ui now imports @plannotator/core/html-anchor.

AI-assisted (Claude) under maintainer direction.
2026-08-27 07:55:19 -07:00
Michael Ramos 0b167cc478 perf(ui): lazy diagram and math renderers with eager entries for Plannotator (#1394)
Bundle-weight optimization of @plannotator/ui for multi-chunk hosts, requested by Workspaces: the Mermaid runtime and Graphviz engine load inside the render effect, the username dictionary sits behind a synchronous identity generator slot, and KaTeX sits behind a math renderer slot with a loader seam on configurePlannotatorUI. Plannotator's own apps import eager entries (math, identity, and Mermaid for the plan editor) so their behavior is unchanged: single-file builds within noise of main, math typeset on first paint, identities from the full dictionary, and the share portal keeps Mermaid in its entry chunk so its failure surface matches main. Built-HTML registration markers guard the eager imports. Hosts that omit the eager entries get the lazy paths, a one-shot automatic re-attempt, and a Retry affordance on the diagram error panel; the module-map limitation of in-page retries is documented.

AI-assisted (Claude) under maintainer direction.
2026-08-27 07:35:49 -07:00
Michael Ramos 44611e5300 feat(ui): publish the HTML annotation seams hosts were hand-rolling (#1395)
Parity seams for hosts of @plannotator/ui, requested by Workspaces after the HTML annotation handoff: projectHostThreads and buildPersistedHtmlAnchor in @plannotator/core; HtmlViewer onUnanchoredChange completed over the annotations prop with a restore-keyed report so hosts can drop their mark-applied listeners; published useHtmlRefresh with a fetchSnapshot adapter; published HtmlSurfaceControls (eye, refresh, pen) with label overrides; AnnotationPanel unanchoredIds chip (wired for Plannotator too); HtmlViewer maxAdditionalTargets and scrollBehavior carried on the bridge; shortcuts and utils/inputMethod blessed as consumer exports. Plannotator's behavior is unchanged apart from the new Unanchored chip, verified by a real-browser A/B including the orphan and re-anchor cycle and by a combined cross-surface verification with #1394.

AI-assisted (Claude) under maintainer direction.
2026-08-27 07:35:22 -07:00
Leonardo Reis 6407ef5d97 feat(annotate): manual refresh of rendered HTML from disk (#1232)
Local rendered-HTML annotate sessions get a Refresh action beside Hide tools: the document is re-fetched through /api/doc, the sandboxed viewer remounts, annotations are re-anchored and the ones that no longer match are reported while their comments are kept, and stale diff and share state is reset. Maintainer additions on top of the contributor's work: share-link invalidation no longer keys on the resolver's identity, /api/plan and /api/share-html serve a local root HTML file from its current bytes on both runtimes so a reload does not revert the page under the annotations, the Refresh button keeps keyboard focus via aria-disabled, and the tests were hardened. Verified end to end in a real browser.

Thanks @leoreisdias.

AI-assisted (Claude) under maintainer direction.
2026-08-26 14:53:28 -07:00
Michael Ramos 6903d7a3dd feat(webmcp): expose plan review and annotate as WebMCP tools for browser agents (#1393)
Phase 1 of WebMCP support: a zero-dependency, feature-detected engine in packages/ui/webmcp plus a read-and-comment tool catalog for plan review and annotate (read_document, add_comments, update_comment, remove_comments, reveal, nudge_user, list_documents). No decision tools; the human approves. Zero footprint in browsers without document.modelContext (DOM, network, console, timers, and cookies identical to main), idle until called where the API exists, and never registered inside the annotate iframes. Adds an optional inReplyTo field on annotations for threaded replies. Client-only; no server changes.

AI-assisted (Claude) under maintainer direction.
2026-08-26 14:39:37 -07:00
Michael Ramos b381ecbe12 chore: bump version to 0.27.8 v0.27.8 2026-08-24 09:33:56 -07:00
Michael Ramos 0ae40e73a4 feat(annotate): restore a restricted thumbs-up on comment-only HTML surfaces
The v0.27.5 comment-only ruling removed every label affordance from HTML
and live-app annotate surfaces, leaving no one-click positive feedback:
the only path was opening the composer and typing prose. Restore exactly
ONE affordance, the hardcoded 'Looks good' thumbs-up, on both input
routes:

- selection toolbar: commentOnly + a provided onQuickLabel now renders
  only the thumbs-up (no Delete, no Zap picker, Alt+digit suppressed);
  HtmlViewer passes a handler that filters by label id as defense in
  depth
- pinpoint: the composer gains an optional one-click 'Looks good'
  footer action (disabled once anything is typed, so it can never
  discard a draft), emitting the same isQuickLabel comment shape with
  the draft's multi-select targets

The trust-boundary clamp is untouched: redline/quickLabel modes stay
collapsed to selection, so a hostile page still cannot force a DELETION
or an arbitrary label. THUMBS_UP_LABEL moves to utils/quickLabels as
the canonical definition.
2026-08-24 09:27:43 -07:00
Michael Ramos 1080436d36 chore: refresh lockfile for core 0.24.0 / ui 0.31.0 workspace versions 2026-08-23 10:36:03 -07:00
Michael Ramos d257f7fae2 chore(packages): bump @plannotator/core to 0.24.0, @plannotator/ui to 0.31.0 2026-08-23 10:35:21 -07:00
Michael Ramos aa816b231c feat(ui): add embed media picker seam (#1382) 2026-08-23 10:35:06 -07:00
Michael Ramos 776fcb427b fix(pi): append-only phase framing so plan transitions keep the prompt cache (#1381)
The context filter stripped delivered framing from mid-history at phase
transitions, shifting every later message and invalidating the provider's
cached prefix (88 of 119 messages re-billed in the reporter's session).
History is now append-only: delivered framing stays, and stale instructions
are neutralized by superseding language in the phase templates plus the
existing plan-mode-off countermand.

Fixes #1380
2026-08-23 10:35:03 -07:00
Michael Ramos 34f25e79e2 chore: bump version to 0.27.7 v0.27.7 2026-08-23 07:35:06 -07:00
Ashish Huddar 4c5369ecfc fix(call-flow): keep big-but-valid tree lists instead of failing (#1370)
MAX_TREES=100 rejected CallDiff results at 41 changed files in
Swift-style languages (many small per-entry trees), making Call Flow
unusable on normal branch reviews. MAX_NODES was never the binding
constraint (3,138 nodes at 470 trees).

- Raise MAX_TREES to 2,000 so realistic reviews parse untouched.
- Truncate deterministically at the cap instead of throwing, and add a
  warning diagnostic ("Showing the first 2,000 of N call trees") so the
  omission is never silent.
- Keep hard failure for the unbounded-output guards (MAX_NODES,
  MAX_TREE_DEPTH, raw length) that protect against pathological worker
  output.

Fixes #1351
2026-08-23 07:31:40 -07:00
Michael Ramos d977bcecb1 fix(ai): contain broken provider pipes instead of crashing the host (#1379)
Opening a plan review from Pi on Windows could exit the entire Pi host with
an uncaught `write EPIPE` raised inside `PiProcessNode.send()`. The provider
checked `stdin.destroyed` and then wrote, which cannot close the race: the
nested `pi --mode rpc` child can close the pipe between the check and the
write. Node then reports EPIPE either as a synchronous throw or as an `error`
event on the stream, and because no stream had an `error` listener that
became an `uncaughtException` and terminated the host agent process.

Add a shared guard (`packages/ai/providers/child-io.ts`) and apply it to both
JSONL/JSON-RPC providers:

- `guardChildStreams` attaches `error` listeners to the child and every pipe
  immediately after spawn, so a stream error can never escalate. The previous
  one-shot spawn listener was removed on success, leaving the child with no
  `error` listener for the rest of its life.
- `writeChildLine` reports a synchronous failure through its return value and
  an asynchronous one through the write callback, so both paths converge.
- A failure now resolves as a provider failure: in-flight requests reject, the
  process end is broadcast to listeners so a streaming query terminates, the
  child is reaped, and `alive` flips false so the next query re-spawns.

Previously a failed write also left `sendAndWait` pending forever, because
`send()` was fire-and-forget and the Pi provider has no RPC timeout.

Also guards the Bun variant's FileSink write/flush symmetrically, and switches
Pi's Node stderr from an un-drained "pipe" to "ignore", matching the
deadlock reasoning already documented in codex-app-server.ts.

Regression test runs the provider in a real `node` child against a fake Pi
that closes its own stdin; the child installs no `uncaughtException` handler,
so surviving to print its results is the proof. Against the unfixed provider
that child dies with `Error: write EPIPE`, exit 1.

Reported by @Kaelenx.
2026-08-22 22:21:09 -07:00
Michael Ramos db86d38ca4 feat(skills): top-level plannotator knowledge skill, per-host install, and plannotator.ai/llms.txt (#1377)
* feat(skills): add the plannotator knowledge-layer skill with a CLI freshness guard

A new model-invocable core skill (apps/skills/core/plannotator) that teaches
an agent the whole CLI surface: decision guide, per-command reference with
flags and exit codes, env vars, the external-annotations API, and a do-not
list. The existing plannotator-* core skills stay lightweight action stubs.

A freshness test (apps/hook/server/plannotator-skill-reference.test.ts)
parses the skill's documented subcommands and flags and diffs them against
cli.ts usage text plus the CLI arg-parsing sources, in both directions, so
the reference cannot drift from the real CLI without failing the suite.

Installers copy the single-sourced core body into ~/.claude/skills and
~/.agents/skills on all three platforms; uninstall removes it from both
scopes. The skill ships model-invocable as a documented exception to the
locked-by-default rule, asserted both ways in install.test.ts.

* feat(marketing): serve the plannotator knowledge skill as /llms.txt

Single-sourced at build time from apps/skills/core/plannotator/SKILL.md
per the llmstxt.org spec (H1, blockquote, detail sections, Docs link
list), so the CLI freshness guard transitively keeps llms.txt current.

* fix(skills): reach every install path with the plannotator knowledge skill

The knowledge skill reached Claude Code and ~/.agents but was missing from
three install paths. Six fixes from the install-reach review of #1377.

Kiro: the installer's Kiro leg copied only the two action skills, so Kiro
users got launchers and no CLI reference. One copy line per installer, and
"plannotator" joins uninstall.ts's KIRO_SKILLS.

OpenCode npm: @plannotator/opencode's postinstall copied only commands/*.md.
The package now ships the skill (copied at build time like the HTML assets,
gitignored so the shipped copy cannot drift) and postinstall places it under
${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills/plannotator/, which is a
path OpenCode really scans ({skill,skills}/**/SKILL.md under xdgConfig/
opencode). Uninstall sweeps it, skills only, so a user's own
opencode/commands/plannotator.md stays out of scope.

Pi npm: vendor.sh copies the skill to apps/pi-extension/skills/plannotator/
and package.json declares it under pi.skills, which Pi resolves relative to
the package root. Neither vendored copy carries the // @generated header the
.ts files use: a SKILL.md must open with its frontmatter on line 1.

llms.txt: the endpoint resolved the skill through process.cwd(), which breaks
under any invocation but --cwd apps/marketing. new URL(import.meta.url) does
not fix it either, because Vite rewrites import.meta.url to the emitted SSR
chunk's location. Inlined with Vite's ?raw, resolved by the bundler relative
to the source file. Also drops the summary paragraph the required blockquote
already carries; SKILL.md itself is unchanged.

Uninstall: KNOWLEDGE_SKILLS is a separate list from CORE_SKILLS precisely so
the bare name "plannotator" cannot leak into LEGACY_COMMAND_NAMES or
STALE_CODEX_SKILLS and delete a user's own files. Nothing tested that; now a
test proves the five installed scopes are removed and commands/plannotator.md
(Claude and OpenCode) plus ~/.codex/skills/plannotator survive. Also
cleanupStaleSkillLayout now knows KNOWLEDGE_SKILLS.

Origins: oh-my-pi (#1373) was missing from SKILL.md's PLANNOTATOR_ORIGIN row.
The guard now imports AGENT_CONFIG and asserts the row names every key and
invents none, and its header comment is narrowed to what it actually proves:
bidirectional for subcommands and origins, one-directional for flags.

AI-assisted (Claude) under maintainer direction.
2026-08-22 12:07:42 -07:00
Graeme Folk e206a1f5e8 fix(review): infer the jj line-of-work base from the fork point (#1365)
* fix(review): detect JJ mutable line-of-work base

Use JJ's mutable-stack revset to find the line boundary directly instead of inferring a parent from bookmark ordering, which is ambiguous because JJ has no current bookmark.

* fix(review): harden the JJ line-of-work base inference

Maintainer follow-up on the line-of-work base detection.

Skip the bookmarks `jj git push --change` generates. They name one change,
not a line of work, and they do reach the fork point: a colleague's pushed
change bookmark arrives as an untracked remote bookmark, which makes its
commit immutable and therefore a candidate base, so the reviewer was told
they were comparing against `push-vmopwunwxopv@origin`. The commit id is
used instead.

Pass a full commit id through `jjCompareTargetRevset` as a revision. It has
no separators, so the commit-id fallback was being wrapped as
`bookmarks(exact:"<sha>")`, which resolves to no revisions and made the
whole Line of work diff fail.

Fall back to `trunk()` instead of throwing. The only live caller is
`getJjContext` on the review startup path, which has no handler above it,
so a throw aborted `plannotator review` with a stack trace before the
server was built rather than reporting anything. That also covers a `jj`
too old for `fork_point`/`reachable`.

Make the query explicitly single-record with `latest(..., 1)`. The parser
reads one record, and bookmark preference (remote before local) is only
meaningful within one commit, so the tie-break belongs in the revset rather
than in a silent "first row wins" slice.

Isolate the real-jj test behind its own JJ_CONFIG. It was reading the
developer's real config, where `[signing] behavior = "own"` alone makes it
fail with a GPG error.

Live fixtures cover the generated-push-bookmark stack, the untracked remote
push bookmark, and a stacked local bookmark.

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-08-22 11:45:31 -07:00
FND 8a8d0544c6 feat: detect the oh-my-pi harness as its own agent origin (#1373)
* feat: detect the oh-my-pi harness as its own agent origin

- omp injects OMPCODE=1 (+ a CLAUDECODE=1 compat shim) into every Bash-tool child; the env chain now maps it to a dedicated oh-my-pi origin labeled "Oh My Pi".
- Distinct origin rather than aliasing claude-code, because the claude-code-only gates (permission-mode setup, permissionMode in approve) presuppose a PermissionRequest hook that a bash-invoked plannotator never has; omp has no approve support and no planning integration yet.
- Fallback deliberately left "claude-code"; wording unchanged.
- No dedicated Ask AI provider for oh-my-pi.

* chore: sync lockfile workspace versions

* fix: check OMPCODE last so runtimes inside an OMP session keep their label

OMP exports OMPCODE into every shell it spawns. With the check at the top of the chain, opencode/codex/... launched from an OMP session inherited OMPCODE and were mislabeled "Oh My Pi". Moving it just above the terminal fallback lets specific runtime env vars win; OMPCODE still beats the claude-code fallback for bare shells.

Reported by backnotprop in #1373.
2026-08-21 18:06:23 -07:00
Michael Ramos 6e20ec78e8 chore: bump version to 0.27.6 v0.27.6 2026-08-21 10:30:17 -07:00
Michael Ramos 89f0b6628e feat(pi): live local app annotation through a shared proxy core and Node transport (#1366)
Phase 2 of live app annotation: full parity on Pi over one shared
implementation instead of drifting copies.

- Extract every proxy decision into packages/shared/live-proxy-core.ts
  (HTML injector state machine, loopback/Host/Origin predicates,
  CSP/X-Frame-Options policy, redirect rewrite, WS origin gate, bridge
  assembly, liveAppDraftIdentity) and the CLI probe + live-mode messages
  into packages/shared/live-probe.ts. packages/server/live-proxy.ts is
  now a thin Bun transport over the core; its test suite passes
  unmodified.
- Add packages/shared/live-proxy-node.ts, the node:http transport the Pi
  extension runs: streaming request/response piping through the shared
  injector, and WebSocket (HMR) passthrough that replays the client's
  handshake upstream over raw TCP and pipes the sockets byte-for-byte.
  Transport tests run the proxy in a real node child process, because
  Bun's node:http shim drops writes to an upgrade event's socket.
- Wire Pi: /plannotator-annotate probes loopback URLs live-first with
  the shared probe (same 3s timeout, same <500 gate, same messages),
  recognizes --app/--static via parseAnnotateArgs's liveFlags opt-in
  (OpenCode deliberately does not opt in), and serves mode annotate-app
  from serverAnnotate.ts with the shared per-target draft identity,
  live sessions excluded from history/submissions, the remote hard-off
  throw, and guarded live-proxy shutdown.
- Vendor live-proxy-core/live-probe/live-proxy-node plus the
  dependency-free bridge-script constants to generated/.
- Docs: AGENTS.md phase-gate passages, marketing annotate page, Pi
  README.
2026-08-21 10:29:29 -07:00
Michael Ramos b1a46d0a57 chore: bump version to 0.27.5 v0.27.5 2026-08-21 09:06:36 -07:00
Michael Ramos f4756493cf docs: align AGENTS.md and public docs with v0.27.5 behavior (#1361)
Corrects staleness that landed with the live local app annotation work
(#1352) and the configurable Agent TUI placement (#1050).

- AGENTS.md "Session shape" no longer claims drag selection is disabled
  in live mode. Drag-select commenting is always live on HTML and live
  surfaces, in both the armed and Interact states.
- Adds the shared HTML/live interaction model to the canonical
  "## Annotation System" section: pinpoint armed by default, the Esc
  ladder, pen and Mod+Shift+A re-arm, comment-only clamping at the
  postMessage trust boundary, no toolstrip, and the header eye toggle.
- Documents the agentTerminalSide and agentTerminalDefaultAgent
  config-only settings, including registry precedence.
- Corrects the plan-review shortcut scope list to its actual 12 files.
- Marketing docs: documents live local app annotation on the annotate
  command page and the remote-mode refusal in the env var reference.
- Fixes a stale code comment in App.tsx that contradicted the code
  restoring the toolsHidden flag 17 lines below it.
2026-08-21 09:05:28 -07:00
Michael Ramos 67f47dbac1 fix(annotate): armed-mode interaction fixes from the v0.27.5 QA gate (#1363)
* fix(annotate): pre-release QA fixes for the armed-mode interaction seams

Six confirmed QA findings on the HTML/live annotate surface plus missing
pi-extension resync coverage:

1. Armed pinpoint drifted click (>4px, no selection) was swallowed AND
   leaked to the page: the always-on drag work armed the trailing-click
   suppression on drift alone. The mouseup arming site now requires the
   drag to have actually produced a text selection; drifted clicks pin
   normally and never reach the page. Bridge tests for armed drift,
   armed real drag, and Interact drift.
2. Esc ladder: hover-clear is no longer its own rung; clearing the
   pinpoint outline and posting annotate-exit happen on the same press
   when no draft is open. Draft-close keeps its own press.
3. Compact touch layouts no longer apply a restored toolsHidden:true
   chrome cookie (both header toggles are desktop-only, so applying it
   stranded the user); the cookie value is preserved for desktop.
4. The live-app probe now announces the static-conversion downgrade on
   stderr when a loopback probe fails, naming --app to force live mode.
5. Live-app export: page group headers are now '## Page:' with '### N.'
   entries nested below them; exports without pageUrl stay byte-identical.
6. Shift+1-4 mode shortcuts no longer fire while the annotation
   toolbar's type-to-comment listener owns printable keys, so typing
   ! @ # $ into a starting comment cannot silently switch modes.

Also adds the missing tests for the two resyncPhaseFromSession
executing->idle fallbacks that arm idleNoticePending (verified by
mutation: flipping either arm fails its test).

* fix(annotate): compact arm/disarm affordance, guarded shutdown, restored chrome guards

Follow-up scope from the forensics sweep, same surface:

- Compact touch layouts get Options-menu actions for the HTML/live
  surface: 'Annotate page'/'Interact with page' (the desktop pen and
  Mod+Shift+A were unreachable on touch, so every tap annotated with no
  way out) and 'Show tools'/'Hide tools' (the desktop eye). With the
  menu as the way back, the toolsHidden cookie now applies on compact
  again (desktop parity) instead of being ignored.
- The annotate servers' stop() now guards every disposal step
  individually (Bun: runGuardedShutdown, mirrored inline in Pi): a
  throwing agent-terminal teardown (#1314-class) no longer skips
  liveProxy.stop() and the other disposals after it. Unit-tested with a
  throwing disposer.
- Re-added the two regression guards dropped in the htmlHideTools ->
  htmlChrome test rename: the restore commit never writes stale
  pre-restore chrome values to the cookie, and the sidebar stays
  reachable via Mod+B while tools are hidden.

* fix(annotate): scope the Agent TUI display reset to display settings only

The Display popover's 'Reset terminal display settings' button also called
onSideChange('left'), durably overwriting a user's chosen right/hidden
placement in config.json with no disclosure — the label scopes the reset
to font/appearance. Position is a layout preference with its own explicit
segmented control right below, so the reset no longer touches it: the
button now resets exactly the display settings through the panel's one
sanitized update path, and the popover no longer has any code path from
reset to the side.

AgentTerminalDisplayPopover is now exported with a defaultOpen test seam
(the surrounding panel needs a live WebTUI session to render it); tests
assert reset restores the display defaults without firing onSideChange,
and that the Position control remains the explicit way to change
placement.
2026-08-21 08:55:30 -07:00
Michael Ramos 271fcefded fix(server): live-proxy injection and config write hardening (#1364)
* fix(server): live-proxy injection and config write hardening

Four confirmed pre-release QA findings, each with a test that fails on
the pre-fix source.

live-proxy: the HTML injector scanned for head markers with no notion of
comments, so a codegen banner naming <head> before the real tag captured
the bridge script into a dead comment span: never executed, annotation
silently broken, no warning. The scanner now skips comments and the
'>'-terminated markup-declaration / bogus-comment spans (doctype,
CDATA-ish, <?...>) before matching, inside the same chunk-boundary state
machine. Raw-text element contents are still not tracked; that limit and
its degraded outcome are documented in the source.

live-proxy: new URL(req.url) ran before Host validation, so a Host-less
HTTP/1.0 request threw and served Bun's internal debug page with a stack
trace. Host validation now runs first, and URL construction takes the
same 403 path on failure.

live-proxy: the text/html content-type test was case-sensitive, so a
valid TEXT/HTML response skipped injection and the framing rewrites.

config: saveConfig was an unlocked read-merge-write, so two processes
sharing a data dir dropped each other's keys while both reported success.
The read-merge-write now runs under an O_EXCL advisory lockfile with a
bounded wait and stale takeover, degrading to the old behavior with a
warning rather than ever hanging, and the write itself is temp+rename so
lock-free readers cannot observe a torn file.

Also consolidates the duplicated agent-terminal side predicate onto the
single definition in @plannotator/core.

* fix(annotate): give live app sessions their own draft slot

mode "annotate-app" resolves markdown to "" by construction (the page
lives behind the proxy, not in a string the server holds), and the
autosave draft key was contentHash of that body. Every live session on
the machine therefore collapsed to the one hash of the empty string and
shared a single draft slot: two sessions against different dev servers
read and overwrote each other's in-progress annotations, deterministically.

A live session's identity is its target, exactly as a folder session's
identity is its folder path, so the key is now derived from the target
URL (normalized through the URL parser so the same dev server recovers
its draft when spelled with or without a trailing slash). Classic file
and folder keying is untouched.

Pi has no live app mode (no annotate-app, liveApp or live-proxy outside
its vendored generated/ tree), so there is nothing to mirror there.
2026-08-21 08:42:13 -07:00
Michael Ramos b23ffee9ec fix(vscode): migrate the legacy auto-seeded dark theme cookie to system (#1362)
#1357 made the panel defer to the app's stored theme mode and seeded
System only when no mode was stored. That helped first-time panels and
nobody else: every panel opened before it already stored `dark`, written
by ThemeProvider on its first mount rather than chosen by anyone, so the
seed never fired and the panel stayed dark in a light IDE. That is issue
#1053 exactly, still broken for the users who reported it.

There is no provenance in the store to read: it is one flat cookie string
in globalState with no timestamps and no per-cookie metadata, and the app
writes the same `plannotator-theme=dark` whether the user picked Dark or
never opened the theme settings. The migration leans on the three signals
that do exist. `light` and `system` are values the auto-seed cannot
produce, so they are choices and are never touched. A new
`plannotator-vscode-seed` marker, written on every load, makes the
re-seed run at most once per store, so a Dark picked afterwards is
permanent. And a mode the user actually picked is recorded server-side in
~/.plannotator/config.json by configStore.set, which configStore.init
applies over the cookie and writes back, so a real choice outranks the
seed and re-asserts itself in the same page load.

What remains is a Dark that exists only as a cookie with nothing in
config.json behind it. That is indistinguishable from the auto-seed and
is reset once: invisible in a dark IDE, and in a light IDE one re-pick
makes it stick for good.

Also fixes the type error #1357 shipped in applyPanelCookieDefaults and
adds the extension's own tsc to CI, which had never run there.

Co-authored-by: Michael Ramos <backnotprop@gmail.com>
2026-08-21 08:42:09 -07:00
Leonardo Reis 81ecd67e75 feat(annotate): configurable Agent TUI placement with durable config and Hidden state (#1050)
* Allow annotate terminal to dock on either side

* Allow annotate terminal to dock on either side

* fix(annotate): persist Agent TUI preferences through the settings registry

The Position control introduced in #1050 stored its choice in a cookie via
hand-rolled helpers that bypassed the settings registry. Every annotate
session runs on its own random port, so a cookie is scoped to one session:
the placement silently reset on the next annotate. The sibling
`plannotator-annotate-agent-terminal-default` cookie (preferred agent) had
the same gap.

Both now follow the `conventionalComments` precedent exactly:

* `agentTerminalSide` and `agentTerminalDefaultAgent` join `SETTINGS` with
  serverKey/fromServer/toServer, reusing their existing cookie keys so a
  user who already picked a side keeps it across the upgrade.
* `PlannotatorConfig` gains both as flat keys (only diffOptions, theme,
  reviewAnalysis and prompts deep-merge in saveConfig), emitted from
  `getServerConfig()` behind an `isAgentTerminalSide` guard so a
  hand-edited config.json cannot advertise a side that does not exist.
* Both keys are added to the two /api/config allowlists: the Bun annotate
  server and the hand-mirrored Pi one.

The side vocabulary moves to @plannotator/core/agent-terminal (widened to
include the `hidden` state added next) so the registry can reach it without
closing an import cycle through ConfigStore; the ui util keeps its public
API by re-exporting.

Regenerates the pinned guide viewer manifest, which shifts by 0.1 KB gz
because the settings registry now reaches into core/agent-terminal.

AI-assisted (Claude) under maintainer direction.

* feat(annotate): add a Hidden Agent TUI position and extract its layout

Builds on the Left/Right Position control from #1050.

Hidden (third state of the Position control)

  Hidden is a durable preference that the Agent TUI is not part of this
  user's layout: nothing is docked, and choosing Hidden while the terminal
  is open closes it (from either surface that offers the control). It is a
  default, not a lock. The rail toggle, the Shift Shift shortcut and a
  message routed to the agent all still open the panel for the session, and
  none of them rewrites the preference, so explicit intent wins now without
  changing what happens next session. A `hidden` preference owns no dock
  edge, so a session open falls back to the historic left placement.

  Because the Position control lives inside the terminal's own popover, and
  Hidden closes that popover along with the terminal, the same control is
  now also in the Settings dialog (General tab, annotate mode). That is the
  way back from Hidden, and it also answers the review note that Position
  could not be preconfigured before the terminal was ever opened. It is
  gated on the terminal actually being available in the session so a remote
  or runtime-less annotate never offers a dead control. Both surfaces write
  the same `agentTerminalSide` config value and read it through ConfigStore,
  so they cannot drift.

  The existing transient hide affordances (header X, resize handle click and
  drag-snap, rail toggle, Shift Shift) are unchanged and stay session
  scoped. A running agent still stays mounted off-layout when collapsed, so
  hiding the panel never kills the PTY.

Review fixes

* Extract `getAgentTerminalLayout` from App.tsx into
  packages/editor/agentTerminalLayout.ts with a table test over
  {side including hidden} x {open} x {running} x {wideMode} x
  {belowBreakpoint} x {rightPanelOpen}, asserting the invariants that can
  actually regress: never docked on both edges, never visible below `lg` or
  in wide mode, a collapsed running terminal stays mounted zero-width on its
  own edge, and the right panel is suppressed exactly when a VISIBLE
  right-docked terminal holds the slot.
* Fix `aiSurfaceOpen`, which still read `effectivePanelOpen &&
  rightSidebarTab === 'ai'` after its siblings moved to
  `isRightPanelVisible`. A right-docked terminal visually suppresses the
  panel but left the Ask AI model-discovery effect firing for an invisible
  surface, which is exactly the eager provider work that gate exists to
  avoid. The layout computation is hoisted above the consumer so it can use
  the same fact the JSX does.
* Document the right-slot invariant at both coordination sites. The
  asymmetry is deliberate: the panel evicts the terminal (which keeps
  running off-layout, so reopening resumes the same session), while the
  terminal only suppresses the panel visually so dismissing it restores the
  user's place. Symmetry would make every short terminal detour cost the
  reviewer their open surface.
* Name the `useIsMobile(1024)` literal `AGENT_TERMINAL_LG_BREAKPOINT`, tied
  to the panel's own `hidden lg:flex`.
* Restore `hideAgentTerminal()` in the resize hook instead of the raw
  setter, and point the handle at the resolved placement.

AI-assisted (Claude) under maintainer direction.

---------

Co-authored-by: Michael Ramos <backnotprop@gmail.com>
2026-08-20 17:00:22 -07:00
Michael Ramos 752d33183f fix(pi): honest capability warning when the host lacks ctx.isProjectTrusted (#1355)
The capability-absent warning told every host to update Pi, but forks
that never implemented ctx.isProjectTrusted (oh-my-pi) also hit this
path, and update Pi is wrong advice there. Neither Pi's nor oh-my-pi's
extension context exposes a host name or version, so the two audiences
cannot be reliably told apart at runtime. The warning now states the
capability gap without guessing the host, and says what still works:
bundled and global config load regardless (only project-local config is
trust-gated in loadPlannotatorConfig).

Fail-closed behavior is unchanged: capability absent still skips
.pi/plannotator.json, a host-provided true is still honored verbatim
(the oh-my-pi shim in can1357/oh-my-pi#7958 will work unmodified), and
a throwing trustFn still propagates. Tests pin all four paths.

Reported by @materemias in #1353.
2026-08-20 17:00:18 -07:00
Michael Ramos 4f80360351 fix(vscode): user-chosen theme wins over IDE theme sync (#1357)
The theme bridge wrote VS Code's colors as inline custom properties on
<html>, the same element ThemeProvider stamps `theme-<palette>` and
`light` on, and it forced the `light` class to the IDE's theme kind. An
inline property outranks every `.theme-*` rule, so picking Light in a
dark IDE produced dark VS Code tokens sitting under a `.light` class,
and any palette chosen in Plannotator's settings was painted over.

The bridge now reconciles instead of applying once on arrival: VS Code
colors are only painted while the user is on the default palette and the
app is already rendering the IDE's light/dark side, anything it painted
is removed the moment that stops holding, and it no longer writes the
`light` class except to map System onto the IDE's theme kind.

Panels that have never stored a mode seed System, so a first-time user
in a light IDE still gets a light panel now that the bridge does not
force the mode.

Reported by @it-sha.
2026-08-20 13:02:34 -07:00
Michael Ramos 3bcfc5227b fix(pi): accept Pi's full thinking-level range and warn on unknown values (#1356)
`phases.*.thinking: "max"` was silently ignored. The whitelist in
apps/pi-extension/config.ts was written in #446 (2026-04-01) against Pi's
level set of the day, and Pi added "max" to `ThinkingLevel` on 2026-07-09
(pi fbdd46389). "off", valid in Pi since the type was created, was never
accepted either.

Two changes:

1. The accepted list is now Pi's full set ("off" through "max"), kept as a
   deliberate superset of the pinned pi-agent-core floor (0.79.1 stops at
   "xhigh"). Pi clamps a level the running model does not support, so a
   newer level is safe on an older Pi. A compile-time guard asserts the
   other direction: the next level Pi adds fails the typecheck until it is
   listed, instead of being silently dropped.

2. An unrecognized thinking value now produces a config warning naming the
   value, the JSON path and the file, surfaced through the same Pi
   notification path as the executionMode and systemPrompt warnings. The
   silent drop is what made this bug invisible.

Reported by @edision.
2026-08-20 12:38:28 -07:00
Burak Varlı a0aa448f98 fix(review): clamp annotation toolbar to viewport (#1354) 2026-08-20 12:38:25 -07:00
Michael Ramos 2ca55c8332 feat(annotate): live local app annotation through a loopback reverse proxy (#1352)
* feat(bridge): additive live-mode gate + LIVE_BRIDGE_BOOTSTRAP

Adds the config-gated live branch to BRIDGE_SCRIPT: frame gate, pinned
parent origin, token-stamped postToParent, origin+token checks on both
inbound handlers, pinpoint-only clamp, vim and resize off, pageUrl on
ready, and coalesced page-change reporting for SPA history navigation.
With no config present (srcdoc) every branch is inert and behavior is
unchanged; the existing html-viewer suites pass unmodified as the
regression proof. LIVE_BRIDGE_BOOTSTRAP installs the annotation CSS
from the JSON config prelude before the IIFE runs. New package export
exposes the string constants without the React barrel.

* feat(ui): live-session parent side for proxied app annotation

useHtmlAnnotation gains a live option (origin + token validated before
parseBridgeMessage; token + concrete targetOrigin on every outbound
post) and a validated page-change message with onPageChange. HtmlViewer
gains src/liveSession/currentPageUrl/onPageChange: src-mode iframe with
no sandbox and no srcdoc, ready pageUrl handling, per-page restore
filtering with explicit clear-marks + re-sync on navigation, and one
postToBridge choke point for its direct posts. Annotation.pageUrl is
additive; exportAnnotations groups by page (with global numbering kept)
only when a pageUrl is present, byte-identical otherwise. AnnotationPanel
shows the page label; AnnotationToolstrip can hide the input switch.
The editor app wires mode annotate-app: full-viewport live surface,
forced pinpoint, vim off, diff/share hidden, pageUrl stamping.

* feat(server): loopback reverse proxy for live app annotation

Whole-origin mirror of a local dev server on a dedicated 127.0.0.1
port: streaming bridge injection (after the head open tag, before a
bare </head>, or appended; exactly one per document; 8-byte holdback
plus a state machine for tags split across chunks), header hygiene
(upstream Host rewrite, X-Forwarded-*, identity Accept-Encoding on
document intent only, hop-by-hop strip), CSP drop-and-replace with
frame-ancestors listing the editor origins, X-Frame-Options removal,
target-origin Location rewrite, byte-identical passthrough for assets
and encoded HTML (no injection, once-per-session diagnostic), SSE
streaming, and WebSocket passthrough with a bounded pending queue for
HMR. Host header validation runs before any upstream contact; the bind
is the literal loopback constant and the advertised-URL override is
never applied. Tests boot a fake dev server and cover injection,
hygiene, fidelity, WS echo, and the security posture.

* feat(annotate): annotate-app server mode + CLI live probe with remote hard-off

startAnnotateServer gains mode annotate-app and a liveApp option: it
throws under PLANNOTATOR_REMOTE, generates the per-session token,
composes the proxy-served bridge body (JSON config prelude with both
editor origin forms, localhost first, plus bootstrap and bridge
supplied by the caller so packages/server never imports
@plannotator/ui), starts the loopback proxy after the annotate port is
known, serves the live /api/plan payload (no rawHtml, no version
fields, sharing off), and stops the proxy with the server. Version
history and durable submission records stay excluded via the explicit
mode gate.

The CLI resolution probes loopback http URLs (3s, accept text/html)
and defaults them to live mode when the probe returns HTML; --static
forces conversion, --app forces live and fails loudly on non-loopback,
https, unreachable, or non-HTML targets; both flags are mutually
exclusive transport-shape flags never echoed in the tolerant handoff.
A live resolution under PLANNOTATOR_REMOTE is a startup failure
suggesting --static. OpenCode and Pi parsers are untouched this phase.

* test(live-annotate): protocol, server, and probe suites + smoke script + docs

htmlLiveProtocol.test.tsx covers the parent trust boundary (origin and
token rejection before parseBridgeMessage, token + targetOrigin on
every outbound post, validated page-change and ready pageUrl, per-page
restore filtering with full-list numbering) and the bridge live gate,
executed as the composed config + bootstrap + bridge body inside a
dedicated harness iframe so the srcdoc suites keep running the same
script uncontaminated in this process. annotate.test.ts gains
annotate-app cases (live payload shape, composed bridge served by the
proxy, no-history version endpoints, proxy stopped with the server,
remote rejection); annotate-live-resolution.test.ts covers the probe
matrix. The two post helpers now drop unmatched-targetOrigin posts
silently, matching browser semantics where some DOM environments throw.
Adds the manual Vite/Next smoke script and the AGENTS.md live app
annotation section (phase gate, security posture, limitations).

* test(annotate-cli): cover the CLI layer of the live app remote hard-off

Spawns the real CLI entry (async, so the in-process fake app can answer
the live probe) with PLANNOTATOR_REMOTE=1 against a loopback HTML
server and asserts the startup-failure exit with the --static hint.
Completes per-layer coverage of the three-layer hard-off (CLI exit,
server throw, unconditional loopback proxy bind).

* fix(live-annotate): harden the loopback trust boundary end to end

- isLoopbackHostname (now canonical in live-proxy.ts, re-exported by the
  CLI resolution) requires localhost, ::1, or a LITERAL 127/8 IPv4
  address: DNS names like 127.0.0.1.evil.example no longer classify as
  loopback, so neither the default probe nor --app can start a live
  proxy against an off-box origin.
- The live-eligibility probe judges the FINAL response URL: a target
  that redirects off its loopback origin falls back to the static
  pipeline (or fails loudly under --app) instead of opening a live
  session whose iframe immediately leaves the proxy.
- WS upgrades with a browser Origin not naming the proxy itself are
  refused, so a hostile page's cross-site connect is never laundered
  into the origin-less shape dev servers trust as a non-browser client
  (Vite CVE-2025-24010 class).
- /__plannotator__/bridge.js refuses cross-site/same-site
  Sec-Fetch-Site fetches: the per-session token is no longer readable
  via an off-origin script include on modern browsers.
- X-Frame-Options is stripped only on HTML responses (where
  frame-ancestors replaces it); non-HTML responses keep the app's own
  framing protection.
- Redirect Locations are re-anchored by loopback-host + port
  equivalence instead of a string prefix: alternate loopback spellings
  are now caught and lookalike ports (5173 vs 51730) pass through
  untouched.
- --app on a non-URL target fails loudly instead of being silently
  swallowed.

* fix(live-annotate): session correctness for SPA restores, origins, and pathful targets

- A live find-and-mark that resolves nothing keeps its record, seeded
  with unresolved placeholder targets from the durable anchor/text
  params, so the mutation-driven reconcile re-acquires the pin once a
  lazy route or data-dependent tree renders (SPA navigation no longer
  permanently drops pins). Srcdoc restores keep the fail-closed drop.
- The bridge posts every outbound message once per listed editor
  origin; the browser delivers only the one matching the parent
  document, so an editor opened at 127.0.0.1 instead of localhost no
  longer silently loses ready and every subsequent message.
- The advertised appUrl is the proxy under its localhost spelling with
  the target URL's own path and query: the framed app stays same-site
  with the editor, shares the dev app's host-only localhost cookies
  and storage, and a pathful target opens its page instead of the app
  root. The proxy still binds the 127.0.0.1 literal.

* ci(live-annotate): run the live protocol DOM suite; document the hardened posture

htmlLiveProtocol.test.tsx is DOM-gated and was absent from the
workflow's DOM_TESTS file list, so none of its trust-boundary
assertions ran in CI. Add it, and update the live-app section of the
project docs: literal-loopback gate, probe redirect rule, WS Origin
check, bridge.js delivery gate, localhost appUrl advertisement, live
restore resilience, and the remote-mode behavior change (loopback URL
annotate under PLANNOTATOR_REMOTE now exits asking for --static
instead of silently converting).

* fix(live-annotate): absorb the v0.27 mainline into the live session surface

Post-rebase seam work after replaying the branch onto main (v0.27.4 era):

- Route the bridge's unanchored-transparency report through postToParent so
  live sessions deliver it token-stamped to the listed editor origins; the
  raw '*' post main introduced for srcdoc would be dropped by the live
  parent's message authentication exactly where restores fail most. New
  live-harness test pins the contract.
- Extend the live remote hard-off to --tailscale sessions (flag postdates
  the branch): CLI startup failure + startAnnotateServer throw keyed on
  tailnetPublished, matching how the annotate agent terminal treats tailnet
  publication. Covered in annotate.test.ts and documented in AGENTS.md.
- Keep main's compact-touch input controls and effective mode/input values
  on the HTML surface while preserving the live pinpoint-only clamps.
- Regenerate the pinned guide-viewer manifest (CSS hash moved with the new
  UI classes; JS unchanged).

* feat(live-annotate): Interact/Annotate mode toggle for live app and raw HTML sessions

A live app session used to be unusable: the pinpoint capture-phase click
handler owned every click, so buttons, checkboxes, inputs, and links never
fired. One boolean mode now governs the HTML/live viewer surface:

- Interact: the bridge is fully passive. Pinpoint capture, hover outline,
  drag-selection toolbar, [data-annotate] clicks, and committed-highlight
  click interception are all gated behind annotateModeActive, so clicks,
  forms, text selection, and SPA navigation reach the page natively.
  Committed markers and highlights stay VISIBLE, and marker buttons keep
  their clicks (a marker click still opens its comment).
- Annotate: classic behavior, unchanged. Live sessions annotate exclusively
  via pinpoint while armed.

Control: a single bubble icon button in the editor header (icon never
changes; armed = accent + visible border, idle = transparent border of the
same width, so the box is pixel-identical in both states), plus a subtle
inset accent ring floated over the viewer while armed (pointer-transparent,
no layout shift). Keyboard: Mod+Shift+A through the shortcut registry
(html-annotate scope; the bridge mirrors the chord inside the iframe and
forwards it over the authenticated postToParent path). Esc gains a final
ladder rung: draft closes first, then the hover outline clears, then Esc
exits Annotate back to Interact (bridge posts annotate-exit; a parent-side
listener covers Esc with editor focus). The parent owns the mode and pushes
it with the same re-post-on-ready pattern as set-input-method, so it
survives live page changes, HMR reloads, and bridge re-injection without
ever reloading the iframe.

Defaults: live app sessions START in Interact; static/raw HTML sessions
START in Annotate (today's behavior preserved, and the srcdoc bridge default
keeps behavior byte-identical when no set-annotate-mode ever arrives).
Session-only state, no persistence. Vim navigation is available only while
Annotate is armed.

Covered by new bridge-harness and parent-side DOM tests in
htmlLiveProtocol.test.tsx and htmlPinpointProtocol.test.tsx: Interact
pass-through, armed capture, the Esc ladder order, mode survival across
re-injection, marker clicks in Interact, and both defaults.

* feat(live-annotate): pinpoint-armed default, always-on drag comments, comment-only HTML surfaces

Simplifies the Interact/Annotate design after live review. The new
contract replaces the previous one where they conflict:

- BOTH surfaces (raw HTML and live app) now START ARMED with pinpoint;
  the live-session Interact default is gone. Esc keeps the ladder
  (close draft, clear hover, then exit to Interact) and the header
  toggle re-arms. The bridge also paints the pinpoint cursor at init
  instead of waiting for the parent's first round trip.
- The header toggle is a PEN icon: the old bubble sat next to the
  annotations-panel bubble and the two were indistinguishable. Same
  box geometry (armed = accent + visible border, idle = transparent
  border of identical width), aria-pressed, Mod+Shift+A, and the
  armed ring over the viewer are all unchanged.
- Text drag-selection commenting is ALWAYS live on HTML/live surfaces,
  in BOTH states: the selection pass is ungated from annotateModeActive
  and from the pinpoint input method. In armed pinpoint, click = pin an
  element and drag = select text, simultaneously; the >4px drag arming
  decides which one a gesture was, a completed drag's trailing click
  never re-pins (one-shot dragEndedClick), and a plain click is never
  swallowed (the pass only acts on a real selection and never
  preventDefaults). Esc in Interact still closes an open drag draft
  before yielding to the page.
- HTML/live surfaces are COMMENT-ONLY: useHtmlAnnotation clamps
  redline/quickLabel (both the host mode and a bridge-posted
  modeOverride, so a hostile page cannot force a DELETION), the
  selection toolbar drops Delete and quick labels behind a new
  commentOnly seam on AnnotationToolbar, and the quick-label picker
  portal is gone from HtmlViewer. Markdown surfaces keep the full
  toolbar, and persisted DELETION annotations still restore.
- The "Show tools"/"Hide tools" header button is removed. It hid the
  floating toolstrip (now gone from HTML surfaces entirely: with
  comment-only plus both input paths live there is nothing left to
  switch), the collapsed sidebar tab flags, and the viewer's floating
  action cluster (attachments + global comment + version-diff toggle),
  all of which are now always visible. htmlChrome persistence keeps
  only the sidebar/panel state; an old cookie's toolsHidden flag is
  read tolerantly and ignored, so a stale record cannot strand a user
  with hidden chrome and no way back.
- HTML surfaces pin the viewer input method to pinpoint (the drag/
  pinpoint switch is meaningless when both are live); the Alt input
  switch no-ops there. Vim stays armed-only, as built.

No server, proxy, or protocol-security changes; the armed flag stays
session-only.

Tests: the live-bridge harness is reworked around the armed default
(forged-DISARM posture, drag-selection passes in armed and Interact,
the trailing-click guard), the pinpoint suite covers the comment-only
toolbar and the redline/quickLabel clamp at the trust boundary, a new
AnnotationToolbar.commentOnly seam test guards both surfaces'
toolbars, App.htmlChrome.test.tsx replaces App.htmlHideTools.test.tsx
(no tools button, stale-cookie tolerance, pen armed default), and the
htmlChrome tests cover the narrowed persisted shape.

* feat(live-annotate): collapsible floating controls cluster

The simplification removed the Hide tools toggle, which left the floating
comment/attachments cluster permanently over the page. Restore a hide
affordance on the cluster itself: a collapse chevron shrinks it to a small
expand pill in the same corner, so the page is never obstructed without a
way back. Collapsed state persists with the rest of the HTML chrome cookie
(sidebar/panel), tolerantly read. Hosts that do not wire the toggle
(readOnly viewers, review-editor panels) are unchanged.

* feat(live-annotate): header Show/Hide tools replaces the collapse pill

The collapse pill was a half measure: it left its own artifact over the
page and the sidebar tongue tabs stayed. Revert it and restore the real
thing as a header control: an eye toggle immediately left of the pen that
removes ALL floating chrome over the page from the DOM (sidebar tongue
tabs + the comment/attachments cluster), leaving nothing behind. The
toggle lives in the header, so a hidden state always has a way back,
which also makes honoring a persisted (or pre-existing) toolsHidden
cookie safe again.
2026-08-19 10:44:21 -07:00
Michael Ramos 8d735cb7dd fix(pi): countermand stale plan-mode instructions on toggle-off (#1348)
* fix(pi): countermand stale plan-mode instructions on toggle-off (#1320)

Toggling plan mode off mid-session left the model behaving as if planning
continued: the idle context filter silently strips the phase framing, but
the model's own plan-mode turns and blocked-write tool results stay in
history and keep steering it.

Deliver a one-shot hidden plan-mode-off notice on the first prompt after a
planning/executing -> idle transition, armed only in returnToIdle so fresh
idle sessions still inject nothing (the #1269 promise). The idle filter now
anchors the newest idle framing (the notice) instead of stripping it, and
stays deterministic across idle turns. Cache-wise the notice is a
conversation-suffix append at a boundary where stripping the framing has
already invalidated the cached prefix, so it costs no additional misses.

* fix(pi): review round on the plan-toggle countermand

Opus review findings applied:
- F2: the two resyncPhaseFromSession executing-to-idle fallbacks (plan file
  gone, no path recorded) now arm the countermand notice. Both demote a
  RECORDED executing phase, so the session provably used plan mode and the
  #1269 fresh-session promise cannot be violated from these sites; leaving
  them silent was the one remaining reproduction of #1320.
- F1: the planning re-entry test now asserts the persisted planning entry
  carries idleNoticePending: false, which is the actual load-bearing contract
  of enterPlanning's latch clear (kills the mutation the old assertion missed).
- README: split the notice bullet; document that the notice stays anchored for
  the idle session and is not re-delivered after compaction.
2026-08-18 11:14:58 -07:00