Skip to content

Add rich reference previews and back matter - #177

Merged
HamptonMakes merged 9 commits into
mainfrom
hampton/reference-previews
Aug 17, 2026
Merged

Add rich reference previews and back matter#177
HamptonMakes merged 9 commits into
mainfrom
hampton/reference-previews

Conversation

@HamptonMakes

@HamptonMakes HamptonMakes commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Why

Agents currently write footnote citations and § section references that look meaningful but either require readers to jump away from their place or have no link semantics at all. Readers also need enough source identity to judge whether cited evidence is trustworthy.

What

  • Preview footnote citations and explicitly linked numbered sections on hover or keyboard focus
  • Join Markdown citation context to the structured CoPlan::Reference inventory by URL
  • Render one compact, numbered References section with evidence context, source title/type/domain, and remaining linked or curated resources
  • Follow internal references immediately on click and open external HTTP(S) sources in new tabs
  • Keep citation back matter, extracted resources, and counts current after live agent edits without broadcasting user-specific form tokens
  • Teach agents when to use citations, internal links, and structured reference metadata
  • Cover rendering, structured joins, post-extraction live updates, and interactions with helper, request, model, service, and system specs

Risk Assessment

Low to medium — this changes Markdown rendering and plan-page interactions, but only footnotes and valid explicit section links opt into previews. Bare section symbols remain unchanged, structured reference records remain available through the API, and the full Rails suite passes.

Screenshots

Citation preview with source identity

Citation preview with source title, type, and domain

Compact unified References section

Numbered citation evidence and a structured resource in one References section

Generated with Amp

HamptonMakes and others added 8 commits August 15, 2026 11:37
Connect footnote citations and explicit section links to instant hover and click previews, and document the authoring contract for agents.

Amp-Thread-ID: https://ampcode.com/threads/T-01a00633-0812-75de-9a33-a2283280078f
Co-authored-by: Amp <amp@ampcode.com>
Let reference clicks jump directly to their targets, keep previews on hover and focus, and avoid repeating auto-extracted citation links in back matter.

Amp-Thread-ID: https://ampcode.com/threads/T-01a00633-0812-75de-9a33-a2283280078f
Co-authored-by: Amp <amp@ampcode.com>
Keep hover cards clear of the document's overflow boundary while retaining direct click navigation to each reference target.

Amp-Thread-ID: https://ampcode.com/threads/T-01a00633-0812-75de-9a33-a2283280078f
Co-authored-by: Amp <amp@ampcode.com>
Use the structured reference classifier to display each source title, type, and domain, and clarify that explicit entries are additional resources.

Amp-Thread-ID: https://ampcode.com/threads/T-01a00633-0812-75de-9a33-a2283280078f
Co-authored-by: Amp <amp@ampcode.com>
@HamptonMakes HamptonMakes changed the title Add inline reference previews Add rich reference previews and back matter Aug 17, 2026
@HamptonMakes
HamptonMakes marked this pull request as ready for review August 17, 2026 19:49
@HamptonMakes
HamptonMakes merged commit 2dff37a into main Aug 17, 2026
3 checks passed
@HamptonMakes
HamptonMakes deleted the hampton/reference-previews branch August 17, 2026 19:50

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a5665e2426

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +61 to +63
def listed_plan_references(references, cited_urls)
references.reject { |reference| cited_urls.include?(reference.url) }
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep cited explicit references removable

When a URL appears in a footnote, this rejects its Reference record from the only list that renders removal controls. If a user explicitly adds or curates that same URL, the record is converted to source: "explicit" but is displayed only inside citation HTML, which has no remove button; unlike before this commit, even an authorized plan owner can no longer remove it through the UI. Preserve an actionable representation for cited explicit references or add the removal action to the citation entry.

Useful? React with 👍 / 👎.

Comment on lines 54 to +57
html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { extension: EXTENSION_OPTIONS, render: render_options }, plugins: { syntax_highlighter: nil })
with_chips = transform_mention_anchors(html)
sanitized = sanitize(with_chips, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
with_references = transform_reference_anchors(with_chips, numbered_sections: footnote_prefix.nil?)
sanitized = sanitize(with_references, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Mount previews wherever enhanced Markdown is rendered

This unconditionally adds coplan--reference-preview actions and aria-haspopup="dialog" to footnote and section links, but only the current plan page mounts that controller and its popover. Other existing callers such as plan_versions/show.html.erb:40 therefore render controls that advertise a dialog yet do nothing on hover or focus, so citations in historical versions lose the new preview behavior and expose misleading accessibility state. Gate the enhancement behind an option or mount the controller/popover in every rendering context.

Useful? React with 👍 / 👎.

HamptonMakes added a commit that referenced this pull request Sep 3, 2026
… way to wake an agent (#197)

* Agent collaboration: live event inbox, presence pill, diff flashes, bridge

Agents that author plans can now hear about comments the moment they land
and collaborate visibly:

- AgentEvent inbox + AgentSession (Linear-style pending/active/
  awaiting_input/complete/stale) with fan-out from the existing
  notification choke point; agents never woken by their own activity.
- GET /api/v1/agent/events long-poll + SSE with UUIDv7 cursor resume and
  explicit ack; POST/PATCH/DELETE /api/v1/plans/:id/agent_session drives
  a live presence pill on the plan masthead.
- Content broadcasts now carry changed-section keys; live_update flashes
  changed blocks with word-level ins/del diffs that settle after ~2.5s.
- API ergonomics: GET single comment thread, dismiss route alias (docs
  said dismiss, router said discard), API threads get the same initial-
  status rule as the web flow, agent_name on ApiToken.
- script/coplan-bridge: harness-agnostic daemon (claude/codex/goose/
  openhands/amp adapters + in-process demo agent) that drains the inbox
  and resumes your local harness session per event.
- /agent-instructions documents the realtime loop and session etiquette.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Voice tier, collaboration docs, and API orphan-thread fix

- Push-to-talk mic button on the plan page (Web Speech API tier):
  speaks feedback into a comment, listens for the agent pill to speak
  "Got it." / "Done — take a look." cues. Hidden when unsupported.
- voice/ Pipecat sidecar scaffold (MLX Whisper + Kokoro via
  OpenAI-compatible endpoint) documenting the higher-fidelity local
  pipeline and its provider plugin seams.
- docs/AGENT_COLLABORATION.md: the live feedback loop, bridge config,
  per-harness adapter recipes, permission-posture caveat, local demo.
- API fix: wrap thread + first comment creation in a transaction so a
  failed comment (e.g. missing agent_name) can't leave an orphan empty
  thread; regression spec included.
- Bridge: unbuffered stdout, drop a dead line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Attach mode: interrupt a live agent instead of waking a dead one

The bridge assumed the agent had exited and needed a harness resume.
The normal case is an agent that is already alive and watching, which
doesn't need waking — it needs interrupting.

- script/coplan-attach: one held SSE connection, no config file, no
  adapters, no daemon. --once blocks until the first event, prints a
  brief, acks, and exits (the shape a turn-based agent wants); bare
  mode streams. Holds the presence pill while attached, detaches
  cleanly on exit.
- /agent-instructions leads with the held-open stream and states that
  no daemon or harness integration is required; the bridge is now
  documented as the cold-start path only.
- Event typing: derive comment.created vs comment.replied from whether
  the comment opens the thread, not from the notification reason — an
  agent opening a new thread was being reported as a reply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix ghost presence pills and the pill gap during an agent's first turn

Two defects found by running the loop against a real agent session:

- A killed agent left its session `pending` forever, showing a second
  ghost "Claude is on it…" pill. Staleness was enforced only by
  MarkStaleAgentSessionJob, and nothing enforces that a job worker is
  running. `AgentSession.visible` now computes staleness at read time
  with per-state windows (pending 30s, active 5min, awaiting_input 1hr,
  falling back to updated_at). The job remains, but only to broadcast
  the removal promptly — correctness no longer depends on it.
- coplan-attach --once detached on exit, clearing the pill at exactly
  the moment the agent began working, so a slow first turn looked like
  nothing happening. It now hands the pill off as `active` before
  exiting instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Stop attached agents from starving the web server; signal-driven wakes

Measured problem: with three agents attached and RAILS_MAX_THREADS=3,
a plain page load timed out after 10s. Every held connection (SSE or
long-poll) occupies a Rack thread, and nothing bounded how many agents
could hold one.

- New AgentEventBus owns two concerns: a held-connection budget
  (RAILS_MAX_THREADS - 2, override with COPLAN_MAX_AGENT_STREAMS) and
  wake/notify for waiting connections.
- Over budget, long-poll degrades to a non-blocking read with
  "throttled": true and SSE is refused with 503 + Retry-After, so
  agents never queue ahead of ordinary requests. Same three-agent load
  now serves a page in ~25ms.
- Waiting is signal-driven instead of a 500ms poll loop:
  AgentEvents::Publish signals the bus, measured ~180ms end-to-end from
  comment posted to long-poll returning it. Waiters still wake every
  CROSS_PROCESS_INTERVAL so writes from other Puma workers are caught.

Ceiling is still thread-per-agent — fine for a team, not for hundreds
of concurrent agents. Documented in docs/AGENT_COLLABORATION.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove three API frictions found by driving the loop as a real agent

All three surfaced while a live Claude Code session worked a plan over
the API, not in tests:

- agent_name had to be repeated on every comment and reply even though
  the agent already declared it when claiming its session. It now falls
  back to the agent session's name, then the token's agent_name, then
  the token name; an explicit param still wins so one token can post as
  a different persona. An over-long name is truncated to the 20-char
  display limit instead of losing the comment mid-conversation.
- Comment create/reply returned only comment_id/thread_id, while the
  rest of the API returns `id` for the created resource. `id` is now
  included alongside the existing keys.
- Comment had no dependent: on its notifications, so destroying one
  died on a foreign key constraint. Notifications are delivery records
  for that comment and are deleted with it.

Two comments_spec examples asserted the old "reject without agent_name"
contract; they now assert the fallback attribution instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add a `watching` state so an idle attached agent stops claiming to work

The presence pill read "Claude is working…", pulsing, while the agent
sat idle on a socket doing nothing. There was no attached-but-idle
state, so coplan-attach had to claim something on connect and picked
`active`. A pill that animates when nothing is happening trains people
to ignore it.

- New `watching` state, displayed as "Claude is watching" — no ellipsis,
  no verb of effort — with muted chrome and a static dot.
- agent_session#create honored neither `state` nor `detail`; it
  hardcoded "active" and dropped the detail. It now accepts both and
  defaults to `watching`, so claiming means "I'm here", not "I'm busy".
- Liveness comes from the stream's own 15s heartbeat touching the
  session, so a watching pill lasts exactly as long as the connection
  and expires ~2min after it dies.
- watching → pending on a new event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix ghost agent pills and give attached-but-idle agents their own state

Three bugs found by using the feature:

- AgentEvents::Publish woke *every* session for a token, including ones
  whose process was long dead, so a killed agent's pill reappeared on
  each new comment. Gate the wake on session.live?; events are still
  created for detached sessions so the durable inbox is unaffected.
- Sessions had no attached-but-idle state, so a watching agent rendered
  as "Claude is working…" before it had done anything. Add a `watching`
  state that displays just the agent name with a pulsing green dot.
- POST /agent_sessions hardcoded state: "active" and dropped `detail`,
  and clobbered `awaiting_input` when an agent reattached. Honor both
  params and preserve awaiting_input on a bare reattach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add an authority model for comments and mintable session tokens

Two halves of the same question: what is an agent allowed to do, and
who is it doing it as.

**Authority.** Comment events now carry `from_principal` (the commenter
is the human this token belongs to), `from_plan_author` (the commenter
wrote the plan), and an `authority` summary of "principal" or
"collaborator". The two flags come apart deliberately — an agent can be
attached to someone else's plan, and its own principal still outranks
that plan's author. The documented contract: a principal's comment may
be acted on directly; anyone else gets a reply and a proposal in-thread,
not an edit. Because a local_agent comment stores the user behind its
token, a second agent working for the same human speaks with that
human's authority.

**Session tokens.** A token is the unit of event subscription, so two
agents sharing one share an inbox and race for each other's wakes —
which is why running the multi-agent demo meant hand-creating tokens in
the settings UI. POST /api/v1/tokens mints a short-lived child from a
long-lived one: same principal (never escalates), 12h default TTL
clamped to 7 days, one level deep, and revoking the parent revokes
everything it minted. DELETE /api/v1/tokens/current lets an agent clean
up its own credential on exit. coplan-attach grows --mint-only (print a
token to export for the session) and --mint (mint, use, revoke on exit).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep the session token sticky, and out of the agent's hands entirely

Minting a token per agent run only helps if the token survives the run.
An agent that re-minted every turn would get a new inbox and a new
presence pill each time; an agent that kept the token in its context
would lose it to compaction — and go looking for it in places (shell
history, transcripts) where hunting for a credential is exactly the
wrong shape.

So the token lives in a file keyed to the agent run, and the tools read
it:

- script/coplan_session.rb stores one minted child token per session key
  in ~/.coplan/sessions/<key>.json (0700 dir, 0600 file), reusing it
  until it nears expiry. The key defaults to the working directory —
  one agent per checkout is the usual shape, and unlike a harness
  session id it survives compaction and restarts. Harness ids are
  checked first but not depended on: Claude Code's CLAUDE_SESSION_ID
  isn't consistently exported to tool subprocesses.
- script/coplan is a small authenticated client (get/post/patch/put/
  delete, plus reply/say/session/whoami) so no call an agent makes ever
  carries a token on the command line. A 401 — revoked token, revoked
  parent — silently re-mints once instead of surfacing an auth error.
- coplan-attach uses the same sticky session, replacing the ephemeral
  --mint flags. The token deliberately outlives the process so
  successive --once turns share one inbox and one pill.

Minting stays best-effort: if the server can't mint (older server, or
the caller already holds a session token) everything falls back to the
token as given.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Voice commenting: pin dictated comments to the passage they're about

Dictating a comment worked, but the comment went nowhere visible. An
unanchored thread is filtered out of the plan page entirely
(show.html.erb selects anchored threads; the controller only broadcasts
anchored ones), so a voice note existed in the database, reached agents
through the event inbox, and appeared on the document not at all.

Voice comments are now anchored, by two mechanisms:

- Floor: the heading of the section on screen when you spoke. Cheap,
  always available, and roughly where you were looking.
- Better: the AI picks the actual span. A new anchor_suggestions
  endpoint sends the model only the visible text plus the transcript and
  asks which passage the remark was about, so "this bit is too cautious"
  highlights the sentence rather than the section. The model is never
  trusted — a span that doesn't appear verbatim in the excerpt is a
  paraphrase and is discarded. The call is time-boxed at 4s client-side
  and every failure path (no AI configured, slow model, paraphrase,
  network) falls back to the heading.

Two fixes found by testing it:

- anchor_occurrence is 1-based server-side (resolve_anchor_position
  bails below 1), so a 0-based count silently produced a thread with
  anchor_text and no resolved position — a pin pointing at nothing.
- The mic lived in the masthead toolbar, which scrolls away, so you had
  to leave the passage you were talking about in order to talk about it.
  It's now fixed to the viewport — and rendered at page level, because
  .plan-actions sets backdrop-filter and would otherwise become the
  containing block and pin it to the toolbar.

The UI also stops promising an agent. It says what happened ("Comment
added to …"); if an agent is attached and picks it up, its pill and
spoken ack say so, and if none is attached this is simply a dictated
comment that waits in the durable inbox.

Covered by a system spec driving the real controller against a stubbed
SpeechRecognition, asserting the highlight resolves to the exact
sentence, that the heading fallback still anchors, and that nothing
claims an agent is coming.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Clean up dictated remarks and add hold-Shift-to-talk

A dictated comment used to land verbatim: "this bit is like way too
like cautious". Nobody wants that in a thread with their name on it.

InterpretDictation replaces SuggestAnchor and does both jobs in one
round trip, because both need the same two inputs — the transcript and
what was on screen. It returns the remark cleaned up and the passage it
refers to. Neither half is trusted: a span that isn't in the excerpt
character-for-character is a paraphrase and can't be highlighted, and a
rewrite that changes length dramatically has stopped being a cleanup and
started being a summary. Anything that fails falls back to a local
tidy-up of what the person actually said, which the server now does too
— the client's version never ran, since a raw transcript comes back
looking like a successful response.

The tidy-up is deliberately timid about "like": it's a real word far
more often than a tic, so it only goes where the sentence marks it as
filler.

Holding Shift starts listening; releasing sends. Shift is also held for
capitals and selections, so three guards keep it from firing by
accident: Shift alone, held past 350ms, and any other keystroke aborts
without posting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Record audio and transcribe server-side; fix table-cell anchors

Two problems, both found by using it.

**Nothing got pinned.** The excerpt sent to the model is the rendered
text of what was on screen, one block per line. The anchor has to
resolve against the markdown source. Those agree for a paragraph and
part ways at a table: adjacent cells read as adjacent lines, so the
model quoted "Voice (mic button)\n24%", which appears nowhere in
"| Voice (mic button) | 24% |". The thread was created with anchor text
and no position — a comment that is simply invisible on the page.
InterpretDictation now takes the source document as well and narrows a
span to the longest line that actually resolves, or gives up so the
caller falls back to the section heading.

**The transcription was the weak link**, not the pinning. Browser
speech recognition is Chrome-only in practice and guesses phonetically
at anything domain-specific. The control now prefers MediaRecorder and
has the server transcribe with gpt-4o-transcribe, passing the visible
text as the decoder prompt so product names and figures come back as
themselves. That works in Safari and Firefox too, where the mic
previously just hid itself.

Recognition stays as the fallback when no provider is configured. The
trade is a round trip and no live captions while you talk; accuracy is
worth more, since a comment that says something you didn't is worse
than no comment.

Verified against the real API end to end: "the higher fidelity sidecar
stays behind a flag until it is proven" comes back correctly hyphenated
from the context hint. The WebM/Opus path Chrome uses is covered by
format mapping and specs, not by a live round trip — no ffmpeg here to
make one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Never post a recording nobody spoke into

The comment on the trunk-based development plan read "Adopt Trunk-Based
Development." — the page's own heading, pinned to itself, attributed to
someone who had said something else entirely.

Whisper-family models answer silence by repeating their prompt, and we
prompt with the text that was on screen to get the jargon right. Two
seconds of digital silence through CoPlan::Ai.transcribe returns the
first line of whatever context it was handed; confirmed twice against
the live API. So an empty recording doesn't fail, it fabricates, and
what it fabricates is always plausible because it came off the page.

Three defences, because one is not enough for a failure that invents
content in someone's name:

- The browser meters the microphone while recording and won't send a
  take it heard nothing in. Peak level also drives a ring around the
  button, which is the only "it can hear you" signal the recording path
  has — there are no interim captions.
- The server rejects any transcript wholly contained in the prompt it
  sent. Reading a sentence off the page aloud trips this too; being
  told to repeat yourself beats a comment putting words in your mouth.
- Releasing before getUserMedia resolves — the norm on first use, while
  the permission prompt is up and you are already talking — now says
  the mic wasn't ready instead of leaving it stuck listening.

Metering never blocks a recording: if it can't run, assume speech.
Refusing to post what somebody said is the worse failure.

Errors now carry the server's wording, since "heard nothing" and
"couldn't reach the transcriber" call for different next moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Don't let a dead meter call speech silence; say misses out loud

"It should be main and master", spoken clearly, twice, answered both
times with "couldn't hear you". The mic was fine and the recording had
the words in it — the silence guard from the previous commit was
reading a meter that never turned on.

An AudioContext starts suspended unless the browser saw a qualifying
user gesture, and Chrome does not count a bare Shift keydown as one. So
on the push-to-talk path the metering context routinely comes up dead,
and a suspended analyser reads exactly like a silent room: peak 0.
The guard meant to stop fabricated comments was throwing away real
ones — and only on the hold-to-talk gesture, which made it look like
the gesture was broken. The button path worked because a click is a
gesture.

The meter's verdict now only counts if the context actually reached
"running" during the take (plus a resume() nudge for contexts that are
merely waiting). A meter that never ran gets no vote and the recording
is sent — the server's prompt-echo check remains the defence against
true silence, and it distinguishes the cases anyway.

Misses are now also spoken, not just printed: "Hmm, didn't hear
anything" over speechSynthesis, same words as the status chip. In a
voice flow you're talking, not watching a corner of the screen — the
silent failure was why two rejected takes in a row read as mystery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Open the mic at the keydown, not after the hold is confirmed

The dev log for today's session: one dictation posted, then three
rejected as prompt echoes in a row. A synthetic-audio experiment cleared
the transcriber — short and even mid-word-clipped real speech
transcribes fine, with or without the prompt. What actually happened:
hold-to-talk opened the microphone only after the 350ms hold delay plus
getUserMedia latency, so for a short remark ("oh, I meant both of
them") the recording began after most of the words. The near-empty tail
echoed the prompt, and the echo guard told the truth: the mic really
didn't hear anything. The bug was never in the guards — it was that the
capture missed the speech they were guarding.

Push-to-talk now opens an "ear" at the Shift keydown itself: stream and
recorder start buffering immediately, silently, and confirming the hold
adopts a capture already in progress. A tap, a Shift-selection, or a
shortcut discards the take unheard — the cost of a false start is a
blink of the recording indicator. Releasing while the mic is still
opening posts whatever the ear caught instead of demanding a retry.

The retry-without-prompt alternative was tested and rejected: silence
transcribed with no prompt returns hallucinated filler ("Na przykład"),
which would post gibberish instead. Echo rejections are now logged with
the transcript so the next false positive is diagnosable from the log.

Also, since dictation is conversational in a way typing isn't, the
interpreter now receives the last three comments — "oh, I meant both of
them" has no "them" without the comment it follows. And the mic button
reads as a recorder: filled red while listening, level ring, 18px glyph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Resolve spans broken by inline markup instead of falling to the heading

"What does 'releasable' mean?" transcribed perfectly and landed pinned
to the page's H1 — under the mark already owned by an earlier comment,
where the highlighter's one-thread-per-mark rule made it unreachable.
The span was fine; the resolution wasn't. The model reads rendered text
("main is always releasable") and the anchor must resolve against
markdown ("`main` is always releasable"), so any span crossing inline
markup was rejected whole, and rejection means the heading fallback.

The table fix only handled multi-line spans. Now resolution degrades in
steps: whole span, whole lines, then the longest contiguous run of
words still present in the source — here "is always releasable", which
pins to the sentence being asked about. Runs under 8 characters don't
count: a pin on a salvaged "the" points at noise.

One thread per mark remains the rule when two threads genuinely claim
the same range — overlap handling is its own piece of work. This change
just stops manufacturing collisions out of comments that named their
own, distinct target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Refuse comments whose anchor doesn't pin, and stop rejecting spans the resolver can place

A comment whose anchor never resolved renders nowhere — no highlight, no
popover, no way to reach it — yet we created it and said "comment
posted". Three changes close that hole:

- CommentThread validates on create that a present anchor resolved to
  positions; resolution moves to before_validation so validation can see
  it. Content drift after create stays the out_of_date flow.
- InterpretDictation#anchorable now checks spans against the resolver's
  own stripped-markdown translation instead of a raw-substring test that
  was stricter than the resolver — "main is always releasable" survives
  backticks whole instead of being narrowed past the word it points at.
- The resolver learns one more capture shape: mermaid labels broken by
  literal <br/> tags select as concatenated text ("firstfetching"), now
  matched by dropping the tags with the position map carried along.

On refusal, the HTML selection form keeps the draft and shows the error
inline; the voice client retries once with its viewport-heading anchor
before giving up; the JSON API already returned 422 for RecordInvalid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Voice comments open themselves, and the excerpt means readable, not intersecting

Two fixes from the same field report ("it's attaching this last comment
to a place I can't even see"):

- _visibleText counted any block intersecting the viewport, so a
  paragraph with one line poking over the fold was quotable and the pin
  could land below anything the speaker had read. A block now has to be
  readably on screen — most of itself visible, or a real slice of the
  viewport.
- Nothing showed where a voice comment landed. The speaker never chose a
  spot — the model did — so the client now renders the create response's
  streams immediately, then scrolls to the thread and opens its popover
  (same treatment as arriving via ?thread=ID, via a coplan:open-thread
  event the text-selection controller listens for).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Excerpt accumulates across the take; open-thread goes through Stimulus actions

The excerpt was a snapshot of the viewport at the moment talking began.
People start on one paragraph and scroll to another mid-sentence, so it
now accumulates every block that was readably on screen at any point —
sampled at start, on each listening tick, and once more at submit. The
last thing you looked at is always in; what never crossed the screen
stays off the wire. Document order and live-update survival come from
filtering a fresh block list against the seen set rather than
serializing the set.

The auto-open handoff also drops its hand-rolled document listener for
the idiom this element already uses: the voice controller dispatches
coplan:open-thread via this.dispatch, and show.html.erb routes it with a
data-action descriptor next to keydown.esc@document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* One dictation, several comments: "rename both of these" is two placements

The interpret contract widens from one {text, span} to
{"comments": [{text, span}, ...]}, capped at four. Repeating a span is
how two copies of the same text are addressed: the client bumps the
occurrence for each repeat, so both mentions of "main" get their own
pin. Every span is vetted individually — a paraphrase falls while its
siblings stand — and the length trust band applies to the rewrite as a
whole, with flat per-comment headroom (standing alone costs roughly a
sentence per split, a constant, not a multiple of the remark).

The client posts each comment with the existing per-comment fallbacks —
except the heading fallback, which belongs to the remark as a whole and
only the first comment takes; the rest post unpinned rather than piling
onto one heading. The first thread auto-opens and the status counts the
rest. The old singular body/anchor_text keys stay in the dictation
response as the first comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Refuse transcripts the recording had no time to hold

gpt-4o-transcribe is a chat model with ears: given an
instruction-shaped remark and a context prompt, it sometimes answers
instead of transcribing. Live, 'add some content about how editing
works' came back as a whole essay with figures lifted from the
prompt, and posted as though the person had said it. The existing
guards missed it by design: the echo check catches transcripts
contained in the prompt, not ones expanded from it, and the
interpreter's length band compares cleanup to transcript — and the
transcript already was the essay.

The physical bound is the fix: the client now sends how long the
take was, and a transcript past ~30 chars/sec was generated, not
heard. First response: retry without the prompt, which leaves the
model nothing to answer from. If the retry still outruns the clock,
422 'Didn't catch that' — the client already voices that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Render comment keyboard nav even when the page loads without threads

The coplan--comment-nav div was gated on @threads.any?, decided at
page render. But comments arrive live — voice dictation, selection
comments, other viewers' broadcasts — so on a fresh plan the first
comment appeared, its popover auto-opened, and d/j/k/r/a/s did
nothing at all: the controller holding the key listeners was never
on the page. Render it unconditionally; it no-ops fine with zero
highlights.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Interpreter: speak as the speaker, and know where they were reading

Two live failures from Hampton's testing:

- 'Hmmm, not enough information on tax attach' came back as 'I will
  add more information' — the model replied to the remark instead of
  editing it, signing a promise in the speaker's name. The prompt now
  frames the job as writing the comment the speaker would have typed
  (their voice, their point of view, appears under their name), with
  that exact failure as a counter-example, and notes the remark
  usually names its own target ('tax attach' → the passage about it).
- Anchors kept landing just above the visible area. The excerpt
  accumulates everything that scrolled past during the take, and the
  model had no idea which part was actually being read. The client
  now samples the blocks crossing the middle band of the viewport
  (people read the middle of the screen) at the moment of release and
  sends them as 'focus'; the prompt marks that as the span's likeliest
  home. Omitted when it would just repeat the excerpt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Align the live-agent loop with the merged identity layer

The merge-back of main (#177#182) brought the token identity work this
branch originally drafted, evolved: hook-auth bootstrap minting,
Bearer-required API, token metadata, api_token_id provenance. This
commit finishes the reconciliation:

- Self-wake suppression now keys on api_token_id explicitly.
  AgentEvents::Publish takes actor_token_id; comment events suppress on
  the comment's api_token_id and content events on the version writer's
  token. The old actor_id matching silently broke when attribution
  started storing the human's id — an agent would have been woken by
  its own edits.
- Agent name resolution is token-first everywhere via api_agent_name,
  so an agent can't sign comments under one label while its versions
  carry another. The session-claim name no longer leaks into writes.
- Dropped the branch's superseded token minting draft (controller,
  model methods, add_parent migration) in favor of main's.
- Guarded the agent-tables migration: tables may pre-exist on
  schema-loaded databases, and api_tokens.agent_name ships with the
  identity migration on hosts that install this one later.
- Removed per-controller token checks that BaseController's
  require_api_token! made unreachable.

Suite: 1554 examples, 0 failures (incl. system).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* coplan-attach: make --timeout able to fire mid-stream

The timeout was only checked between reconnects, but the server holds
an SSE stream open for minutes with heartbeats — so "--timeout 6"
actually meant "when the server retires the stream". Now the remaining
budget becomes the socket read deadline; Net::ReadTimeout loops back to
the top, where the check exits 64 as documented.

Verified live: --timeout 6 exits 64 at ~6s; --once wakes on a comment
in under a second, prints the brief, hands the pill to active, exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* The pill says what the server knows: waking, not working

Field-tested by an Amp local thread: it held the SSE stream, received
every event, and its model never woke — while the pill said "Amp is on
it…". Delivery is not action, and no state copy should promise more
than the server can verify.

- pending now renders "Waking <agent>…" — what the server actually did.
  "On it" is the agent's own claim, made by PATCHing active.
- Claims can only arrive in watching or active: a fresh session claimed
  into awaiting_input parked an unearned "asked a question" pill for up
  to its hour-long stale window.
- pending and stale are now rejected via PATCH too — both are verdicts
  the server reaches about the agent, not reports an agent can file.
- /agent-instructions and docs/AGENT_COLLABORATION.md spell out the
  harness requirement the loop stands on: event arrival must become a
  model turn (blocking tool call / background-exit re-invocation /
  sidecar resume), else drain the durable inbox per turn. Amp's webhook
  ask is recorded as an open design question, not built.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Wakes go where a path exists; promises go where one was kept

The pill only knows two true things: whether a connection is parked
(SSE heartbeats and long-poll parks now both stamp a transport clock —
long-poll agents previously read as absent and went stale mid-loyalty),
and whether this session has ever turned a delivery into a model turn.
So an event only starts the 30-second pending countdown when transport
is live or a wake URL is registered, and the pill only says "Waking
Claude…" once a wake has been answered before — the first one is
quietly a test, shown as plain presence.

New wake path for agents that can't hold a connection or be resumed:
claim with a wake_url and CoPlan POSTs a signed "you have inbox items"
ping per event (HMAC secret minted once at registration, event_id for
dedupe, retries with backoff, fires even on complete sessions — waking
them is the point). The ping carries no payload: the agent pulls and
acks through the cursor API, so at-least-once and authority don't fork.
Fits Amp orbs' createWebhook exactly.

The bridge grows an "acp" adapter: one live agent subprocess speaking
the Agent Client Protocol (initialize → session/new → session/prompt
per event, permission asks answered per config), replacing per-harness
resume dialects for everything in the ACP registry — goose acp, Gemini
--acp, claude-agent-acp, codex-acp, amp-acp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Harden the wake paths against the eleven ways they lied or leaked

An adversarial review of the wake plumbing confirmed eleven defects;
this fixes all of them.

The headline: MarkStaleAgentSessionJob had never fired. wake! passed
woken_at through bare iso8601, truncating to whole seconds, while the
column is datetime(6) — so the wake's own transition always read as
"activity after the wake" and the 30-second retraction behind every
pending pill was a dead letter. woken_at now carries microseconds, and
the job finally has a spec (including the mid-second regression case).

Honesty of the wake proof:
- Only active/awaiting_input count as answering a wake (PROOF_STATES).
  complete and watching are filed mechanically by detach paths and
  supervising loops, and were marking dead harnesses wake-proven.
- coplan-attach --once no longer PATCHes active on its way out for the
  same reason: it cannot know whether its exit wakes anything. The
  woken model's own fast-ack takes the pill over, or pending goes
  stale in 30s — now truthfully.
- Instructions spell out "PATCH, don't re-claim, when answering a
  wake": a fresh claim resets the session instead of answering it.

Safety of the webhook egress:
- New CoPlan::WakeUrlPolicy: wake URLs must resolve entirely to public
  address space (SSRF; one private A record among public ones is
  refused too). Checked at registration and re-checked before every
  POST, since DNS may change its answer between the two. Hosts
  override via config.wake_url_policy; dev/test allow localhost.
- DeliveryFailed messages carry the session id, never the URL — the
  URL can embed a capability token and the messages land in logs and
  solid_queue_failed_executions.
- Rescue now covers EOFError (via IOError), Net::ProtocolError, and
  Net::HTTPBadResponse, which previously escaped straight to the
  failed-executions table.
- Dead-URL circuit breaker: wake_failures_count tracks exhausted retry
  runs; after three, the URL and secret are unregistered — mirroring
  how expired web push subscriptions are destroyed, not hammered.
- The enqueue moved inside ActiveRecord.after_all_transactions_commit:
  the queue lives in a separate database, so a worker could pick the
  ping up before the AgentEvent row was visible and no-op the wake.

Robustness of the ACP bridge:
- pump_until now shares one deadline per turn (acp_turn_timeout,
  default 600s) via IO.select; a wedged agent is killed instead of
  wedging the bridge on a blocking read forever.
- A replayed prompt after a respawn is prefixed with a recovery note
  so a half-completed first attempt doesn't get duplicated replies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make the first encounter one curl: served tools, setup branches, bridge flags

A new agent meeting a CoPlan server previously needed a repo checkout to
go live. Now the server hands over everything itself.

- The agent scripts move into the engine (engine/agent_tools/) and every
  CoPlan deployment serves them read-only at /agent-tools/coplan-attach,
  /agent-tools/coplan_session.rb, and /agent-tools/coplan-bridge —
  whitelisted names only, rendered inline for the same X-Sendfile reason
  as the service worker, public for the same reason /agent-instructions
  is. Thin shims keep script/* working for local development.

- /agent-instructions gains "Setup: Your First Five Minutes as a Live
  Agent": download the tools with curl, then follow the one branch that
  matches what your harness can do — background attach (exit is the
  wake), blocking attach, ACP bridge, webhook wake, or per-turn inbox
  drain. Verified end-to-end: fresh directory, two curls, one command,
  clean exit 64 on a quiet plan.

- coplan-bridge learns flags so the simple path needs no config file:
  --acp "goose acp" --plan <id> --name Goose (plus --adapter/--session/
  --base/--token/--approve). Flags win over the config file; base URL
  and token fall back to COPLAN_BASE/COPLAN_TOKEN. One deliberate
  behavior change: the adapter must now be named explicitly — the old
  silent default was "demo", which replies to threads and edits the
  plan, a rude surprise for anyone running a freshly downloaded script
  with only a --plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Harden the bridge's config surface and survive mount prefixes

An adversarial review of the first-encounter bootstrap found seven
real defects, all in the seams between flags, config files, and
deployment shapes:

- A leftover ~/.config/coplan/bridge.json silently steered flags-only
  runs: the file's base_url/token beat $COPLAN_BASE/$COPLAN_TOKEN, so a
  stale credential could claim sessions nobody asked for. Precedence is
  now flags > ENV > file, and the bridge announces which config file it
  loaded. Malformed JSON aborts naming the file instead of raising.
- --plan unioned with the file's plan list instead of replacing it, so
  "watch this one plan" quietly watched the old ones too.
- The adapter was validated at the first wake, not at startup: a typo'd
  --adapter claimed sessions, flipped pills active, then died hours
  later on the first event. Same for adapter acp with no command, and
  --acp "" (an unset shell var) turned every event into ack-and-lose.
  All four now abort before anything is claimed.
- URI.join discards an engine mount prefix ("http://host/coplan" +
  "/api/v1/..." → "http://host/api/v1/..."), which broke every served
  script on prefix-mounted deployments — token minting 404'd silently
  and SSE aborted. All four scripts now concatenate.
- Setup branch C rooted the ACP agent in ~/.coplan/bin via a chained
  cd; the bridge now takes --cwd, and the instructions say to run it
  from the directory the agent should work in.
- Instructions referenced sections by names that don't exist and
  pointed at repo paths for revocation; both now reference the served
  /agent-tools/ scripts and real section titles.

Verified end-to-end with a fixture stale config in a fake HOME: ENV
wins, plans replace, the announcement prints, and all four startup
aborts fire before any session is claimed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Lead the setup with curl, not with downloaded code

The "first five minutes" section told a brand-new agent to download
Ruby scripts from the server and execute them — backwards on two
counts Hampton called out: Ruby is not on every machine, and agents
should not be taught to blindly run network-fetched code.

Flipped: the section now opens with the three HTTP calls the live
loop actually is (claim, wait, ack), gives the branch-A wake as a
seven-line shell loop any agent can port to its own runtime, and
tells the agent to make the wiring durable — save it as a skill or
saved command, a custom tool, or a standing ACP bridge config, so
tomorrow's run attaches with one action instead of rediscovering the
page. The served scripts drop to an optional convenience behind a
read-before-you-run checklist scoped per script (attach + helper:
this server only, writes only under ~/.coplan/$COPLAN_HOME, no
subprocesses; the bridge: reads its config file and execs exactly
the agent command you configured), with the download and the run in
separate blocks so the paste-able unit never fetch-then-executes.

An adversarial review of the rewrite (4 lenses, 18 findings) caught
the checklist being literally false for the bridge, the example loop
spinning silently forever on a dead token (now it exits loudly — a
process exit is the wake either way), a literal-secret export in the
one place the page otherwise keeps tokens out of command lines, and
curl -O saving error pages as scripts (now -f, verified empirically).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Appease the newly CI-enforced RuboCop across the agent files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Absorb main's changed-sections struct and browse catchall

ChangedSections now returns keys + rewritten (#192); the agent event
payload carries that richer shape and the specs assert it. The URL
catchall (#191) swallows the encoded-slash traversal probe before the
agent-tools route sees it, so the spec now asserts the property that
matters (no file leaves the server) plus a direct single-segment probe.
agent-tools joins the reserved handles next to agent-instructions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Codex review: seven confirmed findings

- Live updates broke on main's ChangedSections struct: the stream
  attribute now parses {keys, rewritten} (bare arrays still accepted),
  so viewer tabs render remote edits again; a rewrite arrives with no
  keys and stays flash-free by design.
- The flash no longer scrolls: a remote edit must never move a reader
  who didn't ask to navigate.
- The bridge acks per dispatched event and stops the batch on failure —
  the request-level ack was silently swallowing events whose harness
  dispatch raised, breaking at-least-once.
- Status changes carry the acting token through CreateNotificationsJob
  into self-suppression: an agent that resolves a thread no longer
  wakes itself from its own status event.
- Wake webhooks connect to the address the policy vetted (ipaddr pin,
  hostname kept for Host/SNI): resolving twice let a rebinding host
  answer the check publicly and the connection privately.
- AgentEvent and AgentSession get ActiveAdmin registrations and
  ransackable allowlists (wake_secret deliberately excluded).
- Plans and API tokens delete their collaboration rows (delete_all)
  instead of raising on the new FKs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant