[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette - #1491

Merged
waleedkadous merged 14 commits into
mainfrom
builder/air-1474
Sep 4, 2026
Merged

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette#1491
waleedkadous merged 14 commits into
mainfrom
builder/air-1474

Conversation

@mohidmakhdoomi

Copy link
Copy Markdown
Collaborator

Implements #1474.

What was actually wrong

AGY_MARKER = /^> / (gate-profiles.ts:72) treated any line starting with > as agy's
composer prompt, and findMarkerRow is last-match-wins. The issue called this "the weakest link"
in an empirically-derived profile. Measured against a real, authenticated agy 1.1.13 under a
PTY at 110×32, it is worse than a theoretical looseness — non-composer > rows are routine:

real screenlast > row the old marker pickedthat row's fgcursor rowthe actual composer
idle (accept-edits)11palette 121111 ✅
idle (no-hint mode)none — bare > never matched /^> /palette 121111 ❌
draft24palette 122424 ✅
slash menu (/)13 — the menu's selection cursorpalette 121111 ❌
trust dialog8 — the selected optionpalette 1212 (off-row)none ❌
settled after an answer20palette 122020 ✅
torn mid-repaint frame10 — the transcript echo of the sent turnpalette 4variesabsent ❌

Three findings drove the design:

  1. agy echoes every submitted turn into the transcript as a > line (SGR 34;1 → palette 4).
    Non-composer > rows accumulate one per conversation turn.
  2. The slash menu's selection cursor is also > , also palette-12, and renders BELOW the
    composer
    — so it won the marker scan outright. A color anchor alone does not separate
    these; the cursor row does.
  3. In agy's no-hint mode the composer is a bare >, which right-trims to ">" and never
    matched /^> / — so the gate held every message to an agy in that mode forever. A
    pre-existing false-HOLD, fixed here, since the issue's ask is that the marker identify the
    actual composer.

Markdown blockquotes turned out to render as , not > — so the issue's "quoted output" risk
arrives via the turn echo rather than via blockquotes.

Honest scope of the defect. I could not reproduce an actual false-CLEAN from the captures: on
every mis-bounded frame the wrong region still happened to contain counted text, so the verdict
landed busy anyway. What is demonstrated is that the classifier bounds the wrong region on
real screens
, and that getting busy out of a wrong region is luck rather than a guarantee. The
corruption risk is latent, not observed — stated plainly rather than overclaimed.

The change

Two optional, per-app GateProfile anchors — profile data, in keeping with the spike's
constraint 9 — set only on agy, both measured:

  • markerRequiresCursorRow: true — the marker row must hold the buffer cursor. Only the live
    input row does; menu rows, dialog options and transcript echoes never do.
  • markerFgPalette: 12 — the marker glyph's own color, which separates the composer from the
    palette-4 turn echo.

Plus the marker separator relaxed to /^>(\s|$)/ for the bare-> mode. claude/codex set neither
anchor and are byte-for-byte unaffected (pinned by a test).

Every failure direction is toward HOLD: a row that fails an anchor is simply not a marker, so
drift yields no-composer-marker → held and re-checked, never a false clean. Sustained holds
already escalate through the mailbox liveness telemetry (recordStreak).

Verdict changes on the real captures

fixturebeforeafter
agy-idle.cleancleanclean
agy-draft.busybusy / user-textbusy / user-text
agy-menu.busybusy / no-region-end (marker was the menu item — right verdict, wrong reason)busy / user-text (right region: the / typed in the composer)
agy-trust.busybusy / no-region-end (incidental — no rule under the option)busy / no-composer-marker (honest: there is no composer)
agy-torn-echo.busybusy / no-region-end (marker was the palette-4 echo)busy / no-composer-marker
agy-turn-echo.cleancleanclean
agy-baremarker.cleanbusy / no-composer-marker (false HOLD)clean

Cost of the tightening, measured

Sweeping every byte-prefix of a real stream showed 70 CLEAN frames before vs 9 after — alarming
until you notice byte-prefix sampling cuts mid-repaint, which production never does. Sampling
the way production actually classifies (wall-clock, against a live agy driven through boot → idle
→ streaming → settled, classifying the mirror every 200 ms) the two profiles are identical:

idle samples= 30 OLD clean=100% NEW clean=100%
streaming samples= 205 OLD clean= 98% NEW clean= 98%
settled samples= 40 OLD clean=100% NEW clean=100%

No measurable false-HOLD cost, and the bare-> mode goes from permanently held to deliverable.

Fixtures

The issue asked for captured real-agy fixtures across states, and agy is authenticated in this
environment, so the three synthesized Phase-3 fixtures are replaced by seven real captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn frame). agy's banner embeds the
account email and session cwd; both are replaced with same-length placeholders so the rendered
screen stays byte-for-byte equivalent — no attribute is retouched. Each file is 1.7–8.7 KB, so
they are committed plain rather than gzipped (gzip is used for the multi-hundred-KB claude
replays). The torn-frame fixture is real bytes cut mid-repaint, which is the tear shape #1361
documents for the adopt seed.

Tests

55 pass in render-gate.test.ts. New coverage: per-fixture verdicts for all seven states; the
reason for each (a mis-bounded region that returns busy is not the same as a correctly-bounded
one); synthetic branch tests for each anchor independently, including the palette-12 dialog option
with the cursor on it — the one shape the anchors cannot reject, which pins that the occupancy
count still catches it; and a guard that claude/codex are unaffected by cursor position.

porch check 1474: build ✓ (13.1s), tests ✓ (28.7s).

Notes for the reviewer

  • The cursor-row anchor assumes buffer.cursorY is viewport-relative, the same convention
    isGhostCursorCell already relies on. True whenever viewportY === baseY (no manual scrollback),
    which holds on both gate paths.
  • The one shape neither anchor rejects is a dialog that parks the cursor on a palette-12 > option.
    The trust dialog does not (measured), and the occupancy count plus the region-end guard both still
    catch it — but it is the seam to watch if agy adds dialogs.

🤖 Generated with Claude Code

mohidmakhdoomiand others added 5 commits August 17, 2026 19:44
… and marker palette
`AGY_MARKER = /^> /` treated any `> `-prefixed line as agy's composer. Measured against
real agy 1.1.13 under a PTY, that is not a hypothetical looseness: agy echoes every
submitted turn into the transcript as `> <message>` (palette-4), its slash-menu selection
cursor is also `> ` in palette-12 and renders BELOW the composer, and the trust dialog's
selected option is `> Yes, I trust this folder`. Since `findMarkerRow` is last-match-wins,
the menu item and the turn echo won the marker scan over the composer — so the classifier
bounded and scanned the wrong region on ordinary screens.
Adds two optional, per-app `GateProfile` anchors, both set only on agy and both measured:
`markerRequiresCursorRow` (the marker row must hold the cursor — only the live input row
does) and `markerFgPalette` (the marker glyph's own color, 12, which separates the composer
from the palette-4 turn echo). Also relaxes the marker separator to `\s|$`: agy's no-hint
mode renders the empty composer as a bare `>`, which `/^> /` never matched, so the gate
held every message to an agy in that mode forever.
Replaces the three synthesized agy fixtures with seven real, sanitized captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn mid-repaint frame).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er thread
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Test status, stated precisely

porch check 1474: build ✓ (13.5s), unit tests ✓ (28.6s) — 55 pass in render-gate.test.ts.

The e2e_tests gate check is a no-op here. AIR defines it as npm run test:e2e 2>&1 || echo 'e2e tests skipped (not configured)' and the repo root has no test:e2e script, so it reports ✓ in 0.1s having executed nothing. It is marked optional in the protocol, so that is intended — but it is not evidence, so I ran the real suite:

pnpm --filter @cluesmith/codev test:e2e
Test Files 1 failed | 19 passed | 5 skipped (25)
Tests 3 failed | 171 passed | 21 skipped (195)

All 3 failures are in tower-api.e2e.test.tsPOST /api/terminals returning 500 where 201 is expected.

They are pre-existing and unrelated to this PR. Verified rather than asserted: I reverted both changed source files to the branch base (141b493), rebuilt, and re-ran that file — identical 3 failures. Files restored afterwards (all committed, so lossless; working tree verified clean against HEAD, unit tests re-run green).

I did not skip or annotate them as flaky, because they are neither mine nor intermittent — they reproduce consistently. Flagging for a maintainer instead. Plausibly environmental: this machine is running four-plus concurrent builder sessions plus Tower, and all three failures are PTY-spawn-through-the-API.

@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Architect integration review — 3-way CMAP (risk tier: High — render-gate classifier, false-CLEAN direction)

Verdicts: gemini APPROVE · codex APPROVE · claude COMMENT ("merge it, with follow-ups") — all HIGH confidence, zero blocking correctness issues. Architect read concurs: the two positive-evidence anchors (markerRequiresCursorRow, markerFgPalette) fit the existing per-app-profile seam, every failure direction lands on no-composer-marker → HOLD, and claude/codex profiles are untouched. The real-capture measurement table is the load-bearing artifact, and the PR's refusal to overclaim (mis-bounding demonstrated, false-CLEAN latent; no-hint 'held forever' rests on live observation) is noted and appreciated.

Independently verified across the reviews: viewport-relative cursorY is sound on the production gate (viewportY === baseY for SessionScreen); the tightening's failure direction is observable (no-composer-marker feeds isClassifierStuck → escalation at streak 10); fixture sanitization is complete (no emails/paths survive); the bare-> relaxation is gated behind both anchors so it cannot loosen anything — and it fixes a real pre-existing total delivery outage in agy's no-hint mode.

Requested before the gate (one item)

  1. Add an agy case to the production-mirror-path suite (render-gate.test.ts:364). These are the first anchors that depend on cursor state, the one dimension where the transient path and the SessionScreen mirror path could conceivably diverge — a parity test (same fixture, same verdict via both paths) closes the only untested seam between what the tests prove and what production runs.

Follow-ups (this PR or noted for later — builder's call, say which)

  • arch.md:1795 still describes the agy gate as a placeholderFgPalette rule; the definition of "marker present" changed for agy. Two-line touch or explicit MAINTAIN deferral.
  • Commit the capture/sanitization harness (Spec 1313 precedent: codev/spir-1313-captures/*.mjs) so re-measuring against a future agy doesn't start from the PR description.
  • Cosmetic: required-fixtures test title still says "idle/draft/trust" while requiring seven.
  • Keep the dual 4-bit/256-color palette-12 matching — it is deliberate, not redundant (real fixtures use [94m, synthetic [38;5;12m).

For the maintainer

Parked for maintainer approval + merge; we are not maintainers.

…low-ups
CMAP requested item: the agy anchors are the first classifier input that depends on
CURSOR STATE, and the cursor is the one dimension where the transient replay path and the
long-lived SessionScreen mirror could diverge. Adds a per-fixture parity case to the
production-mirror-path suite — same bytes through both paths, same verdict — fed twice,
once in production-sized chunks and once in 7-byte chunks that deliberately split the
cursor-positioning CSI across feed() calls, since a mis-parsed cursor is now a verdict
change rather than a cosmetic one.
Follow-ups taken in-PR rather than deferred:
- arch.md: "marker present" now means more than a text match for agy — records the two
anchors and their fail-toward-HOLD direction.
- Commits the capture + sanitization harness (codev/air-1474-captures/) so re-measuring
against a future agy starts from a script, not the PR description. Verified by
re-deriving four committed fixtures byte-identically from the raw captures.
- Fixes the sanitizer's leak check, which reported `/home/` on every run because the
placeholder path itself starts with it — a check that always fires checks nothing. Now
matches paths that are not the placeholder, and refuses to write on a leak.
- Cosmetic: required-fixtures test title listed three agy states while requiring seven.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

CMAP items addressed — all four taken in-PR, none deferred

Requested before the gate ✅

agy case in the production-mirror-path suite (render-gate.test.ts). Agreed on the reasoning: these anchors are the first classifier input that depends on cursor state, so the transient-vs-mirror seam is newly load-bearing. Added a parity case per agy fixture (7 of them) rather than one representative — same bytes through both paths, toEqual on the whole verdict, plus an assertion that the transient path's own verdict matches the filename, so the parity check can't pass by both paths being wrong together.

Each fixture is fed twice: once in production-sized 64 KiB chunks, and once in 7-byte chunks that deliberately split escape sequences across feed() calls. That second pass is the one with teeth here — the cursor-positioning CSI (ESC[<row>;<col>H, and the relative ESC[2A/ESC[2C agy actually emits) is exactly what a torn chunk boundary would corrupt, and a mis-parsed cursor is now a verdict change rather than a cosmetic one. Both pass. 62 tests in the file; full package suite green (4876 passed / 48 skipped).

Follow-ups — taken, not deferred

  • arch.md ✅ — the agy entry now records that "marker present" is more than a text match, names both anchors, and states the fail-toward-no-composer-marker → HOLD direction.
  • Capture/sanitization harness ✅ — committed at codev/air-1474-captures/ (capture script, sanitizer, README covering auth prerequisite, per-state recipes, the 110×32 geometry constraint, and how the torn fixture is derived). Verified rather than assumed: re-ran the committed sanitizer against the raw captures and re-derived four committed fixtures byte-identically.
  • Cosmetic test title ✅ — now lists all seven agy states.
  • Dual 4-bit/256-color palette matching — kept as-is, per your note that it is deliberate.

One thing the harness work turned up

The sanitizer's leak check was self-defeating: it tested for the bare prefixes /home/ and /tmp/, but the replacement path is /home/agent/project, so it reported a leak on every successful run — including on the fixtures already in this PR. A check that always fires checks nothing, and I'd rather not ship that as the documented safety step. It now matches paths that are not the placeholder, and refuses to write the fixture if anything survives. Re-verified against all four captures: leaks=[], output byte-identical.

To be clear about what this did and didn't mean: the committed fixtures were always clean — I verified that independently with a direct grep before the first push, and the byte-identical re-derivation confirms it again. The defect was in the check, not in the fixtures.

Ready for the pr gate.

@waleedkadous

Copy link
Copy Markdown
Contributor

Thanks for the patience on this one — the review queue is moving now. This PR is showing as CONFLICTING against main after this week's merges in the same area (serializer convergence #1492, delivery verification #1573/#1584, self-attesting frames #1574, and the recipient/reply-hint changes to message-format.ts and tower-routes.ts). Could you rebase onto current main when you get a chance? I'll review the moment it's green — the four non-conflicting PRs in your list are in review right now.

mohidmakhdoomiand others added 2 commits September 3, 2026 14:19
Resolves the one conflict in render-gate.ts: main added the exported
`bufferLines()` helper (#1573 echo verification) directly above
`findMarkerRow`, whose doc and signature this branch rewrote for the
cursor-row/palette anchors. Both are kept — they are adjacent, not
competing.
Typecheck clean; render-gate suite 66/66.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alarm
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Brought up to current main — conflicts resolved, CI green, architect integration check

This branch had gone CONFLICTING (770 commits behind). It is now MERGEABLE at 470d49b2d, with all 7 checks passing, via a merge of origin/main (818337fa2) — not a rebase, per this repo's merge-never-squash convention. (mergeStateStatus: BLOCKED is now only the missing maintainer approval; no conflict, no failing check.)

The conflict was adjacency, not disagreement

Exactly one file conflicted: packages/codev/src/agent-farm/servers/render-gate.ts. The sole commit to touch it since the merge-base was #1573 (35f2e637f), whose change there is purely additive — one new exported bufferLines(term), inserted directly above findMarkerRow, whose doc comment and signature this branch had rewritten for the cursor-row/palette anchors. Both sides kept verbatim.

The two functions now sit adjacent and are worth keeping straight:

Architect verification (independent of the builder's report)

Post-merge suite: 275 files passed / 3 skipped / 0 failed; 5477 tests passed, typecheck clean. The render-gate suite is 66 cases now rather than 55 — main's own additions to that file merged in cleanly alongside these.

Two notes for whoever merges

  1. Merge-order: PR Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 (issue Render gate: replace the perf wall-clock assertion with a deterministic op-count check #1471) also touches render-gate.test.ts, in a different region. Whichever of Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 / [Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette #1491 merges second may need a trivial rebase.
  2. Resuming any long-parked branch in this repo: the first post-merge pnpm test here reported 14 failures in request-auth.test.ts / tower-routes.test.ts (WS_KEY_PROTOCOL_PREFIX, TOWER_KEY_HEADER arriving undefined). All falsepackages/types/dist predated the constants main had since added, and node_modules predated a new three dependency. pnpm install && pnpm build first, then believe a test result.

Status

Parked for maintainer review — we are not maintainers of this repo, so we are not merging this. Please approve and merge when it suits you. Issue #1474 stays open and the worktree stays intact until you do.

mohidmakhdoomi added a commit that referenced this pull request Sep 3, 2026
Retrospective at codev/reviews/1482-f1-tower-vs-pty-dimension-dive.md, plus
the governance routing it describes.
The Summary leads with the user-visible harm rather than the mechanism: the
owner starvation notice offered `afx interrupt` for EVERY hold, and for a
`user-text` hold that is advice to interrupt a person mid-draft. The dims fix
and the detail column are how that becomes possible to tell apart, not the
point in themselves.
Both gaps the human approved knowingly are stated in the review rather than
left for a reviewer to find: the dashboard popover still renders a bare `busy`
(reverted deliberately — no worktree.devCommand, no Playwright here, and the
project requires a browser check), and evidence item 3 is covered by test
rather than induced live because send.ts constructs `new TowerClient()` with
no port and so reaches only the live Tower on 4100.
Also called out for the maintainer: the beyond-plan 409 RESIZE_DROPPED route
change and CronDeliveryResult gaining `detail`; the v18-vs-v17 migration
collision surface (in two places — the constant and the pinned source
assertion that reads it back); and the conflict surface with the two parked
PRs, #1486 (commands/inbox.ts, commands/send.ts, tower-routes.ts) and #1491
(render-gate.ts, render-gate.test.ts).
**Governance routed COLD only.** Both hot files sit exactly at their caps
(10 facts / 10 lessons) and nothing here justified displacing an existing
entry; the hot map already routes readers to "Invariants & Constraints", so
the tiering works without growing the always-injected tier.
- arch.md: new invariant #10, "Terminal dimensions must be earned, not
assumed" (requested/applied/outstanding, 409 vs 404, WELCOME adoption, and
why the gate depends on it); and the Spec 1313 mailbox section's response
vocabulary extended with the v18 `detail` column and why it carries no CHECK
constraint.
- lessons-learned.md: three Architecture entries (a failure boolean is only as
honest as its callers; two-state models fail at the third state — the
missing bit was OUTSTANDING, not DIFFERENT; commit derived state only after
what it describes is confirmed) and three Process entries (read the ignore
rule's own reason before `git add -f`; report the boundary of a gap, not the
instance you tripped over; mutation-check a test written to close someone
else's finding).
Full suite green on this tree: 278 files passed / 3 skipped, 5495 tests passed
/ 48 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Really thorough work — seven real agy captures, per-fixture mirror-vs-transient parity tests (including the 7-byte chunking that splits cursor CSIs), and a failure direction that stays HOLD rather than deliver. The render-gate change itself is approve-quality and claude's lane approved it outright; the one thing I'd like fixed before merge is in the capture tooling, because it's a leak-safety guarantee that currently isn't one:

  1. codev/air-1474-captures/sanitize.py writes the output before running its leak check (the open(dst, …).write(data) at ~line 137 precedes the # Leak check block). If identifiers survive, it raises only after the unsafe fixture is already on disk — contradicting its own "REFUSING to sanitize" message and creating an accidental-commit risk. Run every check before writing, or delete dst on failure. Relatedly, its path regex misses /Users/…, so a macOS capture would pass the check while leaking a username (the committed fixtures are clean; this is about the next capture).

Two small things you might consider alongside, neither blocking:

  • buf.cursorY is baseY-relative in xterm's public contract; the code relies on viewportY === baseY. buf.baseY + buf.cursorY - buf.viewportY removes that assumption for free, and it's now load-bearing for delivery.
  • Idle-session drift is silent — onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm, the same shape as the bare-> bug this fixes. A follow-up on profile-drift observability (classifier-stuck escalation for idle sessions, or an afx doctor check that classifies each live mirror once) would close that.

Also noting markerFgPalette will want to accept multiple colors before agy ships a truecolor theme — follow-up territory.

…hrough baseY
Addresses the PR #1491 maintainer review.
BLOCKING — sanitize.py wrote the output file before checking it for leaks, so a
capture that still carried an identifier landed on disk and the "REFUSING to
sanitize" message was false by the time it printed. Now every check runs before
anything reaches the filesystem, chosen over delete-on-failure: a guarantee that
depends on cleanup running is weaker than one that never creates the file.
PATH_RE also gains /Users/, without which a macOS capture sanitizes cleanly while
leaking a username — the prefixes the scrubber rewrites and the prefixes the leak
check inspects have to be one list.
Writing the --selftest exposed a second defect in the predicate: same-length
substitution truncates a cwd shorter than the placeholder (/home/ab/x becomes
/home/agent/p), which failed startswith() and read as a leak, making every
short-cwd capture unsanitizable. is_placeholder() now accepts prefixes of the
placeholder. All seven committed fixtures re-verified clean under it.
The selftest runs the refusal path for real — it stubs out the redaction, pushes
a leaking capture through, and stats the output path — and CI runs the selftest
(air-1474-sanitize-ordering.test.ts, skipped where python3 is absent).
NON-BLOCKING — classifyBuffer read buf.cursorY alongside top = buf.viewportY,
assuming viewportY === baseY. Expected to be hygiene; measured as a real false
clean. With the viewport scrolled up, the unconverted row lands on a stale
composer still in scrollback — empty, palette-12, rule beneath it — so the region
bounds and classifies `empty`: CLEAN, while the live composer is off-view holding
a draft. Verified against the built old code ({clean: true, detail: 'empty'}) and
the fix (no-composer-marker). baseY + cursorY - top removes it.
Both new tests mutation-checked: revert either fix and the matching test fails.
pnpm test: 276 files / 5479 tests pass, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Thanks — the blocking item was right, and chasing it turned up a second defect in the same file.

Blocking: sanitize.py wrote before checking. Fixed by check-then-write rather than delete-on-failure, for the reason you gave: a guarantee that depends on cleanup running is weaker than one that never creates the file. PATH_RE gains /Users/… — you were right that the committed fixtures are clean and this is about the next capture; the underlying rule is that the prefixes the scrubber rewrites and the prefixes the leak check inspects have to be one list, or the check is narrower than the risk.

Writing the self-check exposed a second defect, in the predicate I had already "fixed" once. Same-length substitution truncates a cwd shorter than the placeholder, so /home/ab/x/home/agent/p, which failed startswith(PLACEHOLDER_PATH) and was reported as a leak — every short-cwd capture would have been unsanitizable. Fail-safe, so it would have surfaced as a mysterious refusal rather than a leak, which is the kind of thing that gets worked around by disabling the check. is_placeholder() now accepts prefixes of the placeholder too, and all seven committed fixtures re-verify leaks=[] under the widened predicate.

Per your note about proving rather than asserting: sanitize.py --selftest stubs out the redaction, pushes a leaking capture through, and stats the output path to confirm no file exists. air-1474-sanitize-ordering.test.ts runs it in CI. The rest of the harness still stays out of CI (it needs an authenticated agy and a PTY), but this part's failure mode is committing someone's username, so it runs. Mutation-checked: restore write-then-check and the test fails with leaking capture left /tmp/…/fixture.txt on disk.

Non-blocking, cursorY vs viewportY: taken, and it was not hygiene. I went to write a defensive test and measured a real false clean. Scroll the viewport up and the unconverted row lands on a stale composer still in scrollback — empty, palette-12, rule beneath it — so the region bounds and classifies empty. CLEAN, while the live composer is off-view holding a half-typed draft. Confirmed against the built old code ({clean: true, detail: "empty"}) and the fix (no-composer-marker). buf.baseY + buf.cursorY - top removes it; comments at both sites updated.

The regression test pins the rigging itself — that the viewport is genuinely off the bottom, that the two conventions disagree, and that the row the old reading picked really was a marker+rule pair — so it fails loudly rather than quietly becoming a tautology if xterm changes. Mutation-checked the same way.

Non-blocking, not done here, and I agree it is a real gap: silent idle-session profile drift. onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm — structurally the same failure as the bare-> bug this PR fixes, which is what makes it worth its own issue rather than a rider on this one. Same for markerFgPalette accepting multiple colors before agy ships a truecolor theme. Both are being filed separately.

pnpm test: 276 files / 5479 tests pass, 0 failed.

waleedkadous
waleedkadous previously approved these changes Sep 4, 2026

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-verified: sanitize_file now finds leaks before any write and refuses with nothing on disk, backed by a self-test that stats the path; /Users/… added to PATH_RE so the scrub set and the leak-check set are one list; and the short-cwd false-positive you found while writing the self-test is exactly the kind of second defect a real check surfaces. CI 7/7 on 8d9d666. Approving and merging.

@waleedkadous
waleedkadous merged commit e132ddb into mainSep 4, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render gate: tighten the loose agy prompt marker (AGY_MARKER = /^> /)

2 participants

@mohidmakhdoomi@waleedkadous
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette - #1491

Merged
waleedkadous merged 14 commits into
mainfrom
builder/air-1474
Sep 4, 2026
Merged

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette#1491
waleedkadous merged 14 commits into
mainfrom
builder/air-1474

Conversation

@mohidmakhdoomi

Copy link
Copy Markdown
Collaborator

Implements #1474.

What was actually wrong

AGY_MARKER = /^> / (gate-profiles.ts:72) treated any line starting with > as agy's
composer prompt, and findMarkerRow is last-match-wins. The issue called this "the weakest link"
in an empirically-derived profile. Measured against a real, authenticated agy 1.1.13 under a
PTY at 110×32, it is worse than a theoretical looseness — non-composer > rows are routine:

real screenlast > row the old marker pickedthat row's fgcursor rowthe actual composer
idle (accept-edits)11palette 121111 ✅
idle (no-hint mode)none — bare > never matched /^> /palette 121111 ❌
draft24palette 122424 ✅
slash menu (/)13 — the menu's selection cursorpalette 121111 ❌
trust dialog8 — the selected optionpalette 1212 (off-row)none ❌
settled after an answer20palette 122020 ✅
torn mid-repaint frame10 — the transcript echo of the sent turnpalette 4variesabsent ❌

Three findings drove the design:

  1. agy echoes every submitted turn into the transcript as a > line (SGR 34;1 → palette 4).
    Non-composer > rows accumulate one per conversation turn.
  2. The slash menu's selection cursor is also > , also palette-12, and renders BELOW the
    composer
    — so it won the marker scan outright. A color anchor alone does not separate
    these; the cursor row does.
  3. In agy's no-hint mode the composer is a bare >, which right-trims to ">" and never
    matched /^> / — so the gate held every message to an agy in that mode forever. A
    pre-existing false-HOLD, fixed here, since the issue's ask is that the marker identify the
    actual composer.

Markdown blockquotes turned out to render as , not > — so the issue's "quoted output" risk
arrives via the turn echo rather than via blockquotes.

Honest scope of the defect. I could not reproduce an actual false-CLEAN from the captures: on
every mis-bounded frame the wrong region still happened to contain counted text, so the verdict
landed busy anyway. What is demonstrated is that the classifier bounds the wrong region on
real screens
, and that getting busy out of a wrong region is luck rather than a guarantee. The
corruption risk is latent, not observed — stated plainly rather than overclaimed.

The change

Two optional, per-app GateProfile anchors — profile data, in keeping with the spike's
constraint 9 — set only on agy, both measured:

  • markerRequiresCursorRow: true — the marker row must hold the buffer cursor. Only the live
    input row does; menu rows, dialog options and transcript echoes never do.
  • markerFgPalette: 12 — the marker glyph's own color, which separates the composer from the
    palette-4 turn echo.

Plus the marker separator relaxed to /^>(\s|$)/ for the bare-> mode. claude/codex set neither
anchor and are byte-for-byte unaffected (pinned by a test).

Every failure direction is toward HOLD: a row that fails an anchor is simply not a marker, so
drift yields no-composer-marker → held and re-checked, never a false clean. Sustained holds
already escalate through the mailbox liveness telemetry (recordStreak).

Verdict changes on the real captures

fixturebeforeafter
agy-idle.cleancleanclean
agy-draft.busybusy / user-textbusy / user-text
agy-menu.busybusy / no-region-end (marker was the menu item — right verdict, wrong reason)busy / user-text (right region: the / typed in the composer)
agy-trust.busybusy / no-region-end (incidental — no rule under the option)busy / no-composer-marker (honest: there is no composer)
agy-torn-echo.busybusy / no-region-end (marker was the palette-4 echo)busy / no-composer-marker
agy-turn-echo.cleancleanclean
agy-baremarker.cleanbusy / no-composer-marker (false HOLD)clean

Cost of the tightening, measured

Sweeping every byte-prefix of a real stream showed 70 CLEAN frames before vs 9 after — alarming
until you notice byte-prefix sampling cuts mid-repaint, which production never does. Sampling
the way production actually classifies (wall-clock, against a live agy driven through boot → idle
→ streaming → settled, classifying the mirror every 200 ms) the two profiles are identical:

idle samples= 30 OLD clean=100% NEW clean=100%
streaming samples= 205 OLD clean= 98% NEW clean= 98%
settled samples= 40 OLD clean=100% NEW clean=100%

No measurable false-HOLD cost, and the bare-> mode goes from permanently held to deliverable.

Fixtures

The issue asked for captured real-agy fixtures across states, and agy is authenticated in this
environment, so the three synthesized Phase-3 fixtures are replaced by seven real captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn frame). agy's banner embeds the
account email and session cwd; both are replaced with same-length placeholders so the rendered
screen stays byte-for-byte equivalent — no attribute is retouched. Each file is 1.7–8.7 KB, so
they are committed plain rather than gzipped (gzip is used for the multi-hundred-KB claude
replays). The torn-frame fixture is real bytes cut mid-repaint, which is the tear shape #1361
documents for the adopt seed.

Tests

55 pass in render-gate.test.ts. New coverage: per-fixture verdicts for all seven states; the
reason for each (a mis-bounded region that returns busy is not the same as a correctly-bounded
one); synthetic branch tests for each anchor independently, including the palette-12 dialog option
with the cursor on it — the one shape the anchors cannot reject, which pins that the occupancy
count still catches it; and a guard that claude/codex are unaffected by cursor position.

porch check 1474: build ✓ (13.1s), tests ✓ (28.7s).

Notes for the reviewer

  • The cursor-row anchor assumes buffer.cursorY is viewport-relative, the same convention
    isGhostCursorCell already relies on. True whenever viewportY === baseY (no manual scrollback),
    which holds on both gate paths.
  • The one shape neither anchor rejects is a dialog that parks the cursor on a palette-12 > option.
    The trust dialog does not (measured), and the occupancy count plus the region-end guard both still
    catch it — but it is the seam to watch if agy adds dialogs.

🤖 Generated with Claude Code

mohidmakhdoomiand others added 5 commits August 17, 2026 19:44
… and marker palette
`AGY_MARKER = /^> /` treated any `> `-prefixed line as agy's composer. Measured against
real agy 1.1.13 under a PTY, that is not a hypothetical looseness: agy echoes every
submitted turn into the transcript as `> <message>` (palette-4), its slash-menu selection
cursor is also `> ` in palette-12 and renders BELOW the composer, and the trust dialog's
selected option is `> Yes, I trust this folder`. Since `findMarkerRow` is last-match-wins,
the menu item and the turn echo won the marker scan over the composer — so the classifier
bounded and scanned the wrong region on ordinary screens.
Adds two optional, per-app `GateProfile` anchors, both set only on agy and both measured:
`markerRequiresCursorRow` (the marker row must hold the cursor — only the live input row
does) and `markerFgPalette` (the marker glyph's own color, 12, which separates the composer
from the palette-4 turn echo). Also relaxes the marker separator to `\s|$`: agy's no-hint
mode renders the empty composer as a bare `>`, which `/^> /` never matched, so the gate
held every message to an agy in that mode forever.
Replaces the three synthesized agy fixtures with seven real, sanitized captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn mid-repaint frame).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er thread
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Test status, stated precisely

porch check 1474: build ✓ (13.5s), unit tests ✓ (28.6s) — 55 pass in render-gate.test.ts.

The e2e_tests gate check is a no-op here. AIR defines it as npm run test:e2e 2>&1 || echo 'e2e tests skipped (not configured)' and the repo root has no test:e2e script, so it reports ✓ in 0.1s having executed nothing. It is marked optional in the protocol, so that is intended — but it is not evidence, so I ran the real suite:

pnpm --filter @cluesmith/codev test:e2e
Test Files 1 failed | 19 passed | 5 skipped (25)
Tests 3 failed | 171 passed | 21 skipped (195)

All 3 failures are in tower-api.e2e.test.tsPOST /api/terminals returning 500 where 201 is expected.

They are pre-existing and unrelated to this PR. Verified rather than asserted: I reverted both changed source files to the branch base (141b493), rebuilt, and re-ran that file — identical 3 failures. Files restored afterwards (all committed, so lossless; working tree verified clean against HEAD, unit tests re-run green).

I did not skip or annotate them as flaky, because they are neither mine nor intermittent — they reproduce consistently. Flagging for a maintainer instead. Plausibly environmental: this machine is running four-plus concurrent builder sessions plus Tower, and all three failures are PTY-spawn-through-the-API.

@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Architect integration review — 3-way CMAP (risk tier: High — render-gate classifier, false-CLEAN direction)

Verdicts: gemini APPROVE · codex APPROVE · claude COMMENT ("merge it, with follow-ups") — all HIGH confidence, zero blocking correctness issues. Architect read concurs: the two positive-evidence anchors (markerRequiresCursorRow, markerFgPalette) fit the existing per-app-profile seam, every failure direction lands on no-composer-marker → HOLD, and claude/codex profiles are untouched. The real-capture measurement table is the load-bearing artifact, and the PR's refusal to overclaim (mis-bounding demonstrated, false-CLEAN latent; no-hint 'held forever' rests on live observation) is noted and appreciated.

Independently verified across the reviews: viewport-relative cursorY is sound on the production gate (viewportY === baseY for SessionScreen); the tightening's failure direction is observable (no-composer-marker feeds isClassifierStuck → escalation at streak 10); fixture sanitization is complete (no emails/paths survive); the bare-> relaxation is gated behind both anchors so it cannot loosen anything — and it fixes a real pre-existing total delivery outage in agy's no-hint mode.

Requested before the gate (one item)

  1. Add an agy case to the production-mirror-path suite (render-gate.test.ts:364). These are the first anchors that depend on cursor state, the one dimension where the transient path and the SessionScreen mirror path could conceivably diverge — a parity test (same fixture, same verdict via both paths) closes the only untested seam between what the tests prove and what production runs.

Follow-ups (this PR or noted for later — builder's call, say which)

  • arch.md:1795 still describes the agy gate as a placeholderFgPalette rule; the definition of "marker present" changed for agy. Two-line touch or explicit MAINTAIN deferral.
  • Commit the capture/sanitization harness (Spec 1313 precedent: codev/spir-1313-captures/*.mjs) so re-measuring against a future agy doesn't start from the PR description.
  • Cosmetic: required-fixtures test title still says "idle/draft/trust" while requiring seven.
  • Keep the dual 4-bit/256-color palette-12 matching — it is deliberate, not redundant (real fixtures use [94m, synthetic [38;5;12m).

For the maintainer

Parked for maintainer approval + merge; we are not maintainers.

…low-ups
CMAP requested item: the agy anchors are the first classifier input that depends on
CURSOR STATE, and the cursor is the one dimension where the transient replay path and the
long-lived SessionScreen mirror could diverge. Adds a per-fixture parity case to the
production-mirror-path suite — same bytes through both paths, same verdict — fed twice,
once in production-sized chunks and once in 7-byte chunks that deliberately split the
cursor-positioning CSI across feed() calls, since a mis-parsed cursor is now a verdict
change rather than a cosmetic one.
Follow-ups taken in-PR rather than deferred:
- arch.md: "marker present" now means more than a text match for agy — records the two
anchors and their fail-toward-HOLD direction.
- Commits the capture + sanitization harness (codev/air-1474-captures/) so re-measuring
against a future agy starts from a script, not the PR description. Verified by
re-deriving four committed fixtures byte-identically from the raw captures.
- Fixes the sanitizer's leak check, which reported `/home/` on every run because the
placeholder path itself starts with it — a check that always fires checks nothing. Now
matches paths that are not the placeholder, and refuses to write on a leak.
- Cosmetic: required-fixtures test title listed three agy states while requiring seven.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

CMAP items addressed — all four taken in-PR, none deferred

Requested before the gate ✅

agy case in the production-mirror-path suite (render-gate.test.ts). Agreed on the reasoning: these anchors are the first classifier input that depends on cursor state, so the transient-vs-mirror seam is newly load-bearing. Added a parity case per agy fixture (7 of them) rather than one representative — same bytes through both paths, toEqual on the whole verdict, plus an assertion that the transient path's own verdict matches the filename, so the parity check can't pass by both paths being wrong together.

Each fixture is fed twice: once in production-sized 64 KiB chunks, and once in 7-byte chunks that deliberately split escape sequences across feed() calls. That second pass is the one with teeth here — the cursor-positioning CSI (ESC[<row>;<col>H, and the relative ESC[2A/ESC[2C agy actually emits) is exactly what a torn chunk boundary would corrupt, and a mis-parsed cursor is now a verdict change rather than a cosmetic one. Both pass. 62 tests in the file; full package suite green (4876 passed / 48 skipped).

Follow-ups — taken, not deferred

  • arch.md ✅ — the agy entry now records that "marker present" is more than a text match, names both anchors, and states the fail-toward-no-composer-marker → HOLD direction.
  • Capture/sanitization harness ✅ — committed at codev/air-1474-captures/ (capture script, sanitizer, README covering auth prerequisite, per-state recipes, the 110×32 geometry constraint, and how the torn fixture is derived). Verified rather than assumed: re-ran the committed sanitizer against the raw captures and re-derived four committed fixtures byte-identically.
  • Cosmetic test title ✅ — now lists all seven agy states.
  • Dual 4-bit/256-color palette matching — kept as-is, per your note that it is deliberate.

One thing the harness work turned up

The sanitizer's leak check was self-defeating: it tested for the bare prefixes /home/ and /tmp/, but the replacement path is /home/agent/project, so it reported a leak on every successful run — including on the fixtures already in this PR. A check that always fires checks nothing, and I'd rather not ship that as the documented safety step. It now matches paths that are not the placeholder, and refuses to write the fixture if anything survives. Re-verified against all four captures: leaks=[], output byte-identical.

To be clear about what this did and didn't mean: the committed fixtures were always clean — I verified that independently with a direct grep before the first push, and the byte-identical re-derivation confirms it again. The defect was in the check, not in the fixtures.

Ready for the pr gate.

@waleedkadous

Copy link
Copy Markdown
Contributor

Thanks for the patience on this one — the review queue is moving now. This PR is showing as CONFLICTING against main after this week's merges in the same area (serializer convergence #1492, delivery verification #1573/#1584, self-attesting frames #1574, and the recipient/reply-hint changes to message-format.ts and tower-routes.ts). Could you rebase onto current main when you get a chance? I'll review the moment it's green — the four non-conflicting PRs in your list are in review right now.

mohidmakhdoomiand others added 2 commits September 3, 2026 14:19
Resolves the one conflict in render-gate.ts: main added the exported
`bufferLines()` helper (#1573 echo verification) directly above
`findMarkerRow`, whose doc and signature this branch rewrote for the
cursor-row/palette anchors. Both are kept — they are adjacent, not
competing.
Typecheck clean; render-gate suite 66/66.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alarm
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Brought up to current main — conflicts resolved, CI green, architect integration check

This branch had gone CONFLICTING (770 commits behind). It is now MERGEABLE at 470d49b2d, with all 7 checks passing, via a merge of origin/main (818337fa2) — not a rebase, per this repo's merge-never-squash convention. (mergeStateStatus: BLOCKED is now only the missing maintainer approval; no conflict, no failing check.)

The conflict was adjacency, not disagreement

Exactly one file conflicted: packages/codev/src/agent-farm/servers/render-gate.ts. The sole commit to touch it since the merge-base was #1573 (35f2e637f), whose change there is purely additive — one new exported bufferLines(term), inserted directly above findMarkerRow, whose doc comment and signature this branch had rewritten for the cursor-row/palette anchors. Both sides kept verbatim.

The two functions now sit adjacent and are worth keeping straight:

Architect verification (independent of the builder's report)

Post-merge suite: 275 files passed / 3 skipped / 0 failed; 5477 tests passed, typecheck clean. The render-gate suite is 66 cases now rather than 55 — main's own additions to that file merged in cleanly alongside these.

Two notes for whoever merges

  1. Merge-order: PR Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 (issue Render gate: replace the perf wall-clock assertion with a deterministic op-count check #1471) also touches render-gate.test.ts, in a different region. Whichever of Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 / [Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette #1491 merges second may need a trivial rebase.
  2. Resuming any long-parked branch in this repo: the first post-merge pnpm test here reported 14 failures in request-auth.test.ts / tower-routes.test.ts (WS_KEY_PROTOCOL_PREFIX, TOWER_KEY_HEADER arriving undefined). All falsepackages/types/dist predated the constants main had since added, and node_modules predated a new three dependency. pnpm install && pnpm build first, then believe a test result.

Status

Parked for maintainer review — we are not maintainers of this repo, so we are not merging this. Please approve and merge when it suits you. Issue #1474 stays open and the worktree stays intact until you do.

mohidmakhdoomi added a commit that referenced this pull request Sep 3, 2026
Retrospective at codev/reviews/1482-f1-tower-vs-pty-dimension-dive.md, plus
the governance routing it describes.
The Summary leads with the user-visible harm rather than the mechanism: the
owner starvation notice offered `afx interrupt` for EVERY hold, and for a
`user-text` hold that is advice to interrupt a person mid-draft. The dims fix
and the detail column are how that becomes possible to tell apart, not the
point in themselves.
Both gaps the human approved knowingly are stated in the review rather than
left for a reviewer to find: the dashboard popover still renders a bare `busy`
(reverted deliberately — no worktree.devCommand, no Playwright here, and the
project requires a browser check), and evidence item 3 is covered by test
rather than induced live because send.ts constructs `new TowerClient()` with
no port and so reaches only the live Tower on 4100.
Also called out for the maintainer: the beyond-plan 409 RESIZE_DROPPED route
change and CronDeliveryResult gaining `detail`; the v18-vs-v17 migration
collision surface (in two places — the constant and the pinned source
assertion that reads it back); and the conflict surface with the two parked
PRs, #1486 (commands/inbox.ts, commands/send.ts, tower-routes.ts) and #1491
(render-gate.ts, render-gate.test.ts).
**Governance routed COLD only.** Both hot files sit exactly at their caps
(10 facts / 10 lessons) and nothing here justified displacing an existing
entry; the hot map already routes readers to "Invariants & Constraints", so
the tiering works without growing the always-injected tier.
- arch.md: new invariant #10, "Terminal dimensions must be earned, not
assumed" (requested/applied/outstanding, 409 vs 404, WELCOME adoption, and
why the gate depends on it); and the Spec 1313 mailbox section's response
vocabulary extended with the v18 `detail` column and why it carries no CHECK
constraint.
- lessons-learned.md: three Architecture entries (a failure boolean is only as
honest as its callers; two-state models fail at the third state — the
missing bit was OUTSTANDING, not DIFFERENT; commit derived state only after
what it describes is confirmed) and three Process entries (read the ignore
rule's own reason before `git add -f`; report the boundary of a gap, not the
instance you tripped over; mutation-check a test written to close someone
else's finding).
Full suite green on this tree: 278 files passed / 3 skipped, 5495 tests passed
/ 48 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Really thorough work — seven real agy captures, per-fixture mirror-vs-transient parity tests (including the 7-byte chunking that splits cursor CSIs), and a failure direction that stays HOLD rather than deliver. The render-gate change itself is approve-quality and claude's lane approved it outright; the one thing I'd like fixed before merge is in the capture tooling, because it's a leak-safety guarantee that currently isn't one:

  1. codev/air-1474-captures/sanitize.py writes the output before running its leak check (the open(dst, …).write(data) at ~line 137 precedes the # Leak check block). If identifiers survive, it raises only after the unsafe fixture is already on disk — contradicting its own "REFUSING to sanitize" message and creating an accidental-commit risk. Run every check before writing, or delete dst on failure. Relatedly, its path regex misses /Users/…, so a macOS capture would pass the check while leaking a username (the committed fixtures are clean; this is about the next capture).

Two small things you might consider alongside, neither blocking:

  • buf.cursorY is baseY-relative in xterm's public contract; the code relies on viewportY === baseY. buf.baseY + buf.cursorY - buf.viewportY removes that assumption for free, and it's now load-bearing for delivery.
  • Idle-session drift is silent — onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm, the same shape as the bare-> bug this fixes. A follow-up on profile-drift observability (classifier-stuck escalation for idle sessions, or an afx doctor check that classifies each live mirror once) would close that.

Also noting markerFgPalette will want to accept multiple colors before agy ships a truecolor theme — follow-up territory.

…hrough baseY
Addresses the PR #1491 maintainer review.
BLOCKING — sanitize.py wrote the output file before checking it for leaks, so a
capture that still carried an identifier landed on disk and the "REFUSING to
sanitize" message was false by the time it printed. Now every check runs before
anything reaches the filesystem, chosen over delete-on-failure: a guarantee that
depends on cleanup running is weaker than one that never creates the file.
PATH_RE also gains /Users/, without which a macOS capture sanitizes cleanly while
leaking a username — the prefixes the scrubber rewrites and the prefixes the leak
check inspects have to be one list.
Writing the --selftest exposed a second defect in the predicate: same-length
substitution truncates a cwd shorter than the placeholder (/home/ab/x becomes
/home/agent/p), which failed startswith() and read as a leak, making every
short-cwd capture unsanitizable. is_placeholder() now accepts prefixes of the
placeholder. All seven committed fixtures re-verified clean under it.
The selftest runs the refusal path for real — it stubs out the redaction, pushes
a leaking capture through, and stats the output path — and CI runs the selftest
(air-1474-sanitize-ordering.test.ts, skipped where python3 is absent).
NON-BLOCKING — classifyBuffer read buf.cursorY alongside top = buf.viewportY,
assuming viewportY === baseY. Expected to be hygiene; measured as a real false
clean. With the viewport scrolled up, the unconverted row lands on a stale
composer still in scrollback — empty, palette-12, rule beneath it — so the region
bounds and classifies `empty`: CLEAN, while the live composer is off-view holding
a draft. Verified against the built old code ({clean: true, detail: 'empty'}) and
the fix (no-composer-marker). baseY + cursorY - top removes it.
Both new tests mutation-checked: revert either fix and the matching test fails.
pnpm test: 276 files / 5479 tests pass, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Thanks — the blocking item was right, and chasing it turned up a second defect in the same file.

Blocking: sanitize.py wrote before checking. Fixed by check-then-write rather than delete-on-failure, for the reason you gave: a guarantee that depends on cleanup running is weaker than one that never creates the file. PATH_RE gains /Users/… — you were right that the committed fixtures are clean and this is about the next capture; the underlying rule is that the prefixes the scrubber rewrites and the prefixes the leak check inspects have to be one list, or the check is narrower than the risk.

Writing the self-check exposed a second defect, in the predicate I had already "fixed" once. Same-length substitution truncates a cwd shorter than the placeholder, so /home/ab/x/home/agent/p, which failed startswith(PLACEHOLDER_PATH) and was reported as a leak — every short-cwd capture would have been unsanitizable. Fail-safe, so it would have surfaced as a mysterious refusal rather than a leak, which is the kind of thing that gets worked around by disabling the check. is_placeholder() now accepts prefixes of the placeholder too, and all seven committed fixtures re-verify leaks=[] under the widened predicate.

Per your note about proving rather than asserting: sanitize.py --selftest stubs out the redaction, pushes a leaking capture through, and stats the output path to confirm no file exists. air-1474-sanitize-ordering.test.ts runs it in CI. The rest of the harness still stays out of CI (it needs an authenticated agy and a PTY), but this part's failure mode is committing someone's username, so it runs. Mutation-checked: restore write-then-check and the test fails with leaking capture left /tmp/…/fixture.txt on disk.

Non-blocking, cursorY vs viewportY: taken, and it was not hygiene. I went to write a defensive test and measured a real false clean. Scroll the viewport up and the unconverted row lands on a stale composer still in scrollback — empty, palette-12, rule beneath it — so the region bounds and classifies empty. CLEAN, while the live composer is off-view holding a half-typed draft. Confirmed against the built old code ({clean: true, detail: "empty"}) and the fix (no-composer-marker). buf.baseY + buf.cursorY - top removes it; comments at both sites updated.

The regression test pins the rigging itself — that the viewport is genuinely off the bottom, that the two conventions disagree, and that the row the old reading picked really was a marker+rule pair — so it fails loudly rather than quietly becoming a tautology if xterm changes. Mutation-checked the same way.

Non-blocking, not done here, and I agree it is a real gap: silent idle-session profile drift. onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm — structurally the same failure as the bare-> bug this PR fixes, which is what makes it worth its own issue rather than a rider on this one. Same for markerFgPalette accepting multiple colors before agy ships a truecolor theme. Both are being filed separately.

pnpm test: 276 files / 5479 tests pass, 0 failed.

waleedkadous
waleedkadous previously approved these changes Sep 4, 2026

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-verified: sanitize_file now finds leaks before any write and refuses with nothing on disk, backed by a self-test that stats the path; /Users/… added to PATH_RE so the scrub set and the leak-check set are one list; and the short-cwd false-positive you found while writing the self-test is exactly the kind of second defect a real check surfaces. CI 7/7 on 8d9d666. Approving and merging.

@waleedkadous
waleedkadous merged commit e132ddb into mainSep 4, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render gate: tighten the loose agy prompt marker (AGY_MARKER = /^> /)

2 participants

@mohidmakhdoomi@waleedkadous
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette - #1491

Merged
waleedkadous merged 14 commits into
mainfrom
builder/air-1474
Sep 4, 2026
Merged

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette#1491
waleedkadous merged 14 commits into
mainfrom
builder/air-1474

Conversation

@mohidmakhdoomi

Copy link
Copy Markdown
Collaborator

Implements #1474.

What was actually wrong

AGY_MARKER = /^> / (gate-profiles.ts:72) treated any line starting with > as agy's
composer prompt, and findMarkerRow is last-match-wins. The issue called this "the weakest link"
in an empirically-derived profile. Measured against a real, authenticated agy 1.1.13 under a
PTY at 110×32, it is worse than a theoretical looseness — non-composer > rows are routine:

real screenlast > row the old marker pickedthat row's fgcursor rowthe actual composer
idle (accept-edits)11palette 121111 ✅
idle (no-hint mode)none — bare > never matched /^> /palette 121111 ❌
draft24palette 122424 ✅
slash menu (/)13 — the menu's selection cursorpalette 121111 ❌
trust dialog8 — the selected optionpalette 1212 (off-row)none ❌
settled after an answer20palette 122020 ✅
torn mid-repaint frame10 — the transcript echo of the sent turnpalette 4variesabsent ❌

Three findings drove the design:

  1. agy echoes every submitted turn into the transcript as a > line (SGR 34;1 → palette 4).
    Non-composer > rows accumulate one per conversation turn.
  2. The slash menu's selection cursor is also > , also palette-12, and renders BELOW the
    composer
    — so it won the marker scan outright. A color anchor alone does not separate
    these; the cursor row does.
  3. In agy's no-hint mode the composer is a bare >, which right-trims to ">" and never
    matched /^> / — so the gate held every message to an agy in that mode forever. A
    pre-existing false-HOLD, fixed here, since the issue's ask is that the marker identify the
    actual composer.

Markdown blockquotes turned out to render as , not > — so the issue's "quoted output" risk
arrives via the turn echo rather than via blockquotes.

Honest scope of the defect. I could not reproduce an actual false-CLEAN from the captures: on
every mis-bounded frame the wrong region still happened to contain counted text, so the verdict
landed busy anyway. What is demonstrated is that the classifier bounds the wrong region on
real screens
, and that getting busy out of a wrong region is luck rather than a guarantee. The
corruption risk is latent, not observed — stated plainly rather than overclaimed.

The change

Two optional, per-app GateProfile anchors — profile data, in keeping with the spike's
constraint 9 — set only on agy, both measured:

  • markerRequiresCursorRow: true — the marker row must hold the buffer cursor. Only the live
    input row does; menu rows, dialog options and transcript echoes never do.
  • markerFgPalette: 12 — the marker glyph's own color, which separates the composer from the
    palette-4 turn echo.

Plus the marker separator relaxed to /^>(\s|$)/ for the bare-> mode. claude/codex set neither
anchor and are byte-for-byte unaffected (pinned by a test).

Every failure direction is toward HOLD: a row that fails an anchor is simply not a marker, so
drift yields no-composer-marker → held and re-checked, never a false clean. Sustained holds
already escalate through the mailbox liveness telemetry (recordStreak).

Verdict changes on the real captures

fixturebeforeafter
agy-idle.cleancleanclean
agy-draft.busybusy / user-textbusy / user-text
agy-menu.busybusy / no-region-end (marker was the menu item — right verdict, wrong reason)busy / user-text (right region: the / typed in the composer)
agy-trust.busybusy / no-region-end (incidental — no rule under the option)busy / no-composer-marker (honest: there is no composer)
agy-torn-echo.busybusy / no-region-end (marker was the palette-4 echo)busy / no-composer-marker
agy-turn-echo.cleancleanclean
agy-baremarker.cleanbusy / no-composer-marker (false HOLD)clean

Cost of the tightening, measured

Sweeping every byte-prefix of a real stream showed 70 CLEAN frames before vs 9 after — alarming
until you notice byte-prefix sampling cuts mid-repaint, which production never does. Sampling
the way production actually classifies (wall-clock, against a live agy driven through boot → idle
→ streaming → settled, classifying the mirror every 200 ms) the two profiles are identical:

idle samples= 30 OLD clean=100% NEW clean=100%
streaming samples= 205 OLD clean= 98% NEW clean= 98%
settled samples= 40 OLD clean=100% NEW clean=100%

No measurable false-HOLD cost, and the bare-> mode goes from permanently held to deliverable.

Fixtures

The issue asked for captured real-agy fixtures across states, and agy is authenticated in this
environment, so the three synthesized Phase-3 fixtures are replaced by seven real captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn frame). agy's banner embeds the
account email and session cwd; both are replaced with same-length placeholders so the rendered
screen stays byte-for-byte equivalent — no attribute is retouched. Each file is 1.7–8.7 KB, so
they are committed plain rather than gzipped (gzip is used for the multi-hundred-KB claude
replays). The torn-frame fixture is real bytes cut mid-repaint, which is the tear shape #1361
documents for the adopt seed.

Tests

55 pass in render-gate.test.ts. New coverage: per-fixture verdicts for all seven states; the
reason for each (a mis-bounded region that returns busy is not the same as a correctly-bounded
one); synthetic branch tests for each anchor independently, including the palette-12 dialog option
with the cursor on it — the one shape the anchors cannot reject, which pins that the occupancy
count still catches it; and a guard that claude/codex are unaffected by cursor position.

porch check 1474: build ✓ (13.1s), tests ✓ (28.7s).

Notes for the reviewer

  • The cursor-row anchor assumes buffer.cursorY is viewport-relative, the same convention
    isGhostCursorCell already relies on. True whenever viewportY === baseY (no manual scrollback),
    which holds on both gate paths.
  • The one shape neither anchor rejects is a dialog that parks the cursor on a palette-12 > option.
    The trust dialog does not (measured), and the occupancy count plus the region-end guard both still
    catch it — but it is the seam to watch if agy adds dialogs.

🤖 Generated with Claude Code

mohidmakhdoomiand others added 5 commits August 17, 2026 19:44
… and marker palette
`AGY_MARKER = /^> /` treated any `> `-prefixed line as agy's composer. Measured against
real agy 1.1.13 under a PTY, that is not a hypothetical looseness: agy echoes every
submitted turn into the transcript as `> <message>` (palette-4), its slash-menu selection
cursor is also `> ` in palette-12 and renders BELOW the composer, and the trust dialog's
selected option is `> Yes, I trust this folder`. Since `findMarkerRow` is last-match-wins,
the menu item and the turn echo won the marker scan over the composer — so the classifier
bounded and scanned the wrong region on ordinary screens.
Adds two optional, per-app `GateProfile` anchors, both set only on agy and both measured:
`markerRequiresCursorRow` (the marker row must hold the cursor — only the live input row
does) and `markerFgPalette` (the marker glyph's own color, 12, which separates the composer
from the palette-4 turn echo). Also relaxes the marker separator to `\s|$`: agy's no-hint
mode renders the empty composer as a bare `>`, which `/^> /` never matched, so the gate
held every message to an agy in that mode forever.
Replaces the three synthesized agy fixtures with seven real, sanitized captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn mid-repaint frame).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er thread
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Test status, stated precisely

porch check 1474: build ✓ (13.5s), unit tests ✓ (28.6s) — 55 pass in render-gate.test.ts.

The e2e_tests gate check is a no-op here. AIR defines it as npm run test:e2e 2>&1 || echo 'e2e tests skipped (not configured)' and the repo root has no test:e2e script, so it reports ✓ in 0.1s having executed nothing. It is marked optional in the protocol, so that is intended — but it is not evidence, so I ran the real suite:

pnpm --filter @cluesmith/codev test:e2e
Test Files 1 failed | 19 passed | 5 skipped (25)
Tests 3 failed | 171 passed | 21 skipped (195)

All 3 failures are in tower-api.e2e.test.tsPOST /api/terminals returning 500 where 201 is expected.

They are pre-existing and unrelated to this PR. Verified rather than asserted: I reverted both changed source files to the branch base (141b493), rebuilt, and re-ran that file — identical 3 failures. Files restored afterwards (all committed, so lossless; working tree verified clean against HEAD, unit tests re-run green).

I did not skip or annotate them as flaky, because they are neither mine nor intermittent — they reproduce consistently. Flagging for a maintainer instead. Plausibly environmental: this machine is running four-plus concurrent builder sessions plus Tower, and all three failures are PTY-spawn-through-the-API.

@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Architect integration review — 3-way CMAP (risk tier: High — render-gate classifier, false-CLEAN direction)

Verdicts: gemini APPROVE · codex APPROVE · claude COMMENT ("merge it, with follow-ups") — all HIGH confidence, zero blocking correctness issues. Architect read concurs: the two positive-evidence anchors (markerRequiresCursorRow, markerFgPalette) fit the existing per-app-profile seam, every failure direction lands on no-composer-marker → HOLD, and claude/codex profiles are untouched. The real-capture measurement table is the load-bearing artifact, and the PR's refusal to overclaim (mis-bounding demonstrated, false-CLEAN latent; no-hint 'held forever' rests on live observation) is noted and appreciated.

Independently verified across the reviews: viewport-relative cursorY is sound on the production gate (viewportY === baseY for SessionScreen); the tightening's failure direction is observable (no-composer-marker feeds isClassifierStuck → escalation at streak 10); fixture sanitization is complete (no emails/paths survive); the bare-> relaxation is gated behind both anchors so it cannot loosen anything — and it fixes a real pre-existing total delivery outage in agy's no-hint mode.

Requested before the gate (one item)

  1. Add an agy case to the production-mirror-path suite (render-gate.test.ts:364). These are the first anchors that depend on cursor state, the one dimension where the transient path and the SessionScreen mirror path could conceivably diverge — a parity test (same fixture, same verdict via both paths) closes the only untested seam between what the tests prove and what production runs.

Follow-ups (this PR or noted for later — builder's call, say which)

  • arch.md:1795 still describes the agy gate as a placeholderFgPalette rule; the definition of "marker present" changed for agy. Two-line touch or explicit MAINTAIN deferral.
  • Commit the capture/sanitization harness (Spec 1313 precedent: codev/spir-1313-captures/*.mjs) so re-measuring against a future agy doesn't start from the PR description.
  • Cosmetic: required-fixtures test title still says "idle/draft/trust" while requiring seven.
  • Keep the dual 4-bit/256-color palette-12 matching — it is deliberate, not redundant (real fixtures use [94m, synthetic [38;5;12m).

For the maintainer

Parked for maintainer approval + merge; we are not maintainers.

…low-ups
CMAP requested item: the agy anchors are the first classifier input that depends on
CURSOR STATE, and the cursor is the one dimension where the transient replay path and the
long-lived SessionScreen mirror could diverge. Adds a per-fixture parity case to the
production-mirror-path suite — same bytes through both paths, same verdict — fed twice,
once in production-sized chunks and once in 7-byte chunks that deliberately split the
cursor-positioning CSI across feed() calls, since a mis-parsed cursor is now a verdict
change rather than a cosmetic one.
Follow-ups taken in-PR rather than deferred:
- arch.md: "marker present" now means more than a text match for agy — records the two
anchors and their fail-toward-HOLD direction.
- Commits the capture + sanitization harness (codev/air-1474-captures/) so re-measuring
against a future agy starts from a script, not the PR description. Verified by
re-deriving four committed fixtures byte-identically from the raw captures.
- Fixes the sanitizer's leak check, which reported `/home/` on every run because the
placeholder path itself starts with it — a check that always fires checks nothing. Now
matches paths that are not the placeholder, and refuses to write on a leak.
- Cosmetic: required-fixtures test title listed three agy states while requiring seven.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

CMAP items addressed — all four taken in-PR, none deferred

Requested before the gate ✅

agy case in the production-mirror-path suite (render-gate.test.ts). Agreed on the reasoning: these anchors are the first classifier input that depends on cursor state, so the transient-vs-mirror seam is newly load-bearing. Added a parity case per agy fixture (7 of them) rather than one representative — same bytes through both paths, toEqual on the whole verdict, plus an assertion that the transient path's own verdict matches the filename, so the parity check can't pass by both paths being wrong together.

Each fixture is fed twice: once in production-sized 64 KiB chunks, and once in 7-byte chunks that deliberately split escape sequences across feed() calls. That second pass is the one with teeth here — the cursor-positioning CSI (ESC[<row>;<col>H, and the relative ESC[2A/ESC[2C agy actually emits) is exactly what a torn chunk boundary would corrupt, and a mis-parsed cursor is now a verdict change rather than a cosmetic one. Both pass. 62 tests in the file; full package suite green (4876 passed / 48 skipped).

Follow-ups — taken, not deferred

  • arch.md ✅ — the agy entry now records that "marker present" is more than a text match, names both anchors, and states the fail-toward-no-composer-marker → HOLD direction.
  • Capture/sanitization harness ✅ — committed at codev/air-1474-captures/ (capture script, sanitizer, README covering auth prerequisite, per-state recipes, the 110×32 geometry constraint, and how the torn fixture is derived). Verified rather than assumed: re-ran the committed sanitizer against the raw captures and re-derived four committed fixtures byte-identically.
  • Cosmetic test title ✅ — now lists all seven agy states.
  • Dual 4-bit/256-color palette matching — kept as-is, per your note that it is deliberate.

One thing the harness work turned up

The sanitizer's leak check was self-defeating: it tested for the bare prefixes /home/ and /tmp/, but the replacement path is /home/agent/project, so it reported a leak on every successful run — including on the fixtures already in this PR. A check that always fires checks nothing, and I'd rather not ship that as the documented safety step. It now matches paths that are not the placeholder, and refuses to write the fixture if anything survives. Re-verified against all four captures: leaks=[], output byte-identical.

To be clear about what this did and didn't mean: the committed fixtures were always clean — I verified that independently with a direct grep before the first push, and the byte-identical re-derivation confirms it again. The defect was in the check, not in the fixtures.

Ready for the pr gate.

@waleedkadous

Copy link
Copy Markdown
Contributor

Thanks for the patience on this one — the review queue is moving now. This PR is showing as CONFLICTING against main after this week's merges in the same area (serializer convergence #1492, delivery verification #1573/#1584, self-attesting frames #1574, and the recipient/reply-hint changes to message-format.ts and tower-routes.ts). Could you rebase onto current main when you get a chance? I'll review the moment it's green — the four non-conflicting PRs in your list are in review right now.

mohidmakhdoomiand others added 2 commits September 3, 2026 14:19
Resolves the one conflict in render-gate.ts: main added the exported
`bufferLines()` helper (#1573 echo verification) directly above
`findMarkerRow`, whose doc and signature this branch rewrote for the
cursor-row/palette anchors. Both are kept — they are adjacent, not
competing.
Typecheck clean; render-gate suite 66/66.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alarm
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Brought up to current main — conflicts resolved, CI green, architect integration check

This branch had gone CONFLICTING (770 commits behind). It is now MERGEABLE at 470d49b2d, with all 7 checks passing, via a merge of origin/main (818337fa2) — not a rebase, per this repo's merge-never-squash convention. (mergeStateStatus: BLOCKED is now only the missing maintainer approval; no conflict, no failing check.)

The conflict was adjacency, not disagreement

Exactly one file conflicted: packages/codev/src/agent-farm/servers/render-gate.ts. The sole commit to touch it since the merge-base was #1573 (35f2e637f), whose change there is purely additive — one new exported bufferLines(term), inserted directly above findMarkerRow, whose doc comment and signature this branch had rewritten for the cursor-row/palette anchors. Both sides kept verbatim.

The two functions now sit adjacent and are worth keeping straight:

Architect verification (independent of the builder's report)

Post-merge suite: 275 files passed / 3 skipped / 0 failed; 5477 tests passed, typecheck clean. The render-gate suite is 66 cases now rather than 55 — main's own additions to that file merged in cleanly alongside these.

Two notes for whoever merges

  1. Merge-order: PR Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 (issue Render gate: replace the perf wall-clock assertion with a deterministic op-count check #1471) also touches render-gate.test.ts, in a different region. Whichever of Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 / [Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette #1491 merges second may need a trivial rebase.
  2. Resuming any long-parked branch in this repo: the first post-merge pnpm test here reported 14 failures in request-auth.test.ts / tower-routes.test.ts (WS_KEY_PROTOCOL_PREFIX, TOWER_KEY_HEADER arriving undefined). All falsepackages/types/dist predated the constants main had since added, and node_modules predated a new three dependency. pnpm install && pnpm build first, then believe a test result.

Status

Parked for maintainer review — we are not maintainers of this repo, so we are not merging this. Please approve and merge when it suits you. Issue #1474 stays open and the worktree stays intact until you do.

mohidmakhdoomi added a commit that referenced this pull request Sep 3, 2026
Retrospective at codev/reviews/1482-f1-tower-vs-pty-dimension-dive.md, plus
the governance routing it describes.
The Summary leads with the user-visible harm rather than the mechanism: the
owner starvation notice offered `afx interrupt` for EVERY hold, and for a
`user-text` hold that is advice to interrupt a person mid-draft. The dims fix
and the detail column are how that becomes possible to tell apart, not the
point in themselves.
Both gaps the human approved knowingly are stated in the review rather than
left for a reviewer to find: the dashboard popover still renders a bare `busy`
(reverted deliberately — no worktree.devCommand, no Playwright here, and the
project requires a browser check), and evidence item 3 is covered by test
rather than induced live because send.ts constructs `new TowerClient()` with
no port and so reaches only the live Tower on 4100.
Also called out for the maintainer: the beyond-plan 409 RESIZE_DROPPED route
change and CronDeliveryResult gaining `detail`; the v18-vs-v17 migration
collision surface (in two places — the constant and the pinned source
assertion that reads it back); and the conflict surface with the two parked
PRs, #1486 (commands/inbox.ts, commands/send.ts, tower-routes.ts) and #1491
(render-gate.ts, render-gate.test.ts).
**Governance routed COLD only.** Both hot files sit exactly at their caps
(10 facts / 10 lessons) and nothing here justified displacing an existing
entry; the hot map already routes readers to "Invariants & Constraints", so
the tiering works without growing the always-injected tier.
- arch.md: new invariant #10, "Terminal dimensions must be earned, not
assumed" (requested/applied/outstanding, 409 vs 404, WELCOME adoption, and
why the gate depends on it); and the Spec 1313 mailbox section's response
vocabulary extended with the v18 `detail` column and why it carries no CHECK
constraint.
- lessons-learned.md: three Architecture entries (a failure boolean is only as
honest as its callers; two-state models fail at the third state — the
missing bit was OUTSTANDING, not DIFFERENT; commit derived state only after
what it describes is confirmed) and three Process entries (read the ignore
rule's own reason before `git add -f`; report the boundary of a gap, not the
instance you tripped over; mutation-check a test written to close someone
else's finding).
Full suite green on this tree: 278 files passed / 3 skipped, 5495 tests passed
/ 48 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Really thorough work — seven real agy captures, per-fixture mirror-vs-transient parity tests (including the 7-byte chunking that splits cursor CSIs), and a failure direction that stays HOLD rather than deliver. The render-gate change itself is approve-quality and claude's lane approved it outright; the one thing I'd like fixed before merge is in the capture tooling, because it's a leak-safety guarantee that currently isn't one:

  1. codev/air-1474-captures/sanitize.py writes the output before running its leak check (the open(dst, …).write(data) at ~line 137 precedes the # Leak check block). If identifiers survive, it raises only after the unsafe fixture is already on disk — contradicting its own "REFUSING to sanitize" message and creating an accidental-commit risk. Run every check before writing, or delete dst on failure. Relatedly, its path regex misses /Users/…, so a macOS capture would pass the check while leaking a username (the committed fixtures are clean; this is about the next capture).

Two small things you might consider alongside, neither blocking:

  • buf.cursorY is baseY-relative in xterm's public contract; the code relies on viewportY === baseY. buf.baseY + buf.cursorY - buf.viewportY removes that assumption for free, and it's now load-bearing for delivery.
  • Idle-session drift is silent — onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm, the same shape as the bare-> bug this fixes. A follow-up on profile-drift observability (classifier-stuck escalation for idle sessions, or an afx doctor check that classifies each live mirror once) would close that.

Also noting markerFgPalette will want to accept multiple colors before agy ships a truecolor theme — follow-up territory.

…hrough baseY
Addresses the PR #1491 maintainer review.
BLOCKING — sanitize.py wrote the output file before checking it for leaks, so a
capture that still carried an identifier landed on disk and the "REFUSING to
sanitize" message was false by the time it printed. Now every check runs before
anything reaches the filesystem, chosen over delete-on-failure: a guarantee that
depends on cleanup running is weaker than one that never creates the file.
PATH_RE also gains /Users/, without which a macOS capture sanitizes cleanly while
leaking a username — the prefixes the scrubber rewrites and the prefixes the leak
check inspects have to be one list.
Writing the --selftest exposed a second defect in the predicate: same-length
substitution truncates a cwd shorter than the placeholder (/home/ab/x becomes
/home/agent/p), which failed startswith() and read as a leak, making every
short-cwd capture unsanitizable. is_placeholder() now accepts prefixes of the
placeholder. All seven committed fixtures re-verified clean under it.
The selftest runs the refusal path for real — it stubs out the redaction, pushes
a leaking capture through, and stats the output path — and CI runs the selftest
(air-1474-sanitize-ordering.test.ts, skipped where python3 is absent).
NON-BLOCKING — classifyBuffer read buf.cursorY alongside top = buf.viewportY,
assuming viewportY === baseY. Expected to be hygiene; measured as a real false
clean. With the viewport scrolled up, the unconverted row lands on a stale
composer still in scrollback — empty, palette-12, rule beneath it — so the region
bounds and classifies `empty`: CLEAN, while the live composer is off-view holding
a draft. Verified against the built old code ({clean: true, detail: 'empty'}) and
the fix (no-composer-marker). baseY + cursorY - top removes it.
Both new tests mutation-checked: revert either fix and the matching test fails.
pnpm test: 276 files / 5479 tests pass, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Thanks — the blocking item was right, and chasing it turned up a second defect in the same file.

Blocking: sanitize.py wrote before checking. Fixed by check-then-write rather than delete-on-failure, for the reason you gave: a guarantee that depends on cleanup running is weaker than one that never creates the file. PATH_RE gains /Users/… — you were right that the committed fixtures are clean and this is about the next capture; the underlying rule is that the prefixes the scrubber rewrites and the prefixes the leak check inspects have to be one list, or the check is narrower than the risk.

Writing the self-check exposed a second defect, in the predicate I had already "fixed" once. Same-length substitution truncates a cwd shorter than the placeholder, so /home/ab/x/home/agent/p, which failed startswith(PLACEHOLDER_PATH) and was reported as a leak — every short-cwd capture would have been unsanitizable. Fail-safe, so it would have surfaced as a mysterious refusal rather than a leak, which is the kind of thing that gets worked around by disabling the check. is_placeholder() now accepts prefixes of the placeholder too, and all seven committed fixtures re-verify leaks=[] under the widened predicate.

Per your note about proving rather than asserting: sanitize.py --selftest stubs out the redaction, pushes a leaking capture through, and stats the output path to confirm no file exists. air-1474-sanitize-ordering.test.ts runs it in CI. The rest of the harness still stays out of CI (it needs an authenticated agy and a PTY), but this part's failure mode is committing someone's username, so it runs. Mutation-checked: restore write-then-check and the test fails with leaking capture left /tmp/…/fixture.txt on disk.

Non-blocking, cursorY vs viewportY: taken, and it was not hygiene. I went to write a defensive test and measured a real false clean. Scroll the viewport up and the unconverted row lands on a stale composer still in scrollback — empty, palette-12, rule beneath it — so the region bounds and classifies empty. CLEAN, while the live composer is off-view holding a half-typed draft. Confirmed against the built old code ({clean: true, detail: "empty"}) and the fix (no-composer-marker). buf.baseY + buf.cursorY - top removes it; comments at both sites updated.

The regression test pins the rigging itself — that the viewport is genuinely off the bottom, that the two conventions disagree, and that the row the old reading picked really was a marker+rule pair — so it fails loudly rather than quietly becoming a tautology if xterm changes. Mutation-checked the same way.

Non-blocking, not done here, and I agree it is a real gap: silent idle-session profile drift. onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm — structurally the same failure as the bare-> bug this PR fixes, which is what makes it worth its own issue rather than a rider on this one. Same for markerFgPalette accepting multiple colors before agy ships a truecolor theme. Both are being filed separately.

pnpm test: 276 files / 5479 tests pass, 0 failed.

waleedkadous
waleedkadous previously approved these changes Sep 4, 2026

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-verified: sanitize_file now finds leaks before any write and refuses with nothing on disk, backed by a self-test that stats the path; /Users/… added to PATH_RE so the scrub set and the leak-check set are one list; and the short-cwd false-positive you found while writing the self-test is exactly the kind of second defect a real check surfaces. CI 7/7 on 8d9d666. Approving and merging.

@waleedkadous
waleedkadous merged commit e132ddb into mainSep 4, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render gate: tighten the loose agy prompt marker (AGY_MARKER = /^> /)

2 participants

@mohidmakhdoomi@waleedkadous
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette - #1491

Merged
waleedkadous merged 14 commits into
mainfrom
builder/air-1474
Sep 4, 2026
Merged

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette#1491
waleedkadous merged 14 commits into
mainfrom
builder/air-1474

Conversation

@mohidmakhdoomi

Copy link
Copy Markdown
Collaborator

Implements #1474.

What was actually wrong

AGY_MARKER = /^> / (gate-profiles.ts:72) treated any line starting with > as agy's
composer prompt, and findMarkerRow is last-match-wins. The issue called this "the weakest link"
in an empirically-derived profile. Measured against a real, authenticated agy 1.1.13 under a
PTY at 110×32, it is worse than a theoretical looseness — non-composer > rows are routine:

real screenlast > row the old marker pickedthat row's fgcursor rowthe actual composer
idle (accept-edits)11palette 121111 ✅
idle (no-hint mode)none — bare > never matched /^> /palette 121111 ❌
draft24palette 122424 ✅
slash menu (/)13 — the menu's selection cursorpalette 121111 ❌
trust dialog8 — the selected optionpalette 1212 (off-row)none ❌
settled after an answer20palette 122020 ✅
torn mid-repaint frame10 — the transcript echo of the sent turnpalette 4variesabsent ❌

Three findings drove the design:

  1. agy echoes every submitted turn into the transcript as a > line (SGR 34;1 → palette 4).
    Non-composer > rows accumulate one per conversation turn.
  2. The slash menu's selection cursor is also > , also palette-12, and renders BELOW the
    composer
    — so it won the marker scan outright. A color anchor alone does not separate
    these; the cursor row does.
  3. In agy's no-hint mode the composer is a bare >, which right-trims to ">" and never
    matched /^> / — so the gate held every message to an agy in that mode forever. A
    pre-existing false-HOLD, fixed here, since the issue's ask is that the marker identify the
    actual composer.

Markdown blockquotes turned out to render as , not > — so the issue's "quoted output" risk
arrives via the turn echo rather than via blockquotes.

Honest scope of the defect. I could not reproduce an actual false-CLEAN from the captures: on
every mis-bounded frame the wrong region still happened to contain counted text, so the verdict
landed busy anyway. What is demonstrated is that the classifier bounds the wrong region on
real screens
, and that getting busy out of a wrong region is luck rather than a guarantee. The
corruption risk is latent, not observed — stated plainly rather than overclaimed.

The change

Two optional, per-app GateProfile anchors — profile data, in keeping with the spike's
constraint 9 — set only on agy, both measured:

  • markerRequiresCursorRow: true — the marker row must hold the buffer cursor. Only the live
    input row does; menu rows, dialog options and transcript echoes never do.
  • markerFgPalette: 12 — the marker glyph's own color, which separates the composer from the
    palette-4 turn echo.

Plus the marker separator relaxed to /^>(\s|$)/ for the bare-> mode. claude/codex set neither
anchor and are byte-for-byte unaffected (pinned by a test).

Every failure direction is toward HOLD: a row that fails an anchor is simply not a marker, so
drift yields no-composer-marker → held and re-checked, never a false clean. Sustained holds
already escalate through the mailbox liveness telemetry (recordStreak).

Verdict changes on the real captures

fixturebeforeafter
agy-idle.cleancleanclean
agy-draft.busybusy / user-textbusy / user-text
agy-menu.busybusy / no-region-end (marker was the menu item — right verdict, wrong reason)busy / user-text (right region: the / typed in the composer)
agy-trust.busybusy / no-region-end (incidental — no rule under the option)busy / no-composer-marker (honest: there is no composer)
agy-torn-echo.busybusy / no-region-end (marker was the palette-4 echo)busy / no-composer-marker
agy-turn-echo.cleancleanclean
agy-baremarker.cleanbusy / no-composer-marker (false HOLD)clean

Cost of the tightening, measured

Sweeping every byte-prefix of a real stream showed 70 CLEAN frames before vs 9 after — alarming
until you notice byte-prefix sampling cuts mid-repaint, which production never does. Sampling
the way production actually classifies (wall-clock, against a live agy driven through boot → idle
→ streaming → settled, classifying the mirror every 200 ms) the two profiles are identical:

idle samples= 30 OLD clean=100% NEW clean=100%
streaming samples= 205 OLD clean= 98% NEW clean= 98%
settled samples= 40 OLD clean=100% NEW clean=100%

No measurable false-HOLD cost, and the bare-> mode goes from permanently held to deliverable.

Fixtures

The issue asked for captured real-agy fixtures across states, and agy is authenticated in this
environment, so the three synthesized Phase-3 fixtures are replaced by seven real captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn frame). agy's banner embeds the
account email and session cwd; both are replaced with same-length placeholders so the rendered
screen stays byte-for-byte equivalent — no attribute is retouched. Each file is 1.7–8.7 KB, so
they are committed plain rather than gzipped (gzip is used for the multi-hundred-KB claude
replays). The torn-frame fixture is real bytes cut mid-repaint, which is the tear shape #1361
documents for the adopt seed.

Tests

55 pass in render-gate.test.ts. New coverage: per-fixture verdicts for all seven states; the
reason for each (a mis-bounded region that returns busy is not the same as a correctly-bounded
one); synthetic branch tests for each anchor independently, including the palette-12 dialog option
with the cursor on it — the one shape the anchors cannot reject, which pins that the occupancy
count still catches it; and a guard that claude/codex are unaffected by cursor position.

porch check 1474: build ✓ (13.1s), tests ✓ (28.7s).

Notes for the reviewer

  • The cursor-row anchor assumes buffer.cursorY is viewport-relative, the same convention
    isGhostCursorCell already relies on. True whenever viewportY === baseY (no manual scrollback),
    which holds on both gate paths.
  • The one shape neither anchor rejects is a dialog that parks the cursor on a palette-12 > option.
    The trust dialog does not (measured), and the occupancy count plus the region-end guard both still
    catch it — but it is the seam to watch if agy adds dialogs.

🤖 Generated with Claude Code

mohidmakhdoomiand others added 5 commits August 17, 2026 19:44
… and marker palette
`AGY_MARKER = /^> /` treated any `> `-prefixed line as agy's composer. Measured against
real agy 1.1.13 under a PTY, that is not a hypothetical looseness: agy echoes every
submitted turn into the transcript as `> <message>` (palette-4), its slash-menu selection
cursor is also `> ` in palette-12 and renders BELOW the composer, and the trust dialog's
selected option is `> Yes, I trust this folder`. Since `findMarkerRow` is last-match-wins,
the menu item and the turn echo won the marker scan over the composer — so the classifier
bounded and scanned the wrong region on ordinary screens.
Adds two optional, per-app `GateProfile` anchors, both set only on agy and both measured:
`markerRequiresCursorRow` (the marker row must hold the cursor — only the live input row
does) and `markerFgPalette` (the marker glyph's own color, 12, which separates the composer
from the palette-4 turn echo). Also relaxes the marker separator to `\s|$`: agy's no-hint
mode renders the empty composer as a bare `>`, which `/^> /` never matched, so the gate
held every message to an agy in that mode forever.
Replaces the three synthesized agy fixtures with seven real, sanitized captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn mid-repaint frame).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er thread
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Test status, stated precisely

porch check 1474: build ✓ (13.5s), unit tests ✓ (28.6s) — 55 pass in render-gate.test.ts.

The e2e_tests gate check is a no-op here. AIR defines it as npm run test:e2e 2>&1 || echo 'e2e tests skipped (not configured)' and the repo root has no test:e2e script, so it reports ✓ in 0.1s having executed nothing. It is marked optional in the protocol, so that is intended — but it is not evidence, so I ran the real suite:

pnpm --filter @cluesmith/codev test:e2e
Test Files 1 failed | 19 passed | 5 skipped (25)
Tests 3 failed | 171 passed | 21 skipped (195)

All 3 failures are in tower-api.e2e.test.tsPOST /api/terminals returning 500 where 201 is expected.

They are pre-existing and unrelated to this PR. Verified rather than asserted: I reverted both changed source files to the branch base (141b493), rebuilt, and re-ran that file — identical 3 failures. Files restored afterwards (all committed, so lossless; working tree verified clean against HEAD, unit tests re-run green).

I did not skip or annotate them as flaky, because they are neither mine nor intermittent — they reproduce consistently. Flagging for a maintainer instead. Plausibly environmental: this machine is running four-plus concurrent builder sessions plus Tower, and all three failures are PTY-spawn-through-the-API.

@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Architect integration review — 3-way CMAP (risk tier: High — render-gate classifier, false-CLEAN direction)

Verdicts: gemini APPROVE · codex APPROVE · claude COMMENT ("merge it, with follow-ups") — all HIGH confidence, zero blocking correctness issues. Architect read concurs: the two positive-evidence anchors (markerRequiresCursorRow, markerFgPalette) fit the existing per-app-profile seam, every failure direction lands on no-composer-marker → HOLD, and claude/codex profiles are untouched. The real-capture measurement table is the load-bearing artifact, and the PR's refusal to overclaim (mis-bounding demonstrated, false-CLEAN latent; no-hint 'held forever' rests on live observation) is noted and appreciated.

Independently verified across the reviews: viewport-relative cursorY is sound on the production gate (viewportY === baseY for SessionScreen); the tightening's failure direction is observable (no-composer-marker feeds isClassifierStuck → escalation at streak 10); fixture sanitization is complete (no emails/paths survive); the bare-> relaxation is gated behind both anchors so it cannot loosen anything — and it fixes a real pre-existing total delivery outage in agy's no-hint mode.

Requested before the gate (one item)

  1. Add an agy case to the production-mirror-path suite (render-gate.test.ts:364). These are the first anchors that depend on cursor state, the one dimension where the transient path and the SessionScreen mirror path could conceivably diverge — a parity test (same fixture, same verdict via both paths) closes the only untested seam between what the tests prove and what production runs.

Follow-ups (this PR or noted for later — builder's call, say which)

  • arch.md:1795 still describes the agy gate as a placeholderFgPalette rule; the definition of "marker present" changed for agy. Two-line touch or explicit MAINTAIN deferral.
  • Commit the capture/sanitization harness (Spec 1313 precedent: codev/spir-1313-captures/*.mjs) so re-measuring against a future agy doesn't start from the PR description.
  • Cosmetic: required-fixtures test title still says "idle/draft/trust" while requiring seven.
  • Keep the dual 4-bit/256-color palette-12 matching — it is deliberate, not redundant (real fixtures use [94m, synthetic [38;5;12m).

For the maintainer

Parked for maintainer approval + merge; we are not maintainers.

…low-ups
CMAP requested item: the agy anchors are the first classifier input that depends on
CURSOR STATE, and the cursor is the one dimension where the transient replay path and the
long-lived SessionScreen mirror could diverge. Adds a per-fixture parity case to the
production-mirror-path suite — same bytes through both paths, same verdict — fed twice,
once in production-sized chunks and once in 7-byte chunks that deliberately split the
cursor-positioning CSI across feed() calls, since a mis-parsed cursor is now a verdict
change rather than a cosmetic one.
Follow-ups taken in-PR rather than deferred:
- arch.md: "marker present" now means more than a text match for agy — records the two
anchors and their fail-toward-HOLD direction.
- Commits the capture + sanitization harness (codev/air-1474-captures/) so re-measuring
against a future agy starts from a script, not the PR description. Verified by
re-deriving four committed fixtures byte-identically from the raw captures.
- Fixes the sanitizer's leak check, which reported `/home/` on every run because the
placeholder path itself starts with it — a check that always fires checks nothing. Now
matches paths that are not the placeholder, and refuses to write on a leak.
- Cosmetic: required-fixtures test title listed three agy states while requiring seven.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

CMAP items addressed — all four taken in-PR, none deferred

Requested before the gate ✅

agy case in the production-mirror-path suite (render-gate.test.ts). Agreed on the reasoning: these anchors are the first classifier input that depends on cursor state, so the transient-vs-mirror seam is newly load-bearing. Added a parity case per agy fixture (7 of them) rather than one representative — same bytes through both paths, toEqual on the whole verdict, plus an assertion that the transient path's own verdict matches the filename, so the parity check can't pass by both paths being wrong together.

Each fixture is fed twice: once in production-sized 64 KiB chunks, and once in 7-byte chunks that deliberately split escape sequences across feed() calls. That second pass is the one with teeth here — the cursor-positioning CSI (ESC[<row>;<col>H, and the relative ESC[2A/ESC[2C agy actually emits) is exactly what a torn chunk boundary would corrupt, and a mis-parsed cursor is now a verdict change rather than a cosmetic one. Both pass. 62 tests in the file; full package suite green (4876 passed / 48 skipped).

Follow-ups — taken, not deferred

  • arch.md ✅ — the agy entry now records that "marker present" is more than a text match, names both anchors, and states the fail-toward-no-composer-marker → HOLD direction.
  • Capture/sanitization harness ✅ — committed at codev/air-1474-captures/ (capture script, sanitizer, README covering auth prerequisite, per-state recipes, the 110×32 geometry constraint, and how the torn fixture is derived). Verified rather than assumed: re-ran the committed sanitizer against the raw captures and re-derived four committed fixtures byte-identically.
  • Cosmetic test title ✅ — now lists all seven agy states.
  • Dual 4-bit/256-color palette matching — kept as-is, per your note that it is deliberate.

One thing the harness work turned up

The sanitizer's leak check was self-defeating: it tested for the bare prefixes /home/ and /tmp/, but the replacement path is /home/agent/project, so it reported a leak on every successful run — including on the fixtures already in this PR. A check that always fires checks nothing, and I'd rather not ship that as the documented safety step. It now matches paths that are not the placeholder, and refuses to write the fixture if anything survives. Re-verified against all four captures: leaks=[], output byte-identical.

To be clear about what this did and didn't mean: the committed fixtures were always clean — I verified that independently with a direct grep before the first push, and the byte-identical re-derivation confirms it again. The defect was in the check, not in the fixtures.

Ready for the pr gate.

@waleedkadous

Copy link
Copy Markdown
Contributor

Thanks for the patience on this one — the review queue is moving now. This PR is showing as CONFLICTING against main after this week's merges in the same area (serializer convergence #1492, delivery verification #1573/#1584, self-attesting frames #1574, and the recipient/reply-hint changes to message-format.ts and tower-routes.ts). Could you rebase onto current main when you get a chance? I'll review the moment it's green — the four non-conflicting PRs in your list are in review right now.

mohidmakhdoomiand others added 2 commits September 3, 2026 14:19
Resolves the one conflict in render-gate.ts: main added the exported
`bufferLines()` helper (#1573 echo verification) directly above
`findMarkerRow`, whose doc and signature this branch rewrote for the
cursor-row/palette anchors. Both are kept — they are adjacent, not
competing.
Typecheck clean; render-gate suite 66/66.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alarm
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Brought up to current main — conflicts resolved, CI green, architect integration check

This branch had gone CONFLICTING (770 commits behind). It is now MERGEABLE at 470d49b2d, with all 7 checks passing, via a merge of origin/main (818337fa2) — not a rebase, per this repo's merge-never-squash convention. (mergeStateStatus: BLOCKED is now only the missing maintainer approval; no conflict, no failing check.)

The conflict was adjacency, not disagreement

Exactly one file conflicted: packages/codev/src/agent-farm/servers/render-gate.ts. The sole commit to touch it since the merge-base was #1573 (35f2e637f), whose change there is purely additive — one new exported bufferLines(term), inserted directly above findMarkerRow, whose doc comment and signature this branch had rewritten for the cursor-row/palette anchors. Both sides kept verbatim.

The two functions now sit adjacent and are worth keeping straight:

Architect verification (independent of the builder's report)

Post-merge suite: 275 files passed / 3 skipped / 0 failed; 5477 tests passed, typecheck clean. The render-gate suite is 66 cases now rather than 55 — main's own additions to that file merged in cleanly alongside these.

Two notes for whoever merges

  1. Merge-order: PR Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 (issue Render gate: replace the perf wall-clock assertion with a deterministic op-count check #1471) also touches render-gate.test.ts, in a different region. Whichever of Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 / [Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette #1491 merges second may need a trivial rebase.
  2. Resuming any long-parked branch in this repo: the first post-merge pnpm test here reported 14 failures in request-auth.test.ts / tower-routes.test.ts (WS_KEY_PROTOCOL_PREFIX, TOWER_KEY_HEADER arriving undefined). All falsepackages/types/dist predated the constants main had since added, and node_modules predated a new three dependency. pnpm install && pnpm build first, then believe a test result.

Status

Parked for maintainer review — we are not maintainers of this repo, so we are not merging this. Please approve and merge when it suits you. Issue #1474 stays open and the worktree stays intact until you do.

mohidmakhdoomi added a commit that referenced this pull request Sep 3, 2026
Retrospective at codev/reviews/1482-f1-tower-vs-pty-dimension-dive.md, plus
the governance routing it describes.
The Summary leads with the user-visible harm rather than the mechanism: the
owner starvation notice offered `afx interrupt` for EVERY hold, and for a
`user-text` hold that is advice to interrupt a person mid-draft. The dims fix
and the detail column are how that becomes possible to tell apart, not the
point in themselves.
Both gaps the human approved knowingly are stated in the review rather than
left for a reviewer to find: the dashboard popover still renders a bare `busy`
(reverted deliberately — no worktree.devCommand, no Playwright here, and the
project requires a browser check), and evidence item 3 is covered by test
rather than induced live because send.ts constructs `new TowerClient()` with
no port and so reaches only the live Tower on 4100.
Also called out for the maintainer: the beyond-plan 409 RESIZE_DROPPED route
change and CronDeliveryResult gaining `detail`; the v18-vs-v17 migration
collision surface (in two places — the constant and the pinned source
assertion that reads it back); and the conflict surface with the two parked
PRs, #1486 (commands/inbox.ts, commands/send.ts, tower-routes.ts) and #1491
(render-gate.ts, render-gate.test.ts).
**Governance routed COLD only.** Both hot files sit exactly at their caps
(10 facts / 10 lessons) and nothing here justified displacing an existing
entry; the hot map already routes readers to "Invariants & Constraints", so
the tiering works without growing the always-injected tier.
- arch.md: new invariant #10, "Terminal dimensions must be earned, not
assumed" (requested/applied/outstanding, 409 vs 404, WELCOME adoption, and
why the gate depends on it); and the Spec 1313 mailbox section's response
vocabulary extended with the v18 `detail` column and why it carries no CHECK
constraint.
- lessons-learned.md: three Architecture entries (a failure boolean is only as
honest as its callers; two-state models fail at the third state — the
missing bit was OUTSTANDING, not DIFFERENT; commit derived state only after
what it describes is confirmed) and three Process entries (read the ignore
rule's own reason before `git add -f`; report the boundary of a gap, not the
instance you tripped over; mutation-check a test written to close someone
else's finding).
Full suite green on this tree: 278 files passed / 3 skipped, 5495 tests passed
/ 48 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Really thorough work — seven real agy captures, per-fixture mirror-vs-transient parity tests (including the 7-byte chunking that splits cursor CSIs), and a failure direction that stays HOLD rather than deliver. The render-gate change itself is approve-quality and claude's lane approved it outright; the one thing I'd like fixed before merge is in the capture tooling, because it's a leak-safety guarantee that currently isn't one:

  1. codev/air-1474-captures/sanitize.py writes the output before running its leak check (the open(dst, …).write(data) at ~line 137 precedes the # Leak check block). If identifiers survive, it raises only after the unsafe fixture is already on disk — contradicting its own "REFUSING to sanitize" message and creating an accidental-commit risk. Run every check before writing, or delete dst on failure. Relatedly, its path regex misses /Users/…, so a macOS capture would pass the check while leaking a username (the committed fixtures are clean; this is about the next capture).

Two small things you might consider alongside, neither blocking:

  • buf.cursorY is baseY-relative in xterm's public contract; the code relies on viewportY === baseY. buf.baseY + buf.cursorY - buf.viewportY removes that assumption for free, and it's now load-bearing for delivery.
  • Idle-session drift is silent — onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm, the same shape as the bare-> bug this fixes. A follow-up on profile-drift observability (classifier-stuck escalation for idle sessions, or an afx doctor check that classifies each live mirror once) would close that.

Also noting markerFgPalette will want to accept multiple colors before agy ships a truecolor theme — follow-up territory.

…hrough baseY
Addresses the PR #1491 maintainer review.
BLOCKING — sanitize.py wrote the output file before checking it for leaks, so a
capture that still carried an identifier landed on disk and the "REFUSING to
sanitize" message was false by the time it printed. Now every check runs before
anything reaches the filesystem, chosen over delete-on-failure: a guarantee that
depends on cleanup running is weaker than one that never creates the file.
PATH_RE also gains /Users/, without which a macOS capture sanitizes cleanly while
leaking a username — the prefixes the scrubber rewrites and the prefixes the leak
check inspects have to be one list.
Writing the --selftest exposed a second defect in the predicate: same-length
substitution truncates a cwd shorter than the placeholder (/home/ab/x becomes
/home/agent/p), which failed startswith() and read as a leak, making every
short-cwd capture unsanitizable. is_placeholder() now accepts prefixes of the
placeholder. All seven committed fixtures re-verified clean under it.
The selftest runs the refusal path for real — it stubs out the redaction, pushes
a leaking capture through, and stats the output path — and CI runs the selftest
(air-1474-sanitize-ordering.test.ts, skipped where python3 is absent).
NON-BLOCKING — classifyBuffer read buf.cursorY alongside top = buf.viewportY,
assuming viewportY === baseY. Expected to be hygiene; measured as a real false
clean. With the viewport scrolled up, the unconverted row lands on a stale
composer still in scrollback — empty, palette-12, rule beneath it — so the region
bounds and classifies `empty`: CLEAN, while the live composer is off-view holding
a draft. Verified against the built old code ({clean: true, detail: 'empty'}) and
the fix (no-composer-marker). baseY + cursorY - top removes it.
Both new tests mutation-checked: revert either fix and the matching test fails.
pnpm test: 276 files / 5479 tests pass, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Thanks — the blocking item was right, and chasing it turned up a second defect in the same file.

Blocking: sanitize.py wrote before checking. Fixed by check-then-write rather than delete-on-failure, for the reason you gave: a guarantee that depends on cleanup running is weaker than one that never creates the file. PATH_RE gains /Users/… — you were right that the committed fixtures are clean and this is about the next capture; the underlying rule is that the prefixes the scrubber rewrites and the prefixes the leak check inspects have to be one list, or the check is narrower than the risk.

Writing the self-check exposed a second defect, in the predicate I had already "fixed" once. Same-length substitution truncates a cwd shorter than the placeholder, so /home/ab/x/home/agent/p, which failed startswith(PLACEHOLDER_PATH) and was reported as a leak — every short-cwd capture would have been unsanitizable. Fail-safe, so it would have surfaced as a mysterious refusal rather than a leak, which is the kind of thing that gets worked around by disabling the check. is_placeholder() now accepts prefixes of the placeholder too, and all seven committed fixtures re-verify leaks=[] under the widened predicate.

Per your note about proving rather than asserting: sanitize.py --selftest stubs out the redaction, pushes a leaking capture through, and stats the output path to confirm no file exists. air-1474-sanitize-ordering.test.ts runs it in CI. The rest of the harness still stays out of CI (it needs an authenticated agy and a PTY), but this part's failure mode is committing someone's username, so it runs. Mutation-checked: restore write-then-check and the test fails with leaking capture left /tmp/…/fixture.txt on disk.

Non-blocking, cursorY vs viewportY: taken, and it was not hygiene. I went to write a defensive test and measured a real false clean. Scroll the viewport up and the unconverted row lands on a stale composer still in scrollback — empty, palette-12, rule beneath it — so the region bounds and classifies empty. CLEAN, while the live composer is off-view holding a half-typed draft. Confirmed against the built old code ({clean: true, detail: "empty"}) and the fix (no-composer-marker). buf.baseY + buf.cursorY - top removes it; comments at both sites updated.

The regression test pins the rigging itself — that the viewport is genuinely off the bottom, that the two conventions disagree, and that the row the old reading picked really was a marker+rule pair — so it fails loudly rather than quietly becoming a tautology if xterm changes. Mutation-checked the same way.

Non-blocking, not done here, and I agree it is a real gap: silent idle-session profile drift. onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm — structurally the same failure as the bare-> bug this PR fixes, which is what makes it worth its own issue rather than a rider on this one. Same for markerFgPalette accepting multiple colors before agy ships a truecolor theme. Both are being filed separately.

pnpm test: 276 files / 5479 tests pass, 0 failed.

waleedkadous
waleedkadous previously approved these changes Sep 4, 2026

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-verified: sanitize_file now finds leaks before any write and refuses with nothing on disk, backed by a self-test that stats the path; /Users/… added to PATH_RE so the scrub set and the leak-check set are one list; and the short-cwd false-positive you found while writing the self-test is exactly the kind of second defect a real check surfaces. CI 7/7 on 8d9d666. Approving and merging.

@waleedkadous
waleedkadous merged commit e132ddb into mainSep 4, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render gate: tighten the loose agy prompt marker (AGY_MARKER = /^> /)

2 participants

@mohidmakhdoomi@waleedkadous
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette - #1491

Merged
waleedkadous merged 14 commits into
mainfrom
builder/air-1474
Sep 4, 2026
Merged

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette#1491
waleedkadous merged 14 commits into
mainfrom
builder/air-1474

Conversation

@mohidmakhdoomi

Copy link
Copy Markdown
Collaborator

Implements #1474.

What was actually wrong

AGY_MARKER = /^> / (gate-profiles.ts:72) treated any line starting with > as agy's
composer prompt, and findMarkerRow is last-match-wins. The issue called this "the weakest link"
in an empirically-derived profile. Measured against a real, authenticated agy 1.1.13 under a
PTY at 110×32, it is worse than a theoretical looseness — non-composer > rows are routine:

real screenlast > row the old marker pickedthat row's fgcursor rowthe actual composer
idle (accept-edits)11palette 121111 ✅
idle (no-hint mode)none — bare > never matched /^> /palette 121111 ❌
draft24palette 122424 ✅
slash menu (/)13 — the menu's selection cursorpalette 121111 ❌
trust dialog8 — the selected optionpalette 1212 (off-row)none ❌
settled after an answer20palette 122020 ✅
torn mid-repaint frame10 — the transcript echo of the sent turnpalette 4variesabsent ❌

Three findings drove the design:

  1. agy echoes every submitted turn into the transcript as a > line (SGR 34;1 → palette 4).
    Non-composer > rows accumulate one per conversation turn.
  2. The slash menu's selection cursor is also > , also palette-12, and renders BELOW the
    composer
    — so it won the marker scan outright. A color anchor alone does not separate
    these; the cursor row does.
  3. In agy's no-hint mode the composer is a bare >, which right-trims to ">" and never
    matched /^> / — so the gate held every message to an agy in that mode forever. A
    pre-existing false-HOLD, fixed here, since the issue's ask is that the marker identify the
    actual composer.

Markdown blockquotes turned out to render as , not > — so the issue's "quoted output" risk
arrives via the turn echo rather than via blockquotes.

Honest scope of the defect. I could not reproduce an actual false-CLEAN from the captures: on
every mis-bounded frame the wrong region still happened to contain counted text, so the verdict
landed busy anyway. What is demonstrated is that the classifier bounds the wrong region on
real screens
, and that getting busy out of a wrong region is luck rather than a guarantee. The
corruption risk is latent, not observed — stated plainly rather than overclaimed.

The change

Two optional, per-app GateProfile anchors — profile data, in keeping with the spike's
constraint 9 — set only on agy, both measured:

  • markerRequiresCursorRow: true — the marker row must hold the buffer cursor. Only the live
    input row does; menu rows, dialog options and transcript echoes never do.
  • markerFgPalette: 12 — the marker glyph's own color, which separates the composer from the
    palette-4 turn echo.

Plus the marker separator relaxed to /^>(\s|$)/ for the bare-> mode. claude/codex set neither
anchor and are byte-for-byte unaffected (pinned by a test).

Every failure direction is toward HOLD: a row that fails an anchor is simply not a marker, so
drift yields no-composer-marker → held and re-checked, never a false clean. Sustained holds
already escalate through the mailbox liveness telemetry (recordStreak).

Verdict changes on the real captures

fixturebeforeafter
agy-idle.cleancleanclean
agy-draft.busybusy / user-textbusy / user-text
agy-menu.busybusy / no-region-end (marker was the menu item — right verdict, wrong reason)busy / user-text (right region: the / typed in the composer)
agy-trust.busybusy / no-region-end (incidental — no rule under the option)busy / no-composer-marker (honest: there is no composer)
agy-torn-echo.busybusy / no-region-end (marker was the palette-4 echo)busy / no-composer-marker
agy-turn-echo.cleancleanclean
agy-baremarker.cleanbusy / no-composer-marker (false HOLD)clean

Cost of the tightening, measured

Sweeping every byte-prefix of a real stream showed 70 CLEAN frames before vs 9 after — alarming
until you notice byte-prefix sampling cuts mid-repaint, which production never does. Sampling
the way production actually classifies (wall-clock, against a live agy driven through boot → idle
→ streaming → settled, classifying the mirror every 200 ms) the two profiles are identical:

idle samples= 30 OLD clean=100% NEW clean=100%
streaming samples= 205 OLD clean= 98% NEW clean= 98%
settled samples= 40 OLD clean=100% NEW clean=100%

No measurable false-HOLD cost, and the bare-> mode goes from permanently held to deliverable.

Fixtures

The issue asked for captured real-agy fixtures across states, and agy is authenticated in this
environment, so the three synthesized Phase-3 fixtures are replaced by seven real captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn frame). agy's banner embeds the
account email and session cwd; both are replaced with same-length placeholders so the rendered
screen stays byte-for-byte equivalent — no attribute is retouched. Each file is 1.7–8.7 KB, so
they are committed plain rather than gzipped (gzip is used for the multi-hundred-KB claude
replays). The torn-frame fixture is real bytes cut mid-repaint, which is the tear shape #1361
documents for the adopt seed.

Tests

55 pass in render-gate.test.ts. New coverage: per-fixture verdicts for all seven states; the
reason for each (a mis-bounded region that returns busy is not the same as a correctly-bounded
one); synthetic branch tests for each anchor independently, including the palette-12 dialog option
with the cursor on it — the one shape the anchors cannot reject, which pins that the occupancy
count still catches it; and a guard that claude/codex are unaffected by cursor position.

porch check 1474: build ✓ (13.1s), tests ✓ (28.7s).

Notes for the reviewer

  • The cursor-row anchor assumes buffer.cursorY is viewport-relative, the same convention
    isGhostCursorCell already relies on. True whenever viewportY === baseY (no manual scrollback),
    which holds on both gate paths.
  • The one shape neither anchor rejects is a dialog that parks the cursor on a palette-12 > option.
    The trust dialog does not (measured), and the occupancy count plus the region-end guard both still
    catch it — but it is the seam to watch if agy adds dialogs.

🤖 Generated with Claude Code

mohidmakhdoomiand others added 5 commits August 17, 2026 19:44
… and marker palette
`AGY_MARKER = /^> /` treated any `> `-prefixed line as agy's composer. Measured against
real agy 1.1.13 under a PTY, that is not a hypothetical looseness: agy echoes every
submitted turn into the transcript as `> <message>` (palette-4), its slash-menu selection
cursor is also `> ` in palette-12 and renders BELOW the composer, and the trust dialog's
selected option is `> Yes, I trust this folder`. Since `findMarkerRow` is last-match-wins,
the menu item and the turn echo won the marker scan over the composer — so the classifier
bounded and scanned the wrong region on ordinary screens.
Adds two optional, per-app `GateProfile` anchors, both set only on agy and both measured:
`markerRequiresCursorRow` (the marker row must hold the cursor — only the live input row
does) and `markerFgPalette` (the marker glyph's own color, 12, which separates the composer
from the palette-4 turn echo). Also relaxes the marker separator to `\s|$`: agy's no-hint
mode renders the empty composer as a bare `>`, which `/^> /` never matched, so the gate
held every message to an agy in that mode forever.
Replaces the three synthesized agy fixtures with seven real, sanitized captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn mid-repaint frame).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er thread
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Test status, stated precisely

porch check 1474: build ✓ (13.5s), unit tests ✓ (28.6s) — 55 pass in render-gate.test.ts.

The e2e_tests gate check is a no-op here. AIR defines it as npm run test:e2e 2>&1 || echo 'e2e tests skipped (not configured)' and the repo root has no test:e2e script, so it reports ✓ in 0.1s having executed nothing. It is marked optional in the protocol, so that is intended — but it is not evidence, so I ran the real suite:

pnpm --filter @cluesmith/codev test:e2e
Test Files 1 failed | 19 passed | 5 skipped (25)
Tests 3 failed | 171 passed | 21 skipped (195)

All 3 failures are in tower-api.e2e.test.tsPOST /api/terminals returning 500 where 201 is expected.

They are pre-existing and unrelated to this PR. Verified rather than asserted: I reverted both changed source files to the branch base (141b493), rebuilt, and re-ran that file — identical 3 failures. Files restored afterwards (all committed, so lossless; working tree verified clean against HEAD, unit tests re-run green).

I did not skip or annotate them as flaky, because they are neither mine nor intermittent — they reproduce consistently. Flagging for a maintainer instead. Plausibly environmental: this machine is running four-plus concurrent builder sessions plus Tower, and all three failures are PTY-spawn-through-the-API.

@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Architect integration review — 3-way CMAP (risk tier: High — render-gate classifier, false-CLEAN direction)

Verdicts: gemini APPROVE · codex APPROVE · claude COMMENT ("merge it, with follow-ups") — all HIGH confidence, zero blocking correctness issues. Architect read concurs: the two positive-evidence anchors (markerRequiresCursorRow, markerFgPalette) fit the existing per-app-profile seam, every failure direction lands on no-composer-marker → HOLD, and claude/codex profiles are untouched. The real-capture measurement table is the load-bearing artifact, and the PR's refusal to overclaim (mis-bounding demonstrated, false-CLEAN latent; no-hint 'held forever' rests on live observation) is noted and appreciated.

Independently verified across the reviews: viewport-relative cursorY is sound on the production gate (viewportY === baseY for SessionScreen); the tightening's failure direction is observable (no-composer-marker feeds isClassifierStuck → escalation at streak 10); fixture sanitization is complete (no emails/paths survive); the bare-> relaxation is gated behind both anchors so it cannot loosen anything — and it fixes a real pre-existing total delivery outage in agy's no-hint mode.

Requested before the gate (one item)

  1. Add an agy case to the production-mirror-path suite (render-gate.test.ts:364). These are the first anchors that depend on cursor state, the one dimension where the transient path and the SessionScreen mirror path could conceivably diverge — a parity test (same fixture, same verdict via both paths) closes the only untested seam between what the tests prove and what production runs.

Follow-ups (this PR or noted for later — builder's call, say which)

  • arch.md:1795 still describes the agy gate as a placeholderFgPalette rule; the definition of "marker present" changed for agy. Two-line touch or explicit MAINTAIN deferral.
  • Commit the capture/sanitization harness (Spec 1313 precedent: codev/spir-1313-captures/*.mjs) so re-measuring against a future agy doesn't start from the PR description.
  • Cosmetic: required-fixtures test title still says "idle/draft/trust" while requiring seven.
  • Keep the dual 4-bit/256-color palette-12 matching — it is deliberate, not redundant (real fixtures use [94m, synthetic [38;5;12m).

For the maintainer

Parked for maintainer approval + merge; we are not maintainers.

…low-ups
CMAP requested item: the agy anchors are the first classifier input that depends on
CURSOR STATE, and the cursor is the one dimension where the transient replay path and the
long-lived SessionScreen mirror could diverge. Adds a per-fixture parity case to the
production-mirror-path suite — same bytes through both paths, same verdict — fed twice,
once in production-sized chunks and once in 7-byte chunks that deliberately split the
cursor-positioning CSI across feed() calls, since a mis-parsed cursor is now a verdict
change rather than a cosmetic one.
Follow-ups taken in-PR rather than deferred:
- arch.md: "marker present" now means more than a text match for agy — records the two
anchors and their fail-toward-HOLD direction.
- Commits the capture + sanitization harness (codev/air-1474-captures/) so re-measuring
against a future agy starts from a script, not the PR description. Verified by
re-deriving four committed fixtures byte-identically from the raw captures.
- Fixes the sanitizer's leak check, which reported `/home/` on every run because the
placeholder path itself starts with it — a check that always fires checks nothing. Now
matches paths that are not the placeholder, and refuses to write on a leak.
- Cosmetic: required-fixtures test title listed three agy states while requiring seven.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

CMAP items addressed — all four taken in-PR, none deferred

Requested before the gate ✅

agy case in the production-mirror-path suite (render-gate.test.ts). Agreed on the reasoning: these anchors are the first classifier input that depends on cursor state, so the transient-vs-mirror seam is newly load-bearing. Added a parity case per agy fixture (7 of them) rather than one representative — same bytes through both paths, toEqual on the whole verdict, plus an assertion that the transient path's own verdict matches the filename, so the parity check can't pass by both paths being wrong together.

Each fixture is fed twice: once in production-sized 64 KiB chunks, and once in 7-byte chunks that deliberately split escape sequences across feed() calls. That second pass is the one with teeth here — the cursor-positioning CSI (ESC[<row>;<col>H, and the relative ESC[2A/ESC[2C agy actually emits) is exactly what a torn chunk boundary would corrupt, and a mis-parsed cursor is now a verdict change rather than a cosmetic one. Both pass. 62 tests in the file; full package suite green (4876 passed / 48 skipped).

Follow-ups — taken, not deferred

  • arch.md ✅ — the agy entry now records that "marker present" is more than a text match, names both anchors, and states the fail-toward-no-composer-marker → HOLD direction.
  • Capture/sanitization harness ✅ — committed at codev/air-1474-captures/ (capture script, sanitizer, README covering auth prerequisite, per-state recipes, the 110×32 geometry constraint, and how the torn fixture is derived). Verified rather than assumed: re-ran the committed sanitizer against the raw captures and re-derived four committed fixtures byte-identically.
  • Cosmetic test title ✅ — now lists all seven agy states.
  • Dual 4-bit/256-color palette matching — kept as-is, per your note that it is deliberate.

One thing the harness work turned up

The sanitizer's leak check was self-defeating: it tested for the bare prefixes /home/ and /tmp/, but the replacement path is /home/agent/project, so it reported a leak on every successful run — including on the fixtures already in this PR. A check that always fires checks nothing, and I'd rather not ship that as the documented safety step. It now matches paths that are not the placeholder, and refuses to write the fixture if anything survives. Re-verified against all four captures: leaks=[], output byte-identical.

To be clear about what this did and didn't mean: the committed fixtures were always clean — I verified that independently with a direct grep before the first push, and the byte-identical re-derivation confirms it again. The defect was in the check, not in the fixtures.

Ready for the pr gate.

@waleedkadous

Copy link
Copy Markdown
Contributor

Thanks for the patience on this one — the review queue is moving now. This PR is showing as CONFLICTING against main after this week's merges in the same area (serializer convergence #1492, delivery verification #1573/#1584, self-attesting frames #1574, and the recipient/reply-hint changes to message-format.ts and tower-routes.ts). Could you rebase onto current main when you get a chance? I'll review the moment it's green — the four non-conflicting PRs in your list are in review right now.

mohidmakhdoomiand others added 2 commits September 3, 2026 14:19
Resolves the one conflict in render-gate.ts: main added the exported
`bufferLines()` helper (#1573 echo verification) directly above
`findMarkerRow`, whose doc and signature this branch rewrote for the
cursor-row/palette anchors. Both are kept — they are adjacent, not
competing.
Typecheck clean; render-gate suite 66/66.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alarm
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Brought up to current main — conflicts resolved, CI green, architect integration check

This branch had gone CONFLICTING (770 commits behind). It is now MERGEABLE at 470d49b2d, with all 7 checks passing, via a merge of origin/main (818337fa2) — not a rebase, per this repo's merge-never-squash convention. (mergeStateStatus: BLOCKED is now only the missing maintainer approval; no conflict, no failing check.)

The conflict was adjacency, not disagreement

Exactly one file conflicted: packages/codev/src/agent-farm/servers/render-gate.ts. The sole commit to touch it since the merge-base was #1573 (35f2e637f), whose change there is purely additive — one new exported bufferLines(term), inserted directly above findMarkerRow, whose doc comment and signature this branch had rewritten for the cursor-row/palette anchors. Both sides kept verbatim.

The two functions now sit adjacent and are worth keeping straight:

Architect verification (independent of the builder's report)

Post-merge suite: 275 files passed / 3 skipped / 0 failed; 5477 tests passed, typecheck clean. The render-gate suite is 66 cases now rather than 55 — main's own additions to that file merged in cleanly alongside these.

Two notes for whoever merges

  1. Merge-order: PR Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 (issue Render gate: replace the perf wall-clock assertion with a deterministic op-count check #1471) also touches render-gate.test.ts, in a different region. Whichever of Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 / [Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette #1491 merges second may need a trivial rebase.
  2. Resuming any long-parked branch in this repo: the first post-merge pnpm test here reported 14 failures in request-auth.test.ts / tower-routes.test.ts (WS_KEY_PROTOCOL_PREFIX, TOWER_KEY_HEADER arriving undefined). All falsepackages/types/dist predated the constants main had since added, and node_modules predated a new three dependency. pnpm install && pnpm build first, then believe a test result.

Status

Parked for maintainer review — we are not maintainers of this repo, so we are not merging this. Please approve and merge when it suits you. Issue #1474 stays open and the worktree stays intact until you do.

mohidmakhdoomi added a commit that referenced this pull request Sep 3, 2026
Retrospective at codev/reviews/1482-f1-tower-vs-pty-dimension-dive.md, plus
the governance routing it describes.
The Summary leads with the user-visible harm rather than the mechanism: the
owner starvation notice offered `afx interrupt` for EVERY hold, and for a
`user-text` hold that is advice to interrupt a person mid-draft. The dims fix
and the detail column are how that becomes possible to tell apart, not the
point in themselves.
Both gaps the human approved knowingly are stated in the review rather than
left for a reviewer to find: the dashboard popover still renders a bare `busy`
(reverted deliberately — no worktree.devCommand, no Playwright here, and the
project requires a browser check), and evidence item 3 is covered by test
rather than induced live because send.ts constructs `new TowerClient()` with
no port and so reaches only the live Tower on 4100.
Also called out for the maintainer: the beyond-plan 409 RESIZE_DROPPED route
change and CronDeliveryResult gaining `detail`; the v18-vs-v17 migration
collision surface (in two places — the constant and the pinned source
assertion that reads it back); and the conflict surface with the two parked
PRs, #1486 (commands/inbox.ts, commands/send.ts, tower-routes.ts) and #1491
(render-gate.ts, render-gate.test.ts).
**Governance routed COLD only.** Both hot files sit exactly at their caps
(10 facts / 10 lessons) and nothing here justified displacing an existing
entry; the hot map already routes readers to "Invariants & Constraints", so
the tiering works without growing the always-injected tier.
- arch.md: new invariant #10, "Terminal dimensions must be earned, not
assumed" (requested/applied/outstanding, 409 vs 404, WELCOME adoption, and
why the gate depends on it); and the Spec 1313 mailbox section's response
vocabulary extended with the v18 `detail` column and why it carries no CHECK
constraint.
- lessons-learned.md: three Architecture entries (a failure boolean is only as
honest as its callers; two-state models fail at the third state — the
missing bit was OUTSTANDING, not DIFFERENT; commit derived state only after
what it describes is confirmed) and three Process entries (read the ignore
rule's own reason before `git add -f`; report the boundary of a gap, not the
instance you tripped over; mutation-check a test written to close someone
else's finding).
Full suite green on this tree: 278 files passed / 3 skipped, 5495 tests passed
/ 48 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Really thorough work — seven real agy captures, per-fixture mirror-vs-transient parity tests (including the 7-byte chunking that splits cursor CSIs), and a failure direction that stays HOLD rather than deliver. The render-gate change itself is approve-quality and claude's lane approved it outright; the one thing I'd like fixed before merge is in the capture tooling, because it's a leak-safety guarantee that currently isn't one:

  1. codev/air-1474-captures/sanitize.py writes the output before running its leak check (the open(dst, …).write(data) at ~line 137 precedes the # Leak check block). If identifiers survive, it raises only after the unsafe fixture is already on disk — contradicting its own "REFUSING to sanitize" message and creating an accidental-commit risk. Run every check before writing, or delete dst on failure. Relatedly, its path regex misses /Users/…, so a macOS capture would pass the check while leaking a username (the committed fixtures are clean; this is about the next capture).

Two small things you might consider alongside, neither blocking:

  • buf.cursorY is baseY-relative in xterm's public contract; the code relies on viewportY === baseY. buf.baseY + buf.cursorY - buf.viewportY removes that assumption for free, and it's now load-bearing for delivery.
  • Idle-session drift is silent — onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm, the same shape as the bare-> bug this fixes. A follow-up on profile-drift observability (classifier-stuck escalation for idle sessions, or an afx doctor check that classifies each live mirror once) would close that.

Also noting markerFgPalette will want to accept multiple colors before agy ships a truecolor theme — follow-up territory.

…hrough baseY
Addresses the PR #1491 maintainer review.
BLOCKING — sanitize.py wrote the output file before checking it for leaks, so a
capture that still carried an identifier landed on disk and the "REFUSING to
sanitize" message was false by the time it printed. Now every check runs before
anything reaches the filesystem, chosen over delete-on-failure: a guarantee that
depends on cleanup running is weaker than one that never creates the file.
PATH_RE also gains /Users/, without which a macOS capture sanitizes cleanly while
leaking a username — the prefixes the scrubber rewrites and the prefixes the leak
check inspects have to be one list.
Writing the --selftest exposed a second defect in the predicate: same-length
substitution truncates a cwd shorter than the placeholder (/home/ab/x becomes
/home/agent/p), which failed startswith() and read as a leak, making every
short-cwd capture unsanitizable. is_placeholder() now accepts prefixes of the
placeholder. All seven committed fixtures re-verified clean under it.
The selftest runs the refusal path for real — it stubs out the redaction, pushes
a leaking capture through, and stats the output path — and CI runs the selftest
(air-1474-sanitize-ordering.test.ts, skipped where python3 is absent).
NON-BLOCKING — classifyBuffer read buf.cursorY alongside top = buf.viewportY,
assuming viewportY === baseY. Expected to be hygiene; measured as a real false
clean. With the viewport scrolled up, the unconverted row lands on a stale
composer still in scrollback — empty, palette-12, rule beneath it — so the region
bounds and classifies `empty`: CLEAN, while the live composer is off-view holding
a draft. Verified against the built old code ({clean: true, detail: 'empty'}) and
the fix (no-composer-marker). baseY + cursorY - top removes it.
Both new tests mutation-checked: revert either fix and the matching test fails.
pnpm test: 276 files / 5479 tests pass, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Thanks — the blocking item was right, and chasing it turned up a second defect in the same file.

Blocking: sanitize.py wrote before checking. Fixed by check-then-write rather than delete-on-failure, for the reason you gave: a guarantee that depends on cleanup running is weaker than one that never creates the file. PATH_RE gains /Users/… — you were right that the committed fixtures are clean and this is about the next capture; the underlying rule is that the prefixes the scrubber rewrites and the prefixes the leak check inspects have to be one list, or the check is narrower than the risk.

Writing the self-check exposed a second defect, in the predicate I had already "fixed" once. Same-length substitution truncates a cwd shorter than the placeholder, so /home/ab/x/home/agent/p, which failed startswith(PLACEHOLDER_PATH) and was reported as a leak — every short-cwd capture would have been unsanitizable. Fail-safe, so it would have surfaced as a mysterious refusal rather than a leak, which is the kind of thing that gets worked around by disabling the check. is_placeholder() now accepts prefixes of the placeholder too, and all seven committed fixtures re-verify leaks=[] under the widened predicate.

Per your note about proving rather than asserting: sanitize.py --selftest stubs out the redaction, pushes a leaking capture through, and stats the output path to confirm no file exists. air-1474-sanitize-ordering.test.ts runs it in CI. The rest of the harness still stays out of CI (it needs an authenticated agy and a PTY), but this part's failure mode is committing someone's username, so it runs. Mutation-checked: restore write-then-check and the test fails with leaking capture left /tmp/…/fixture.txt on disk.

Non-blocking, cursorY vs viewportY: taken, and it was not hygiene. I went to write a defensive test and measured a real false clean. Scroll the viewport up and the unconverted row lands on a stale composer still in scrollback — empty, palette-12, rule beneath it — so the region bounds and classifies empty. CLEAN, while the live composer is off-view holding a half-typed draft. Confirmed against the built old code ({clean: true, detail: "empty"}) and the fix (no-composer-marker). buf.baseY + buf.cursorY - top removes it; comments at both sites updated.

The regression test pins the rigging itself — that the viewport is genuinely off the bottom, that the two conventions disagree, and that the row the old reading picked really was a marker+rule pair — so it fails loudly rather than quietly becoming a tautology if xterm changes. Mutation-checked the same way.

Non-blocking, not done here, and I agree it is a real gap: silent idle-session profile drift. onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm — structurally the same failure as the bare-> bug this PR fixes, which is what makes it worth its own issue rather than a rider on this one. Same for markerFgPalette accepting multiple colors before agy ships a truecolor theme. Both are being filed separately.

pnpm test: 276 files / 5479 tests pass, 0 failed.

waleedkadous
waleedkadous previously approved these changes Sep 4, 2026

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-verified: sanitize_file now finds leaks before any write and refuses with nothing on disk, backed by a self-test that stats the path; /Users/… added to PATH_RE so the scrub set and the leak-check set are one list; and the short-cwd false-positive you found while writing the self-test is exactly the kind of second defect a real check surfaces. CI 7/7 on 8d9d666. Approving and merging.

@waleedkadous
waleedkadous merged commit e132ddb into mainSep 4, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render gate: tighten the loose agy prompt marker (AGY_MARKER = /^> /)

2 participants

@mohidmakhdoomi@waleedkadous
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette - #1491

Merged
waleedkadous merged 14 commits into
mainfrom
builder/air-1474
Sep 4, 2026
Merged

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette#1491
waleedkadous merged 14 commits into
mainfrom
builder/air-1474

Conversation

@mohidmakhdoomi

Copy link
Copy Markdown
Collaborator

Implements #1474.

What was actually wrong

AGY_MARKER = /^> / (gate-profiles.ts:72) treated any line starting with > as agy's
composer prompt, and findMarkerRow is last-match-wins. The issue called this "the weakest link"
in an empirically-derived profile. Measured against a real, authenticated agy 1.1.13 under a
PTY at 110×32, it is worse than a theoretical looseness — non-composer > rows are routine:

real screenlast > row the old marker pickedthat row's fgcursor rowthe actual composer
idle (accept-edits)11palette 121111 ✅
idle (no-hint mode)none — bare > never matched /^> /palette 121111 ❌
draft24palette 122424 ✅
slash menu (/)13 — the menu's selection cursorpalette 121111 ❌
trust dialog8 — the selected optionpalette 1212 (off-row)none ❌
settled after an answer20palette 122020 ✅
torn mid-repaint frame10 — the transcript echo of the sent turnpalette 4variesabsent ❌

Three findings drove the design:

  1. agy echoes every submitted turn into the transcript as a > line (SGR 34;1 → palette 4).
    Non-composer > rows accumulate one per conversation turn.
  2. The slash menu's selection cursor is also > , also palette-12, and renders BELOW the
    composer
    — so it won the marker scan outright. A color anchor alone does not separate
    these; the cursor row does.
  3. In agy's no-hint mode the composer is a bare >, which right-trims to ">" and never
    matched /^> / — so the gate held every message to an agy in that mode forever. A
    pre-existing false-HOLD, fixed here, since the issue's ask is that the marker identify the
    actual composer.

Markdown blockquotes turned out to render as , not > — so the issue's "quoted output" risk
arrives via the turn echo rather than via blockquotes.

Honest scope of the defect. I could not reproduce an actual false-CLEAN from the captures: on
every mis-bounded frame the wrong region still happened to contain counted text, so the verdict
landed busy anyway. What is demonstrated is that the classifier bounds the wrong region on
real screens
, and that getting busy out of a wrong region is luck rather than a guarantee. The
corruption risk is latent, not observed — stated plainly rather than overclaimed.

The change

Two optional, per-app GateProfile anchors — profile data, in keeping with the spike's
constraint 9 — set only on agy, both measured:

  • markerRequiresCursorRow: true — the marker row must hold the buffer cursor. Only the live
    input row does; menu rows, dialog options and transcript echoes never do.
  • markerFgPalette: 12 — the marker glyph's own color, which separates the composer from the
    palette-4 turn echo.

Plus the marker separator relaxed to /^>(\s|$)/ for the bare-> mode. claude/codex set neither
anchor and are byte-for-byte unaffected (pinned by a test).

Every failure direction is toward HOLD: a row that fails an anchor is simply not a marker, so
drift yields no-composer-marker → held and re-checked, never a false clean. Sustained holds
already escalate through the mailbox liveness telemetry (recordStreak).

Verdict changes on the real captures

fixturebeforeafter
agy-idle.cleancleanclean
agy-draft.busybusy / user-textbusy / user-text
agy-menu.busybusy / no-region-end (marker was the menu item — right verdict, wrong reason)busy / user-text (right region: the / typed in the composer)
agy-trust.busybusy / no-region-end (incidental — no rule under the option)busy / no-composer-marker (honest: there is no composer)
agy-torn-echo.busybusy / no-region-end (marker was the palette-4 echo)busy / no-composer-marker
agy-turn-echo.cleancleanclean
agy-baremarker.cleanbusy / no-composer-marker (false HOLD)clean

Cost of the tightening, measured

Sweeping every byte-prefix of a real stream showed 70 CLEAN frames before vs 9 after — alarming
until you notice byte-prefix sampling cuts mid-repaint, which production never does. Sampling
the way production actually classifies (wall-clock, against a live agy driven through boot → idle
→ streaming → settled, classifying the mirror every 200 ms) the two profiles are identical:

idle samples= 30 OLD clean=100% NEW clean=100%
streaming samples= 205 OLD clean= 98% NEW clean= 98%
settled samples= 40 OLD clean=100% NEW clean=100%

No measurable false-HOLD cost, and the bare-> mode goes from permanently held to deliverable.

Fixtures

The issue asked for captured real-agy fixtures across states, and agy is authenticated in this
environment, so the three synthesized Phase-3 fixtures are replaced by seven real captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn frame). agy's banner embeds the
account email and session cwd; both are replaced with same-length placeholders so the rendered
screen stays byte-for-byte equivalent — no attribute is retouched. Each file is 1.7–8.7 KB, so
they are committed plain rather than gzipped (gzip is used for the multi-hundred-KB claude
replays). The torn-frame fixture is real bytes cut mid-repaint, which is the tear shape #1361
documents for the adopt seed.

Tests

55 pass in render-gate.test.ts. New coverage: per-fixture verdicts for all seven states; the
reason for each (a mis-bounded region that returns busy is not the same as a correctly-bounded
one); synthetic branch tests for each anchor independently, including the palette-12 dialog option
with the cursor on it — the one shape the anchors cannot reject, which pins that the occupancy
count still catches it; and a guard that claude/codex are unaffected by cursor position.

porch check 1474: build ✓ (13.1s), tests ✓ (28.7s).

Notes for the reviewer

  • The cursor-row anchor assumes buffer.cursorY is viewport-relative, the same convention
    isGhostCursorCell already relies on. True whenever viewportY === baseY (no manual scrollback),
    which holds on both gate paths.
  • The one shape neither anchor rejects is a dialog that parks the cursor on a palette-12 > option.
    The trust dialog does not (measured), and the occupancy count plus the region-end guard both still
    catch it — but it is the seam to watch if agy adds dialogs.

🤖 Generated with Claude Code

mohidmakhdoomiand others added 5 commits August 17, 2026 19:44
… and marker palette
`AGY_MARKER = /^> /` treated any `> `-prefixed line as agy's composer. Measured against
real agy 1.1.13 under a PTY, that is not a hypothetical looseness: agy echoes every
submitted turn into the transcript as `> <message>` (palette-4), its slash-menu selection
cursor is also `> ` in palette-12 and renders BELOW the composer, and the trust dialog's
selected option is `> Yes, I trust this folder`. Since `findMarkerRow` is last-match-wins,
the menu item and the turn echo won the marker scan over the composer — so the classifier
bounded and scanned the wrong region on ordinary screens.
Adds two optional, per-app `GateProfile` anchors, both set only on agy and both measured:
`markerRequiresCursorRow` (the marker row must hold the cursor — only the live input row
does) and `markerFgPalette` (the marker glyph's own color, 12, which separates the composer
from the palette-4 turn echo). Also relaxes the marker separator to `\s|$`: agy's no-hint
mode renders the empty composer as a bare `>`, which `/^> /` never matched, so the gate
held every message to an agy in that mode forever.
Replaces the three synthesized agy fixtures with seven real, sanitized captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn mid-repaint frame).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er thread
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Test status, stated precisely

porch check 1474: build ✓ (13.5s), unit tests ✓ (28.6s) — 55 pass in render-gate.test.ts.

The e2e_tests gate check is a no-op here. AIR defines it as npm run test:e2e 2>&1 || echo 'e2e tests skipped (not configured)' and the repo root has no test:e2e script, so it reports ✓ in 0.1s having executed nothing. It is marked optional in the protocol, so that is intended — but it is not evidence, so I ran the real suite:

pnpm --filter @cluesmith/codev test:e2e
Test Files 1 failed | 19 passed | 5 skipped (25)
Tests 3 failed | 171 passed | 21 skipped (195)

All 3 failures are in tower-api.e2e.test.tsPOST /api/terminals returning 500 where 201 is expected.

They are pre-existing and unrelated to this PR. Verified rather than asserted: I reverted both changed source files to the branch base (141b493), rebuilt, and re-ran that file — identical 3 failures. Files restored afterwards (all committed, so lossless; working tree verified clean against HEAD, unit tests re-run green).

I did not skip or annotate them as flaky, because they are neither mine nor intermittent — they reproduce consistently. Flagging for a maintainer instead. Plausibly environmental: this machine is running four-plus concurrent builder sessions plus Tower, and all three failures are PTY-spawn-through-the-API.

@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Architect integration review — 3-way CMAP (risk tier: High — render-gate classifier, false-CLEAN direction)

Verdicts: gemini APPROVE · codex APPROVE · claude COMMENT ("merge it, with follow-ups") — all HIGH confidence, zero blocking correctness issues. Architect read concurs: the two positive-evidence anchors (markerRequiresCursorRow, markerFgPalette) fit the existing per-app-profile seam, every failure direction lands on no-composer-marker → HOLD, and claude/codex profiles are untouched. The real-capture measurement table is the load-bearing artifact, and the PR's refusal to overclaim (mis-bounding demonstrated, false-CLEAN latent; no-hint 'held forever' rests on live observation) is noted and appreciated.

Independently verified across the reviews: viewport-relative cursorY is sound on the production gate (viewportY === baseY for SessionScreen); the tightening's failure direction is observable (no-composer-marker feeds isClassifierStuck → escalation at streak 10); fixture sanitization is complete (no emails/paths survive); the bare-> relaxation is gated behind both anchors so it cannot loosen anything — and it fixes a real pre-existing total delivery outage in agy's no-hint mode.

Requested before the gate (one item)

  1. Add an agy case to the production-mirror-path suite (render-gate.test.ts:364). These are the first anchors that depend on cursor state, the one dimension where the transient path and the SessionScreen mirror path could conceivably diverge — a parity test (same fixture, same verdict via both paths) closes the only untested seam between what the tests prove and what production runs.

Follow-ups (this PR or noted for later — builder's call, say which)

  • arch.md:1795 still describes the agy gate as a placeholderFgPalette rule; the definition of "marker present" changed for agy. Two-line touch or explicit MAINTAIN deferral.
  • Commit the capture/sanitization harness (Spec 1313 precedent: codev/spir-1313-captures/*.mjs) so re-measuring against a future agy doesn't start from the PR description.
  • Cosmetic: required-fixtures test title still says "idle/draft/trust" while requiring seven.
  • Keep the dual 4-bit/256-color palette-12 matching — it is deliberate, not redundant (real fixtures use [94m, synthetic [38;5;12m).

For the maintainer

Parked for maintainer approval + merge; we are not maintainers.

…low-ups
CMAP requested item: the agy anchors are the first classifier input that depends on
CURSOR STATE, and the cursor is the one dimension where the transient replay path and the
long-lived SessionScreen mirror could diverge. Adds a per-fixture parity case to the
production-mirror-path suite — same bytes through both paths, same verdict — fed twice,
once in production-sized chunks and once in 7-byte chunks that deliberately split the
cursor-positioning CSI across feed() calls, since a mis-parsed cursor is now a verdict
change rather than a cosmetic one.
Follow-ups taken in-PR rather than deferred:
- arch.md: "marker present" now means more than a text match for agy — records the two
anchors and their fail-toward-HOLD direction.
- Commits the capture + sanitization harness (codev/air-1474-captures/) so re-measuring
against a future agy starts from a script, not the PR description. Verified by
re-deriving four committed fixtures byte-identically from the raw captures.
- Fixes the sanitizer's leak check, which reported `/home/` on every run because the
placeholder path itself starts with it — a check that always fires checks nothing. Now
matches paths that are not the placeholder, and refuses to write on a leak.
- Cosmetic: required-fixtures test title listed three agy states while requiring seven.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

CMAP items addressed — all four taken in-PR, none deferred

Requested before the gate ✅

agy case in the production-mirror-path suite (render-gate.test.ts). Agreed on the reasoning: these anchors are the first classifier input that depends on cursor state, so the transient-vs-mirror seam is newly load-bearing. Added a parity case per agy fixture (7 of them) rather than one representative — same bytes through both paths, toEqual on the whole verdict, plus an assertion that the transient path's own verdict matches the filename, so the parity check can't pass by both paths being wrong together.

Each fixture is fed twice: once in production-sized 64 KiB chunks, and once in 7-byte chunks that deliberately split escape sequences across feed() calls. That second pass is the one with teeth here — the cursor-positioning CSI (ESC[<row>;<col>H, and the relative ESC[2A/ESC[2C agy actually emits) is exactly what a torn chunk boundary would corrupt, and a mis-parsed cursor is now a verdict change rather than a cosmetic one. Both pass. 62 tests in the file; full package suite green (4876 passed / 48 skipped).

Follow-ups — taken, not deferred

  • arch.md ✅ — the agy entry now records that "marker present" is more than a text match, names both anchors, and states the fail-toward-no-composer-marker → HOLD direction.
  • Capture/sanitization harness ✅ — committed at codev/air-1474-captures/ (capture script, sanitizer, README covering auth prerequisite, per-state recipes, the 110×32 geometry constraint, and how the torn fixture is derived). Verified rather than assumed: re-ran the committed sanitizer against the raw captures and re-derived four committed fixtures byte-identically.
  • Cosmetic test title ✅ — now lists all seven agy states.
  • Dual 4-bit/256-color palette matching — kept as-is, per your note that it is deliberate.

One thing the harness work turned up

The sanitizer's leak check was self-defeating: it tested for the bare prefixes /home/ and /tmp/, but the replacement path is /home/agent/project, so it reported a leak on every successful run — including on the fixtures already in this PR. A check that always fires checks nothing, and I'd rather not ship that as the documented safety step. It now matches paths that are not the placeholder, and refuses to write the fixture if anything survives. Re-verified against all four captures: leaks=[], output byte-identical.

To be clear about what this did and didn't mean: the committed fixtures were always clean — I verified that independently with a direct grep before the first push, and the byte-identical re-derivation confirms it again. The defect was in the check, not in the fixtures.

Ready for the pr gate.

@waleedkadous

Copy link
Copy Markdown
Contributor

Thanks for the patience on this one — the review queue is moving now. This PR is showing as CONFLICTING against main after this week's merges in the same area (serializer convergence #1492, delivery verification #1573/#1584, self-attesting frames #1574, and the recipient/reply-hint changes to message-format.ts and tower-routes.ts). Could you rebase onto current main when you get a chance? I'll review the moment it's green — the four non-conflicting PRs in your list are in review right now.

mohidmakhdoomiand others added 2 commits September 3, 2026 14:19
Resolves the one conflict in render-gate.ts: main added the exported
`bufferLines()` helper (#1573 echo verification) directly above
`findMarkerRow`, whose doc and signature this branch rewrote for the
cursor-row/palette anchors. Both are kept — they are adjacent, not
competing.
Typecheck clean; render-gate suite 66/66.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alarm
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Brought up to current main — conflicts resolved, CI green, architect integration check

This branch had gone CONFLICTING (770 commits behind). It is now MERGEABLE at 470d49b2d, with all 7 checks passing, via a merge of origin/main (818337fa2) — not a rebase, per this repo's merge-never-squash convention. (mergeStateStatus: BLOCKED is now only the missing maintainer approval; no conflict, no failing check.)

The conflict was adjacency, not disagreement

Exactly one file conflicted: packages/codev/src/agent-farm/servers/render-gate.ts. The sole commit to touch it since the merge-base was #1573 (35f2e637f), whose change there is purely additive — one new exported bufferLines(term), inserted directly above findMarkerRow, whose doc comment and signature this branch had rewritten for the cursor-row/palette anchors. Both sides kept verbatim.

The two functions now sit adjacent and are worth keeping straight:

Architect verification (independent of the builder's report)

Post-merge suite: 275 files passed / 3 skipped / 0 failed; 5477 tests passed, typecheck clean. The render-gate suite is 66 cases now rather than 55 — main's own additions to that file merged in cleanly alongside these.

Two notes for whoever merges

  1. Merge-order: PR Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 (issue Render gate: replace the perf wall-clock assertion with a deterministic op-count check #1471) also touches render-gate.test.ts, in a different region. Whichever of Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 / [Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette #1491 merges second may need a trivial rebase.
  2. Resuming any long-parked branch in this repo: the first post-merge pnpm test here reported 14 failures in request-auth.test.ts / tower-routes.test.ts (WS_KEY_PROTOCOL_PREFIX, TOWER_KEY_HEADER arriving undefined). All falsepackages/types/dist predated the constants main had since added, and node_modules predated a new three dependency. pnpm install && pnpm build first, then believe a test result.

Status

Parked for maintainer review — we are not maintainers of this repo, so we are not merging this. Please approve and merge when it suits you. Issue #1474 stays open and the worktree stays intact until you do.

mohidmakhdoomi added a commit that referenced this pull request Sep 3, 2026
Retrospective at codev/reviews/1482-f1-tower-vs-pty-dimension-dive.md, plus
the governance routing it describes.
The Summary leads with the user-visible harm rather than the mechanism: the
owner starvation notice offered `afx interrupt` for EVERY hold, and for a
`user-text` hold that is advice to interrupt a person mid-draft. The dims fix
and the detail column are how that becomes possible to tell apart, not the
point in themselves.
Both gaps the human approved knowingly are stated in the review rather than
left for a reviewer to find: the dashboard popover still renders a bare `busy`
(reverted deliberately — no worktree.devCommand, no Playwright here, and the
project requires a browser check), and evidence item 3 is covered by test
rather than induced live because send.ts constructs `new TowerClient()` with
no port and so reaches only the live Tower on 4100.
Also called out for the maintainer: the beyond-plan 409 RESIZE_DROPPED route
change and CronDeliveryResult gaining `detail`; the v18-vs-v17 migration
collision surface (in two places — the constant and the pinned source
assertion that reads it back); and the conflict surface with the two parked
PRs, #1486 (commands/inbox.ts, commands/send.ts, tower-routes.ts) and #1491
(render-gate.ts, render-gate.test.ts).
**Governance routed COLD only.** Both hot files sit exactly at their caps
(10 facts / 10 lessons) and nothing here justified displacing an existing
entry; the hot map already routes readers to "Invariants & Constraints", so
the tiering works without growing the always-injected tier.
- arch.md: new invariant #10, "Terminal dimensions must be earned, not
assumed" (requested/applied/outstanding, 409 vs 404, WELCOME adoption, and
why the gate depends on it); and the Spec 1313 mailbox section's response
vocabulary extended with the v18 `detail` column and why it carries no CHECK
constraint.
- lessons-learned.md: three Architecture entries (a failure boolean is only as
honest as its callers; two-state models fail at the third state — the
missing bit was OUTSTANDING, not DIFFERENT; commit derived state only after
what it describes is confirmed) and three Process entries (read the ignore
rule's own reason before `git add -f`; report the boundary of a gap, not the
instance you tripped over; mutation-check a test written to close someone
else's finding).
Full suite green on this tree: 278 files passed / 3 skipped, 5495 tests passed
/ 48 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Really thorough work — seven real agy captures, per-fixture mirror-vs-transient parity tests (including the 7-byte chunking that splits cursor CSIs), and a failure direction that stays HOLD rather than deliver. The render-gate change itself is approve-quality and claude's lane approved it outright; the one thing I'd like fixed before merge is in the capture tooling, because it's a leak-safety guarantee that currently isn't one:

  1. codev/air-1474-captures/sanitize.py writes the output before running its leak check (the open(dst, …).write(data) at ~line 137 precedes the # Leak check block). If identifiers survive, it raises only after the unsafe fixture is already on disk — contradicting its own "REFUSING to sanitize" message and creating an accidental-commit risk. Run every check before writing, or delete dst on failure. Relatedly, its path regex misses /Users/…, so a macOS capture would pass the check while leaking a username (the committed fixtures are clean; this is about the next capture).

Two small things you might consider alongside, neither blocking:

  • buf.cursorY is baseY-relative in xterm's public contract; the code relies on viewportY === baseY. buf.baseY + buf.cursorY - buf.viewportY removes that assumption for free, and it's now load-bearing for delivery.
  • Idle-session drift is silent — onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm, the same shape as the bare-> bug this fixes. A follow-up on profile-drift observability (classifier-stuck escalation for idle sessions, or an afx doctor check that classifies each live mirror once) would close that.

Also noting markerFgPalette will want to accept multiple colors before agy ships a truecolor theme — follow-up territory.

…hrough baseY
Addresses the PR #1491 maintainer review.
BLOCKING — sanitize.py wrote the output file before checking it for leaks, so a
capture that still carried an identifier landed on disk and the "REFUSING to
sanitize" message was false by the time it printed. Now every check runs before
anything reaches the filesystem, chosen over delete-on-failure: a guarantee that
depends on cleanup running is weaker than one that never creates the file.
PATH_RE also gains /Users/, without which a macOS capture sanitizes cleanly while
leaking a username — the prefixes the scrubber rewrites and the prefixes the leak
check inspects have to be one list.
Writing the --selftest exposed a second defect in the predicate: same-length
substitution truncates a cwd shorter than the placeholder (/home/ab/x becomes
/home/agent/p), which failed startswith() and read as a leak, making every
short-cwd capture unsanitizable. is_placeholder() now accepts prefixes of the
placeholder. All seven committed fixtures re-verified clean under it.
The selftest runs the refusal path for real — it stubs out the redaction, pushes
a leaking capture through, and stats the output path — and CI runs the selftest
(air-1474-sanitize-ordering.test.ts, skipped where python3 is absent).
NON-BLOCKING — classifyBuffer read buf.cursorY alongside top = buf.viewportY,
assuming viewportY === baseY. Expected to be hygiene; measured as a real false
clean. With the viewport scrolled up, the unconverted row lands on a stale
composer still in scrollback — empty, palette-12, rule beneath it — so the region
bounds and classifies `empty`: CLEAN, while the live composer is off-view holding
a draft. Verified against the built old code ({clean: true, detail: 'empty'}) and
the fix (no-composer-marker). baseY + cursorY - top removes it.
Both new tests mutation-checked: revert either fix and the matching test fails.
pnpm test: 276 files / 5479 tests pass, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Thanks — the blocking item was right, and chasing it turned up a second defect in the same file.

Blocking: sanitize.py wrote before checking. Fixed by check-then-write rather than delete-on-failure, for the reason you gave: a guarantee that depends on cleanup running is weaker than one that never creates the file. PATH_RE gains /Users/… — you were right that the committed fixtures are clean and this is about the next capture; the underlying rule is that the prefixes the scrubber rewrites and the prefixes the leak check inspects have to be one list, or the check is narrower than the risk.

Writing the self-check exposed a second defect, in the predicate I had already "fixed" once. Same-length substitution truncates a cwd shorter than the placeholder, so /home/ab/x/home/agent/p, which failed startswith(PLACEHOLDER_PATH) and was reported as a leak — every short-cwd capture would have been unsanitizable. Fail-safe, so it would have surfaced as a mysterious refusal rather than a leak, which is the kind of thing that gets worked around by disabling the check. is_placeholder() now accepts prefixes of the placeholder too, and all seven committed fixtures re-verify leaks=[] under the widened predicate.

Per your note about proving rather than asserting: sanitize.py --selftest stubs out the redaction, pushes a leaking capture through, and stats the output path to confirm no file exists. air-1474-sanitize-ordering.test.ts runs it in CI. The rest of the harness still stays out of CI (it needs an authenticated agy and a PTY), but this part's failure mode is committing someone's username, so it runs. Mutation-checked: restore write-then-check and the test fails with leaking capture left /tmp/…/fixture.txt on disk.

Non-blocking, cursorY vs viewportY: taken, and it was not hygiene. I went to write a defensive test and measured a real false clean. Scroll the viewport up and the unconverted row lands on a stale composer still in scrollback — empty, palette-12, rule beneath it — so the region bounds and classifies empty. CLEAN, while the live composer is off-view holding a half-typed draft. Confirmed against the built old code ({clean: true, detail: "empty"}) and the fix (no-composer-marker). buf.baseY + buf.cursorY - top removes it; comments at both sites updated.

The regression test pins the rigging itself — that the viewport is genuinely off the bottom, that the two conventions disagree, and that the row the old reading picked really was a marker+rule pair — so it fails loudly rather than quietly becoming a tautology if xterm changes. Mutation-checked the same way.

Non-blocking, not done here, and I agree it is a real gap: silent idle-session profile drift. onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm — structurally the same failure as the bare-> bug this PR fixes, which is what makes it worth its own issue rather than a rider on this one. Same for markerFgPalette accepting multiple colors before agy ships a truecolor theme. Both are being filed separately.

pnpm test: 276 files / 5479 tests pass, 0 failed.

waleedkadous
waleedkadous previously approved these changes Sep 4, 2026

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-verified: sanitize_file now finds leaks before any write and refuses with nothing on disk, backed by a self-test that stats the path; /Users/… added to PATH_RE so the scrub set and the leak-check set are one list; and the short-cwd false-positive you found while writing the self-test is exactly the kind of second defect a real check surfaces. CI 7/7 on 8d9d666. Approving and merging.

@waleedkadous
waleedkadous merged commit e132ddb into mainSep 4, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render gate: tighten the loose agy prompt marker (AGY_MARKER = /^> /)

2 participants

@mohidmakhdoomi@waleedkadous
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette - #1491

Merged
waleedkadous merged 14 commits into
mainfrom
builder/air-1474
Sep 4, 2026
Merged

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette#1491
waleedkadous merged 14 commits into
mainfrom
builder/air-1474

Conversation

@mohidmakhdoomi

Copy link
Copy Markdown
Collaborator

Implements #1474.

What was actually wrong

AGY_MARKER = /^> / (gate-profiles.ts:72) treated any line starting with > as agy's
composer prompt, and findMarkerRow is last-match-wins. The issue called this "the weakest link"
in an empirically-derived profile. Measured against a real, authenticated agy 1.1.13 under a
PTY at 110×32, it is worse than a theoretical looseness — non-composer > rows are routine:

real screenlast > row the old marker pickedthat row's fgcursor rowthe actual composer
idle (accept-edits)11palette 121111 ✅
idle (no-hint mode)none — bare > never matched /^> /palette 121111 ❌
draft24palette 122424 ✅
slash menu (/)13 — the menu's selection cursorpalette 121111 ❌
trust dialog8 — the selected optionpalette 1212 (off-row)none ❌
settled after an answer20palette 122020 ✅
torn mid-repaint frame10 — the transcript echo of the sent turnpalette 4variesabsent ❌

Three findings drove the design:

  1. agy echoes every submitted turn into the transcript as a > line (SGR 34;1 → palette 4).
    Non-composer > rows accumulate one per conversation turn.
  2. The slash menu's selection cursor is also > , also palette-12, and renders BELOW the
    composer
    — so it won the marker scan outright. A color anchor alone does not separate
    these; the cursor row does.
  3. In agy's no-hint mode the composer is a bare >, which right-trims to ">" and never
    matched /^> / — so the gate held every message to an agy in that mode forever. A
    pre-existing false-HOLD, fixed here, since the issue's ask is that the marker identify the
    actual composer.

Markdown blockquotes turned out to render as , not > — so the issue's "quoted output" risk
arrives via the turn echo rather than via blockquotes.

Honest scope of the defect. I could not reproduce an actual false-CLEAN from the captures: on
every mis-bounded frame the wrong region still happened to contain counted text, so the verdict
landed busy anyway. What is demonstrated is that the classifier bounds the wrong region on
real screens
, and that getting busy out of a wrong region is luck rather than a guarantee. The
corruption risk is latent, not observed — stated plainly rather than overclaimed.

The change

Two optional, per-app GateProfile anchors — profile data, in keeping with the spike's
constraint 9 — set only on agy, both measured:

  • markerRequiresCursorRow: true — the marker row must hold the buffer cursor. Only the live
    input row does; menu rows, dialog options and transcript echoes never do.
  • markerFgPalette: 12 — the marker glyph's own color, which separates the composer from the
    palette-4 turn echo.

Plus the marker separator relaxed to /^>(\s|$)/ for the bare-> mode. claude/codex set neither
anchor and are byte-for-byte unaffected (pinned by a test).

Every failure direction is toward HOLD: a row that fails an anchor is simply not a marker, so
drift yields no-composer-marker → held and re-checked, never a false clean. Sustained holds
already escalate through the mailbox liveness telemetry (recordStreak).

Verdict changes on the real captures

fixturebeforeafter
agy-idle.cleancleanclean
agy-draft.busybusy / user-textbusy / user-text
agy-menu.busybusy / no-region-end (marker was the menu item — right verdict, wrong reason)busy / user-text (right region: the / typed in the composer)
agy-trust.busybusy / no-region-end (incidental — no rule under the option)busy / no-composer-marker (honest: there is no composer)
agy-torn-echo.busybusy / no-region-end (marker was the palette-4 echo)busy / no-composer-marker
agy-turn-echo.cleancleanclean
agy-baremarker.cleanbusy / no-composer-marker (false HOLD)clean

Cost of the tightening, measured

Sweeping every byte-prefix of a real stream showed 70 CLEAN frames before vs 9 after — alarming
until you notice byte-prefix sampling cuts mid-repaint, which production never does. Sampling
the way production actually classifies (wall-clock, against a live agy driven through boot → idle
→ streaming → settled, classifying the mirror every 200 ms) the two profiles are identical:

idle samples= 30 OLD clean=100% NEW clean=100%
streaming samples= 205 OLD clean= 98% NEW clean= 98%
settled samples= 40 OLD clean=100% NEW clean=100%

No measurable false-HOLD cost, and the bare-> mode goes from permanently held to deliverable.

Fixtures

The issue asked for captured real-agy fixtures across states, and agy is authenticated in this
environment, so the three synthesized Phase-3 fixtures are replaced by seven real captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn frame). agy's banner embeds the
account email and session cwd; both are replaced with same-length placeholders so the rendered
screen stays byte-for-byte equivalent — no attribute is retouched. Each file is 1.7–8.7 KB, so
they are committed plain rather than gzipped (gzip is used for the multi-hundred-KB claude
replays). The torn-frame fixture is real bytes cut mid-repaint, which is the tear shape #1361
documents for the adopt seed.

Tests

55 pass in render-gate.test.ts. New coverage: per-fixture verdicts for all seven states; the
reason for each (a mis-bounded region that returns busy is not the same as a correctly-bounded
one); synthetic branch tests for each anchor independently, including the palette-12 dialog option
with the cursor on it — the one shape the anchors cannot reject, which pins that the occupancy
count still catches it; and a guard that claude/codex are unaffected by cursor position.

porch check 1474: build ✓ (13.1s), tests ✓ (28.7s).

Notes for the reviewer

  • The cursor-row anchor assumes buffer.cursorY is viewport-relative, the same convention
    isGhostCursorCell already relies on. True whenever viewportY === baseY (no manual scrollback),
    which holds on both gate paths.
  • The one shape neither anchor rejects is a dialog that parks the cursor on a palette-12 > option.
    The trust dialog does not (measured), and the occupancy count plus the region-end guard both still
    catch it — but it is the seam to watch if agy adds dialogs.

🤖 Generated with Claude Code

mohidmakhdoomiand others added 5 commits August 17, 2026 19:44
… and marker palette
`AGY_MARKER = /^> /` treated any `> `-prefixed line as agy's composer. Measured against
real agy 1.1.13 under a PTY, that is not a hypothetical looseness: agy echoes every
submitted turn into the transcript as `> <message>` (palette-4), its slash-menu selection
cursor is also `> ` in palette-12 and renders BELOW the composer, and the trust dialog's
selected option is `> Yes, I trust this folder`. Since `findMarkerRow` is last-match-wins,
the menu item and the turn echo won the marker scan over the composer — so the classifier
bounded and scanned the wrong region on ordinary screens.
Adds two optional, per-app `GateProfile` anchors, both set only on agy and both measured:
`markerRequiresCursorRow` (the marker row must hold the cursor — only the live input row
does) and `markerFgPalette` (the marker glyph's own color, 12, which separates the composer
from the palette-4 turn echo). Also relaxes the marker separator to `\s|$`: agy's no-hint
mode renders the empty composer as a bare `>`, which `/^> /` never matched, so the gate
held every message to an agy in that mode forever.
Replaces the three synthesized agy fixtures with seven real, sanitized captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn mid-repaint frame).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er thread
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Test status, stated precisely

porch check 1474: build ✓ (13.5s), unit tests ✓ (28.6s) — 55 pass in render-gate.test.ts.

The e2e_tests gate check is a no-op here. AIR defines it as npm run test:e2e 2>&1 || echo 'e2e tests skipped (not configured)' and the repo root has no test:e2e script, so it reports ✓ in 0.1s having executed nothing. It is marked optional in the protocol, so that is intended — but it is not evidence, so I ran the real suite:

pnpm --filter @cluesmith/codev test:e2e
Test Files 1 failed | 19 passed | 5 skipped (25)
Tests 3 failed | 171 passed | 21 skipped (195)

All 3 failures are in tower-api.e2e.test.tsPOST /api/terminals returning 500 where 201 is expected.

They are pre-existing and unrelated to this PR. Verified rather than asserted: I reverted both changed source files to the branch base (141b493), rebuilt, and re-ran that file — identical 3 failures. Files restored afterwards (all committed, so lossless; working tree verified clean against HEAD, unit tests re-run green).

I did not skip or annotate them as flaky, because they are neither mine nor intermittent — they reproduce consistently. Flagging for a maintainer instead. Plausibly environmental: this machine is running four-plus concurrent builder sessions plus Tower, and all three failures are PTY-spawn-through-the-API.

@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Architect integration review — 3-way CMAP (risk tier: High — render-gate classifier, false-CLEAN direction)

Verdicts: gemini APPROVE · codex APPROVE · claude COMMENT ("merge it, with follow-ups") — all HIGH confidence, zero blocking correctness issues. Architect read concurs: the two positive-evidence anchors (markerRequiresCursorRow, markerFgPalette) fit the existing per-app-profile seam, every failure direction lands on no-composer-marker → HOLD, and claude/codex profiles are untouched. The real-capture measurement table is the load-bearing artifact, and the PR's refusal to overclaim (mis-bounding demonstrated, false-CLEAN latent; no-hint 'held forever' rests on live observation) is noted and appreciated.

Independently verified across the reviews: viewport-relative cursorY is sound on the production gate (viewportY === baseY for SessionScreen); the tightening's failure direction is observable (no-composer-marker feeds isClassifierStuck → escalation at streak 10); fixture sanitization is complete (no emails/paths survive); the bare-> relaxation is gated behind both anchors so it cannot loosen anything — and it fixes a real pre-existing total delivery outage in agy's no-hint mode.

Requested before the gate (one item)

  1. Add an agy case to the production-mirror-path suite (render-gate.test.ts:364). These are the first anchors that depend on cursor state, the one dimension where the transient path and the SessionScreen mirror path could conceivably diverge — a parity test (same fixture, same verdict via both paths) closes the only untested seam between what the tests prove and what production runs.

Follow-ups (this PR or noted for later — builder's call, say which)

  • arch.md:1795 still describes the agy gate as a placeholderFgPalette rule; the definition of "marker present" changed for agy. Two-line touch or explicit MAINTAIN deferral.
  • Commit the capture/sanitization harness (Spec 1313 precedent: codev/spir-1313-captures/*.mjs) so re-measuring against a future agy doesn't start from the PR description.
  • Cosmetic: required-fixtures test title still says "idle/draft/trust" while requiring seven.
  • Keep the dual 4-bit/256-color palette-12 matching — it is deliberate, not redundant (real fixtures use [94m, synthetic [38;5;12m).

For the maintainer

Parked for maintainer approval + merge; we are not maintainers.

…low-ups
CMAP requested item: the agy anchors are the first classifier input that depends on
CURSOR STATE, and the cursor is the one dimension where the transient replay path and the
long-lived SessionScreen mirror could diverge. Adds a per-fixture parity case to the
production-mirror-path suite — same bytes through both paths, same verdict — fed twice,
once in production-sized chunks and once in 7-byte chunks that deliberately split the
cursor-positioning CSI across feed() calls, since a mis-parsed cursor is now a verdict
change rather than a cosmetic one.
Follow-ups taken in-PR rather than deferred:
- arch.md: "marker present" now means more than a text match for agy — records the two
anchors and their fail-toward-HOLD direction.
- Commits the capture + sanitization harness (codev/air-1474-captures/) so re-measuring
against a future agy starts from a script, not the PR description. Verified by
re-deriving four committed fixtures byte-identically from the raw captures.
- Fixes the sanitizer's leak check, which reported `/home/` on every run because the
placeholder path itself starts with it — a check that always fires checks nothing. Now
matches paths that are not the placeholder, and refuses to write on a leak.
- Cosmetic: required-fixtures test title listed three agy states while requiring seven.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

CMAP items addressed — all four taken in-PR, none deferred

Requested before the gate ✅

agy case in the production-mirror-path suite (render-gate.test.ts). Agreed on the reasoning: these anchors are the first classifier input that depends on cursor state, so the transient-vs-mirror seam is newly load-bearing. Added a parity case per agy fixture (7 of them) rather than one representative — same bytes through both paths, toEqual on the whole verdict, plus an assertion that the transient path's own verdict matches the filename, so the parity check can't pass by both paths being wrong together.

Each fixture is fed twice: once in production-sized 64 KiB chunks, and once in 7-byte chunks that deliberately split escape sequences across feed() calls. That second pass is the one with teeth here — the cursor-positioning CSI (ESC[<row>;<col>H, and the relative ESC[2A/ESC[2C agy actually emits) is exactly what a torn chunk boundary would corrupt, and a mis-parsed cursor is now a verdict change rather than a cosmetic one. Both pass. 62 tests in the file; full package suite green (4876 passed / 48 skipped).

Follow-ups — taken, not deferred

  • arch.md ✅ — the agy entry now records that "marker present" is more than a text match, names both anchors, and states the fail-toward-no-composer-marker → HOLD direction.
  • Capture/sanitization harness ✅ — committed at codev/air-1474-captures/ (capture script, sanitizer, README covering auth prerequisite, per-state recipes, the 110×32 geometry constraint, and how the torn fixture is derived). Verified rather than assumed: re-ran the committed sanitizer against the raw captures and re-derived four committed fixtures byte-identically.
  • Cosmetic test title ✅ — now lists all seven agy states.
  • Dual 4-bit/256-color palette matching — kept as-is, per your note that it is deliberate.

One thing the harness work turned up

The sanitizer's leak check was self-defeating: it tested for the bare prefixes /home/ and /tmp/, but the replacement path is /home/agent/project, so it reported a leak on every successful run — including on the fixtures already in this PR. A check that always fires checks nothing, and I'd rather not ship that as the documented safety step. It now matches paths that are not the placeholder, and refuses to write the fixture if anything survives. Re-verified against all four captures: leaks=[], output byte-identical.

To be clear about what this did and didn't mean: the committed fixtures were always clean — I verified that independently with a direct grep before the first push, and the byte-identical re-derivation confirms it again. The defect was in the check, not in the fixtures.

Ready for the pr gate.

@waleedkadous

Copy link
Copy Markdown
Contributor

Thanks for the patience on this one — the review queue is moving now. This PR is showing as CONFLICTING against main after this week's merges in the same area (serializer convergence #1492, delivery verification #1573/#1584, self-attesting frames #1574, and the recipient/reply-hint changes to message-format.ts and tower-routes.ts). Could you rebase onto current main when you get a chance? I'll review the moment it's green — the four non-conflicting PRs in your list are in review right now.

mohidmakhdoomiand others added 2 commits September 3, 2026 14:19
Resolves the one conflict in render-gate.ts: main added the exported
`bufferLines()` helper (#1573 echo verification) directly above
`findMarkerRow`, whose doc and signature this branch rewrote for the
cursor-row/palette anchors. Both are kept — they are adjacent, not
competing.
Typecheck clean; render-gate suite 66/66.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alarm
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Brought up to current main — conflicts resolved, CI green, architect integration check

This branch had gone CONFLICTING (770 commits behind). It is now MERGEABLE at 470d49b2d, with all 7 checks passing, via a merge of origin/main (818337fa2) — not a rebase, per this repo's merge-never-squash convention. (mergeStateStatus: BLOCKED is now only the missing maintainer approval; no conflict, no failing check.)

The conflict was adjacency, not disagreement

Exactly one file conflicted: packages/codev/src/agent-farm/servers/render-gate.ts. The sole commit to touch it since the merge-base was #1573 (35f2e637f), whose change there is purely additive — one new exported bufferLines(term), inserted directly above findMarkerRow, whose doc comment and signature this branch had rewritten for the cursor-row/palette anchors. Both sides kept verbatim.

The two functions now sit adjacent and are worth keeping straight:

Architect verification (independent of the builder's report)

Post-merge suite: 275 files passed / 3 skipped / 0 failed; 5477 tests passed, typecheck clean. The render-gate suite is 66 cases now rather than 55 — main's own additions to that file merged in cleanly alongside these.

Two notes for whoever merges

  1. Merge-order: PR Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 (issue Render gate: replace the perf wall-clock assertion with a deterministic op-count check #1471) also touches render-gate.test.ts, in a different region. Whichever of Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 / [Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette #1491 merges second may need a trivial rebase.
  2. Resuming any long-parked branch in this repo: the first post-merge pnpm test here reported 14 failures in request-auth.test.ts / tower-routes.test.ts (WS_KEY_PROTOCOL_PREFIX, TOWER_KEY_HEADER arriving undefined). All falsepackages/types/dist predated the constants main had since added, and node_modules predated a new three dependency. pnpm install && pnpm build first, then believe a test result.

Status

Parked for maintainer review — we are not maintainers of this repo, so we are not merging this. Please approve and merge when it suits you. Issue #1474 stays open and the worktree stays intact until you do.

mohidmakhdoomi added a commit that referenced this pull request Sep 3, 2026
Retrospective at codev/reviews/1482-f1-tower-vs-pty-dimension-dive.md, plus
the governance routing it describes.
The Summary leads with the user-visible harm rather than the mechanism: the
owner starvation notice offered `afx interrupt` for EVERY hold, and for a
`user-text` hold that is advice to interrupt a person mid-draft. The dims fix
and the detail column are how that becomes possible to tell apart, not the
point in themselves.
Both gaps the human approved knowingly are stated in the review rather than
left for a reviewer to find: the dashboard popover still renders a bare `busy`
(reverted deliberately — no worktree.devCommand, no Playwright here, and the
project requires a browser check), and evidence item 3 is covered by test
rather than induced live because send.ts constructs `new TowerClient()` with
no port and so reaches only the live Tower on 4100.
Also called out for the maintainer: the beyond-plan 409 RESIZE_DROPPED route
change and CronDeliveryResult gaining `detail`; the v18-vs-v17 migration
collision surface (in two places — the constant and the pinned source
assertion that reads it back); and the conflict surface with the two parked
PRs, #1486 (commands/inbox.ts, commands/send.ts, tower-routes.ts) and #1491
(render-gate.ts, render-gate.test.ts).
**Governance routed COLD only.** Both hot files sit exactly at their caps
(10 facts / 10 lessons) and nothing here justified displacing an existing
entry; the hot map already routes readers to "Invariants & Constraints", so
the tiering works without growing the always-injected tier.
- arch.md: new invariant #10, "Terminal dimensions must be earned, not
assumed" (requested/applied/outstanding, 409 vs 404, WELCOME adoption, and
why the gate depends on it); and the Spec 1313 mailbox section's response
vocabulary extended with the v18 `detail` column and why it carries no CHECK
constraint.
- lessons-learned.md: three Architecture entries (a failure boolean is only as
honest as its callers; two-state models fail at the third state — the
missing bit was OUTSTANDING, not DIFFERENT; commit derived state only after
what it describes is confirmed) and three Process entries (read the ignore
rule's own reason before `git add -f`; report the boundary of a gap, not the
instance you tripped over; mutation-check a test written to close someone
else's finding).
Full suite green on this tree: 278 files passed / 3 skipped, 5495 tests passed
/ 48 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Really thorough work — seven real agy captures, per-fixture mirror-vs-transient parity tests (including the 7-byte chunking that splits cursor CSIs), and a failure direction that stays HOLD rather than deliver. The render-gate change itself is approve-quality and claude's lane approved it outright; the one thing I'd like fixed before merge is in the capture tooling, because it's a leak-safety guarantee that currently isn't one:

  1. codev/air-1474-captures/sanitize.py writes the output before running its leak check (the open(dst, …).write(data) at ~line 137 precedes the # Leak check block). If identifiers survive, it raises only after the unsafe fixture is already on disk — contradicting its own "REFUSING to sanitize" message and creating an accidental-commit risk. Run every check before writing, or delete dst on failure. Relatedly, its path regex misses /Users/…, so a macOS capture would pass the check while leaking a username (the committed fixtures are clean; this is about the next capture).

Two small things you might consider alongside, neither blocking:

  • buf.cursorY is baseY-relative in xterm's public contract; the code relies on viewportY === baseY. buf.baseY + buf.cursorY - buf.viewportY removes that assumption for free, and it's now load-bearing for delivery.
  • Idle-session drift is silent — onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm, the same shape as the bare-> bug this fixes. A follow-up on profile-drift observability (classifier-stuck escalation for idle sessions, or an afx doctor check that classifies each live mirror once) would close that.

Also noting markerFgPalette will want to accept multiple colors before agy ships a truecolor theme — follow-up territory.

…hrough baseY
Addresses the PR #1491 maintainer review.
BLOCKING — sanitize.py wrote the output file before checking it for leaks, so a
capture that still carried an identifier landed on disk and the "REFUSING to
sanitize" message was false by the time it printed. Now every check runs before
anything reaches the filesystem, chosen over delete-on-failure: a guarantee that
depends on cleanup running is weaker than one that never creates the file.
PATH_RE also gains /Users/, without which a macOS capture sanitizes cleanly while
leaking a username — the prefixes the scrubber rewrites and the prefixes the leak
check inspects have to be one list.
Writing the --selftest exposed a second defect in the predicate: same-length
substitution truncates a cwd shorter than the placeholder (/home/ab/x becomes
/home/agent/p), which failed startswith() and read as a leak, making every
short-cwd capture unsanitizable. is_placeholder() now accepts prefixes of the
placeholder. All seven committed fixtures re-verified clean under it.
The selftest runs the refusal path for real — it stubs out the redaction, pushes
a leaking capture through, and stats the output path — and CI runs the selftest
(air-1474-sanitize-ordering.test.ts, skipped where python3 is absent).
NON-BLOCKING — classifyBuffer read buf.cursorY alongside top = buf.viewportY,
assuming viewportY === baseY. Expected to be hygiene; measured as a real false
clean. With the viewport scrolled up, the unconverted row lands on a stale
composer still in scrollback — empty, palette-12, rule beneath it — so the region
bounds and classifies `empty`: CLEAN, while the live composer is off-view holding
a draft. Verified against the built old code ({clean: true, detail: 'empty'}) and
the fix (no-composer-marker). baseY + cursorY - top removes it.
Both new tests mutation-checked: revert either fix and the matching test fails.
pnpm test: 276 files / 5479 tests pass, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Thanks — the blocking item was right, and chasing it turned up a second defect in the same file.

Blocking: sanitize.py wrote before checking. Fixed by check-then-write rather than delete-on-failure, for the reason you gave: a guarantee that depends on cleanup running is weaker than one that never creates the file. PATH_RE gains /Users/… — you were right that the committed fixtures are clean and this is about the next capture; the underlying rule is that the prefixes the scrubber rewrites and the prefixes the leak check inspects have to be one list, or the check is narrower than the risk.

Writing the self-check exposed a second defect, in the predicate I had already "fixed" once. Same-length substitution truncates a cwd shorter than the placeholder, so /home/ab/x/home/agent/p, which failed startswith(PLACEHOLDER_PATH) and was reported as a leak — every short-cwd capture would have been unsanitizable. Fail-safe, so it would have surfaced as a mysterious refusal rather than a leak, which is the kind of thing that gets worked around by disabling the check. is_placeholder() now accepts prefixes of the placeholder too, and all seven committed fixtures re-verify leaks=[] under the widened predicate.

Per your note about proving rather than asserting: sanitize.py --selftest stubs out the redaction, pushes a leaking capture through, and stats the output path to confirm no file exists. air-1474-sanitize-ordering.test.ts runs it in CI. The rest of the harness still stays out of CI (it needs an authenticated agy and a PTY), but this part's failure mode is committing someone's username, so it runs. Mutation-checked: restore write-then-check and the test fails with leaking capture left /tmp/…/fixture.txt on disk.

Non-blocking, cursorY vs viewportY: taken, and it was not hygiene. I went to write a defensive test and measured a real false clean. Scroll the viewport up and the unconverted row lands on a stale composer still in scrollback — empty, palette-12, rule beneath it — so the region bounds and classifies empty. CLEAN, while the live composer is off-view holding a half-typed draft. Confirmed against the built old code ({clean: true, detail: "empty"}) and the fix (no-composer-marker). buf.baseY + buf.cursorY - top removes it; comments at both sites updated.

The regression test pins the rigging itself — that the viewport is genuinely off the bottom, that the two conventions disagree, and that the row the old reading picked really was a marker+rule pair — so it fails loudly rather than quietly becoming a tautology if xterm changes. Mutation-checked the same way.

Non-blocking, not done here, and I agree it is a real gap: silent idle-session profile drift. onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm — structurally the same failure as the bare-> bug this PR fixes, which is what makes it worth its own issue rather than a rider on this one. Same for markerFgPalette accepting multiple colors before agy ships a truecolor theme. Both are being filed separately.

pnpm test: 276 files / 5479 tests pass, 0 failed.

waleedkadous
waleedkadous previously approved these changes Sep 4, 2026

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-verified: sanitize_file now finds leaks before any write and refuses with nothing on disk, backed by a self-test that stats the path; /Users/… added to PATH_RE so the scrub set and the leak-check set are one list; and the short-cwd false-positive you found while writing the self-test is exactly the kind of second defect a real check surfaces. CI 7/7 on 8d9d666. Approving and merging.

@waleedkadous
waleedkadous merged commit e132ddb into mainSep 4, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render gate: tighten the loose agy prompt marker (AGY_MARKER = /^> /)

2 participants

@mohidmakhdoomi@waleedkadous
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette - #1491

Merged
waleedkadous merged 14 commits into
mainfrom
builder/air-1474
Sep 4, 2026
Merged

[Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette#1491
waleedkadous merged 14 commits into
mainfrom
builder/air-1474

Conversation

@mohidmakhdoomi

Copy link
Copy Markdown
Collaborator

Implements #1474.

What was actually wrong

AGY_MARKER = /^> / (gate-profiles.ts:72) treated any line starting with > as agy's
composer prompt, and findMarkerRow is last-match-wins. The issue called this "the weakest link"
in an empirically-derived profile. Measured against a real, authenticated agy 1.1.13 under a
PTY at 110×32, it is worse than a theoretical looseness — non-composer > rows are routine:

real screenlast > row the old marker pickedthat row's fgcursor rowthe actual composer
idle (accept-edits)11palette 121111 ✅
idle (no-hint mode)none — bare > never matched /^> /palette 121111 ❌
draft24palette 122424 ✅
slash menu (/)13 — the menu's selection cursorpalette 121111 ❌
trust dialog8 — the selected optionpalette 1212 (off-row)none ❌
settled after an answer20palette 122020 ✅
torn mid-repaint frame10 — the transcript echo of the sent turnpalette 4variesabsent ❌

Three findings drove the design:

  1. agy echoes every submitted turn into the transcript as a > line (SGR 34;1 → palette 4).
    Non-composer > rows accumulate one per conversation turn.
  2. The slash menu's selection cursor is also > , also palette-12, and renders BELOW the
    composer
    — so it won the marker scan outright. A color anchor alone does not separate
    these; the cursor row does.
  3. In agy's no-hint mode the composer is a bare >, which right-trims to ">" and never
    matched /^> / — so the gate held every message to an agy in that mode forever. A
    pre-existing false-HOLD, fixed here, since the issue's ask is that the marker identify the
    actual composer.

Markdown blockquotes turned out to render as , not > — so the issue's "quoted output" risk
arrives via the turn echo rather than via blockquotes.

Honest scope of the defect. I could not reproduce an actual false-CLEAN from the captures: on
every mis-bounded frame the wrong region still happened to contain counted text, so the verdict
landed busy anyway. What is demonstrated is that the classifier bounds the wrong region on
real screens
, and that getting busy out of a wrong region is luck rather than a guarantee. The
corruption risk is latent, not observed — stated plainly rather than overclaimed.

The change

Two optional, per-app GateProfile anchors — profile data, in keeping with the spike's
constraint 9 — set only on agy, both measured:

  • markerRequiresCursorRow: true — the marker row must hold the buffer cursor. Only the live
    input row does; menu rows, dialog options and transcript echoes never do.
  • markerFgPalette: 12 — the marker glyph's own color, which separates the composer from the
    palette-4 turn echo.

Plus the marker separator relaxed to /^>(\s|$)/ for the bare-> mode. claude/codex set neither
anchor and are byte-for-byte unaffected (pinned by a test).

Every failure direction is toward HOLD: a row that fails an anchor is simply not a marker, so
drift yields no-composer-marker → held and re-checked, never a false clean. Sustained holds
already escalate through the mailbox liveness telemetry (recordStreak).

Verdict changes on the real captures

fixturebeforeafter
agy-idle.cleancleanclean
agy-draft.busybusy / user-textbusy / user-text
agy-menu.busybusy / no-region-end (marker was the menu item — right verdict, wrong reason)busy / user-text (right region: the / typed in the composer)
agy-trust.busybusy / no-region-end (incidental — no rule under the option)busy / no-composer-marker (honest: there is no composer)
agy-torn-echo.busybusy / no-region-end (marker was the palette-4 echo)busy / no-composer-marker
agy-turn-echo.cleancleanclean
agy-baremarker.cleanbusy / no-composer-marker (false HOLD)clean

Cost of the tightening, measured

Sweeping every byte-prefix of a real stream showed 70 CLEAN frames before vs 9 after — alarming
until you notice byte-prefix sampling cuts mid-repaint, which production never does. Sampling
the way production actually classifies (wall-clock, against a live agy driven through boot → idle
→ streaming → settled, classifying the mirror every 200 ms) the two profiles are identical:

idle samples= 30 OLD clean=100% NEW clean=100%
streaming samples= 205 OLD clean= 98% NEW clean= 98%
settled samples= 40 OLD clean=100% NEW clean=100%

No measurable false-HOLD cost, and the bare-> mode goes from permanently held to deliverable.

Fixtures

The issue asked for captured real-agy fixtures across states, and agy is authenticated in this
environment, so the three synthesized Phase-3 fixtures are replaced by seven real captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn frame). agy's banner embeds the
account email and session cwd; both are replaced with same-length placeholders so the rendered
screen stays byte-for-byte equivalent — no attribute is retouched. Each file is 1.7–8.7 KB, so
they are committed plain rather than gzipped (gzip is used for the multi-hundred-KB claude
replays). The torn-frame fixture is real bytes cut mid-repaint, which is the tear shape #1361
documents for the adopt seed.

Tests

55 pass in render-gate.test.ts. New coverage: per-fixture verdicts for all seven states; the
reason for each (a mis-bounded region that returns busy is not the same as a correctly-bounded
one); synthetic branch tests for each anchor independently, including the palette-12 dialog option
with the cursor on it — the one shape the anchors cannot reject, which pins that the occupancy
count still catches it; and a guard that claude/codex are unaffected by cursor position.

porch check 1474: build ✓ (13.1s), tests ✓ (28.7s).

Notes for the reviewer

  • The cursor-row anchor assumes buffer.cursorY is viewport-relative, the same convention
    isGhostCursorCell already relies on. True whenever viewportY === baseY (no manual scrollback),
    which holds on both gate paths.
  • The one shape neither anchor rejects is a dialog that parks the cursor on a palette-12 > option.
    The trust dialog does not (measured), and the occupancy count plus the region-end guard both still
    catch it — but it is the seam to watch if agy adds dialogs.

🤖 Generated with Claude Code

mohidmakhdoomiand others added 5 commits August 17, 2026 19:44
… and marker palette
`AGY_MARKER = /^> /` treated any `> `-prefixed line as agy's composer. Measured against
real agy 1.1.13 under a PTY, that is not a hypothetical looseness: agy echoes every
submitted turn into the transcript as `> <message>` (palette-4), its slash-menu selection
cursor is also `> ` in palette-12 and renders BELOW the composer, and the trust dialog's
selected option is `> Yes, I trust this folder`. Since `findMarkerRow` is last-match-wins,
the menu item and the turn echo won the marker scan over the composer — so the classifier
bounded and scanned the wrong region on ordinary screens.
Adds two optional, per-app `GateProfile` anchors, both set only on agy and both measured:
`markerRequiresCursorRow` (the marker row must hold the cursor — only the live input row
does) and `markerFgPalette` (the marker glyph's own color, 12, which separates the composer
from the palette-4 turn echo). Also relaxes the marker separator to `\s|$`: agy's no-hint
mode renders the empty composer as a bare `>`, which `/^> /` never matched, so the gate
held every message to an agy in that mode forever.
Replaces the three synthesized agy fixtures with seven real, sanitized captures
(idle / bare-marker / draft / menu / trust / turn-echo / torn mid-repaint frame).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er thread
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Test status, stated precisely

porch check 1474: build ✓ (13.5s), unit tests ✓ (28.6s) — 55 pass in render-gate.test.ts.

The e2e_tests gate check is a no-op here. AIR defines it as npm run test:e2e 2>&1 || echo 'e2e tests skipped (not configured)' and the repo root has no test:e2e script, so it reports ✓ in 0.1s having executed nothing. It is marked optional in the protocol, so that is intended — but it is not evidence, so I ran the real suite:

pnpm --filter @cluesmith/codev test:e2e
Test Files 1 failed | 19 passed | 5 skipped (25)
Tests 3 failed | 171 passed | 21 skipped (195)

All 3 failures are in tower-api.e2e.test.tsPOST /api/terminals returning 500 where 201 is expected.

They are pre-existing and unrelated to this PR. Verified rather than asserted: I reverted both changed source files to the branch base (141b493), rebuilt, and re-ran that file — identical 3 failures. Files restored afterwards (all committed, so lossless; working tree verified clean against HEAD, unit tests re-run green).

I did not skip or annotate them as flaky, because they are neither mine nor intermittent — they reproduce consistently. Flagging for a maintainer instead. Plausibly environmental: this machine is running four-plus concurrent builder sessions plus Tower, and all three failures are PTY-spawn-through-the-API.

@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Architect integration review — 3-way CMAP (risk tier: High — render-gate classifier, false-CLEAN direction)

Verdicts: gemini APPROVE · codex APPROVE · claude COMMENT ("merge it, with follow-ups") — all HIGH confidence, zero blocking correctness issues. Architect read concurs: the two positive-evidence anchors (markerRequiresCursorRow, markerFgPalette) fit the existing per-app-profile seam, every failure direction lands on no-composer-marker → HOLD, and claude/codex profiles are untouched. The real-capture measurement table is the load-bearing artifact, and the PR's refusal to overclaim (mis-bounding demonstrated, false-CLEAN latent; no-hint 'held forever' rests on live observation) is noted and appreciated.

Independently verified across the reviews: viewport-relative cursorY is sound on the production gate (viewportY === baseY for SessionScreen); the tightening's failure direction is observable (no-composer-marker feeds isClassifierStuck → escalation at streak 10); fixture sanitization is complete (no emails/paths survive); the bare-> relaxation is gated behind both anchors so it cannot loosen anything — and it fixes a real pre-existing total delivery outage in agy's no-hint mode.

Requested before the gate (one item)

  1. Add an agy case to the production-mirror-path suite (render-gate.test.ts:364). These are the first anchors that depend on cursor state, the one dimension where the transient path and the SessionScreen mirror path could conceivably diverge — a parity test (same fixture, same verdict via both paths) closes the only untested seam between what the tests prove and what production runs.

Follow-ups (this PR or noted for later — builder's call, say which)

  • arch.md:1795 still describes the agy gate as a placeholderFgPalette rule; the definition of "marker present" changed for agy. Two-line touch or explicit MAINTAIN deferral.
  • Commit the capture/sanitization harness (Spec 1313 precedent: codev/spir-1313-captures/*.mjs) so re-measuring against a future agy doesn't start from the PR description.
  • Cosmetic: required-fixtures test title still says "idle/draft/trust" while requiring seven.
  • Keep the dual 4-bit/256-color palette-12 matching — it is deliberate, not redundant (real fixtures use [94m, synthetic [38;5;12m).

For the maintainer

Parked for maintainer approval + merge; we are not maintainers.

…low-ups
CMAP requested item: the agy anchors are the first classifier input that depends on
CURSOR STATE, and the cursor is the one dimension where the transient replay path and the
long-lived SessionScreen mirror could diverge. Adds a per-fixture parity case to the
production-mirror-path suite — same bytes through both paths, same verdict — fed twice,
once in production-sized chunks and once in 7-byte chunks that deliberately split the
cursor-positioning CSI across feed() calls, since a mis-parsed cursor is now a verdict
change rather than a cosmetic one.
Follow-ups taken in-PR rather than deferred:
- arch.md: "marker present" now means more than a text match for agy — records the two
anchors and their fail-toward-HOLD direction.
- Commits the capture + sanitization harness (codev/air-1474-captures/) so re-measuring
against a future agy starts from a script, not the PR description. Verified by
re-deriving four committed fixtures byte-identically from the raw captures.
- Fixes the sanitizer's leak check, which reported `/home/` on every run because the
placeholder path itself starts with it — a check that always fires checks nothing. Now
matches paths that are not the placeholder, and refuses to write on a leak.
- Cosmetic: required-fixtures test title listed three agy states while requiring seven.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

CMAP items addressed — all four taken in-PR, none deferred

Requested before the gate ✅

agy case in the production-mirror-path suite (render-gate.test.ts). Agreed on the reasoning: these anchors are the first classifier input that depends on cursor state, so the transient-vs-mirror seam is newly load-bearing. Added a parity case per agy fixture (7 of them) rather than one representative — same bytes through both paths, toEqual on the whole verdict, plus an assertion that the transient path's own verdict matches the filename, so the parity check can't pass by both paths being wrong together.

Each fixture is fed twice: once in production-sized 64 KiB chunks, and once in 7-byte chunks that deliberately split escape sequences across feed() calls. That second pass is the one with teeth here — the cursor-positioning CSI (ESC[<row>;<col>H, and the relative ESC[2A/ESC[2C agy actually emits) is exactly what a torn chunk boundary would corrupt, and a mis-parsed cursor is now a verdict change rather than a cosmetic one. Both pass. 62 tests in the file; full package suite green (4876 passed / 48 skipped).

Follow-ups — taken, not deferred

  • arch.md ✅ — the agy entry now records that "marker present" is more than a text match, names both anchors, and states the fail-toward-no-composer-marker → HOLD direction.
  • Capture/sanitization harness ✅ — committed at codev/air-1474-captures/ (capture script, sanitizer, README covering auth prerequisite, per-state recipes, the 110×32 geometry constraint, and how the torn fixture is derived). Verified rather than assumed: re-ran the committed sanitizer against the raw captures and re-derived four committed fixtures byte-identically.
  • Cosmetic test title ✅ — now lists all seven agy states.
  • Dual 4-bit/256-color palette matching — kept as-is, per your note that it is deliberate.

One thing the harness work turned up

The sanitizer's leak check was self-defeating: it tested for the bare prefixes /home/ and /tmp/, but the replacement path is /home/agent/project, so it reported a leak on every successful run — including on the fixtures already in this PR. A check that always fires checks nothing, and I'd rather not ship that as the documented safety step. It now matches paths that are not the placeholder, and refuses to write the fixture if anything survives. Re-verified against all four captures: leaks=[], output byte-identical.

To be clear about what this did and didn't mean: the committed fixtures were always clean — I verified that independently with a direct grep before the first push, and the byte-identical re-derivation confirms it again. The defect was in the check, not in the fixtures.

Ready for the pr gate.

@waleedkadous

Copy link
Copy Markdown
Contributor

Thanks for the patience on this one — the review queue is moving now. This PR is showing as CONFLICTING against main after this week's merges in the same area (serializer convergence #1492, delivery verification #1573/#1584, self-attesting frames #1574, and the recipient/reply-hint changes to message-format.ts and tower-routes.ts). Could you rebase onto current main when you get a chance? I'll review the moment it's green — the four non-conflicting PRs in your list are in review right now.

mohidmakhdoomiand others added 2 commits September 3, 2026 14:19
Resolves the one conflict in render-gate.ts: main added the exported
`bufferLines()` helper (#1573 echo verification) directly above
`findMarkerRow`, whose doc and signature this branch rewrote for the
cursor-row/palette anchors. Both are kept — they are adjacent, not
competing.
Typecheck clean; render-gate suite 66/66.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alarm
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Brought up to current main — conflicts resolved, CI green, architect integration check

This branch had gone CONFLICTING (770 commits behind). It is now MERGEABLE at 470d49b2d, with all 7 checks passing, via a merge of origin/main (818337fa2) — not a rebase, per this repo's merge-never-squash convention. (mergeStateStatus: BLOCKED is now only the missing maintainer approval; no conflict, no failing check.)

The conflict was adjacency, not disagreement

Exactly one file conflicted: packages/codev/src/agent-farm/servers/render-gate.ts. The sole commit to touch it since the merge-base was #1573 (35f2e637f), whose change there is purely additive — one new exported bufferLines(term), inserted directly above findMarkerRow, whose doc comment and signature this branch had rewritten for the cursor-row/palette anchors. Both sides kept verbatim.

The two functions now sit adjacent and are worth keeping straight:

Architect verification (independent of the builder's report)

Post-merge suite: 275 files passed / 3 skipped / 0 failed; 5477 tests passed, typecheck clean. The render-gate suite is 66 cases now rather than 55 — main's own additions to that file merged in cleanly alongside these.

Two notes for whoever merges

  1. Merge-order: PR Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 (issue Render gate: replace the perf wall-clock assertion with a deterministic op-count check #1471) also touches render-gate.test.ts, in a different region. Whichever of Fix #1471: replace the render-gate perf wall-clock bound with a deterministic op-count check #1487 / [Air #1474] Render gate: anchor the agy prompt marker to the cursor row and marker palette #1491 merges second may need a trivial rebase.
  2. Resuming any long-parked branch in this repo: the first post-merge pnpm test here reported 14 failures in request-auth.test.ts / tower-routes.test.ts (WS_KEY_PROTOCOL_PREFIX, TOWER_KEY_HEADER arriving undefined). All falsepackages/types/dist predated the constants main had since added, and node_modules predated a new three dependency. pnpm install && pnpm build first, then believe a test result.

Status

Parked for maintainer review — we are not maintainers of this repo, so we are not merging this. Please approve and merge when it suits you. Issue #1474 stays open and the worktree stays intact until you do.

mohidmakhdoomi added a commit that referenced this pull request Sep 3, 2026
Retrospective at codev/reviews/1482-f1-tower-vs-pty-dimension-dive.md, plus
the governance routing it describes.
The Summary leads with the user-visible harm rather than the mechanism: the
owner starvation notice offered `afx interrupt` for EVERY hold, and for a
`user-text` hold that is advice to interrupt a person mid-draft. The dims fix
and the detail column are how that becomes possible to tell apart, not the
point in themselves.
Both gaps the human approved knowingly are stated in the review rather than
left for a reviewer to find: the dashboard popover still renders a bare `busy`
(reverted deliberately — no worktree.devCommand, no Playwright here, and the
project requires a browser check), and evidence item 3 is covered by test
rather than induced live because send.ts constructs `new TowerClient()` with
no port and so reaches only the live Tower on 4100.
Also called out for the maintainer: the beyond-plan 409 RESIZE_DROPPED route
change and CronDeliveryResult gaining `detail`; the v18-vs-v17 migration
collision surface (in two places — the constant and the pinned source
assertion that reads it back); and the conflict surface with the two parked
PRs, #1486 (commands/inbox.ts, commands/send.ts, tower-routes.ts) and #1491
(render-gate.ts, render-gate.test.ts).
**Governance routed COLD only.** Both hot files sit exactly at their caps
(10 facts / 10 lessons) and nothing here justified displacing an existing
entry; the hot map already routes readers to "Invariants & Constraints", so
the tiering works without growing the always-injected tier.
- arch.md: new invariant #10, "Terminal dimensions must be earned, not
assumed" (requested/applied/outstanding, 409 vs 404, WELCOME adoption, and
why the gate depends on it); and the Spec 1313 mailbox section's response
vocabulary extended with the v18 `detail` column and why it carries no CHECK
constraint.
- lessons-learned.md: three Architecture entries (a failure boolean is only as
honest as its callers; two-state models fail at the third state — the
missing bit was OUTSTANDING, not DIFFERENT; commit derived state only after
what it describes is confirmed) and three Process entries (read the ignore
rule's own reason before `git add -f`; report the boundary of a gap, not the
instance you tripped over; mutation-check a test written to close someone
else's finding).
Full suite green on this tree: 278 files passed / 3 skipped, 5495 tests passed
/ 48 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Really thorough work — seven real agy captures, per-fixture mirror-vs-transient parity tests (including the 7-byte chunking that splits cursor CSIs), and a failure direction that stays HOLD rather than deliver. The render-gate change itself is approve-quality and claude's lane approved it outright; the one thing I'd like fixed before merge is in the capture tooling, because it's a leak-safety guarantee that currently isn't one:

  1. codev/air-1474-captures/sanitize.py writes the output before running its leak check (the open(dst, …).write(data) at ~line 137 precedes the # Leak check block). If identifiers survive, it raises only after the unsafe fixture is already on disk — contradicting its own "REFUSING to sanitize" message and creating an accidental-commit risk. Run every check before writing, or delete dst on failure. Relatedly, its path regex misses /Users/…, so a macOS capture would pass the check while leaking a username (the committed fixtures are clean; this is about the next capture).

Two small things you might consider alongside, neither blocking:

  • buf.cursorY is baseY-relative in xterm's public contract; the code relies on viewportY === baseY. buf.baseY + buf.cursorY - buf.viewportY removes that assumption for free, and it's now load-bearing for delivery.
  • Idle-session drift is silent — onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm, the same shape as the bare-> bug this fixes. A follow-up on profile-drift observability (classifier-stuck escalation for idle sessions, or an afx doctor check that classifies each live mirror once) would close that.

Also noting markerFgPalette will want to accept multiple colors before agy ships a truecolor theme — follow-up territory.

…hrough baseY
Addresses the PR #1491 maintainer review.
BLOCKING — sanitize.py wrote the output file before checking it for leaks, so a
capture that still carried an identifier landed on disk and the "REFUSING to
sanitize" message was false by the time it printed. Now every check runs before
anything reaches the filesystem, chosen over delete-on-failure: a guarantee that
depends on cleanup running is weaker than one that never creates the file.
PATH_RE also gains /Users/, without which a macOS capture sanitizes cleanly while
leaking a username — the prefixes the scrubber rewrites and the prefixes the leak
check inspects have to be one list.
Writing the --selftest exposed a second defect in the predicate: same-length
substitution truncates a cwd shorter than the placeholder (/home/ab/x becomes
/home/agent/p), which failed startswith() and read as a leak, making every
short-cwd capture unsanitizable. is_placeholder() now accepts prefixes of the
placeholder. All seven committed fixtures re-verified clean under it.
The selftest runs the refusal path for real — it stubs out the redaction, pushes
a leaking capture through, and stats the output path — and CI runs the selftest
(air-1474-sanitize-ordering.test.ts, skipped where python3 is absent).
NON-BLOCKING — classifyBuffer read buf.cursorY alongside top = buf.viewportY,
assuming viewportY === baseY. Expected to be hygiene; measured as a real false
clean. With the viewport scrolled up, the unconverted row lands on a stale
composer still in scrollback — empty, palette-12, rule beneath it — so the region
bounds and classifies `empty`: CLEAN, while the live composer is off-view holding
a draft. Verified against the built old code ({clean: true, detail: 'empty'}) and
the fix (no-composer-marker). baseY + cursorY - top removes it.
Both new tests mutation-checked: revert either fix and the matching test fails.
pnpm test: 276 files / 5479 tests pass, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mohidmakhdoomi

Copy link
Copy Markdown
CollaboratorAuthor

Thanks — the blocking item was right, and chasing it turned up a second defect in the same file.

Blocking: sanitize.py wrote before checking. Fixed by check-then-write rather than delete-on-failure, for the reason you gave: a guarantee that depends on cleanup running is weaker than one that never creates the file. PATH_RE gains /Users/… — you were right that the committed fixtures are clean and this is about the next capture; the underlying rule is that the prefixes the scrubber rewrites and the prefixes the leak check inspects have to be one list, or the check is narrower than the risk.

Writing the self-check exposed a second defect, in the predicate I had already "fixed" once. Same-length substitution truncates a cwd shorter than the placeholder, so /home/ab/x/home/agent/p, which failed startswith(PLACEHOLDER_PATH) and was reported as a leak — every short-cwd capture would have been unsanitizable. Fail-safe, so it would have surfaced as a mysterious refusal rather than a leak, which is the kind of thing that gets worked around by disabling the check. is_placeholder() now accepts prefixes of the placeholder too, and all seven committed fixtures re-verify leaks=[] under the widened predicate.

Per your note about proving rather than asserting: sanitize.py --selftest stubs out the redaction, pushes a leaking capture through, and stats the output path to confirm no file exists. air-1474-sanitize-ordering.test.ts runs it in CI. The rest of the harness still stays out of CI (it needs an authenticated agy and a PTY), but this part's failure mode is committing someone's username, so it runs. Mutation-checked: restore write-then-check and the test fails with leaking capture left /tmp/…/fixture.txt on disk.

Non-blocking, cursorY vs viewportY: taken, and it was not hygiene. I went to write a defensive test and measured a real false clean. Scroll the viewport up and the unconverted row lands on a stale composer still in scrollback — empty, palette-12, rule beneath it — so the region bounds and classifies empty. CLEAN, while the live composer is off-view holding a half-typed draft. Confirmed against the built old code ({clean: true, detail: "empty"}) and the fix (no-composer-marker). buf.baseY + buf.cursorY - top removes it; comments at both sites updated.

The regression test pins the rigging itself — that the viewport is genuinely off the bottom, that the two conventions disagree, and that the row the old reading picked really was a marker+rule pair — so it fails loudly rather than quietly becoming a tautology if xterm changes. Mutation-checked the same way.

Non-blocking, not done here, and I agree it is a real gap: silent idle-session profile drift. onLiveness gates on recent output, so a re-themed agy sitting idle holds forever with no alarm — structurally the same failure as the bare-> bug this PR fixes, which is what makes it worth its own issue rather than a rider on this one. Same for markerFgPalette accepting multiple colors before agy ships a truecolor theme. Both are being filed separately.

pnpm test: 276 files / 5479 tests pass, 0 failed.

waleedkadous
waleedkadous previously approved these changes Sep 4, 2026

@waleedkadouswaleedkadous left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-verified: sanitize_file now finds leaks before any write and refuses with nothing on disk, backed by a self-test that stats the path; /Users/… added to PATH_RE so the scrub set and the leak-check set are one list; and the short-cwd false-positive you found while writing the self-test is exactly the kind of second defect a real check surfaces. CI 7/7 on 8d9d666. Approving and merging.

@waleedkadous
waleedkadous merged commit e132ddb into mainSep 4, 2026
7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render gate: tighten the loose agy prompt marker (AGY_MARKER = /^> /)

2 participants

@mohidmakhdoomi@waleedkadous