diff --git a/app/admin/agent_events.rb b/app/admin/agent_events.rb new file mode 100644 index 00000000..e294bbec --- /dev/null +++ b/app/admin/agent_events.rb @@ -0,0 +1,28 @@ +ActiveAdmin.register CoPlan::AgentEvent, as: "AgentEvent" do + # Inbox rows are written by the platform and acked by agents; admin is + # for inspection only (a stuck inbox, a delivery question). + actions :index, :show + + index do + id_column + column :event_type + column :plan + column :api_token + column :acked_at + column :created_at + end + + show do + attributes_table do + row :id + row :event_type + row :plan + row :api_token + row :comment_thread_id + row :comment_id + row :payload + row :acked_at + row :created_at + end + end +end diff --git a/app/admin/agent_sessions.rb b/app/admin/agent_sessions.rb new file mode 100644 index 00000000..b8f22ae5 --- /dev/null +++ b/app/admin/agent_sessions.rb @@ -0,0 +1,38 @@ +ActiveAdmin.register CoPlan::AgentSession, as: "AgentSession" do + # Read-mostly: sessions are claimed and updated over the API. Destroy + # stays available so an operator can clear a stuck row. + actions :index, :show, :destroy + + index do + selectable_column + id_column + column :agent_name + column :plan + column :api_token + column :state + column :state_detail + column :last_activity_at + column :wakes_answered_count + column :wake_failures_count + actions + end + + show do + attributes_table do + row :id + row :agent_name + row :plan + row :api_token + row :state + row :state_detail + row :last_activity_at + row :last_transport_at + # wake_secret is deliberately not rendered — it's a credential. + row :wake_url + row :wakes_answered_count + row :wake_failures_count + row :created_at + row :updated_at + end + end +end diff --git a/config/initializers/coplan.rb b/config/initializers/coplan.rb index 7c1b2f82..acf06e8a 100644 --- a/config/initializers/coplan.rb +++ b/config/initializers/coplan.rb @@ -40,6 +40,14 @@ # } # } + # Wake webhook egress: the engine default refuses URLs whose hosts + # resolve to private/loopback/link-local space (SSRF). Locally that's + # exactly where agents live, and specs use non-resolving example hosts, + # so dev and test allow any well-formed http(s) URL. + if Rails.env.development? || Rails.env.test? + config.wake_url_policy = ->(uri) { true } + end + config.notification_handler = ->(event, payload) { case event when :comment_created diff --git a/db/cable_schema.rb b/db/cable_schema.rb index 23666604..eef9db1d 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -4,8 +4,8 @@ t.binary "payload", limit: 536870912, null: false t.datetime "created_at", null: false t.integer "channel_hash", limit: 8, null: false - t.index ["channel"], name: "index_solid_cable_messages_on_channel" - t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" - t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + t.index [ "channel" ], name: "index_solid_cable_messages_on_channel" + t.index [ "channel_hash" ], name: "index_solid_cable_messages_on_channel_hash" + t.index [ "created_at" ], name: "index_solid_cable_messages_on_created_at" end end diff --git a/db/cache_schema.rb b/db/cache_schema.rb index 81a410d1..8ddd39fa 100644 --- a/db/cache_schema.rb +++ b/db/cache_schema.rb @@ -5,8 +5,8 @@ t.datetime "created_at", null: false t.integer "key_hash", limit: 8, null: false t.integer "byte_size", limit: 4, null: false - t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" - t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" - t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + t.index [ "byte_size" ], name: "index_solid_cache_entries_on_byte_size" + t.index [ "key_hash", "byte_size" ], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index [ "key_hash" ], name: "index_solid_cache_entries_on_key_hash", unique: true end end diff --git a/db/migrate/20260807215107_create_agent_collaboration_tables.co_plan.rb b/db/migrate/20260807215107_create_agent_collaboration_tables.co_plan.rb new file mode 100644 index 00000000..35bf8902 --- /dev/null +++ b/db/migrate/20260807215107_create_agent_collaboration_tables.co_plan.rb @@ -0,0 +1,59 @@ +# This migration comes from co_plan (originally 20260807000000) +class CreateAgentCollaborationTables < ActiveRecord::Migration[8.1] + # Guarded because these objects may already exist on both sides of the + # split this migration heals: databases loaded from schema.rb carry the + # tables (they leaked into the schema via #175's regeneration against a + # dev database), and api_tokens.agent_name ships with the identity + # migration (20260815000000), which runs first on hosts that install + # this one later. + def change + # Durable per-agent event inbox. IDs are UUIDv7 (time-ordered), so the + # id doubles as the pagination cursor: "give me events after ". + unless table_exists?(:coplan_agent_events) + create_table :coplan_agent_events, id: { type: :string, limit: 36 } do |t| + t.string :api_token_id, limit: 36, null: false + t.string :plan_id, limit: 36, null: false + t.string :comment_thread_id, limit: 36 + t.string :comment_id, limit: 36 + t.string :event_type, null: false + t.json :payload + t.datetime :acked_at + t.datetime :created_at, null: false + + t.index [ :api_token_id, :id ] + t.index [ :api_token_id, :acked_at ] + t.index :plan_id + end + + add_foreign_key :coplan_agent_events, :coplan_api_tokens, column: :api_token_id + add_foreign_key :coplan_agent_events, :coplan_plans, column: :plan_id + end + + # One session per (plan, agent token) — Linear-style delegation state + # machine driving the presence pill: pending / active / awaiting_input / + # complete / stale. + unless table_exists?(:coplan_agent_sessions) + create_table :coplan_agent_sessions, id: { type: :string, limit: 36 } do |t| + t.string :plan_id, limit: 36, null: false + t.string :api_token_id, limit: 36, null: false + t.string :agent_name, null: false + t.string :state, null: false, default: "pending" + t.string :state_detail + t.datetime :last_activity_at + t.timestamps + + t.index [ :plan_id, :api_token_id ], unique: true + t.index :api_token_id + end + + add_foreign_key :coplan_agent_sessions, :coplan_api_tokens, column: :api_token_id + add_foreign_key :coplan_agent_sessions, :coplan_plans, column: :plan_id + end + + # Stable display identity for an agent token, instead of the free-text + # per-comment agent_name. Normally added by 20260815000000 already. + unless column_exists?(:coplan_api_tokens, :agent_name) + add_column :coplan_api_tokens, :agent_name, :string + end + end +end diff --git a/db/migrate/20260820193542_add_wake_plumbing_to_agent_sessions.co_plan.rb b/db/migrate/20260820193542_add_wake_plumbing_to_agent_sessions.co_plan.rb new file mode 100644 index 00000000..d09546be --- /dev/null +++ b/db/migrate/20260820193542_add_wake_plumbing_to_agent_sessions.co_plan.rb @@ -0,0 +1,34 @@ +# This migration comes from co_plan (originally 20260820000000) +class AddWakePlumbingToAgentSessions < ActiveRecord::Migration[8.0] + # Guarded like 20260807000000: main's schema.rb has carried leaked + # agent-collab structure before, so never assume a clean slate. + def change + return unless table_exists?(:coplan_agent_sessions) + + # Evidence a connection is actually parked on this session's token — + # SSE heartbeats and long-poll parks touch it. Distinct from + # last_activity_at, which tracks the agent/state machine: a held + # socket must not keep a `pending` promise alive forever. + unless column_exists?(:coplan_agent_sessions, :last_transport_at) + add_column :coplan_agent_sessions, :last_transport_at, :datetime + end + + # How many wakes this session has demonstrably answered (pending → + # an agent-driven state). Zero means the loop is unproven and the + # pill makes no wake promise. + unless column_exists?(:coplan_agent_sessions, :wakes_answered_count) + add_column :coplan_agent_sessions, :wakes_answered_count, :integer, default: 0, null: false + end + + # Webhook wake: a session may register a URL CoPlan POSTs a signed + # "you have inbox items" ping to — the wake path for hosted agents + # that can receive HTTP but can't hold a connection or be resumed. + unless column_exists?(:coplan_agent_sessions, :wake_url) + add_column :coplan_agent_sessions, :wake_url, :string + end + + unless column_exists?(:coplan_agent_sessions, :wake_secret) + add_column :coplan_agent_sessions, :wake_secret, :string + end + end +end diff --git a/db/migrate/20260820201255_add_wake_failures_count_to_agent_sessions.co_plan.rb b/db/migrate/20260820201255_add_wake_failures_count_to_agent_sessions.co_plan.rb new file mode 100644 index 00000000..1a5ea1eb --- /dev/null +++ b/db/migrate/20260820201255_add_wake_failures_count_to_agent_sessions.co_plan.rb @@ -0,0 +1,11 @@ +# This migration comes from co_plan (originally 20260821000000) +class AddWakeFailuresCountToAgentSessions < ActiveRecord::Migration[8.0] + def change + return unless table_exists?(:coplan_agent_sessions) + return if column_exists?(:coplan_agent_sessions, :wake_failures_count) + + # Exhausted wake-webhook delivery runs since the last success; the + # URL is unregistered once this crosses WakeWebhookJob::MAX_EXHAUSTIONS. + add_column :coplan_agent_sessions, :wake_failures_count, :integer, default: 0, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 279cde0f..957a9166 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -72,10 +72,15 @@ t.string "api_token_id", limit: 36, null: false t.datetime "created_at", null: false t.datetime "last_activity_at" + t.datetime "last_transport_at" t.string "plan_id", limit: 36, null: false t.string "state", default: "pending", null: false t.string "state_detail" t.datetime "updated_at", null: false + t.integer "wake_failures_count", default: 0, null: false + t.string "wake_secret" + t.string "wake_url" + t.integer "wakes_answered_count", default: 0, null: false t.index ["api_token_id"], name: "index_coplan_agent_sessions_on_api_token_id" t.index ["plan_id", "api_token_id"], name: "index_coplan_agent_sessions_on_plan_id_and_api_token_id", unique: true end diff --git a/docs/AGENT_COLLABORATION.md b/docs/AGENT_COLLABORATION.md new file mode 100644 index 00000000..c67c282b --- /dev/null +++ b/docs/AGENT_COLLABORATION.md @@ -0,0 +1,243 @@ +# Agent collaboration: the live feedback loop + +How an agent that authored a plan hears about your comment within a +second, shows you it's working, replies, and edits the doc while you +watch. Design docs (options + tradeoffs) live in CoPlan itself — see the +"Agent Collaboration in CoPlan" umbrella plan. + +## The loop + +1. A human comments (typed, or spoken via the mic button). +2. The comment fans out to the **agent event inbox** (`AgentEvent`) of + every agent session on the plan — never back to the actor itself. +3. The agent (or `script/coplan-bridge` on its behalf) is long-polling + `GET /api/v1/agent/events` and wakes. +4. It flips its **agent session** to `active` (the presence pill humans + see), replies on the thread narrating what it'll change, PUTs the + edit, and lands on `complete`. +5. Every open tab gets the new content over Turbo Streams; the sections + that changed **flash** — word-level ins/del for edited paragraphs — + then settle. + +The delivery is pull-based on purpose: agents run on laptops behind NAT, +so CoPlan never assumes it can push. Same endpoint serves long-poll +(plain JSON, curl-friendly) and SSE (`Accept: text/event-stream`). +Cursor = last event id (UUIDv7, time-ordered); delivery is +at-least-once with explicit ack. + +## Capacity: attached agents vs. everyone else + +Every attached agent holds a Rack thread for the life of its connection, +and `RAILS_MAX_THREADS` is small (3 by default). Measured on this +codebase: with three agents attached and no budget, the app **stopped +serving pages** — a plain page load timed out after 10s. + +So `AgentEventBus` caps concurrent held connections at +`RAILS_MAX_THREADS - 2` (override with `COPLAN_MAX_AGENT_STREAMS`), +always leaving threads for ordinary traffic. Over budget: + +- **long-poll** answers immediately with `"throttled": true` — the + client falls back to its own polling cadence, nothing breaks +- **SSE** is refused with `503` + `Retry-After`, pointing at long-poll + +With the budget on, the same three-agent load serves a page in ~25ms. + +Waiting is signal-driven, not polled: `AgentEvents::Publish` signals the +bus, so a parked long-poll returns in **~180ms** end-to-end from comment +to delivery. Waiters still wake every few seconds to catch writes from +another Puma worker, so cross-process delivery degrades to that interval +rather than failing. + +The honest ceiling: this is thread-per-agent. It's fine for a team, not +for hundreds of concurrent attached agents — that would want a real +pub/sub transport rather than held Rack threads. + +Full API reference: `GET /agent-instructions` → "Live Collaboration". + +## Attaching (the normal case) + +An agent that is **alive and watching** doesn't need waking — it needs +interrupting. `script/coplan-attach` holds one server-driven SSE +connection and prints the moment something happens: no interval to tune, +no daemon, no config file, no harness integration. + +The scripts live in the engine (`engine/agent_tools/`) and every CoPlan +server serves them at `/agent-tools/coplan-attach`, +`/agent-tools/coplan_session.rb`, and `/agent-tools/coplan-bridge` — +but they are deliberately not the front door. Served scripts are +executable code fetched from the network (and need Ruby), so the +"Setup: Your First Five Minutes as a Live Agent" section of +`/agent-instructions` leads with a raw-curl wait loop any agent can +run, then tells the agent to save that wiring as a durable local +skill, saved command, or standing ACP bridge config — and only then +offers the scripts as an optional convenience, behind a +read-before-you-run checklist scoped per script (attach and its +helper: network calls to this server only, writes only under +`~/.coplan`/`$COPLAN_HOME`, no subprocesses; the bridge: additionally +reads its config file and execs exactly the one agent command you +configured). + +```bash +export COPLAN_BASE=http://localhost:3222 COPLAN_TOKEN= + +# Turn-based agents: blocks until the first event, prints a brief, acks, exits. +script/coplan-attach --plan --name Claude --once + +# Or stay attached and stream everything: +script/coplan-attach --plan --name Claude +``` + +`--once` is the shape a harness wants: run it as a tool call, get woken +by its output, act on the brief, run it again. While attached you hold +the presence pill; Ctrl-C detaches cleanly. `--timeout N` exits 64 if +nothing arrives, so a supervising loop can decide when to stop watching. + +It's a thin convenience over the API — an agent that can curl can do the +same thing straight from `/agent-instructions`, and doesn't need this +script at all. + +## What the harness must provide + +CoPlan can deliver an event in ~180ms; it cannot start the model's next +turn. That last inch of wiring belongs to the harness, and it is the +whole game: an event printed by a background process nobody wakes up for +is transport without collaboration. + +Field report (Amp, local thread, 2026-08-19): it held the SSE stream +fine, received every event with full context, and sat there — the model +never woke, and the human had to nudge it by hand before it could act on +comments that had been buffered for minutes. + +The shapes that close the loop, most portable first: + +1. **Blocking tool call.** Run `coplan-attach --once` (or the long-poll + curl) as a foreground tool call; the event returns as tool output. + Works in every harness; occupies the turn while waiting. +2. **Background process + exit re-invocation.** `coplan-attach --once` + in the background; the process exiting is the wake. Requires the + harness to re-invoke the model when a background task completes — + Claude Code does, most others don't. +3. **Sidecar resume.** `script/coplan-bridge` drains the inbox from + outside the harness and injects each event — over ACP into one live + agent session (`"adapter": "acp"`, works with anything in the ACP + registry), or via a per-harness resume-with-message command (adapter + table below). +4. **Webhook wake.** For hosted agents that can receive HTTP but can't + hold connections or be exec-resumed (Amp orbs, scheduled runners): + claim the session with a `wake_url` and CoPlan POSTs a signed "you + have inbox items" ping there on every event (`X-CoPlan-Signature`: + HMAC-SHA256 of the body with the `wake_secret` returned once at + registration; `event_id` for dedupe; retried with backoff). The ping + carries no payload — the agent pulls and acks through the cursor API + like every other transport, so at-least-once semantics and the + authority model don't fork. Two guardrails on the URL itself: hosts + must resolve to public address space (checked at registration and + again before every POST; deployments override via + `config.wake_url_policy`), and a URL that eats several entire retry + runs is presumed dead and unregistered — mirroring how expired web + push subscriptions are destroyed rather than hammered forever. + +A harness with none of these can still be a correct — just not live — +collaborator: the inbox is durable, so drain it with `wait=0` at the +start of each turn. + +Presence stays honest whichever way delivery goes, on two principles: + +- **A wake is only attempted where a path for it exists.** SSE + heartbeats and long-poll parks stamp the session's transport clock; + an event only flips a session to `pending` if a connection touched + transport recently or a wake URL is registered. No path → the event + just queues, and the pill doesn't move. +- **Wakeability is demonstrated, never declared.** A session that has + never answered a wake gets no promise: its first `pending` keeps the + plain-name pill while the wake quietly tests it. Once it has moved + itself out of `pending` (the one observable proof that delivery became + a model turn), later wakes earn "Waking Claude…". Either way `pending` + holds for at most 30 seconds before going stale, only the agent itself + can claim to be working (`active`), and the API refuses to *claim* a + session into a turn state like `awaiting_input` — no unearned "asked a + question" pill on arrival. + +## The bridge (only for agents that have exited) + +If nothing is running, something has to start it. The bridge claims +sessions on the plans you list, drains the inbox, and injects each event +into your harness via ACP or a "resume session with message" command. It +flips the pill to `active` before the harness even boots, so the human +sees life immediately. + +This is strictly the cold-start path. If you keep a session attached +while you work, skip the bridge entirely. + +The simple path is flags — no config file: + +```bash +export COPLAN_BASE=http://localhost:3222 COPLAN_TOKEN= +script/coplan-bridge --acp "goose acp" --plan --name Goose +``` + +(`--adapter --session ` for the exec-resume rows; `--approve` +to auto-grant ACP permission asks; an adapter must always be named — +there is no default, so nobody gets the plan-editing demo agent by +surprise — and it's validated at startup, not at the first wake.) Setups +worth writing down go in a config file, with the same keys. Precedence +is flags > `$COPLAN_BASE`/`$COPLAN_TOKEN` > file, `--plan` replaces the +file's plan list outright, and the bridge prints which config file it +loaded — a leftover `~/.config/coplan/bridge.json` never silently +steers a flags-only run: + +```json +{ + "base_url": "http://localhost:3222", + "token": "", + "agent_name": "Claude", + "plans": [""], + "adapter": "claude", + "harness_session": "" +} +``` + +### Per-harness adapters + +`acp` is the preferred adapter: one protocol, one live agent session that +events are pushed into as prompt turns, no per-harness dialect. The +exec-resume rows survive for harnesses without an ACP server. + +| `adapter` | Command shape | Notes | +|---|---|---| +| `acp` | `"acp_command"`: `["goose", "acp"]`, `["npx", "@google/gemini-cli", "--acp"]`, `["npx", "@agentclientprotocol/claude-agent-acp"]`, `["npx", "@agentclientprotocol/codex-acp"]`, `["npx", "-y", "amp-acp"]`, … | JSON-RPC over stdio (`initialize` → `session/new` → `session/prompt` per event). Agent stays alive between events. `"acp_permission": "approve"` to auto-grant permission asks (default reject). | +| `claude` | `claude -p --resume ` | exec-resume | +| `codex` | `codex exec resume ` | exec-resume; or ACP via `codex-acp` above | +| `goose` | `goose run --name --resume -t ` | exec-resume; or ACP via `goose acp` above | +| `openhands` | `openhands --headless --resume -t ` | exec-resume; also ships `openhands acp` | +| `amp` | `amp threads continue -x ` | exec-resume into a local thread; Amp declined native ACP — community `amp-acp` wraps it. For Amp **orbs**, skip the bridge: register the orb's `amp.createWebhook` URL as the session's `wake_url`. | +| `demo` | in-process deterministic agent (ack → reply → small edit) — no harness or tokens needed | — | + +Unattended runs need each harness's permission-relaxation flag +(`--permission-mode acceptEdits`, `--full-auto`, `GOOSE_MODE=auto`, …). +The stock `claude` command template uses `acceptEdits`; choose your own +posture deliberately — the bridge never escalates beyond what the +config says. + +An agent doesn't need the bridge at all: any agent that can run curl in +a loop can follow the "Live Collaboration" section of +`/agent-instructions` directly. + +## Demoing locally + +```bash +bin/rails server -p 3222 +# Make a token (Settings → API Tokens) for the user who authors the plan. + +# Terminal 2 — the "agent": +script/coplan-bridge --config bridge.json # adapter: "demo" for zero-cost + +# Browser: open the plan, comment on a stiff sentence +# ("this is way too formal"). Watch: pill appears → reply lands → +# section flashes with word-level diffs. +``` + +Voice: the mic button on the plan page (Chrome) speaks your feedback into +the same loop, with spoken "Got it." / "Done — take a look." cues. The +higher-fidelity local pipeline (Pipecat + MLX Whisper + Kokoro) lives in +`voice/`. diff --git a/engine/agent_tools/coplan-attach b/engine/agent_tools/coplan-attach new file mode 100755 index 00000000..99d231f3 --- /dev/null +++ b/engine/agent_tools/coplan-attach @@ -0,0 +1,250 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# coplan-attach — for an agent that is ALIVE and watching a plan. +# +# No harness adapters, no session resume, no daemon: you are already +# running, so you just hold one connection open and get interrupted when +# something happens. This is the "attached" model — the counterpart to +# coplan-bridge (served next to this file at /agent-tools/coplan-bridge), +# which exists only to wake an agent that has already exited. +# +# export COPLAN_BASE=http://localhost:3223 +# export COPLAN_TOKEN= +# coplan-attach --plan --once +# +# COPLAN_TOKEN is the machine's long-lived token; this mints a short-lived +# child from it, sticky to this agent run, and uses that. The secret never +# passes through the agent — see coplan_session.rb, which must sit in the +# same directory as this script. +# +# The connection is server-driven (SSE): the process sits idle on a +# socket and prints the moment an event lands — no repeated requests, +# no interval to tune. +# +# Two ways to use it: +# +# --once Block until the first event, print it, ack it, exit 0. +# This is the shape a turn-based agent wants: run it as a +# tool call, get woken by the output, act, run it again. +# Exits 64 if --timeout elapses with nothing to report. +# +# (none) Stay attached and stream every event as it arrives. Good +# for a human watching, or an agent that can consume a +# streaming tool. +# +# While attached, the plan shows your presence pill. Ctrl-C detaches +# cleanly (session -> complete) so you don't linger as a ghost. + +require "json" +require "net/http" +require "uri" +require "optparse" +require_relative "coplan_session" + +$stdout.sync = true + +options = { + base: ENV["COPLAN_BASE"] || "http://localhost:3000", + token: ENV["COPLAN_TOKEN"], + agent_name: ENV["COPLAN_AGENT_NAME"] || "Agent", + once: false, + timeout: nil, + claim: true +} + +OptionParser.new do |opts| + opts.banner = "Usage: coplan-attach --plan PLAN_ID [--once] [--timeout SECONDS]" + opts.on("--plan PLAN_ID", "Plan to attach to (repeatable)") { |v| (options[:plans] ||= []) << v } + opts.on("--base URL", "CoPlan base URL (default $COPLAN_BASE)") { |v| options[:base] = v } + opts.on("--token TOKEN", "API token (default $COPLAN_TOKEN)") { |v| options[:token] = v } + opts.on("--name NAME", "Agent name shown on the pill") { |v| options[:agent_name] = v } + opts.on("--once", "Exit after the first event (turn-based agents)") { options[:once] = true } + opts.on("--timeout SECONDS", Integer, "Give up waiting after N seconds (exit 64)") { |v| options[:timeout] = v } + opts.on("--no-claim", "Don't claim an agent session / show a pill") { options[:claim] = false } + opts.on("--json", "Emit raw event JSON instead of a prose brief") { options[:json] = true } + opts.on("--session KEY", "Session key for the sticky token (default: harness session id)") { |v| options[:session_key] = v } + opts.on("--no-session", "Use $COPLAN_TOKEN directly instead of a sticky session token") { options[:session] = false } +end.parse! + +abort "coplan-attach: --plan is required" if Array(options[:plans]).empty? +abort "coplan-attach: no token (set COPLAN_TOKEN or pass --token)" if options[:token].to_s.empty? + +BASE = URI(options[:base]) + +# A token is the unit of event subscription, so two agents sharing one +# share an inbox and race for each other's wakes. Each run gets its own +# short-lived child token — but "its own" has to survive the agent's own +# turns, so it's keyed to the harness session and stored on disk rather +# than minted fresh on every --once invocation (which would churn a new +# identity, and a new presence pill, every turn). +TOKEN = + if options[:session] == false + options[:token] + else + CoPlanSession.ensure_token( + base: BASE, + parent: options[:token], + agent_name: options[:agent_name], + key: options[:session_key] + ) + end + +def api(method, path, body: nil) + # Concatenation, not URI.join: an absolute path in URI.join discards + # the base's mount prefix (host/coplan + /api/v1/x -> host/api/v1/x), + # and CoPlan engines may be mounted under a prefix. + uri = URI("#{BASE.to_s.chomp("/")}#{path}") + klass = { get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch, + delete: Net::HTTP::Delete }.fetch(method) + req = klass.new(uri) + req["Authorization"] = "Bearer #{TOKEN}" + req["Content-Type"] = "application/json" + req.body = body.to_json if body + Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http| + res = http.request(req) + warn "coplan-attach: #{method.upcase} #{path} -> #{res.code}" unless res.code.start_with?("2") + res.body.to_s.empty? ? {} : JSON.parse(res.body) + end +rescue JSON::ParserError + {} +end + +def claim(plans, agent_name) + # `watching` = attached and idle. Claiming must not claim to be working. + plans.each { |id| api(:post, "/api/v1/plans/#{id}/agent_session", body: { agent_name: agent_name, state: "watching" }) } +end + +def detach(plans) + plans.each { |id| api(:patch, "/api/v1/plans/#{id}/agent_session", body: { state: "complete" }) } +end + +# Whose comment this is decides how much rope the agent has. Your own +# principal can tell you to rewrite the doc; a third party commenting on +# a plan you're attached to gets an answer and a proposal, not an edit. +def authority_note(payload) + who = payload["from_plan_author"] ? "the plan's author" : "a collaborator" + if payload["authority"] == "principal" + "This comment is from YOUR principal (#{who}) — you may act on it directly." + else + "This comment is from #{who}, NOT your principal — reply and propose the " \ + "change in-thread; wait for your principal before editing the document." + end +end + +# What the agent actually needs to read: who said what, where, and what +# it's expected to do about it. +def brief(event) + p = event["payload"] || {} + lines = [ "--- coplan event: #{event["type"]} ---" ] + lines << "plan: #{p["plan_title"]} (#{event["plan_id"]})" + lines << "thread: #{event["comment_thread_id"]}" if event["comment_thread_id"] + lines << "anchored to: \"#{p["anchor_text"]}\"" if p["anchor_text"] + lines << "#{p["comment_author"]} said: #{p["comment_body"].to_s.strip}" if p["comment_body"] + if p["changed_sections"] + lines << "sections changed: #{Array(p["changed_sections"]).join(", ")} (revision #{p["revision"]})" + end + lines << "" + lines << "You are the agent attached to this plan. Reply on the thread first" + lines << "(POST /api/v1/plans/#{event["plan_id"]}/comments/#{event["comment_thread_id"]}/reply)," if event["comment_thread_id"] + lines << "then apply any edit with PUT /api/v1/plans/#{event["plan_id"]}/content." + lines << authority_note(p) if p.key?("authority") + lines << "Full API: #{BASE}/agent-instructions" + lines.join("\n") +end + +PLANS = Array(options[:plans]) +claim(PLANS, options[:agent_name]) if options[:claim] + +# In --once mode the process exits *because* the agent is about to start +# working, so detaching here would clear the pill at the exact moment the +# human should see "on it". handing_off is set on the way out in that case. +handing_off = false +# The session token deliberately outlives this process — the next --once +# invocation in the same agent run reuses it, so the agent keeps one inbox +# and one pill across its turns. End it with +# `DELETE /api/v1/tokens/current` using the token stored in +# ~/.coplan/sessions/.json (or `script/coplan session --revoke` +# from a CoPlan checkout). +at_exit { detach(PLANS) if options[:claim] && !handing_off } +trap("INT") { exit 0 } + +cursor = nil +started = Time.now +saw_event = false + +# One held socket; reconnect only when the server retires the stream +# (SSE_LIFETIME) or the network drops. Resume from the last cursor so +# nothing is missed across a reconnect. +loop do + if options[:timeout] && Time.now - started > options[:timeout] + warn "coplan-attach: nothing after #{options[:timeout]}s" + exit 64 + end + + uri = URI("#{BASE.to_s.chomp("/")}/api/v1/agent/events") + uri.query = URI.encode_www_form({ cursor: cursor }.compact) + req = Net::HTTP::Get.new(uri) + req["Authorization"] = "Bearer #{TOKEN}" + req["Accept"] = "text/event-stream" + + # A --timeout must be able to fire while blocked on the stream, not just + # between reconnects — the server holds a socket open for minutes and + # heartbeats it, so without a read deadline "give up after N seconds" + # would actually mean "after the server retires the stream". + read_deadline = + options[:timeout] ? [ options[:timeout] - (Time.now - started), 1 ].max : nil + + begin + Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", read_timeout: read_deadline) do |http| + http.request(req) do |res| + abort "coplan-attach: server said #{res.code}" unless res.code.start_with?("2") + id = nil + buffer = [] + res.read_body do |chunk| + chunk.each_line do |line| + line = line.chomp + if line.start_with?("id: ") + id = line.delete_prefix("id: ") + elsif line.start_with?("data: ") + buffer << line.delete_prefix("data: ") + elsif line.empty? && buffer.any? + event = JSON.parse(buffer.join("\n")) rescue nil + buffer = [] + next unless event + + puts(options[:json] ? event.to_json : brief(event)) + cursor = id || event["id"] + api(:post, "/api/v1/agent/events/ack", body: { cursor: cursor }) if cursor + saw_event = true + if options[:once] + # Exit without detaching: the pill stays `pending` and the + # woken agent's own fast-ack (PATCH active, per etiquette) + # takes it over. This script must NOT claim active itself — + # it cannot know whether its exit wakes anything, and a + # mechanical ack here would mark dead-harness sessions + # wake-proven and show "reading your comment" to a human + # nobody is going to answer. If no ack comes, pending goes + # stale in 30s and the pill retracts: the honest outcome. + handing_off = true if options[:claim] + exit 0 + end + started = Time.now + elsif line.empty? + buffer = [] + end + end + end + end + end + rescue Interrupt + exit 0 + rescue Net::ReadTimeout + # Not an error: the wait budget ran out mid-stream. Loop back so the + # timeout check at the top decides (exit 64) — or reattach if events + # were seen and the clock was reset. + rescue => e + warn "coplan-attach: #{e.class}: #{e.message} — reattaching" + sleep 2 + end +end diff --git a/engine/agent_tools/coplan-bridge b/engine/agent_tools/coplan-bridge new file mode 100755 index 00000000..3a148f73 --- /dev/null +++ b/engine/agent_tools/coplan-bridge @@ -0,0 +1,434 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# coplan-bridge — wakes your local coding agent when someone comments on a +# CoPlan plan. Harness-agnostic: it drains the plan's agent event inbox +# (long-poll, NAT-proof) and injects each event into whichever harness you +# run via a per-harness "resume session with a message" command. +# +# The simple path needs no config file at all: +# +# export COPLAN_BASE=https://coplan.example COPLAN_TOKEN= +# coplan-bridge --acp "goose acp" --plan --name Goose +# +# The config file carries the same keys for setups worth writing down. +# Precedence is flags > $COPLAN_BASE/$COPLAN_TOKEN > file, --plan +# replaces the file's plan list, and the bridge announces which config +# file it loaded: +# +# coplan-bridge --config bridge.json +# +# Config (JSON): +# { +# "base_url": "http://localhost:3000", +# "token": "", +# "agent_name": "Claude", +# "plans": ["", ...], // sessions are claimed on start +# "adapter": "claude", // which command template to use +# "harness_session": "", +# "adapters": { ... }, // optional overrides, see DEFAULT_ADAPTERS +# "acp_command": ["goose", "acp"] // for adapter "acp": see AcpAgent +# } +# +# The adapter command receives the rendered wake prompt as its final +# argument ({prompt}) and the harness session id as {session}. The prompt +# tells the agent to do the polite thing: fast-ack the session, reply, +# edit, complete. The bridge itself flips the session pill to `active` +# before the harness even boots, so humans see life within a second. +# +# The "demo" adapter needs no harness or API budget: it acks, replies to +# the thread, and makes a small visible edit — enough to exercise the +# full live-collaboration UI end to end. + +require "json" +require "net/http" +require "uri" +require "optparse" +require "open3" +require "shellwords" + +$stdout.sync = true + +DEFAULT_ADAPTERS = { + # Every entry is "resume a named session with this message" in that + # harness's dialect. {session} and {prompt} are substituted. + "claude" => [ "claude", "-p", "--resume", "{session}", "--permission-mode", "acceptEdits", "{prompt}" ], + "codex" => [ "codex", "exec", "resume", "{session}", "{prompt}" ], + "goose" => [ "goose", "run", "--name", "{session}", "--resume", "-t", "{prompt}" ], + "openhands" => [ "openhands", "--headless", "--resume", "{session}", "-t", "{prompt}" ], + "amp" => [ "amp", "threads", "continue", "{session}", "-x", "{prompt}" ], + "acp" => [], # one live agent subprocess over ACP, see AcpAgent + "demo" => [] # handled in-process, see DemoAgent +}.freeze + +options = { plans: [], overrides: {} } +OptionParser.new do |opts| + opts.banner = "Usage: coplan-bridge [--acp COMMAND] [--plan PLAN_ID]... [--config PATH]" + opts.on("--plan PLAN_ID", "Plan to watch (repeatable; replaces the config file's plans)") { |v| options[:plans] << v } + opts.on("--acp COMMAND", 'ACP agent command, e.g. "goose acp"') do |v| + command = begin + Shellwords.split(v) + rescue ArgumentError => e + abort "coplan-bridge: bad --acp command (#{e.message})" + end + # An empty command sails past every later guard (the config key + # exists) and then fails per event, after the pill said "active" — + # the classic cause is --acp "$SOME_VAR" with the variable unset. + abort "coplan-bridge: --acp needs a command, e.g. --acp \"goose acp\"" if command.empty? + options[:overrides]["adapter"] = "acp" + options[:overrides]["acp_command"] = command + end + opts.on("--adapter NAME", "Adapter to use (#{DEFAULT_ADAPTERS.keys.join(", ")})") { |v| options[:overrides]["adapter"] = v } + opts.on("--session ID", "Harness session/thread id for exec-resume adapters") { |v| options[:overrides]["harness_session"] = v } + opts.on("--name NAME", "Agent name shown on the pill") { |v| options[:overrides]["agent_name"] = v } + opts.on("--cwd DIR", "Working directory for the ACP agent's session (default: where the bridge runs)") { |v| options[:overrides]["acp_cwd"] = v } + opts.on("--base URL", "CoPlan base URL (default $COPLAN_BASE)") { |v| options[:overrides]["base_url"] = v } + opts.on("--token TOKEN", "API token (default $COPLAN_TOKEN)") { |v| options[:overrides]["token"] = v } + opts.on("--approve", "Grant the ACP agent's permission requests (default: reject)") { options[:overrides]["acp_permission"] = "approve" } + opts.on("--config PATH", "Path to bridge config JSON (default ~/.config/coplan/bridge.json)") { |v| options[:config] = v } +end.parse! + +# The default-path file still loads when it exists — that is what a +# default config is for — but never silently: a leftover bridge.json +# quietly steering a flags-only run at an old host would be worse than +# having no default at all. (An explicit --config that doesn't exist is +# an error; silently ignoring a named file would be worse than failing.) +config_path = options[:config] || File.expand_path("~/.config/coplan/bridge.json") +file_config = + if options[:config] || File.exist?(config_path) + begin + JSON.parse(File.read(config_path)).tap { puts "coplan-bridge: using config #{config_path}" } + rescue JSON::ParserError => e + abort "coplan-bridge: could not parse #{config_path}: #{e.message}" + end + else + {} + end +CONFIG = file_config.merge(options[:overrides]) +# --plan REPLACES the file's plans — "flags win over the file" with no +# exceptions. A union would quietly re-attach the bridge to every plan a +# leftover file names, and claim sessions the user never asked for. +CONFIG["plans"] = options[:plans] if options[:plans].any? + +# Base URL and token resolve flags > environment > config file. An env +# var the user just exported must never lose to a file they didn't name. +resolve = lambda do |key, env| + [ options[:overrides][key], ENV[env], file_config[key] ].find { |v| !v.to_s.empty? } +end +BASE = URI(resolve.call("base_url", "COPLAN_BASE") || abort("coplan-bridge: no base URL (set COPLAN_BASE, or pass --base)")) +TOKEN = resolve.call("token", "COPLAN_TOKEN") || abort("coplan-bridge: no token (set COPLAN_TOKEN, or pass --token)") + +abort "coplan-bridge: nothing to watch (pass --plan, or put \"plans\" in the config)" if Array(CONFIG["plans"]).empty? +# The adapter must be chosen, not defaulted: the old default was "demo", +# which replies to threads and edits the plan — a rude surprise for +# someone who downloaded this script and ran it with only a --plan. +abort "coplan-bridge: no adapter (pass --acp \"\" or --adapter NAME; --adapter demo runs the built-in demo agent)" if CONFIG["adapter"].to_s.empty? +# And it must be a real one, checked now — a typo'd --adapter that only +# fails at the first wake leaves a claimed session and an unanswered +# human, hours after anyone was watching the terminal. +KNOWN_ADAPTERS = DEFAULT_ADAPTERS.keys | (CONFIG["adapters"] || {}).keys +abort "coplan-bridge: unknown adapter #{CONFIG["adapter"]} (know: #{KNOWN_ADAPTERS.join(", ")})" unless KNOWN_ADAPTERS.include?(CONFIG["adapter"]) +if CONFIG["adapter"] == "acp" && Array(CONFIG["acp_command"]).empty? + abort "coplan-bridge: adapter \"acp\" needs a command (pass --acp \"goose acp\" or set \"acp_command\")" +end + +def request(method, path, body: nil, params: nil, read_timeout: 90) + # Concatenation, not URI.join: an absolute path in URI.join discards + # the base's mount prefix (host/coplan + /api/v1/x -> host/api/v1/x), + # and CoPlan engines may be mounted under a prefix. + uri = URI("#{BASE.to_s.chomp("/")}#{path}") + uri.query = URI.encode_www_form(params) if params + klass = { get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch, put: Net::HTTP::Put }.fetch(method) + req = klass.new(uri) + req["Authorization"] = "Bearer #{TOKEN}" + req["Content-Type"] = "application/json" + req.body = body.to_json if body + Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", read_timeout: read_timeout) do |http| + res = http.request(req) + warn "coplan-bridge: #{method.upcase} #{path} -> #{res.code}" unless res.code.start_with?("2") + res.body && !res.body.empty? ? JSON.parse(res.body) : {} + end +rescue JSON::ParserError + {} +end + +def claim_sessions + Array(CONFIG["plans"]).each do |plan_id| + request(:post, "/api/v1/plans/#{plan_id}/agent_session", body: { agent_name: CONFIG.fetch("agent_name", "Agent") }) + puts "coplan-bridge: watching plan #{plan_id}" + end +end + +def wake_prompt(event) + payload = event["payload"] || {} + <<~PROMPT + [coplan-bridge] A CoPlan event needs your attention. + + Event: #{event["type"]} on plan "#{payload["plan_title"]}" (plan id #{event["plan_id"]}, thread id #{event["comment_thread_id"]}). + #{payload["comment_author"] ? "#{payload["comment_author"]} wrote: #{payload["comment_body"].to_s.strip}" : ""} + #{payload["anchor_text"] ? "Anchored to: \"#{payload["anchor_text"]}\"" : ""} + + Respond as the plan's agent, over the CoPlan API at #{BASE} (full instructions: #{BASE}/agent-instructions): + 1. PATCH /api/v1/plans/#{event["plan_id"]}/agent_session {"state":"active","detail":""} immediately. + 2. Reply on the thread first (narrate what you'll change), then apply any edit with PUT .../content. + 3. Finish with {"state":"complete"} (or "awaiting_input" if you asked a question). + PROMPT +end + +# One protocol instead of five dialects. The Agent Client Protocol +# (agentclientprotocol.com) is JSON-RPC 2.0 over a subprocess's stdio, +# newline-delimited — and unlike the exec-resume adapters, the agent +# stays *alive* between events: we spawn it once, open one session, and +# push each CoPlan event in as a `session/prompt` turn. Anything that +# ships an ACP server works here unmodified: `goose acp`, Gemini CLI +# `--acp`, Claude Code and Codex via their `@agentclientprotocol/*` +# adapters, Amp via the community `amp-acp` wrapper, and the rest of the +# ACP registry. +# +# Config keys: +# "acp_command": ["goose", "acp"] (required) +# "acp_cwd": working dir for the session (default: bridge's cwd) +# "acp_permission": "reject" | "approve" what to answer when the +# agent asks permission (default reject — the bridge +# never escalates beyond what the config says) +# "acp_turn_timeout": seconds one turn may run before the agent is +# presumed wedged (default 600 — generous, because a +# real turn legitimately thinks for minutes) +class AcpAgent + PROTOCOL_VERSION = 1 + DEFAULT_TURN_TIMEOUT = 600 + + def self.instance + @instance ||= new( + CONFIG.fetch("acp_command") { abort "coplan-bridge: adapter \"acp\" needs \"acp_command\", e.g. [\"goose\", \"acp\"]" }, + cwd: CONFIG.fetch("acp_cwd", Dir.pwd), + permission: CONFIG.fetch("acp_permission", "reject"), + turn_timeout: CONFIG.fetch("acp_turn_timeout", DEFAULT_TURN_TIMEOUT) + ) + end + + def initialize(command, cwd:, permission:, turn_timeout:) + @command = Array(command) + @cwd = File.expand_path(cwd) + @permission = permission + @turn_timeout = turn_timeout + @next_id = 0 + boot + end + + def prompt(text) + rpc("session/prompt", { sessionId: @session_id, prompt: [ { type: "text", text: text } ] }) + rescue AgentGone + warn "coplan-bridge: ACP agent died — respawning" + boot + # The first attempt may have gotten partway through (a reply posted, + # an edit half-applied) before the process died. A fresh session has + # no memory of that, so blindly replaying the same prompt risks + # duplicate replies — tell the agent to look before acting. + recovery = "NOTE: a previous attempt at this event may have partially completed " \ + "before the agent restarted. Check the plan's thread and content first, " \ + "and do not duplicate replies or edits already made.\n\n#{text}" + rpc("session/prompt", { sessionId: @session_id, prompt: [ { type: "text", text: recovery } ] }) + end + + private + + class AgentGone < StandardError; end + + def boot + # stderr passes through so the agent's own logging stays visible. + @stdin, @stdout, @wait_thread = Open3.popen2(*@command) + rpc("initialize", { + protocolVersion: PROTOCOL_VERSION, + # No fs or terminal capabilities: the agent works through its own + # tools, not through us. + clientCapabilities: {}, + clientInfo: { name: "coplan-bridge", version: "1.0" } + }) + @session_id = rpc("session/new", { cwd: @cwd, mcpServers: [] }).fetch("sessionId") + puts "coplan-bridge: ACP session #{@session_id} (#{@command.join(" ")})" + end + + def rpc(method, params) + id = (@next_id += 1) + send_message(jsonrpc: "2.0", id: id, method: method, params: params) + pump_until(id) + end + + def send_message(message) + @stdin.puts(JSON.generate(message)) + @stdin.flush + rescue Errno::EPIPE, IOError + raise AgentGone + end + + # Synchronous message pump: read until the response to `id` arrives, + # servicing whatever the agent sends in the meantime (progress + # notifications, permission requests). The whole turn shares one + # deadline — an agent that stops answering would otherwise wedge the + # bridge forever on a blocking read, silently detaching it from every + # plan it watches. + def pump_until(id) + deadline = Time.now + @turn_timeout + loop do + remaining = deadline - Time.now + unless remaining.positive? && IO.select([ @stdout ], nil, nil, remaining) + warn "coplan-bridge: ACP agent silent for #{@turn_timeout}s — killing it" + Process.kill("KILL", @wait_thread.pid) rescue nil + raise AgentGone + end + line = @stdout.gets or raise AgentGone + message = JSON.parse(line) rescue next + + if message["method"].nil? && message.key?("id") + next unless message["id"] == id + raise "coplan-bridge: ACP error from agent: #{message["error"].to_json}" if message["error"] + return message["result"] || {} + elsif message.key?("id") + answer_request(message) + else + show_progress(message) + end + end + end + + def answer_request(message) + if message["method"] == "session/request_permission" + options = Array(message.dig("params", "options")) + wanted = @permission == "approve" ? "allow" : "reject" + chosen = options.find { |o| o["kind"].to_s.start_with?(wanted) } || (@permission == "approve" ? options.first : nil) + outcome = chosen ? { outcome: "selected", optionId: chosen["optionId"] } : { outcome: "cancelled" } + send_message(jsonrpc: "2.0", id: message["id"], result: { outcome: outcome }) + else + # We advertised no capabilities, so anything else is the agent's + # mistake — refuse it rather than hang its turn. + send_message(jsonrpc: "2.0", id: message["id"], + error: { code: -32601, message: "coplan-bridge does not provide #{message["method"]}" }) + end + end + + def show_progress(message) + return unless message["method"] == "session/update" + + update = message.dig("params", "update") || {} + text = update.dig("content", "text") + print text if update["sessionUpdate"] == "agent_message_chunk" && text + end +end + +# A deterministic stand-in agent so the whole loop can be demoed without +# spending a real harness turn: ack -> narrated reply -> small visible edit. +class DemoAgent + def self.handle(event) + plan_id = event["plan_id"] + payload = event["payload"] || {} + return unless event["type"].start_with?("comment.") + + Bridge.patch_state(plan_id, "active", "rewording that section") + sleep 1 # visible thinking beat + + if event["comment_thread_id"] + Bridge.reply(plan_id, event["comment_thread_id"], "🤖 Got it — making that clearer now.") + end + + snapshot = Bridge.request(:get, "/api/v1/plans/#{plan_id}/snapshot") + content = snapshot["current_content"].to_s + anchor = payload["anchor_text"].to_s + if anchor.length > 3 && content.include?(anchor) + new_content = content.sub(anchor, rewrite(anchor)) + Bridge.request(:put, "/api/v1/plans/#{plan_id}/content", body: { + base_revision: snapshot["current_revision"], + content: new_content, + change_summary: "Reworded per comment (demo agent)" + }) + end + + Bridge.patch_state(plan_id, "complete", nil) + end + + # A visible, boring rewrite: swap stiff words for plain ones. + def self.rewrite(text) + text + .gsub(/\butilize\b/i, "use") + .gsub(/\bleverage\b/i, "use") + .gsub(/\bfacilitate\b/i, "help") + .gsub(/\bin order to\b/i, "to") + .gsub(/\badditionally\b/i, "also") + end +end + +module Bridge + module_function + + def request(method, path, body: nil, params: nil) + Object.send(:request, method, path, body: body, params: params) + end + + def patch_state(plan_id, state, detail) + request(:patch, "/api/v1/plans/#{plan_id}/agent_session", body: { state: state, detail: detail }.compact) + end + + def reply(plan_id, thread_id, text) + request(:post, "/api/v1/plans/#{plan_id}/comments/#{thread_id}/reply", body: { + body_markdown: text, agent_name: CONFIG.fetch("agent_name", "Agent") + }) + end +end + +def dispatch(event) + adapter = CONFIG.fetch("adapter") + if adapter == "demo" + DemoAgent.handle(event) + return + end + + if adapter == "acp" + # Honest pill before the turn starts; the prompt tells the agent to + # land on complete itself. + Bridge.patch_state(event["plan_id"], "active", "reading your comment") + puts "coplan-bridge: dispatching #{event["type"]} -> acp" + AcpAgent.instance.prompt(wake_prompt(event)) + return + end + + template = (CONFIG["adapters"] || {}).fetch(adapter, DEFAULT_ADAPTERS[adapter]) + abort "coplan-bridge: unknown adapter #{adapter}" unless template + + # Honest pill before the harness even boots. + Bridge.patch_state(event["plan_id"], "active", "reading your comment") + + cmd = template.map do |part| + part.gsub("{session}", CONFIG.fetch("harness_session", "coplan")).gsub("{prompt}", wake_prompt(event)) + end + puts "coplan-bridge: dispatching #{event["type"]} -> #{adapter}" + system(*cmd) +end + +claim_sessions +cursor = nil +puts "coplan-bridge: draining events from #{BASE}" +loop do + resp = request(:get, "/api/v1/agent/events", params: { wait: 25, cursor: cursor }.compact) + events = resp["events"] || [] + events.each do |event| + dispatch(event) + # Ack each event only once its dispatch came back — at-least-once + # means a harness failure leaves the event unacked for redelivery, + # never swallowed by a batch-level ack. + cursor = event["id"] + request(:post, "/api/v1/agent/events/ack", body: { cursor: cursor }) + rescue => e + # Stop the batch here: acking later events would also ack this one + # (the cursor is a high-water mark). The next poll redelivers + # everything unacked; the sleep keeps a wedged adapter from spinning. + warn "coplan-bridge: dispatch failed: #{e.class}: #{e.message} — leaving event unacked" + sleep 3 + break + end +rescue Interrupt + puts "\ncoplan-bridge: bye" + exit 0 +rescue => e + warn "coplan-bridge: #{e.class}: #{e.message} — retrying in 3s" + sleep 3 +end diff --git a/engine/agent_tools/coplan_session.rb b/engine/agent_tools/coplan_session.rb new file mode 100644 index 00000000..6dffa969 --- /dev/null +++ b/engine/agent_tools/coplan_session.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +# Sticky per-agent-session tokens. +# +# A token is the unit of event subscription, so each live agent wants its +# own — but "its own" has to survive the agent's own turns. An agent that +# minted a fresh token every invocation would get a new inbox and a new +# presence pill each time, and an agent that kept the token in its context +# would lose it the moment that context was compacted. +# +# So the token lives in a file keyed to the harness's session id, and the +# tools read it. The agent never sees the secret, never pastes it into a +# command line, and never has to go looking for it — which also keeps the +# whole thing out of transcripts and logs. +# +# ~/.coplan/sessions/.json 0600, one minted child token +# +# The parent (machine-wide, long-lived) token still comes from the +# environment. That's the only secret a human ever handles. + +require "json" +require "net/http" +require "uri" +require "fileutils" +require "digest" +require "time" + +module CoPlanSession + # Leave enough runway that a token doesn't expire mid-turn. + REFRESH_MARGIN = 5 * 60 + + module_function + + def home + File.join(ENV["COPLAN_HOME"] || File.join(Dir.home, ".coplan"), "sessions") + end + + # Which agent run this is. + # + # The working directory is the default because it's the one thing that + # reliably identifies an agent run: the usual shape is one agent per + # checkout or worktree, and it survives context compaction, process + # restarts, and harnesses that expose nothing about themselves. Harness + # session ids are checked first but not depended on — Claude Code's + # CLAUDE_SESSION_ID, for one, is not consistently exported to tool + # subprocesses. Two agents sharing a directory should set + # COPLAN_SESSION_KEY explicitly. + def session_key(explicit = nil) + key = explicit || + ENV["COPLAN_SESSION_KEY"] || + ENV["CLAUDE_SESSION_ID"] || + ENV["AGENT_SESSION_ID"] || + "cwd-#{Digest::SHA256.hexdigest(Dir.pwd)[0, 12]}" + key.to_s.gsub(/[^A-Za-z0-9_.-]/, "_") + end + + def path(key) + File.join(home, "#{key}.json") + end + + def read(key) + JSON.parse(File.read(path(key))) + rescue Errno::ENOENT, JSON::ParserError + nil + end + + def write(key, data) + FileUtils.mkdir_p(home, mode: 0o700) + File.open(path(key), File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f| + f.write(JSON.pretty_generate(data)) + end + data + end + + def forget(key) + File.delete(path(key)) + rescue Errno::ENOENT + nil + end + + def fresh?(record, base) + return false unless record.is_a?(Hash) && record["token"] + return false unless record["base"] == base.to_s + return true if record["expires_at"].nil? + + Time.now + REFRESH_MARGIN < Time.parse(record["expires_at"]) + rescue ArgumentError + false + end + + # Returns the session token for this agent run, minting one if needed. + # Falls back to the parent token when minting isn't available (the + # server predates it, or the caller already holds a session token) so + # this is always safe to call. + def ensure_token(base:, parent:, agent_name: nil, ttl: nil, key: nil, force: false) + key = session_key(key) + existing = read(key) + return existing["token"] if !force && fresh?(existing, base) + + minted = mint(base: base, parent: parent, agent_name: agent_name, ttl: ttl) + return parent unless minted + + write(key, { + "token" => minted["token"], + "id" => minted["id"], + "agent_name" => minted["agent_name"], + "expires_at" => minted["expires_at"], + "base" => base.to_s, + "session_key" => key + })["token"] + end + + def mint(base:, parent:, agent_name: nil, ttl: nil) + # Concatenation, not URI.join: an absolute path in URI.join discards + # the base's mount prefix, and CoPlan engines may be mounted under one. + uri = URI("#{base.to_s.chomp("/")}/api/v1/tokens") + req = Net::HTTP::Post.new(uri) + req["Authorization"] = "Bearer #{parent}" + req["Content-Type"] = "application/json" + req.body = { + agent_name: agent_name, + name: [ agent_name, "session" ].compact.join(" "), + ttl_seconds: ttl + }.compact.to_json + + res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) } + return JSON.parse(res.body) if res.code.start_with?("2") + + # 403 = the caller is already a session token; 404 = older server. + # Either way the parent token still works, so don't fail the run. + warn "coplan: could not mint a session token (#{res.code}); using the token as-is" + nil + rescue StandardError => e + warn "coplan: could not reach #{base} to mint a session token (#{e.class}); using the token as-is" + nil + end + + # Revoking server-side is what actually matters; dropping the file just + # keeps us from presenting a dead credential. + def revoke(base:, key: nil) + key = session_key(key) + record = read(key) + return false unless record + + uri = URI("#{base.to_s.chomp("/")}/api/v1/tokens/current") + req = Net::HTTP::Delete.new(uri) + req["Authorization"] = "Bearer #{record["token"]}" + Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) } + forget(key) + true + rescue StandardError + forget(key) + true + end +end diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 0ded574b..b11deca7 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -815,6 +815,134 @@ img, svg { white-space: nowrap; } +/* Agent presence pill — live state of an agent session on this plan. + Broadcast-replaced; must read at a glance as "the agent is engaged". */ +.plan-agent-sessions { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.agent-pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.25rem 0.65rem; + border-radius: 999px; + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-text-muted); + background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)); + border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + white-space: nowrap; +} + +.agent-pill__icon { + flex-shrink: 0; + color: var(--color-primary); +} + +.agent-pill__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-primary); + animation: agent-pill-pulse 1.4s ease-in-out infinite; +} + +/* Listening: just the agent's name and a green pulse — a mic-live + indicator, not a status message. Chrome stays muted so working states + still out-rank it visually. */ +.agent-pill--watching { + background: color-mix(in srgb, var(--color-text-muted) 6%, var(--color-surface)); + border-color: color-mix(in srgb, var(--color-text-muted) 20%, transparent); +} + +.agent-pill--watching .agent-pill__icon { + color: var(--color-text-muted); +} + +.agent-pill--watching .agent-pill__dot { + background: var(--color-success); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-success) 60%, transparent); + animation: agent-pill-listen 2s ease-in-out infinite; +} + +@keyframes agent-pill-listen { + 0%, 100% { + opacity: 1; + box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-success) 55%, transparent); + } + 50% { + opacity: 0.7; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-success) 0%, transparent); + } +} + +.agent-pill--awaiting_input { + background: color-mix(in srgb, var(--color-warning, #b45309) 10%, var(--color-surface)); + border-color: color-mix(in srgb, var(--color-warning, #b45309) 30%, transparent); +} + +.agent-pill--awaiting_input .agent-pill__dot, +.agent-pill--awaiting_input .agent-pill__icon { + color: var(--color-warning, #b45309); + background: var(--color-warning, #b45309); + animation: none; +} + +.agent-pill--awaiting_input .agent-pill__icon { + background: none; +} + +@keyframes agent-pill-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.35; transform: scale(0.8); } +} + +/* Live-edit diff flashes — applied by live_update_controller when a + broadcast revision lands: changed blocks tint-and-fade, inline + insertions get an wash, removed text lingers struck-through in a + then collapses. Green wash = new words, not "success". */ +.agent-flash-block { + animation: agent-flash-bg 2.4s ease-out; +} + +ins.agent-flash { + text-decoration: none; + border-radius: 2px; + animation: agent-flash-ins 2.4s ease-out; +} + +del.agent-flash { + text-decoration: line-through; + border-radius: 2px; + color: var(--color-text-muted); + animation: agent-flash-del 2.4s ease-out forwards; +} + +@keyframes agent-flash-bg { + 0% { background: color-mix(in srgb, var(--color-primary) 14%, transparent); } + 100% { background: transparent; } +} + +@keyframes agent-flash-ins { + 0% { background: color-mix(in srgb, var(--color-success) 30%, transparent); } + 100% { background: transparent; } +} + +@keyframes agent-flash-del { + 0% { background: color-mix(in srgb, var(--color-danger, #b91c1c) 22%, transparent); opacity: 1; } + 60% { background: transparent; opacity: 0.7; } + 100% { opacity: 0; font-size: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .agent-pill__dot { animation: none; } + .agent-flash-block, ins.agent-flash { animation: none; } + del.agent-flash { animation: none; display: none; } +} + /* Voice push-to-talk — mic button + transient transcript/status text. */ /* The mic follows the scroll. A dictated comment is pinned to whatever passage is on screen when you speak, so a control that lives in the diff --git a/engine/app/controllers/coplan/agent_tools_controller.rb b/engine/app/controllers/coplan/agent_tools_controller.rb new file mode 100644 index 00000000..373adb98 --- /dev/null +++ b/engine/app/controllers/coplan/agent_tools_controller.rb @@ -0,0 +1,27 @@ +module CoPlan + # Serves the agent-side scripts (attach, bridge, and the session helper + # they share) so a new agent's first encounter needs nothing but curl — + # no repo checkout, no gem, no install step. The setup section of + # /agent-instructions points here. + # + # Public for the same reason /agent-instructions is: this is the front + # door, and the scripts themselves are not secrets — every credential + # they use arrives via environment variables at run time. + class AgentToolsController < ApplicationController + skip_before_action :authenticate_coplan_user! + + TOOLS_DIR = CoPlan::Engine.root.join("agent_tools") + + # Whitelist rather than glob: params must never pick a path. + TOOLS = %w[coplan-attach coplan-bridge coplan_session.rb].freeze + + def show + return head :not_found unless TOOLS.include?(params[:tool]) + + # Rendered inline rather than send_file for the same reason as the + # service worker: the file lives inside the gem, where a reverse + # proxy intercepting X-Sendfile can't reach it. + render plain: TOOLS_DIR.join(params[:tool]).read, content_type: "text/plain" + end + end +end diff --git a/engine/app/controllers/coplan/api/v1/agent_events_controller.rb b/engine/app/controllers/coplan/api/v1/agent_events_controller.rb new file mode 100644 index 00000000..200ea526 --- /dev/null +++ b/engine/app/controllers/coplan/api/v1/agent_events_controller.rb @@ -0,0 +1,158 @@ +module CoPlan + module Api + module V1 + # The agent-facing event inbox. Agents (or their bridge daemons) on + # laptops behind NAT can't be pushed to, so delivery is pull-based + # with two modes on one endpoint: + # + # Long-poll (default): GET /api/v1/agent/events?wait=25&cursor= + # Returns as soon as an event is available, or after `wait` + # seconds with an empty list. Plain JSON, works with curl in a + # loop through any proxy. + # + # SSE: same URL with Accept: text/event-stream + # Holds the response open and streams events as they land, with + # heartbeat comments so intermediaries don't kill the socket. + # + # Both forms block on AgentEventBus rather than polling, and both + # respect its held-connection budget: an attached agent must never + # be able to starve ordinary page requests of Rack threads. Over + # budget, long-poll answers immediately with `throttled: true` and + # SSE is refused with 503 + Retry-After. + # + # Cursoring: event ids are UUIDv7 (time-ordered), so `cursor` is + # simply the last event id the client has seen. Without a cursor you + # get unacked events, so a crashed client picks up where it left off. + # Delivery is at-least-once — ack after successful processing. + class AgentEventsController < BaseController + include ActionController::Live + + MAX_WAIT = 55 + SSE_LIFETIME = 5.minutes + HEARTBEAT_INTERVAL = 15 + + def index + if request.headers["Accept"].to_s.include?("text/event-stream") + stream_events + else + long_poll_events + end + end + + # POST /api/v1/agent/events/ack {"cursor": ""} + # Marks everything up to and including the cursor as processed. + def ack + cursor = params[:cursor].to_s + if cursor.blank? + render json: { error: "cursor is required" }, status: :unprocessable_content + return + end + + acked = AgentEvent.for_token(@api_token).pending + .where("id <= ?", cursor) + .update_all(acked_at: Time.current) + + render json: { acked: acked } + end + + private + + # Holding a thread for `wait` seconds is only acceptable while the + # server has threads to spare; over budget we answer immediately + # and let the client come back, which is strictly better than + # making everyone's page loads queue behind attached agents. + def long_poll_events + AgentEventBus.with_slot do |granted| + wait = granted ? params[:wait].to_i.clamp(0, MAX_WAIT) : 0 + # A parked long-poll is a held connection just like SSE — it + # must count as transport, or an agent faithfully polling + # every 25s reads as absent and never gets woken. + touch_transport if wait.positive? + deadline = Time.current + wait + + loop do + events = fetch_events + if events.any? || Time.current >= deadline + render json: { + events: events.map(&:as_api_json), + cursor: events.last&.id || params[:cursor], + **(granted ? {} : { throttled: true }) + } + return + end + AgentEventBus.wait(@api_token.id, timeout: deadline - Time.current) + end + end + end + + def stream_events + AgentEventBus.with_slot do |granted| + unless granted + response.headers["Retry-After"] = "5" + render json: { + error: "Too many attached agents on this server", + capacity: AgentEventBus.capacity, + fallback: "Retry, or use the long-poll form of this endpoint (omit the text/event-stream Accept header)." + }, status: :service_unavailable + return + end + + write_event_stream + end + end + + def write_event_stream + response.headers["Content-Type"] = "text/event-stream" + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + + cursor = params[:cursor].presence + deadline = Time.current + SSE_LIFETIME + last_heartbeat = Time.current + touch_transport + + while Time.current < deadline + events = fetch_events(cursor: cursor) + events.each do |event| + response.stream.write("id: #{event.id}\nevent: #{event.event_type}\ndata: #{event.as_api_json.to_json}\n\n") + cursor = event.id + end + + if Time.current - last_heartbeat > HEARTBEAT_INTERVAL + response.stream.write(": heartbeat\n\n") + last_heartbeat = Time.current + # Liveness comes from the connection itself, so a watching + # agent's pill survives as long as it's really attached and + # expires on its own once the socket dies. + touch_transport + end + + AgentEventBus.wait(@api_token.id, timeout: [ HEARTBEAT_INTERVAL, deadline - Time.current ].min) + end + rescue IOError, ActionController::Live::ClientDisconnected + # Client went away — normal for a streaming endpoint. + ensure + response.stream.close + end + + # Two clocks, deliberately separate. `last_transport_at` records + # that a connection is parked — it feeds the wake gate on every + # session regardless of state. `last_activity_at` drives the state + # staleness windows, so transport only refreshes it for `watching` + # (presence lives on the socket): a held socket must never keep a + # `pending` promise, or an `active` claim, alive on its own. + def touch_transport + now = Time.current + AgentSession.where(api_token_id: @api_token.id).update_all(last_transport_at: now) + AgentSession.where(api_token_id: @api_token.id, state: "watching").update_all(last_activity_at: now) + end + + def fetch_events(cursor: params[:cursor].presence) + scope = AgentEvent.for_token(@api_token) + scope = cursor ? scope.after(cursor) : scope.pending + scope.oldest_first.limit(100).to_a + end + end + end + end +end diff --git a/engine/app/controllers/coplan/api/v1/agent_sessions_controller.rb b/engine/app/controllers/coplan/api/v1/agent_sessions_controller.rb new file mode 100644 index 00000000..1d2c54c1 --- /dev/null +++ b/engine/app/controllers/coplan/api/v1/agent_sessions_controller.rb @@ -0,0 +1,119 @@ +module CoPlan + module Api + module V1 + # An agent claims a session on a plan to (a) subscribe its token to + # the plan's event inbox and (b) drive the presence pill humans see. + # + # POST /api/v1/plans/:plan_id/agent_session {"agent_name": "Claude"} + # PATCH /api/v1/plans/:plan_id/agent_session {"state": "active", "detail": "editing Rollout"} + # + # Claim states: watching / active. PATCH states: active / + # awaiting_input / complete (pending and stale are set by the + # server). The etiquette (mirrored from Linear's agent guidelines): + # flip to `active` within seconds of a wake — that's the fast ack + # humans see — then work as slowly as you need; use `awaiting_input` + # when a question is blocking you; land on `complete` when your turn + # is done. + class AgentSessionsController < BaseController + before_action :set_plan + before_action :authorize_plan_access! + + def create + # Claiming a session means "I'm here", not "I'm working" — an + # attached, idle agent defaults to `watching`. Pass `state` to + # say otherwise (the bridge claims straight into `active`). + # + # Only arrival states can be claimed. `awaiting_input` on a + # session that has never done anything would park an unearned + # "asked a question" pill for up to its hour-long stale window, + # and `pending`/`complete`/`stale` are the server's to set. + state = params[:state].presence || "watching" + unless %w[watching active].include?(state) + render json: { + error: "state on claim must be watching or active — turn states (awaiting_input, complete) are set via PATCH once you are in the loop" + }, status: :unprocessable_content + return + end + + session = AgentSession.find_or_initialize_by(plan_id: @plan.id, api_token_id: @api_token.id) + session.agent_name = api_agent_name + + # Reattaching must not erase a question the agent is still + # waiting on: a default claim leaves `awaiting_input` alone, so + # the human keeps seeing whose turn it is. An explicit state wins. + unless params[:state].blank? && session.state == "awaiting_input" + session.state = state + session.state_detail = params[:detail].presence + end + + # A wake URL makes this session wakeable without holding a + # connection: CoPlan POSTs a signed "you have inbox items" ping + # there on every event. The signing secret is minted here, once, + # and returned only in this response — re-claiming keeps it, so + # the receiver's verification doesn't churn; passing an empty + # wake_url unregisters and burns it. + if params.key?(:wake_url) + session.wake_url = params[:wake_url].presence + session.wake_secret = nil if session.wake_url.blank? + end + minted_wake_secret = nil + if session.wake_url.present? && session.wake_secret.blank? + minted_wake_secret = SecureRandom.hex(32) + session.wake_secret = minted_wake_secret + end + + session.last_activity_at = Time.current + session.save! + session.broadcast_pill + + body = session_json(session) + body[:wake_secret] = minted_wake_secret if minted_wake_secret + render json: body, status: :created + rescue ActiveRecord::RecordInvalid => e + render json: { error: e.message }, status: :unprocessable_content + end + + def update + session = AgentSession.find_by(plan_id: @plan.id, api_token_id: @api_token.id) + unless session + render json: { error: "No agent session on this plan — POST to create one" }, status: :not_found + return + end + + # `pending` (an undelivered wake) and `stale` (a wake nobody + # answered) are verdicts the server reaches about the agent — + # an agent reporting either about itself would be nonsense. + state = params[:state].to_s + unless AgentSession::STATES.include?(state) && !%w[pending stale].include?(state) + render json: { error: "state must be one of #{(AgentSession::STATES - %w[pending stale]).join(', ')}" }, status: :unprocessable_content + return + end + + session.transition!(state, detail: params[:detail].presence) + render json: session_json(session) + end + + def destroy + session = AgentSession.find_by(plan_id: @plan.id, api_token_id: @api_token.id) + session&.transition!("complete") + session&.destroy! + head :no_content + end + + private + + def session_json(session) + { + id: session.id, + plan_id: session.plan_id, + agent_name: session.agent_name, + state: session.state, + state_detail: session.state_detail, + last_activity_at: session.last_activity_at, + wake_url: session.wake_url + } + end + end + end + end +end diff --git a/engine/app/controllers/coplan/api/v1/comments_controller.rb b/engine/app/controllers/coplan/api/v1/comments_controller.rb index 7b94a80d..7baffc88 100644 --- a/engine/app/controllers/coplan/api/v1/comments_controller.rb +++ b/engine/app/controllers/coplan/api/v1/comments_controller.rb @@ -5,25 +5,49 @@ class CommentsController < BaseController before_action :set_plan before_action :authorize_plan_access! + # GET /api/v1/plans/:plan_id/comments/:id — a single thread with its + # comments, so an agent reacting to one inbox event doesn't have to + # refetch every thread on the plan. + def show + thread = @plan.comment_threads.includes(:comments, :created_by_user).find_by(id: params[:id]) + unless thread + render json: { error: "Comment thread not found" }, status: :not_found + return + end + + render json: thread_json(thread) + end + def create + # Same initial-status rule as the web flow: the plan author's own + # comments start as "todo" (self-assigned), everyone else's as + # "pending" (awaiting author triage). + initial_status = current_user&.id == @plan.created_by_user_id ? "todo" : "pending" + thread = @plan.comment_threads.new( plan_version: @plan.current_plan_version, anchor_text: params[:anchor_text].presence, anchor_occurrence: params[:anchor_occurrence]&.to_i, start_line: params[:start_line].presence, end_line: params[:end_line].presence, - created_by_user: current_user + created_by_user: current_user, + status: initial_status ) - thread.save! - - comment = thread.comments.create!( - author_type: api_author_type, - author_id: current_user&.id, - body_markdown: params[:body_markdown], - agent_name: api_agent_name, - api_token_id: api_token_id - ) + # Atomic, matching the web flow: a thread whose first comment + # fails validation must not survive as an empty orphan with a + # live anchor highlight. + comment = nil + ActiveRecord::Base.transaction do + thread.save! + comment = thread.comments.create!( + author_type: api_author_type, + author_id: current_user&.id, + body_markdown: params[:body_markdown], + agent_name: api_agent_name, + api_token_id: api_token_id + ) + end reason = comment.agent? ? "agent_response" : "new_comment" CreateNotificationsJob.perform_later( @@ -36,6 +60,7 @@ def create broadcast_new_thread(thread) render json: { + id: thread.id, thread_id: thread.id, comment_id: comment.id, status: thread.status, @@ -60,7 +85,8 @@ def resolve end thread.resolve!(current_user) - CreateNotificationsJob.perform_later(comment_thread_id: thread.id, actor_id: current_user.id, reason: "status_change") + CreateNotificationsJob.perform_later(comment_thread_id: thread.id, actor_id: current_user.id, reason: "status_change", + actor_api_token_id: @api_token&.id) broadcast_thread_update(thread) render json: { thread_id: thread.id, status: thread.status } @@ -80,7 +106,8 @@ def discard end thread.discard!(current_user) - CreateNotificationsJob.perform_later(comment_thread_id: thread.id, actor_id: current_user.id, reason: "status_change") + CreateNotificationsJob.perform_later(comment_thread_id: thread.id, actor_id: current_user.id, reason: "status_change", + actor_api_token_id: @api_token&.id) broadcast_thread_update(thread) render json: { thread_id: thread.id, status: thread.status } @@ -153,7 +180,10 @@ def reply broadcast_new_comment(thread, comment) + # `id` is the created resource, matching every other create in + # this API; comment_id/thread_id stay for existing callers. render json: { + id: comment.id, comment_id: comment.id, thread_id: thread.id, created_at: comment.created_at @@ -165,6 +195,33 @@ def reply private + # Mirrors PlansController#thread_json so GET .../comments/:id returns + # the same shape as the list/snapshot endpoints. + def thread_json(thread) + { + id: thread.id, + status: thread.status, + anchor_text: thread.anchor_text, + anchor_context: thread.anchor_context_with_highlight, + anchor_valid: thread.anchor_valid?, + start_line: thread.start_line, + end_line: thread.end_line, + out_of_date: thread.out_of_date, + created_by: thread.created_by_user&.name, + created_at: thread.created_at, + comments: thread.comments.sort_by(&:created_at).map { |c| + { + id: c.id, + author_type: c.author_type, + author_id: c.author_id, + agent_name: c.agent_name, + body_markdown: c.body_markdown, + created_at: c.created_at + } + } + } + end + def broadcast_new_thread(thread) Broadcaster.append_to( @plan, diff --git a/engine/app/controllers/coplan/dictations_controller.rb b/engine/app/controllers/coplan/dictations_controller.rb index 3a419fa6..044d0fce 100644 --- a/engine/app/controllers/coplan/dictations_controller.rb +++ b/engine/app/controllers/coplan/dictations_controller.rb @@ -43,6 +43,10 @@ def create # rendered text the model was shown. document: @plan.current_content, transcript: transcript, + # What was mid-screen when they stopped talking — the likeliest + # home of the span, since the excerpt accumulates everything + # that scrolled past during the take. + focus: params[:focus].to_s.truncate(4_000, omission: ""), # Dictation is conversational in a way typing isn't: the next # remark after "it should be main" is "oh, I meant both of them", # and without the earlier comment there is no "it" to resolve. diff --git a/engine/app/javascript/controllers/coplan/live_update_controller.js b/engine/app/javascript/controllers/coplan/live_update_controller.js index 924cb999..4b51a6a6 100644 --- a/engine/app/javascript/controllers/coplan/live_update_controller.js +++ b/engine/app/javascript/controllers/coplan/live_update_controller.js @@ -8,9 +8,11 @@ import { Controller } from "@hotwired/stimulus" * (or anyone) commits a new revision elsewhere, the server pushes the new * rendered body to every open tab. This controller decides what to do: * - * * If the user has no unsaved drafts → swap the body in place. Existing - * Stimulus controllers reconnect over the new DOM, comment highlights - * re-attach. + * * If the user has no unsaved drafts → swap the body in place, then + * flash the sections this revision touched (the broadcast carries + * their keys in data-changed-sections): changed blocks tint-and-fade, + * and plain-text paragraphs get word-level / flashes so you + * can literally watch the agent's edit land. * * * If the user is mid-edit (any textarea on the page has non-empty, * non-trim-blank text) → DON'T blow away their typing. Instead, show @@ -51,6 +53,16 @@ export default class extends Controller { const currentRevision = parseInt(target.getAttribute("data-coplan--live-update-revision-value"), 10) || 0 if (incomingRevision && currentRevision >= incomingRevision) return + let changedKeys = [] + try { + // ChangedSections::Result serializes as {"keys": [...], "rewritten": bool}. + // A full rewrite arrives with no keys, so the swap stays flash-free + // instead of lighting up the whole document. A bare array is accepted + // too, in case a not-yet-upgraded server is still broadcasting one. + const parsed = JSON.parse(this.getAttribute("data-changed-sections") || "[]") + changedKeys = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.keys) ? parsed.keys : []) + } catch { /* malformed attribute — fall back to a flash-free swap */ } + // `templateContent` is a DocumentFragment — it has no `innerHTML`. // Use replaceChildren(fragment) to swap the contents of target in one // shot. Stimulus controllers inside target will disconnect + reconnect. @@ -59,11 +71,13 @@ export default class extends Controller { if (hasDirtyDrafts()) { showStaleBanner(target, incomingRevision) } else { + const oldSections = snapshotSections(target, changedKeys) target.replaceChildren(fragment) if (incomingRevision) { target.setAttribute("data-coplan--live-update-revision-value", String(incomingRevision)) } clearStaleBanner() + if (changedKeys.length > 0) flashChangedSections(target, changedKeys, oldSections) } } @@ -89,6 +103,169 @@ function hasDirtyDrafts() { return false } +/* --------------------------------------------------------------------- + * Diff flashes. Section boundaries and slugs use the exact walk the + * changed-sections and TOC controllers use (top-level h1–h3 children of + * .markdown-rendered, slugified text, -2/-3 duplicate suffixes), so + * server keys and client sections stay in lockstep. + */ +const TOP_KEY = "__top__" +const FLASH_MS = 2600 +const MAX_DIFF_TOKENS = 600 + +function slugify(text, used) { + let base = text + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, "") + .replace(/-{2,}/g, "-") + .replace(/^-|-$/g, "") + if (base === "") base = "section" + let slug = base + let suffix = 2 + while (used.has(slug)) slug = `${base}-${suffix++}` + used.add(slug) + return slug +} + +// Map of section key → array of block elements (live nodes for the new +// DOM; for snapshots we keep outerHTML/text copies instead). +function sectionBlocks(root) { + const rendered = root.querySelector(".markdown-rendered") + if (!rendered) return new Map() + + const sections = new Map([[TOP_KEY, []]]) + const used = new Set() + let currentKey = TOP_KEY + + for (const node of Array.from(rendered.children)) { + if (/^H[1-3]$/.test(node.tagName)) { + currentKey = slugify(node.textContent, used) + sections.set(currentKey, []) + } + sections.get(currentKey).push(node) + } + return sections +} + +function snapshotSections(root, keys) { + if (!keys || keys.length === 0) return new Map() + const wanted = new Set(keys) + const snapshot = new Map() + for (const [key, blocks] of sectionBlocks(root)) { + if (!wanted.has(key)) continue + snapshot.set(key, blocks.map((el) => ({ + tag: el.tagName, + html: el.outerHTML, + text: el.textContent, + plainText: el.childElementCount === 0 + }))) + } + return snapshot +} + +function flashChangedSections(root, keys, oldSections) { + const wanted = new Set(keys) + const flashed = [] + + for (const [key, blocks] of sectionBlocks(root)) { + if (!wanted.has(key)) continue + const oldBlocks = oldSections.get(key) || [] + const oldHtml = new Set(oldBlocks.map((b) => b.html)) + + blocks.forEach((block, i) => { + if (oldHtml.has(block.outerHTML)) return // block untouched + + const old = oldBlocks[i] + if ( + old && old.tag === block.tagName && old.plainText && + block.childElementCount === 0 && blocks.length === oldBlocks.length + ) { + renderWordFlash(block, old.text, block.textContent) + } else { + block.classList.add("agent-flash-block") + } + flashed.push(block) + }) + } + + if (flashed.length > 0) { + // Deliberately no scrollIntoView: a remote edit must never move a reader + // who didn't ask to navigate. On-screen changes flash; off-screen ones + // settle unseen, and that's fine. + setTimeout(() => settleFlashes(root), FLASH_MS) + } +} + +// Word-level flash for a plain-text block: rebuild its text as a +// common/inserted/deleted word sequence with / wrappers. The +// wrappers are temporary — settleFlashes() strips them — so comment +// anchors and copy/paste see clean text again within a couple seconds. +function renderWordFlash(block, oldText, newText) { + const oldTokens = tokenize(oldText) + const newTokens = tokenize(newText) + if (oldTokens.length > MAX_DIFF_TOKENS || newTokens.length > MAX_DIFF_TOKENS) { + block.classList.add("agent-flash-block") + return + } + + block.replaceChildren() + for (const part of diffTokens(oldTokens, newTokens)) { + if (part.type === "same") { + block.appendChild(document.createTextNode(part.text)) + } else { + const el = document.createElement(part.type === "ins" ? "ins" : "del") + el.className = "agent-flash" + el.textContent = part.text + block.appendChild(el) + } + } +} + +function tokenize(text) { + return text.split(/(\s+)/).filter((t) => t.length > 0) +} + +// Plain LCS word diff — paragraphs are small, O(n·m) is nothing, and it +// keeps the page dependency-free (no bundler, importmap-only app). +function diffTokens(a, b) { + const n = a.length, m = b.length + const lcs = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1)) + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]) + } + } + + const parts = [] + const push = (type, text) => { + const last = parts[parts.length - 1] + if (last && last.type === type) last.text += text + else parts.push({ type, text }) + } + + let i = 0, j = 0 + while (i < n && j < m) { + if (a[i] === b[j]) { push("same", a[i]); i++; j++ } + else if (lcs[i + 1][j] >= lcs[i][j + 1]) { push("del", a[i]); i++ } + else { push("ins", b[j]); j++ } + } + while (i < n) { push("del", a[i]); i++ } + while (j < m) { push("ins", b[j]); j++ } + return parts +} + +function settleFlashes(root) { + for (const del of root.querySelectorAll("del.agent-flash")) del.remove() + for (const ins of root.querySelectorAll("ins.agent-flash")) { + ins.replaceWith(document.createTextNode(ins.textContent)) + } + for (const block of root.querySelectorAll(".agent-flash-block")) { + block.classList.remove("agent-flash-block") + } + root.normalize() +} + function showStaleBanner(targetEl, revision) { let banner = document.getElementById("plan-stale-banner") if (banner) { diff --git a/engine/app/javascript/controllers/coplan/voice_controller.js b/engine/app/javascript/controllers/coplan/voice_controller.js index c041c0c2..dfee93c3 100644 --- a/engine/app/javascript/controllers/coplan/voice_controller.js +++ b/engine/app/javascript/controllers/coplan/voice_controller.js @@ -552,6 +552,9 @@ export default class extends Controller { async _submit({ transcript, audio, durationMs }) { this._setStatus(audio ? "Transcribing…" : "Tidying up…") + // Sampled before the round trip: the viewport at the moment of + // release is the best evidence of what the remark was about. + this.focus = this._focus() this.excerpt = this._excerpt() // One round trip does every slow job: transcribe if it was audio, @@ -661,6 +664,7 @@ export default class extends Controller { if (audio) form.append("audio", audio, `dictation.${this._extensionFor(audio)}`) if (audio && durationMs > 0) form.append("duration_ms", durationMs) if (this.excerpt) form.append("excerpt", this.excerpt) + if (this.focus) form.append("focus", this.focus) const response = await fetch(this.dictationUrlValue, { method: "POST", @@ -747,6 +751,28 @@ export default class extends Controller { return text.length > 0 ? text : null } + // What the person was most likely reading when they stopped talking: + // the readably-visible blocks that cross the middle band of the + // viewport. People read the middle of the screen — they scroll text + // *into* the middle to read it — so the top and bottom edges are + // usually periphery, and text that scrolled above the fold mid-take + // is what they were reading a sentence ago, not now. The interpreter + // gets this alongside the full excerpt as "the span is usually here". + _focus() { + const bandTop = window.innerHeight * 0.25 + const bandBottom = window.innerHeight * 0.75 + const text = this._visibleBlocks() + .filter((el) => { + const rect = el.getBoundingClientRect() + return rect.bottom > bandTop && rect.top < bandBottom + }) + .map((el) => el.textContent.trim()) + .filter(Boolean) + .join("\n") + + return text.length > 0 ? text : null + } + _allBlocks() { const content = document.getElementById("plan-content-body") if (!content) return [] diff --git a/engine/app/jobs/coplan/create_notifications_job.rb b/engine/app/jobs/coplan/create_notifications_job.rb index ebe658e6..adb3dbc7 100644 --- a/engine/app/jobs/coplan/create_notifications_job.rb +++ b/engine/app/jobs/coplan/create_notifications_job.rb @@ -2,14 +2,15 @@ module CoPlan class CreateNotificationsJob < ApplicationJob queue_as :default - def perform(comment_thread_id:, actor_id:, comment_id: nil, reason:) + def perform(comment_thread_id:, actor_id:, comment_id: nil, reason:, actor_api_token_id: nil) thread = CommentThread.find(comment_thread_id) comment = comment_id ? Comment.find(comment_id) : nil Notifications::Create.call( comment_thread: thread, actor_id: actor_id, comment: comment, - reason: reason + reason: reason, + actor_api_token_id: actor_api_token_id ) end end diff --git a/engine/app/jobs/coplan/mark_stale_agent_session_job.rb b/engine/app/jobs/coplan/mark_stale_agent_session_job.rb new file mode 100644 index 00000000..e7c644f3 --- /dev/null +++ b/engine/app/jobs/coplan/mark_stale_agent_session_job.rb @@ -0,0 +1,18 @@ +module CoPlan + # Scheduled at wake time (AgentSession#wake!). If the session hasn't + # emitted any activity since that wake, the pill would be a lie — flip + # it to stale so the UI stops showing "Waking…" for an agent that never + # showed up. + class MarkStaleAgentSessionJob < ApplicationJob + def perform(agent_session_id:, woken_at:) + session = AgentSession.find_by(id: agent_session_id) + return unless session + return unless session.state == "pending" + + # Any activity after the wake means the agent reacted; leave it be. + return if session.last_activity_at.present? && session.last_activity_at > Time.iso8601(woken_at) + + session.transition!("stale") + end + end +end diff --git a/engine/app/jobs/coplan/wake_webhook_job.rb b/engine/app/jobs/coplan/wake_webhook_job.rb new file mode 100644 index 00000000..9a141e86 --- /dev/null +++ b/engine/app/jobs/coplan/wake_webhook_job.rb @@ -0,0 +1,101 @@ +require "net/http" +require "openssl" + +module CoPlan + # The wake path for hosted agents: POST a signed "you have inbox items" + # ping to the URL the session registered at claim time. Deliberately a + # ping and not a payload — the agent pulls and acks through the cursor + # API exactly like every other transport, so at-least-once semantics + # and the authority model don't fork, and nothing sensitive transits a + # URL we don't control. A spurious or duplicate ping is harmless: "check + # your inbox" is naturally idempotent, and `event_id` is there for + # receivers that want to drop retry duplicates anyway. + class WakeWebhookJob < ApplicationJob + OPEN_TIMEOUT = 5 + READ_TIMEOUT = 5 + + # Exhausted retry runs (not individual failed POSTs) before the URL is + # presumed dead and unregistered. Mirrors WebPushDeliveryJob, which + # destroys a subscription on terminal failure: a URL that has eaten + # three full retry ladders isn't waking anyone, and every event on the + # plan would otherwise keep hammering it forever. + MAX_EXHAUSTIONS = 3 + + # Messages must not carry the wake URL: they surface in logs and in + # solid_queue_failed_executions, and the URL can embed a capability + # token in its path. + class DeliveryFailed < StandardError; end + + # A hosted platform being briefly down shouldn't cost the agent its + # wake; a platform that's gone shouldn't be hammered forever. + retry_on DeliveryFailed, wait: :polynomially_longer, attempts: 5 do |job, _error| + session = AgentSession.find_by(id: job.arguments.first[:agent_session_id]) + next if session.nil? || session.wake_url.blank? + + session.wake_failures_count += 1 + if session.wake_failures_count >= MAX_EXHAUSTIONS + session.assign_attributes(wake_url: nil, wake_secret: nil) + Rails.logger.warn( + "CoPlan::WakeWebhookJob: unregistered dead wake URL for agent session #{session.id} " \ + "after #{MAX_EXHAUSTIONS} exhausted delivery runs" + ) + end + session.save! + end + + def perform(agent_session_id:, agent_event_id:) + session = AgentSession.find_by(id: agent_session_id) + return if session.nil? || session.wake_url.blank? + + event = AgentEvent.find_by(id: agent_event_id) + return if event.nil? || event.acked_at.present? # already processed via another transport + + uri = URI.parse(session.wake_url) + # Re-checked here, not just at registration: DNS may answer + # differently now (rebinding), and the policy itself may have + # changed. A refused URL is skipped, not retried — retrying can't + # make it allowed. + vetted = WakeUrlPolicy.vetted_addresses(uri) + if vetted.nil? + Rails.logger.warn("CoPlan::WakeWebhookJob: egress policy refused wake URL for agent session #{session.id}") + return + end + + body = { + event_id: event.id, + event_type: event.event_type, + plan_id: event.plan_id, + inbox: "/api/v1/agent/events" + }.to_json + + request = Net::HTTP::Post.new(uri) + request["Content-Type"] = "application/json" + request["X-CoPlan-Event-Id"] = event.id + request["X-CoPlan-Signature"] = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", session.wake_secret.to_s, body)}" + request.body = body + + # Connect to the address the policy actually vetted (hostname still + # drives Host/SNI/cert verification); resolving the name a second + # time here would hand a rebinding host the connection the check + # just refused. :unpinned (custom policy) resolves normally. + http_options = { use_ssl: uri.scheme == "https", open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT } + http_options[:ipaddr] = vetted.first.to_s if vetted.is_a?(Array) + response = Net::HTTP.start(uri.hostname, uri.port, **http_options) do |http| + http.request(request) + end + + unless response.code.start_with?("2") + raise DeliveryFailed, "agent session #{session.id} wake answered #{response.code}" + end + + # The URL just proved live again; a past outage shouldn't leave it + # one exhaustion from unregistration forever. + session.update!(wake_failures_count: 0) if session.wake_failures_count.to_i.positive? + rescue Timeout::Error, IOError, SystemCallError, SocketError, + OpenSSL::SSL::SSLError, Net::ProtocolError, Net::HTTPBadResponse => e + # IOError covers EOFError (server closed mid-response); + # Net::HTTPBadResponse is a bare StandardError, not a ProtocolError. + raise DeliveryFailed, "agent session #{session.id} wake failed: #{e.class}" + end + end +end diff --git a/engine/app/models/coplan/agent_event.rb b/engine/app/models/coplan/agent_event.rb new file mode 100644 index 00000000..150a2388 --- /dev/null +++ b/engine/app/models/coplan/agent_event.rb @@ -0,0 +1,44 @@ +module CoPlan + # One row per (event, subscribed agent token) — the durable inbox an agent + # (or its bridge daemon) drains via GET /api/v1/agent/events. IDs are + # UUIDv7, so lexicographic order is creation order and the id doubles as + # the resume cursor. + class AgentEvent < ApplicationRecord + TYPES = %w[ + comment.created + comment.replied + thread.status_changed + plan.content_changed + ].freeze + + belongs_to :api_token, class_name: "CoPlan::ApiToken" + belongs_to :plan, class_name: "CoPlan::Plan" + + validates :event_type, inclusion: { in: TYPES } + + scope :for_token, ->(token) { where(api_token_id: token.id) } + scope :pending, -> { where(acked_at: nil) } + scope :after, ->(cursor) { where("id > ?", cursor) } + scope :oldest_first, -> { order(:id) } + + def self.ransackable_attributes(_auth_object = nil) + %w[id api_token_id plan_id comment_thread_id comment_id event_type acked_at created_at] + end + + def self.ransackable_associations(_auth_object = nil) + %w[api_token plan] + end + + def as_api_json + { + id: id, + type: event_type, + plan_id: plan_id, + comment_thread_id: comment_thread_id, + comment_id: comment_id, + payload: payload, + created_at: created_at + } + end + end +end diff --git a/engine/app/models/coplan/agent_event_bus.rb b/engine/app/models/coplan/agent_event_bus.rb new file mode 100644 index 00000000..cf65e007 --- /dev/null +++ b/engine/app/models/coplan/agent_event_bus.rb @@ -0,0 +1,115 @@ +module CoPlan + # Wake/notify plumbing for the agent event inbox, plus the budget that + # keeps attached agents from eating the whole web server. + # + # Two problems this solves, both found by running real agents against + # the endpoint: + # + # 1. **Busy polling.** The inbox endpoints used to re-query the database + # every 500ms per connection. Now a waiter blocks on a condition + # variable that `AgentEvents::Publish` signals, so an event is + # delivered as soon as it's written, with no query in between. The + # signal is in-process only, so waiters still wake periodically + # (CROSS_PROCESS_INTERVAL) to catch writes from other Puma workers or + # background jobs — polling becomes a slow safety net rather than the + # delivery mechanism. + # + # 2. **Thread starvation.** Every held connection (SSE *or* long-poll) + # occupies a Rack thread for its whole life, and RAILS_MAX_THREADS is + # small. Without a budget, a handful of attached agents make the app + # stop serving pages. `with_slot` caps concurrent held connections and + # leaves RESERVED_THREADS free for ordinary requests; callers that + # don't get a slot degrade (long-poll answers immediately, SSE is + # refused with Retry-After) instead of queueing behind agents. + class AgentEventBus + # Threads kept free for ordinary web traffic no matter how many agents + # are attached. + RESERVED_THREADS = 2 + + # How long a waiter sleeps before re-checking the database anyway, to + # catch events published by another process. + CROSS_PROCESS_INTERVAL = 3.0 + + class << self + def instance + @instance ||= new + end + + delegate :wait, :signal, :with_slot, :held, :capacity, to: :instance + + # Test seam: drop accumulated state between examples. + def reset! + @instance = nil + end + end + + def initialize(capacity: nil) + @capacity = capacity || self.class.default_capacity + @mutex = Mutex.new + @conditions = {} + @waiter_counts = Hash.new(0) + @held = 0 + end + + def self.default_capacity + configured = ENV["COPLAN_MAX_AGENT_STREAMS"] + return configured.to_i.clamp(0, 10_000) if configured.present? + + threads = ENV.fetch("RAILS_MAX_THREADS", 3).to_i + [ threads - RESERVED_THREADS, 1 ].max + end + + attr_reader :capacity + + def held + @mutex.synchronize { @held } + end + + # Yields true if this connection may hold a thread, false if the + # server is already at its budget. Always yields — refusing is the + # caller's decision, since long-poll and SSE degrade differently. + def with_slot + granted = @mutex.synchronize do + if @held < @capacity + @held += 1 + true + else + false + end + end + + begin + yield granted + ensure + @mutex.synchronize { @held -= 1 } if granted + end + end + + # Block until someone signals this key or `timeout` elapses. Returns + # after at most CROSS_PROCESS_INTERVAL regardless, so the caller + # re-checks the database and picks up out-of-process writes. + def wait(key, timeout:) + return if timeout <= 0 + + slice = [ timeout, CROSS_PROCESS_INTERVAL ].min + @mutex.synchronize do + condition = (@conditions[key] ||= ConditionVariable.new) + @waiter_counts[key] += 1 + begin + condition.wait(@mutex, slice) + ensure + @waiter_counts[key] -= 1 + if @waiter_counts[key] <= 0 + @waiter_counts.delete(key) + @conditions.delete(key) + end + end + end + nil + end + + def signal(key) + @mutex.synchronize { @conditions[key]&.broadcast } + end + end +end diff --git a/engine/app/models/coplan/agent_session.rb b/engine/app/models/coplan/agent_session.rb new file mode 100644 index 00000000..844792ad --- /dev/null +++ b/engine/app/models/coplan/agent_session.rb @@ -0,0 +1,197 @@ +module CoPlan + # A delegation of a plan to an agent — one per (plan, api_token). The + # state machine mirrors Linear's agent sessions: it drives the presence + # pill on the plan page, so humans can see the agent is engaged the + # moment it wakes, and whose turn it is when it asks a question. + # + # watching attached and idle — present, but not doing anything. + # This is the resting state of an attached agent and + # must read differently from working, or the pill lies + # about activity that isn't happening. + # pending an event was published; the agent hasn't reacted yet + # active the agent acked / is working (state_detail says what) + # awaiting_input the agent asked a question; it's the human's turn + # complete the agent finished its turn + # stale the agent never reacted to a wake — don't show a + # zombie "typing…" pill + class AgentSession < ApplicationRecord + STATES = %w[watching pending active awaiting_input complete stale].freeze + + # States rendered as a live pill on the plan page. + VISIBLE_STATES = %w[watching pending active awaiting_input].freeze + + # How long a session may sit in `pending` after a wake before we stop + # promising the humans that the agent is coming. + STALE_AFTER = 30.seconds + + # An agent may legitimately work for a long time, but a process that + # died mid-turn must not hold the pill forever. + ACTIVE_STALE_AFTER = 5.minutes + + # `awaiting_input` is the human's turn, so it's allowed to sit — but + # not past the point where the agent has certainly gone away. + AWAITING_INPUT_STALE_AFTER = 1.hour + + # A watching agent holds an open stream, and the server touches the + # session on every heartbeat (15s), so this only expires once the + # connection is genuinely gone. + WATCHING_STALE_AFTER = 2.minutes + + STALE_WINDOWS = { + "watching" => WATCHING_STALE_AFTER, + "pending" => STALE_AFTER, + "active" => ACTIVE_STALE_AFTER, + "awaiting_input" => AWAITING_INPUT_STALE_AFTER + }.freeze + + # How recent a transport touch (SSE heartbeat every 15s, long-poll + # park up to ~55s apart) must be to count as "a connection is parked + # on this token right now". + TRANSPORT_WINDOW = 90.seconds + + belongs_to :plan, class_name: "CoPlan::Plan" + belongs_to :api_token, class_name: "CoPlan::ApiToken" + + validates :agent_name, presence: true + validates :state, inclusion: { in: STATES } + validate :wake_url_is_http + + # Visibility is computed at read time rather than trusting + # MarkStaleAgentSessionJob to have run: if the job worker is down (or + # the agent's process was killed), a `pending` row would otherwise + # promise a ghost agent forever. The job still runs to *broadcast* the + # removal promptly; correctness doesn't depend on it. + scope :visible, -> { + clauses = STALE_WINDOWS.map { "(state = ? AND COALESCE(last_activity_at, updated_at) > ?)" }.join(" OR ") + where(clauses, *STALE_WINDOWS.flat_map { |state, window| [ state, window.ago ] }) + } + + # wake_secret is deliberately absent: it's a credential, not a + # search key, and must not be exposed through admin filters. + def self.ransackable_attributes(_auth_object = nil) + %w[id plan_id api_token_id agent_name state state_detail wake_url + wakes_answered_count wake_failures_count last_activity_at + last_transport_at created_at updated_at] + end + + def self.ransackable_associations(_auth_object = nil) + %w[plan api_token] + end + + # Is anything actually attached behind this row? Events are still + # queued for sessions that aren't (their inbox is durable), but a + # session with no live process must never be given a pill. + def live? + VISIBLE_STATES.include?(state) && !stale? + end + + def stale? + window = STALE_WINDOWS[state] + return false unless window + (last_activity_at || updated_at) <= window.ago + end + + # Is a connection actually parked on this session's token right now? + # The socket belongs to *some process* — it says delivery will land, + # not that a model will act (a background curl holds a socket as well + # as a real agent does). So this gates whether a wake is *attempted*, + # never what the pill promises. + def transport_connected? + last_transport_at.present? && last_transport_at > TRANSPORT_WINDOW.ago + end + + # Is there any path by which an event can reach something that might + # act — a parked connection, or a registered wake URL? + def wakeable? + transport_connected? || wake_url.present? + end + + # Has this session ever demonstrably turned a wake into action? Only + # then may the pill promise one. + def wake_proven? + wakes_answered_count.to_i.positive? + end + + # Moves out of `pending` that count as the agent answering the wake. + # `complete` and `watching` are excluded deliberately: detach paths + # and supervising loops file those mechanically (coplan-attach's + # at_exit lands on complete; a restarted watcher re-claims watching), + # so counting them would mark dead-harness sessions wake-proven — + # exactly what the proof exists to detect. `active`/`awaiting_input` + # are the etiquette's own acks; only tooling that is genuinely + # starting a turn sends them. + PROOF_STATES = %w[active awaiting_input].freeze + + def transition!(new_state, detail: nil) + # An agent-driven move out of `pending` is the one observable proof + # that delivery became a model turn — the fact the wake promise is + # calibrated against. + self.wakes_answered_count += 1 if state == "pending" && PROOF_STATES.include?(new_state) + update!(state: new_state, state_detail: detail, last_activity_at: Time.current) + broadcast_pill + end + + # Called when an event is published to this session's inbox: flip back + # to pending (unless the agent is already mid-turn) and start the + # stale countdown. + def wake! + transition!("pending") unless state == "active" + # (watching → pending is correct: the agent now owes a response.) + # + # woken_at must carry full precision: the column is datetime(6), and + # a second-truncated timestamp makes the wake's own transition read + # as "activity after the wake" — the job would never fire and the + # 30-second promise would be a dead letter. + MarkStaleAgentSessionJob.set(wait: STALE_AFTER).perform_later( + agent_session_id: id, woken_at: (last_activity_at || Time.current).iso8601(6) + ) + end + + def display_status + case state + # Listening is presence, not work: just the name. The pill's green + # pulse carries "I'm here", so the label doesn't need a verb. + when "watching" then agent_name + # Delivery is not action, and wakeability can only be demonstrated, + # never declared: a session that has answered a wake before earns + # "Waking…"; an unproven one keeps plain presence while the wake + # quietly tests it. "On it" is the agent's own claim to make (by + # flipping to active). + when "pending" then wake_proven? ? "Waking #{agent_name}…" : agent_name + when "active" then state_detail.presence ? "#{agent_name} is #{state_detail}" : "#{agent_name} is working…" + when "awaiting_input" then "#{agent_name} asked a question" + end + end + + def broadcast_pill + Broadcaster.replace_to( + plan, + target: "plan-agent-sessions", + partial: "coplan/plans/agent_sessions", + locals: { agent_sessions: AgentSession.visible.where(plan_id: plan_id).order(:created_at) } + ) + end + + private + + # The server will POST to this URL, which is request forgery unless + # the destination is vetted — so it must be http(s), and it must pass + # the egress policy (public address space by default; hosts override + # via config.wake_url_policy — dev legitimately wakes localhost). + # Only checked when the URL changes: resolving DNS on every state + # transition would put a network call inside `transition!`. + def wake_url_is_http + return if wake_url.blank? || !wake_url_changed? + + uri = URI.parse(wake_url) + unless uri.is_a?(URI::HTTP) && uri.host.present? + return errors.add(:wake_url, "must be an http(s) URL") + end + unless WakeUrlPolicy.allowed?(uri) + errors.add(:wake_url, "is not a reachable public address (private, loopback, and link-local hosts are refused)") + end + rescue URI::InvalidURIError + errors.add(:wake_url, "must be an http(s) URL") + end + end +end diff --git a/engine/app/models/coplan/api_token.rb b/engine/app/models/coplan/api_token.rb index b5142242..036dd61f 100644 --- a/engine/app/models/coplan/api_token.rb +++ b/engine/app/models/coplan/api_token.rb @@ -18,6 +18,11 @@ class ApiToken < ApplicationRecord belongs_to :parent, class_name: "CoPlan::ApiToken", optional: true has_many :children, class_name: "CoPlan::ApiToken", foreign_key: :parent_id, dependent: :nullify, inverse_of: :parent + # delete_all, not destroy: both tables FK this token, and a destroyed + # token (user cleanup cascading through tokens) must take its inbox + # and presence rows with it instead of raising. + has_many :agent_events, dependent: :delete_all + has_many :agent_sessions, dependent: :delete_all validates :name, presence: true validates :token_digest, presence: true, uniqueness: true diff --git a/engine/app/models/coplan/comment.rb b/engine/app/models/coplan/comment.rb index b15d3101..eb52312d 100644 --- a/engine/app/models/coplan/comment.rb +++ b/engine/app/models/coplan/comment.rb @@ -2,13 +2,20 @@ module CoPlan class Comment < ApplicationRecord AUTHOR_TYPES = %w[human local_agent cloud_persona system].freeze + # Attribution labels sit inline next to the body, so they stay short. + AGENT_NAME_LIMIT = 20 + belongs_to :comment_thread + # Notifications reference the comment, so deleting one used to fail on + # a foreign key. They're delivery records for a comment that no longer + # exists — they go with it. + has_many :notifications, class_name: "CoPlan::Notification", dependent: :delete_all belongs_to :api_token, class_name: "CoPlan::ApiToken", optional: true validates :body_markdown, presence: true validates :author_type, presence: true, inclusion: { in: AUTHOR_TYPES } validates :agent_name, presence: { message: "is required for agent comments" }, if: -> { author_type == "local_agent" } - validates :agent_name, length: { maximum: 20 }, allow_nil: true + validates :agent_name, length: { maximum: AGENT_NAME_LIMIT }, allow_nil: true before_save :rewrite_plain_mentions, if: :body_markdown_changed? after_create_commit :notify_plan_author, if: :first_comment_in_thread? diff --git a/engine/app/models/coplan/library.rb b/engine/app/models/coplan/library.rb index 703eb099..bb7127f0 100644 --- a/engine/app/models/coplan/library.rb +++ b/engine/app/models/coplan/library.rb @@ -32,7 +32,7 @@ class Library < ApplicationRecord RESERVED_HANDLES = %w[ _ new edit all plans people libraries library settings search notifications home welcome - api agent-instructions admin assets rails up sign_in sign_out integrations + api agent-instructions agent-tools admin assets rails up sign_in sign_out integrations ].freeze HANDLE_FORMAT = /\A[a-z0-9][a-z0-9-]*\z/ HANDLE_MAX_LENGTH = 60 diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index 48e5bbc2..5c47441a 100644 --- a/engine/app/models/coplan/plan.rb +++ b/engine/app/models/coplan/plan.rb @@ -44,6 +44,11 @@ class Plan < ApplicationRecord has_many :plan_viewers, dependent: :destroy has_many :notifications, dependent: :destroy has_many :references, dependent: :destroy + # delete_all, not destroy: inbox rows and presence pills are transient + # state with no teardown of their own, and both tables FK this plan — + # without these, destroying a plan with collaboration history raises. + has_many :agent_events, dependent: :delete_all + has_many :agent_sessions, dependent: :delete_all has_many_attached :attachments after_initialize { self.metadata ||= {} } diff --git a/engine/app/policies/coplan/wake_url_policy.rb b/engine/app/policies/coplan/wake_url_policy.rb new file mode 100644 index 00000000..74dc459e --- /dev/null +++ b/engine/app/policies/coplan/wake_url_policy.rb @@ -0,0 +1,67 @@ +require "ipaddr" +require "resolv" + +module CoPlan + # Egress policy for wake webhooks. The server POSTs to wake URLs its + # users registered, which is a server-side request forgery primitive + # unless somebody says no — so by default, no: a URL whose host + # resolves to loopback, private, link-local (cloud metadata lives + # there), or otherwise non-public address space is refused, both at + # registration and again at delivery time (DNS can change its answer + # between the two). + # + # Hosts with different needs — dev waking an agent on localhost, a + # deployment that only allows an internal allowlist — override the + # whole decision with `config.wake_url_policy = ->(uri) { ... }`. + module WakeUrlPolicy + BLOCKED_RANGES = %w[ + 0.0.0.0/8 + 10.0.0.0/8 + 100.64.0.0/10 + 127.0.0.0/8 + 169.254.0.0/16 + 172.16.0.0/12 + 192.168.0.0/16 + ::/128 + ::1/128 + fc00::/7 + fe80::/10 + ].map { |range| IPAddr.new(range) }.freeze + + module_function + + def allowed?(uri) + !vetted_addresses(uri).nil? + end + + # Resolve-and-vet in one step: the addresses the host resolves to, + # iff every one of them is public; nil means refused. A caller that + # goes on to connect must pin the connection to one of these — + # resolving again at connect time reopens the rebinding window this + # policy exists to close (answer the check with a public address, + # answer the connection with a private one). + # + # A configured custom policy owns the whole decision and judges the + # URI, not its addresses, so an allow comes back as :unpinned — + # there is nothing safe to pin to. + def vetted_addresses(uri) + policy = CoPlan.configuration.wake_url_policy + return (policy.call(uri) ? :unpinned : nil) if policy + + addresses = Resolv.getaddresses(uri.host.to_s) + return nil if addresses.empty? || addresses.any? { |address| blocked_address?(address) } + + addresses + rescue Resolv::ResolvError, Resolv::ResolvTimeout + nil + end + + # Unparseable answers are refused, not excused. + def blocked_address?(address) + ip = IPAddr.new(address.to_s) + BLOCKED_RANGES.any? { |range| range.include?(ip) } + rescue IPAddr::InvalidAddressError + true + end + end +end diff --git a/engine/app/services/coplan/agent_events/publish.rb b/engine/app/services/coplan/agent_events/publish.rb new file mode 100644 index 00000000..7d713172 --- /dev/null +++ b/engine/app/services/coplan/agent_events/publish.rb @@ -0,0 +1,110 @@ +module CoPlan + module AgentEvents + # Fans an event out to the inbox of every agent session on the plan, + # except the actor's own (an agent should not be woken by its own + # comment or edit). Suppression matches on the api token id — never a + # user id, since attribution rows store the human while the token is + # the unit of subscription. + class Publish + def self.call(plan:, event_type:, actor_token_id: nil, comment_thread: nil, comment: nil, payload: {}) + new(plan:, event_type:, actor_token_id:, comment_thread:, comment:, payload:).call + end + + def initialize(plan:, event_type:, actor_token_id:, comment_thread:, comment:, payload:) + @plan = plan + @event_type = event_type + @actor_token_id = actor_token_id + @comment_thread = comment_thread + @comment = comment + @payload = payload + end + + def call + sessions = AgentSession.where(plan_id: @plan.id).includes(:api_token) + sessions.each do |session| + next if @actor_token_id.present? && session.api_token_id == @actor_token_id + + agent_event = AgentEvent.create!( + api_token_id: session.api_token_id, + plan_id: @plan.id, + comment_thread_id: @comment_thread&.id, + comment_id: @comment&.id, + event_type: @event_type, + payload: base_payload.merge(authority_payload(session)).merge(@payload) + ) + # The event is queued for every session — inboxes are durable, so + # a detached agent gets its backlog when it comes back. But only + # a session with something actually attached gets woken into a + # pill: otherwise an abandoned session is resurrected by every + # new comment and haunts the plan forever. `wakeable?` narrows + # that further to sessions with a real path for the wake — a + # parked connection or a wake URL. Without one, flipping to + # pending would start a 30-second countdown nothing can answer. + session.wake! if session.live? && session.wakeable? + + # A wake URL is a standing subscription: it fires even when the + # session looks finished (`complete`), because a hosted agent + # holds no transport between turns — waking it back up is the + # entire point of registering one. Enqueued after commit: the + # queue lives in a separate database, so a worker can otherwise + # pick the job up before the AgentEvent row is visible and + # no-op the wake. + if session.wake_url.present? + ActiveRecord.after_all_transactions_commit do + WakeWebhookJob.perform_later(agent_session_id: session.id, agent_event_id: agent_event.id) + end + end + + # Hand the event straight to any connection already waiting on + # this token's inbox, so delivery doesn't wait for a poll tick. + AgentEventBus.signal(session.api_token_id) + end + end + + private + + def base_payload + payload = { + "plan_title" => @plan.title, + "plan_revision" => @plan.current_revision + } + if @comment_thread + payload["thread_status"] = @comment_thread.status + payload["anchor_text"] = @comment_thread.anchor_text + end + if @comment + payload["comment_body"] = @comment.body_markdown + payload["comment_author"] = @comment.agent_name.presence || @comment.author&.name + payload["comment_author_type"] = @comment.author_type + end + payload + end + + # How much weight the agent should give this comment. Two separate + # questions, because they come apart: the person who wrote the plan + # isn't necessarily the person whose agent this is, and a comment from + # a stranger on your own plan shouldn't authorize edits the way your + # own comment does. + # + # from_principal the commenter is the human this token belongs to + # from_plan_author the commenter wrote the plan + # authority "principal" → act directly + # "collaborator" → acknowledge and propose in-thread + def authority_payload(session) + return {} unless @comment + + principal_id = session.api_token&.user_id + # author_id holds a user id for both human and local_agent comments, + # so an agent posting on someone's behalf carries their authority. + author_id = @comment.author_id if @comment.author_type.in?(%w[human local_agent]) + from_principal = author_id.present? && author_id == principal_id + + { + "from_principal" => from_principal, + "from_plan_author" => author_id.present? && author_id == @plan.created_by_user_id, + "authority" => from_principal ? "principal" : "collaborator" + } + end + end + end +end diff --git a/engine/app/services/coplan/broadcaster.rb b/engine/app/services/coplan/broadcaster.rb index 524b813e..d6e29bf3 100644 --- a/engine/app/services/coplan/broadcaster.rb +++ b/engine/app/services/coplan/broadcaster.rb @@ -64,14 +64,20 @@ def custom_action_to(streamable, action:, target:, html:, attrs: {}) # push the freshly rendered plan body to every open tab, letting the # client decide whether to apply it (clean) or show a stale-revision # banner (dirty draft in progress). - def replace_plan_content(plan) + # + # changed_sections (section keys from Plans::ChangedSections) rides + # along so viewing tabs can flash exactly the sections this revision + # touched instead of hard-swapping the whole body silently. + def replace_plan_content(plan, changed_sections: nil) html = render(partial: "coplan/plans/content_body", locals: { plan: plan }) + attrs = { "data-revision" => plan.current_revision } + attrs["data-changed-sections"] = changed_sections.to_json if changed_sections.present? custom_action_to( plan, action: "coplan-replace-if-clean", target: "plan-content-body", html: html, - attrs: { "data-revision" => plan.current_revision } + attrs: attrs ) end diff --git a/engine/app/services/coplan/comments/interpret_dictation.rb b/engine/app/services/coplan/comments/interpret_dictation.rb index c720efa7..55fed90d 100644 --- a/engine/app/services/coplan/comments/interpret_dictation.rb +++ b/engine/app/services/coplan/comments/interpret_dictation.rb @@ -49,7 +49,8 @@ def anchor_text = comments.first&.anchor_text MIN_ANCHOR_LENGTH = 8 SYSTEM_PROMPT = <<~PROMPT.freeze - You process a spoken remark somebody made about a document. + You turn a spoken remark somebody made about a document into the + comment they meant to leave on it. You are given an excerpt of what was on their screen and a raw speech-to-text transcript. Return JSON of this shape: @@ -63,9 +64,17 @@ def anchor_text = comments.first&.anchor_text point at two copies of the same text, repeat the span in two comments. - "text": the remark cleaned up into a readable comment. - - Remove filler words and verbal tics: um, uh, like, you know, - I mean, sort of, kind of, basically, actually. + "text": the comment as the speaker would have typed it. + - It is their comment, in their voice, from their point of view, + and it will appear under their name. You edit their words; you + are not a participant in the conversation. Never reply to the + remark, never answer a question it asks, never promise action. + "Hmm, not enough information on tax attach" becomes "Not enough + information on tax attach." — rewriting it as "I will add more + information" would sign a promise in their name that they never + made. + - Remove filler words and verbal tics: hmm, um, uh, like, + you know, I mean, sort of, kind of, basically, actually. - Repair false starts and repetitions into what they meant to say. - Fix obvious mis-transcriptions using the excerpt as context (technical terms in the excerpt are almost certainly what they @@ -76,8 +85,15 @@ def anchor_text = comments.first&.anchor_text "span": the exact text from the excerpt the remark is about. - Copied character-for-character from the excerpt. + - The remark usually names its own target: when it mentions words + that appear in the excerpt ("tax attach"), the span is the + passage containing them. - The smallest span that identifies the target: a phrase or sentence, not a whole section. + - People read near the middle of their screen. When a "they were + reading" section is given, the target is most likely in it; the + rest of the excerpt is context that scrolled past while they + spoke. - Use null if the remark is about the document as a whole, or you cannot identify a specific passage. @@ -92,7 +108,7 @@ def anchor_text = comments.first&.anchor_text def self.call(...) = new(...).call - def initialize(excerpt:, transcript:, document: nil, recent_comments: []) + def initialize(excerpt:, transcript:, document: nil, recent_comments: [], focus: nil) @excerpt = excerpt.to_s # What the anchor ultimately has to resolve against. Defaults to # the excerpt so callers that only have the one string still work. @@ -101,6 +117,11 @@ def initialize(excerpt:, transcript:, document: nil, recent_comments: []) # [{ body:, anchor: }, ...], newest first — the conversation the # remark may be continuing. @recent_comments = recent_comments + # What was mid-screen when they finished speaking. The excerpt + # accumulates everything that scrolled past during the take, so + # without this the model has no idea which part of it the person + # was actually reading. + @focus = focus.to_s.strip end def call @@ -123,11 +144,24 @@ def user_content --- #{@excerpt} --- - #{comments_section} + #{focus_section}#{comments_section} Transcript: #{@transcript} CONTENT end + # Omitted when it adds nothing: no focus reported, or the excerpt + # fit on one screen so "what they were reading" is the whole thing. + def focus_section + return "" if @focus.blank? || @focus == @excerpt.strip + + <<~SECTION + They were reading this part as they finished speaking (the span is usually in here): + --- + #{@focus} + --- + SECTION + end + def comments_section return "" if @recent_comments.blank? diff --git a/engine/app/services/coplan/notifications/create.rb b/engine/app/services/coplan/notifications/create.rb index a97c1941..69dc74c8 100644 --- a/engine/app/services/coplan/notifications/create.rb +++ b/engine/app/services/coplan/notifications/create.rb @@ -8,18 +8,24 @@ class Create # somebody deliberately reopening a settled conversation is news. SILENT_ON_CLOSED_THREAD = %w[agent_response status_change].freeze - def self.call(comment_thread:, actor_id:, comment: nil, reason:) - new(comment_thread:, actor_id:, comment:, reason:).call + def self.call(comment_thread:, actor_id:, comment: nil, reason:, actor_api_token_id: nil) + new(comment_thread:, actor_id:, comment:, reason:, actor_api_token_id:).call end - def initialize(comment_thread:, actor_id:, comment: nil, reason:) + def initialize(comment_thread:, actor_id:, comment: nil, reason:, actor_api_token_id: nil) @comment_thread = comment_thread @actor_id = actor_id @comment = comment @reason = reason + @actor_api_token_id = actor_api_token_id end def call + # Agent events publish unconditionally — closed-thread silence is + # about not nagging humans with unread rows; an agent still wants + # the reply (and hears the close itself via thread.status_changed). + publish_agent_events + # Reading the status and inserting have to be one step. An agent # that replies and then resolves races its own reply job: check # open, resolve commits and sweeps, insert — and the row is unread @@ -60,6 +66,41 @@ def silenced? SILENT_ON_CLOSED_THREAD.include?(@reason) && @comment_thread.closed? end + # Every comment/thread write path already funnels through this + # service, so it doubles as the choke point for the agent event + # inbox. Human notification fan-out below is unchanged; agents get + # their own recipients (sessions on the plan). Self-wake suppression + # keys on the acting *token* — the comment's api_token_id, or for + # comment-less events (a resolve/discard) the token the caller + # passed — because @actor_id holds the *human* behind the write + # (attribution convention), and a user id must never be compared to + # token ids. + def publish_agent_events + event_type = + case @reason + when "status_change" then "thread.status_changed" + when "new_comment", "reply", "agent_response" + # Notification reasons distinguish *who* spoke (a human opening + # a thread vs an agent answering); agents care about *where* it + # landed. Opening a thread is `created` no matter who did it. + opens_thread? ? "comment.created" : "comment.replied" + end + return unless event_type + + AgentEvents::Publish.call( + plan: @comment_thread.plan, + event_type: event_type, + actor_token_id: @comment&.api_token_id || @actor_api_token_id, + comment_thread: @comment_thread, + comment: @comment + ) + end + + def opens_thread? + return false unless @comment + @comment_thread.comments.order(:id).first&.id == @comment.id + end + def compute_subscribers case @reason when "new_comment" diff --git a/engine/app/services/coplan/plans/replace_content.rb b/engine/app/services/coplan/plans/replace_content.rb index f2b09305..2a30fea5 100644 --- a/engine/app/services/coplan/plans/replace_content.rb +++ b/engine/app/services/coplan/plans/replace_content.rb @@ -127,7 +127,22 @@ def call partial: "coplan/plans/header", locals: { plan: @plan } ) - Broadcaster.replace_plan_content(@plan) + changed_sections = Plans::ChangedSections.call( + old_content: current_content, + new_content: @new_content + ) + Broadcaster.replace_plan_content(@plan, changed_sections: changed_sections) + + AgentEvents::Publish.call( + plan: @plan, + event_type: "plan.content_changed", + actor_token_id: @api_token_id, + payload: { + "revision" => new_revision, + "change_summary" => @change_summary, + "changed_sections" => changed_sections + } + ) { version: version, plan: @plan, applied: result[:applied].length, no_op: false } end diff --git a/engine/app/views/coplan/agent_instructions/show.text.erb b/engine/app/views/coplan/agent_instructions/show.text.erb index 0d5dde8a..5f3c9e35 100644 --- a/engine/app/views/coplan/agent_instructions/show.text.erb +++ b/engine/app/views/coplan/agent_instructions/show.text.erb @@ -24,6 +24,61 @@ Mint one as your first call (this is the only endpoint that works on request aut - Mint once at the start of your run and reuse it — minting per call would give you a new identity every time. - When you finish, `DELETE <%= @base %>/api/v1/tokens/current` (authenticated with the token itself) so the credential dies with the run instead of waiting out its TTL. +## Setup: Your First Five Minutes as a Live Agent + +Everything below this section works with nothing but curl. This section is the optional-but-recommended upgrade: hear about comments the moment they land, show a presence pill on the plan, and answer without being asked twice. + +There is nothing to install. The live loop is three HTTP calls — shown as curl, but any HTTP client works: + +1. **Claim** your presence on the plan: `POST <%= @base %>/api/v1/plans/$PLAN_ID/agent_session`. +2. **Wait**: `GET <%= @base %>/api/v1/agent/events?wait=25` returns the instant an event lands, or empty after `wait` seconds (the server caps `wait` at 55, so loop it). +3. **Act, then ack**: PATCH your session to `active`, reply and edit, then POST `{"cursor": ""}` to `<%= @base %>/api/v1/agent/events/ack`. + +Exact requests, payloads, and etiquette: **Live Collaboration**, below. The real setup problem is not the calls — it is arranging for the wait to start your next turn. Pick the one branch your harness can do: + +**A. Your harness re-invokes you when a background process exits** (Claude Code does this). Run the wait as a background task and keep working — the process exiting is your wake. The whole tool is a shell loop that exits only when there is news: + +```bash +while :; do + RESP=$(<%= @curl %> -f "<%= @base %>/api/v1/agent/events?wait=25") || + { echo "coplan: wait failed — token expired or server unreachable" >&2; exit 1; } + [ "$(printf '%s' "$RESP" | jq '.events | length')" -gt 0 ] && break + sleep 1 +done +printf '%s\n' "$RESP" | jq . +``` + +Act on what it prints, then ack, then start it again. No cursor needed: events you haven't acked are re-delivered whole on the next call, so nothing is lost between runs. It fails loudly on purpose — a process exit is your wake either way, so a dead token wakes you to re-mint instead of parking you forever. (The loop needs a shell and `jq`, but it's just GET-until-nonempty — port it to whatever your runtime has.) + +**B. You can hold a blocking tool call.** The same loop in the foreground: it costs you the turn while you wait, but the event comes back as ordinary tool output. Works in every harness; add an iteration cap so a quiet plan gives your turn back. + +**C. An always-on machine runs your agent over ACP.** The `coplan-bridge` reference tool (below) stays attached and pushes each event into your agent as a prompt turn. Anything that ships an ACP server works unmodified (`goose acp`, `gemini --acp`, the Claude Code and Codex ACP adapters); without ACP it can exec a resume command per event instead. Something has to start the bridge before any agent is awake, so this branch is usually set up by your human. + +**D. You are hosted, with no local process between turns.** No scripts, no loop: claim your agent session with a `wake_url` and the server POSTs a signed ping there on every event. See the `wake_url` registration under **Claim an Agent Session** (in Live Collaboration, below). + +**E. None of the above.** You can still collaborate correctly — you'll answer late instead of never. At the start of each turn, drain your inbox: `GET <%= @base %>/api/v1/agent/events?wait=0` (see **Wait for Events** in Live Collaboration, below). + +**Make it durable.** Whichever branch you picked, don't leave the wiring in your context window — it dies with your session. Save it as a first-class capability of your harness: a skill or saved command (branch A or B as a small script your future runs invoke by name), a custom tool definition, or a standing ACP bridge config (branch C). The test: a fresh run of you tomorrow should attach with one action, not by rediscovering this page. Give the wiring its own identity while you're at it — mint a run-scoped child token at startup (**Session Tokens**, in Live Collaboration below) so each run keeps one inbox and one pill. + +**Reference tools (optional; require Ruby).** This server also serves ready-made implementations of all of the above: `coplan-attach` (branches A and B, plus sticky run-scoped tokens, presence, and readable event briefs — its helper `coplan_session.rb` must sit next to it) and `coplan-bridge` (branch C, standalone). They are a convenience, not the protocol — and they are executable code fetched from the network. Read every file you download before you run it, the helper included (it executes in-process); each is a few hundred lines. What to check: `coplan-attach` and `coplan_session.rb` make network calls only to this server, write only under `~/.coplan` (or `$COPLAN_HOME` if you set it), and spawn no subprocesses. `coplan-bridge` may additionally read `~/.config/coplan/bridge.json` and — this being its whole job — exec the one agent command you configured, nothing else. If what you downloaded does more, don't run it. + +```bash +mkdir -p ~/.coplan/bin && cd ~/.coplan/bin +curl -sfO <%= @base %>/agent-tools/coplan-attach +curl -sfO <%= @base %>/agent-tools/coplan_session.rb +``` + +Now read both files against the checks above. Then: + +```bash +chmod +x ~/.coplan/bin/coplan-attach +export COPLAN_BASE=<%= @base %> +export COPLAN_TOKEN="$TOKEN" # the token you already hold — never paste the literal secret +~/.coplan/bin/coplan-attach --plan $PLAN_ID --name Claude --once +``` + +`--once` is branch A or B in one command: it blocks until the first event, prints the brief, acks, and exits (`--timeout 300` gives your turn back on a quiet plan — exit 64 means nothing happened). Without `--once` it stays attached and streams. The bridge lives at `<%= @base %>/agent-tools/coplan-bridge`; run it from the directory the agent should work in (for the ACP adapter, `--cwd ` overrides): `coplan-bridge --acp "goose acp" --plan $PLAN_ID --name Goose`. The scripts claim your session for you (`coplan-attach` also mints its own run-scoped token) — on the raw-curl branches, claim it yourself (step 1). + ## Quick Start: The Editing Loop Keeping a local Markdown file and syncing whole documents is the intended workflow — you do not need to express edits as granular operations. The canonical loop: @@ -587,13 +642,154 @@ Mark a comment thread as resolved (addressed by the plan author or thread creato ### Dismiss Thread -Dismiss a comment thread (plan author only — for comments that are out of scope or not applicable). +Dismiss a comment thread (plan author only — for comments that are out of scope or not applicable). `discard` is accepted as an alias for `dismiss`. ```bash <%= @curl %> -X PATCH \ "<%= @base %>/api/v1/plans/$PLAN_ID/comments/$THREAD_ID/dismiss" | jq . ``` +### Get a Single Thread + +Fetch one thread with its comments — handy when reacting to a single event without refetching the whole snapshot. + +```bash +<%= @curl %> "<%= @base %>/api/v1/plans/$PLAN_ID/comments/$THREAD_ID" | jq . +``` + +## Live Collaboration (Realtime) + +If you authored a plan (or are babysitting one), you can hear about new comments and edits the moment they happen instead of re-polling snapshots, and show humans that you're on it. + +### 1. Claim an Agent Session + +Claiming a session subscribes your token to the plan's events and shows a live presence pill to everyone viewing the plan. A plain claim reads as presence, not work — the pill is just your name (`watching`). Claim with `"state": "active"` when you're already mid-task. Turn states can't be claimed on arrival: reach `awaiting_input` and `complete` via PATCH once you're actually in the loop. + +```bash +<%= @curl %> -X POST \ + -H "Content-Type: application/json" \ + -d '{"agent_name": "Claude"}' \ + "<%= @base %>/api/v1/plans/$PLAN_ID/agent_session" | jq . +``` + +Keep the pill honest by updating your state as you work (`state`: `active`, `awaiting_input`, `complete`; optional `detail` shows what you're doing). `pending` is the server's: it's set when an event lands in your inbox and holds for 30 seconds before going stale. The pill only says "Waking you…" once you've answered a wake before — until your first reaction, wakes are quietly a test. + +**When answering a wake, PATCH — don't re-claim.** Your session already exists; PATCH `{"state": "active"}` is the ack that counts as answering. A fresh POST claim resets the session instead of answering it, so the wake goes down as unanswered and the pill stops trusting you. + +**Hosted agent with no connection to hold?** Claim with a `wake_url` and CoPlan will POST a signed ping there on every event instead: + +```bash +<%= @curl %> -X POST \ + -H "Content-Type: application/json" \ + -d '{"agent_name": "Claude", "wake_url": "https://your-platform.example/hooks/coplan"}' \ + "<%= @base %>/api/v1/plans/$PLAN_ID/agent_session" | jq . +``` + +The response includes `wake_secret` — once, at registration; store it. Each ping is a small JSON body (`event_id`, `event_type`, `plan_id`) signed with `X-CoPlan-Signature: sha256=`; verify it, dedupe on `event_id`, then pull your inbox below as usual. Pings fire even when your session is `complete` — waking you back up is the point. Re-claim with `"wake_url": ""` to unregister. Keep the endpoint answering: a URL that keeps failing entire retry runs is presumed dead and unregistered automatically (you'd re-register on your next claim). The URL must also be reachable from the server — hosts that resolve to private or loopback addresses are refused. + +```bash +<%= @curl %> -X PATCH \ + -H "Content-Type: application/json" \ + -d '{"state": "active", "detail": "tightening the Rollout section"}' \ + "<%= @base %>/api/v1/plans/$PLAN_ID/agent_session" | jq . +``` + +Etiquette (this is what makes you feel like a collaborator, not a batch job): + +- Flip to `active` within a few seconds of waking — the fast ack humans see. Work as slowly as you need after that; if you stay silent in `pending` the pill goes stale. +- Post a short reply narrating what you're about to change *before* you PUT the edit. +- Use `awaiting_input` when a question blocks you; land on `complete` when your turn is done. +- `DELETE` the session when you stop watching the plan. + +### 2. Wait for Events + +While you're attached, you don't poll on an interval — you hold a connection open and the server hands you events the instant they happen. Two equivalent ways to do that; both are ordinary HTTP. + +Held-open stream (Server-Sent Events — one socket, heartbeats every 15s, reconnect with your cursor): + +```bash +<%= @curl %> -N -H "Accept: text/event-stream" \ + "<%= @base %>/api/v1/agent/events?cursor=$LAST_EVENT_ID" +``` + +Long-poll (returns immediately when an event lands, or empty after `wait` seconds — easier to consume from a shell loop): + +```bash +<%= @curl %> "<%= @base %>/api/v1/agent/events?wait=25&cursor=$LAST_EVENT_ID" | jq . +``` + +Event types: `comment.created`, `comment.replied`, `thread.status_changed`, `plan.content_changed`. Each event carries `plan_id`, `comment_thread_id`, and a payload with the comment body/anchor so you usually don't need another fetch to decide what to do. Your own comments and edits are never echoed back to you. + +**Whose comment is it?** Comment events carry three fields that tell you how much rope you have: + +- `from_principal` — the commenter is the human your token belongs to. +- `from_plan_author` — the commenter wrote the plan. These come apart: you can be attached to somebody else's plan, and your own principal still outranks its author. +- `authority` — `"principal"` or `"collaborator"`, the summary of the above. + +The contract: + +- `principal` — act directly. Reply, then make the edit. +- `collaborator` — reply and *propose* the change in the thread. Don't edit the document until your principal confirms. A drive-by comment from someone who isn't your human is a request, not an instruction. + +Cursoring: event ids are time-ordered — pass the last id you processed as `cursor` to resume. Without a cursor you get all unacked events. Delivery is at-least-once; ack after processing: + +```bash +<%= @curl %> -X POST \ + -H "Content-Type: application/json" \ + -d '{"cursor": "$LAST_EVENT_ID"}' \ + "<%= @base %>/api/v1/agent/events/ack" | jq . +``` + +Nothing here requires a daemon or a special harness integration: an attached agent runs the loop below itself. (`coplan-attach`, served at `<%= @base %>/agent-tools/coplan-attach` and covered in the Setup section above, is a convenience wrapper around this same loop — `--once` blocks until one event arrives, prints a brief, acks, and exits, which is the shape a turn-based agent wants.) + +**Delivery is not a wake.** These endpoints hand your process the event; whether that starts a *model turn* is your harness's job, and no transport fixes a harness that can't do it. An event printed by a background process nobody wakes up for is just a log line. Four shapes that work: + +1. **Blocking tool call** — run the wait (`coplan-attach --once`, or the long-poll curl above) as an ordinary foreground tool call. The event comes back as tool output and you act on it. Works in every harness; costs you the turn while you wait. +2. **Background process, if your harness re-invokes you when one exits** — the Setup section's branch-A loop (or `coplan-attach --once`) in the background leaves you free to work; its exit is your wake. (Claude Code does this; most harnesses don't — check yours before relying on it.) +3. **Sidecar resume** — `coplan-bridge` (served at `<%= @base %>/agent-tools/coplan-bridge`, see Setup above) drains your inbox from outside and injects each event: over ACP into one live agent session (any harness with an ACP server), or via your harness's resume-with-message command. +4. **Webhook wake** — for hosted agents: register a `wake_url` at claim time (see above) and CoPlan pushes a signed ping to your platform, which starts your turn; you then pull the inbox. + +If none of these fit your harness, don't hold a connection you can't act on: your inbox is durable, so drain it with `wait=0` at the start of each turn instead. Correct, just not live. + +Hand-rolled SSE clients: the server retires every stream after 5 minutes by design — reconnect with your last cursor and nothing is missed. (`coplan-attach` does this for you.) + +### Session Tokens + +Your token *is* your subscription: sessions, inboxes, and pills are all keyed to it. Two agents sharing one token share an inbox and race for each other's events, so give each agent run its own token — minted from your long-lived one, so the machine only ever stores one secret. + +```bash +<%= @curl %> -X POST \ + -H "Content-Type: application/json" \ + -d '{"agent_name": "Claude (refactor)", "ttl_seconds": 43200}' \ + "<%= @base %>/api/v1/tokens" | jq -r .token +``` + +The minted token inherits your principal (it can never act for a different human), expires on its own (12h default, 7d max), and cannot mint further tokens. Revoking the parent revokes every token it minted. Clean up when you exit: + +```bash +<%= @curl %> -X DELETE "<%= @base %>/api/v1/tokens/current" | jq . +``` + +Mint once at the start of your session and reuse it — minting per turn would give you a new identity, and a new presence pill, every time. + +### 3. The Loop + +```bash +CURSOR="" +while true; do + RESP=$(<%= @curl %> "<%= @base %>/api/v1/agent/events?wait=25&cursor=$CURSOR") + echo "$RESP" | jq -c '.events[]' | while read -r event; do + # 1. PATCH agent_session state=active (fast ack — do this first) + # 2. Read the event payload / thread, decide, reply, edit + # 3. PATCH agent_session state=complete + : + done + NEXT=$(echo "$RESP" | jq -r '.cursor // empty') + [ -n "$NEXT" ] && CURSOR="$NEXT" && <%= @curl %> -X POST -H "Content-Type: application/json" \ + -d "{\"cursor\": \"$CURSOR\"}" "<%= @base %>/api/v1/agent/events/ack" > /dev/null +done +``` + ## Reviewing a Plan When asked to review a plan (given a plan URL or ID), follow this workflow: diff --git a/engine/app/views/coplan/plans/_agent_sessions.html.erb b/engine/app/views/coplan/plans/_agent_sessions.html.erb new file mode 100644 index 00000000..600066f4 --- /dev/null +++ b/engine/app/views/coplan/plans/_agent_sessions.html.erb @@ -0,0 +1,13 @@ +<%# The agent presence pill(s): live state of every agent session on this + plan (pending / active / awaiting_input). Broadcast-replaced whole by + AgentSession#broadcast_pill, so keep it requestless-renderable — no + forms, no current_user. %> +
+ <% agent_sessions.each do |session| %> +
+ + + <%= session.display_status %> +
+ <% end %> +
diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index b6c65384..d1280556 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -39,6 +39,7 @@
<%= render partial: "coplan/plans/header", locals: { plan: @plan, placement: @placement } %>
+ <%= render partial: "coplan/plans/agent_sessions", locals: { agent_sessions: CoPlan::AgentSession.visible.where(plan_id: @plan.id).order(:created_at) } %> <%= render partial: "coplan/plans/viewers", locals: { viewers: CoPlan::PlanViewer.active_viewers_for(@plan), current_user: current_user } %> <%= render partial: "coplan/plans/toolbar", locals: { plan: @plan, placement: @placement } %>
diff --git a/engine/config/routes.rb b/engine/config/routes.rb index 1cbdaf23..e56ed504 100644 --- a/engine/config/routes.rb +++ b/engine/config/routes.rb @@ -155,11 +155,18 @@ resources :sessions, only: [ :create, :show ], controller: "sessions" do post :commit, on: :member end - resources :comments, only: [ :create ], controller: "comments" do + resources :comments, only: [ :create, :show ], controller: "comments" do post :reply, on: :member patch :resolve, on: :member patch :discard, on: :member + # Alias: the agent instructions long documented this action as + # "dismiss" while the route said "discard" — accept both so + # agents following either name succeed. + patch :dismiss, on: :member, action: :discard end + # Presence/state for an agent working this plan (drives the + # "Claude is editing…" pill and subscribes the token to events). + resource :agent_session, only: [ :create, :update, :destroy ], controller: "agent_sessions" # Deletes an individual comment (by comment ID, not thread ID). # Distinct from the routes above, which key off thread ID. delete "comments/:id/delete", to: "comments#destroy", as: :destroy_comment @@ -170,6 +177,13 @@ get :search, on: :collection end + # Agent event inbox — pull-based (long-poll or SSE) so agents on + # laptops behind NAT can hear about comments the moment they land. + scope :agent do + get "events", to: "agent_events#index", as: :agent_events + post "events/ack", to: "agent_events#ack", as: :agent_events_ack + end + # Mint a short-lived session token — the one API call that accepts # the host's request auth alone, since it is how an agent gets the # Bearer token every other call requires. DELETE revokes whichever @@ -179,6 +193,14 @@ end end + # The agent-side scripts, downloadable with curl — the setup section of + # /agent-instructions points agents here. format: false so the ".rb" of + # coplan_session.rb reaches the controller instead of being peeled off + # as a format. Root-level for the same reason as agent-instructions: + # it's a published address. + get "agent-tools/:tool", to: "agent_tools#show", as: :agent_tool, + format: false, constraints: { tool: /[A-Za-z0-9_.-]+/ } + # The published entry point for agents — it's in every API response, in # llms.txt, and in whatever config people have already pasted it into. # Same argument as the API above: an external contract stays where it was. diff --git a/engine/coplan.gemspec b/engine/coplan.gemspec index 9c11e535..7f716c0d 100644 --- a/engine/coplan.gemspec +++ b/engine/coplan.gemspec @@ -9,7 +9,7 @@ Gem::Specification.new do |spec| spec.license = "Apache-2.0" spec.files = Dir.chdir(File.expand_path(__dir__)) do - Dir["{app,config,db,lib,prompts}/**/*", "Rakefile"] + Dir["{agent_tools,app,config,db,lib,prompts}/**/*", "Rakefile"] end spec.add_dependency "rails", ">= 8.0" diff --git a/engine/db/migrate/20260807000000_create_agent_collaboration_tables.rb b/engine/db/migrate/20260807000000_create_agent_collaboration_tables.rb new file mode 100644 index 00000000..341af89f --- /dev/null +++ b/engine/db/migrate/20260807000000_create_agent_collaboration_tables.rb @@ -0,0 +1,58 @@ +class CreateAgentCollaborationTables < ActiveRecord::Migration[8.1] + # Guarded because these objects may already exist on both sides of the + # split this migration heals: databases loaded from schema.rb carry the + # tables (they leaked into the schema via #175's regeneration against a + # dev database), and api_tokens.agent_name ships with the identity + # migration (20260815000000), which runs first on hosts that install + # this one later. + def change + # Durable per-agent event inbox. IDs are UUIDv7 (time-ordered), so the + # id doubles as the pagination cursor: "give me events after ". + unless table_exists?(:coplan_agent_events) + create_table :coplan_agent_events, id: { type: :string, limit: 36 } do |t| + t.string :api_token_id, limit: 36, null: false + t.string :plan_id, limit: 36, null: false + t.string :comment_thread_id, limit: 36 + t.string :comment_id, limit: 36 + t.string :event_type, null: false + t.json :payload + t.datetime :acked_at + t.datetime :created_at, null: false + + t.index [ :api_token_id, :id ] + t.index [ :api_token_id, :acked_at ] + t.index :plan_id + end + + add_foreign_key :coplan_agent_events, :coplan_api_tokens, column: :api_token_id + add_foreign_key :coplan_agent_events, :coplan_plans, column: :plan_id + end + + # One session per (plan, agent token) — Linear-style delegation state + # machine driving the presence pill: pending / active / awaiting_input / + # complete / stale. + unless table_exists?(:coplan_agent_sessions) + create_table :coplan_agent_sessions, id: { type: :string, limit: 36 } do |t| + t.string :plan_id, limit: 36, null: false + t.string :api_token_id, limit: 36, null: false + t.string :agent_name, null: false + t.string :state, null: false, default: "pending" + t.string :state_detail + t.datetime :last_activity_at + t.timestamps + + t.index [ :plan_id, :api_token_id ], unique: true + t.index :api_token_id + end + + add_foreign_key :coplan_agent_sessions, :coplan_api_tokens, column: :api_token_id + add_foreign_key :coplan_agent_sessions, :coplan_plans, column: :plan_id + end + + # Stable display identity for an agent token, instead of the free-text + # per-comment agent_name. Normally added by 20260815000000 already. + unless column_exists?(:coplan_api_tokens, :agent_name) + add_column :coplan_api_tokens, :agent_name, :string + end + end +end diff --git a/engine/db/migrate/20260820000000_add_wake_plumbing_to_agent_sessions.rb b/engine/db/migrate/20260820000000_add_wake_plumbing_to_agent_sessions.rb new file mode 100644 index 00000000..fee221ce --- /dev/null +++ b/engine/db/migrate/20260820000000_add_wake_plumbing_to_agent_sessions.rb @@ -0,0 +1,33 @@ +class AddWakePlumbingToAgentSessions < ActiveRecord::Migration[8.0] + # Guarded like 20260807000000: main's schema.rb has carried leaked + # agent-collab structure before, so never assume a clean slate. + def change + return unless table_exists?(:coplan_agent_sessions) + + # Evidence a connection is actually parked on this session's token — + # SSE heartbeats and long-poll parks touch it. Distinct from + # last_activity_at, which tracks the agent/state machine: a held + # socket must not keep a `pending` promise alive forever. + unless column_exists?(:coplan_agent_sessions, :last_transport_at) + add_column :coplan_agent_sessions, :last_transport_at, :datetime + end + + # How many wakes this session has demonstrably answered (pending → + # an agent-driven state). Zero means the loop is unproven and the + # pill makes no wake promise. + unless column_exists?(:coplan_agent_sessions, :wakes_answered_count) + add_column :coplan_agent_sessions, :wakes_answered_count, :integer, default: 0, null: false + end + + # Webhook wake: a session may register a URL CoPlan POSTs a signed + # "you have inbox items" ping to — the wake path for hosted agents + # that can receive HTTP but can't hold a connection or be resumed. + unless column_exists?(:coplan_agent_sessions, :wake_url) + add_column :coplan_agent_sessions, :wake_url, :string + end + + unless column_exists?(:coplan_agent_sessions, :wake_secret) + add_column :coplan_agent_sessions, :wake_secret, :string + end + end +end diff --git a/engine/db/migrate/20260821000000_add_wake_failures_count_to_agent_sessions.rb b/engine/db/migrate/20260821000000_add_wake_failures_count_to_agent_sessions.rb new file mode 100644 index 00000000..b784bbda --- /dev/null +++ b/engine/db/migrate/20260821000000_add_wake_failures_count_to_agent_sessions.rb @@ -0,0 +1,10 @@ +class AddWakeFailuresCountToAgentSessions < ActiveRecord::Migration[8.0] + def change + return unless table_exists?(:coplan_agent_sessions) + return if column_exists?(:coplan_agent_sessions, :wake_failures_count) + + # Exhausted wake-webhook delivery runs since the last success; the + # URL is unregistered once this crosses WakeWebhookJob::MAX_EXHAUSTIONS. + add_column :coplan_agent_sessions, :wake_failures_count, :integer, default: 0, null: false + end +end diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index c59c0a37..380c965f 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -98,6 +98,19 @@ class Configuration # } attr_accessor :directory_profile + # Lambda deciding whether the server may POST wake pings to a given + # URL. Receives a URI, returns truthy to allow. When nil (default), + # CoPlan::WakeUrlPolicy applies: the host must resolve entirely to + # public address space — loopback, RFC1918, link-local (cloud + # metadata), and friends are refused. Deployments that wake agents on + # an internal network (or dev, waking localhost) override this: + # + # config.wake_url_policy = ->(uri) { uri.host.end_with?(".internal.example.com") } + # + # Checked both when a session registers the URL and again before every + # POST, because DNS is allowed to change its mind in between. + attr_accessor :wake_url_policy + # Library handles a host wants to keep out of user hands, on top of # CoPlan::Library::RESERVED_HANDLES. Handles live under /, # so they can't collide with the host's own routes — this is for @@ -108,6 +121,7 @@ class Configuration # config.reserved_handles = %w[square block official] attr_accessor :reserved_handles + def initialize @authenticate = nil @reserved_handles = [] @@ -120,6 +134,7 @@ def initialize @onboarding_banner = 'Want to upload Agentic plans? Give your agent these instructions.' @agent_curl_prefix = 'curl -s -H "Authorization: Bearer $TOKEN"' @seed_plan_types = [] + @wake_url_policy = nil @landing_page_partial = "coplan/welcome/default_landing" @landing_agents_partial = "coplan/welcome/default_agents" @agent_auth_instructions = <<~MARKDOWN diff --git a/script/coplan b/script/coplan new file mode 100755 index 00000000..29a15f02 --- /dev/null +++ b/script/coplan @@ -0,0 +1,123 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# coplan — every CoPlan API call an agent needs, already authenticated. +# +# The point is that the token never passes through the agent. It lives in +# a session file (see script/coplan_session.rb), this script reads it, and +# nothing secret appears in a command line, a transcript, or a log. +# +# script/coplan whoami +# script/coplan get /api/v1/plans/$PLAN/snapshot +# script/coplan post /api/v1/plans/$PLAN/comments '{"body_markdown":"…"}' +# script/coplan reply $PLAN $THREAD "on it — proposing a fix below" +# script/coplan say $PLAN "new thread on the whole doc" +# +# Session control: +# +# script/coplan session show this run's token identity (never the secret) +# script/coplan session --new force a fresh token +# script/coplan session --revoke revoke it server-side and forget it +# +# Setup is one long-lived token in the environment; everything below +# mints and reuses a short-lived child of it, sticky for this agent run: +# +# export COPLAN_BASE=http://localhost:3223 +# export COPLAN_TOKEN= + +require "json" +require "net/http" +require "uri" +require_relative "coplan_session" + +$stdout.sync = true + +BASE = URI(ENV["COPLAN_BASE"] || "http://localhost:3000") +PARENT = ENV["COPLAN_TOKEN"] +AGENT_NAME = ENV["COPLAN_AGENT_NAME"] || "Agent" + +abort "coplan: set COPLAN_TOKEN (a long-lived token from Settings → API tokens)" if PARENT.to_s.empty? + +def token(force: false) + @token = nil if force + @token ||= CoPlanSession.ensure_token(base: BASE, parent: PARENT, agent_name: AGENT_NAME, force: force) +end + +def request(method, path, body = nil, retried: false) + # Concatenation, not URI.join: an absolute path in URI.join discards + # the base's mount prefix, and CoPlan engines may be mounted under one. + uri = URI("#{BASE.to_s.chomp("/")}#{path}") + klass = { + "get" => Net::HTTP::Get, "post" => Net::HTTP::Post, + "patch" => Net::HTTP::Patch, "put" => Net::HTTP::Put, "delete" => Net::HTTP::Delete + }.fetch(method.to_s.downcase) { abort "coplan: unknown method #{method}" } + + req = klass.new(uri) + req["Authorization"] = "Bearer #{token}" + req["Content-Type"] = "application/json" + req.body = body.is_a?(String) ? body : body.to_json if body + + res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) } + + # A stored token can be revoked out from under us (or its parent can be). + # Mint a new one once rather than making the agent debug an auth error. + if res.code == "401" && !retried + CoPlanSession.forget(CoPlanSession.session_key) + token(force: true) + return request(method, path, body, retried: true) + end + + warn "coplan: #{method.upcase} #{path} -> #{res.code}" unless res.code.start_with?("2") + puts res.body unless res.body.to_s.empty? + exit(res.code.start_with?("2") ? 0 : 1) +end + +command = ARGV.shift + +case command +when "get", "post", "patch", "put", "delete" + path = ARGV.shift or abort "coplan: #{command} needs a path" + request(command, path, ARGV.shift) + +when "reply" + plan, thread, *rest = ARGV + abort "coplan: reply PLAN_ID THREAD_ID BODY" unless plan && thread && rest.any? + request("post", "/api/v1/plans/#{plan}/comments/#{thread}/reply", { body_markdown: rest.join(" ") }) + +when "say" + plan, *rest = ARGV + abort "coplan: say PLAN_ID BODY [--anchor TEXT]" unless plan && rest.any? + anchor = nil + if (i = rest.index("--anchor")) + anchor = rest[i + 1] + rest = rest[0...i] + end + request("post", "/api/v1/plans/#{plan}/comments", + { body_markdown: rest.join(" "), anchor_text: anchor }.compact) + +when "session" + if ARGV.include?("--revoke") + CoPlanSession.revoke(base: BASE) + puts "revoked and forgotten" + exit 0 + end + token(force: ARGV.include?("--new")) + key = CoPlanSession.session_key + record = CoPlanSession.read(key) || {} + # Deliberately not the token itself. + puts JSON.pretty_generate({ + "session_key" => key, + "token_id" => record["id"], + "agent_name" => record["agent_name"], + "expires_at" => record["expires_at"], + "stored_at" => CoPlanSession.path(key), + "minted" => record.key?("id") + }) + +when "whoami" + request("get", "/api/v1/plans?limit=1") + +else + puts File.read(__FILE__).lines[2..27].map { |l| l.sub(/^# ?/, "") }.join + exit(command.nil? ? 0 : 1) +end diff --git a/script/coplan-attach b/script/coplan-attach new file mode 100755 index 00000000..1600fd46 --- /dev/null +++ b/script/coplan-attach @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Thin shim: the real script lives in the engine (engine/agent_tools/) so +# any CoPlan server can serve it to agents at /agent-tools/coplan-attach. +# This keeps `script/coplan-attach` working for local development. +load File.expand_path("../engine/agent_tools/coplan-attach", __dir__) diff --git a/script/coplan-bridge b/script/coplan-bridge new file mode 100755 index 00000000..6f558828 --- /dev/null +++ b/script/coplan-bridge @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Thin shim: the real script lives in the engine (engine/agent_tools/) so +# any CoPlan server can serve it to agents at /agent-tools/coplan-bridge. +# This keeps `script/coplan-bridge` working for local development. +load File.expand_path("../engine/agent_tools/coplan-bridge", __dir__) diff --git a/script/coplan_session.rb b/script/coplan_session.rb new file mode 100644 index 00000000..96b3a9e4 --- /dev/null +++ b/script/coplan_session.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Thin shim: the real helper lives in the engine (engine/agent_tools/) so +# any CoPlan server can serve it to agents at +# /agent-tools/coplan_session.rb alongside coplan-attach, which requires +# it. This keeps `require_relative "coplan_session"` working for the +# scripts in this directory. +require_relative "../engine/agent_tools/coplan_session" diff --git a/spec/jobs/coplan/mark_stale_agent_session_job_spec.rb b/spec/jobs/coplan/mark_stale_agent_session_job_spec.rb new file mode 100644 index 00000000..5d2d008b --- /dev/null +++ b/spec/jobs/coplan/mark_stale_agent_session_job_spec.rb @@ -0,0 +1,79 @@ +require "rails_helper" + +RSpec.describe CoPlan::MarkStaleAgentSessionJob, type: :job do + let(:user) { create(:coplan_user) } + let(:plan) { create(:plan, created_by_user: user) } + let(:token) { CoPlan::ApiToken.create_with_raw_token(user: user, name: "agent", agent_name: "Claude").first } + + def session(state:, last_activity_at:) + CoPlan::AgentSession.create!( + plan: plan, api_token: token, agent_name: "Claude", + state: state, last_activity_at: last_activity_at + ) + end + + it "flips a pending session nothing answered to stale" do + record = session(state: "pending", last_activity_at: 1.minute.ago) + + described_class.new.perform(agent_session_id: record.id, woken_at: record.last_activity_at.iso8601(6)) + + expect(record.reload.state).to eq("stale") + end + + it "leaves a session the agent answered after the wake" do + record = session(state: "pending", last_activity_at: 1.minute.ago) + woken_at = record.last_activity_at.iso8601(6) + record.update!(last_activity_at: Time.current) + + described_class.new.perform(agent_session_id: record.id, woken_at: woken_at) + + expect(record.reload.state).to eq("pending") + end + + # Regression: the column is datetime(6). A second-truncated woken_at + # made the wake's own timestamp read as "activity after the wake" + # whenever the wake landed mid-second — the job never fired, and the + # 30-second promise behind the pending pill was a dead letter. + it "fires even when the wake landed mid-second" do + wake_time = Time.zone.parse("2026-08-20 12:00:00.654321") + record = session(state: "pending", last_activity_at: wake_time) + + # With plain .iso8601 this argument would arrive as 12:00:00 — strictly + # before the stored .654321, making the wake's own timestamp read as + # "activity after the wake" and the early return swallow the job. + described_class.new.perform(agent_session_id: record.id, woken_at: wake_time.iso8601(6)) + + expect(record.reload.state).to eq("stale") + end + + it "leaves sessions that already moved on" do + record = session(state: "active", last_activity_at: 1.minute.ago) + + described_class.new.perform(agent_session_id: record.id, woken_at: record.last_activity_at.iso8601(6)) + + expect(record.reload.state).to eq("active") + end + + it "tolerates the session being gone" do + expect { + described_class.new.perform(agent_session_id: 0, woken_at: Time.current.iso8601(6)) + }.not_to raise_error + end + + it "is scheduled by wake! with a full-precision timestamp" do + record = session(state: "watching", last_activity_at: Time.zone.parse("2026-08-20 12:00:00.654321")) + + expect(CoPlan::MarkStaleAgentSessionJob).to receive(:set).with(wait: CoPlan::AgentSession::STALE_AFTER) do + double.tap do |scheduled| + expect(scheduled).to receive(:perform_later) do |agent_session_id:, woken_at:| + expect(agent_session_id).to eq(record.id) + # Microseconds must survive the trip into job arguments, or the + # comparison against a datetime(6) column silently breaks. + expect(Time.iso8601(woken_at)).to eq(record.reload.last_activity_at) + end + end + end + + record.wake! + end +end diff --git a/spec/jobs/coplan/wake_webhook_job_spec.rb b/spec/jobs/coplan/wake_webhook_job_spec.rb new file mode 100644 index 00000000..b4c7a072 --- /dev/null +++ b/spec/jobs/coplan/wake_webhook_job_spec.rb @@ -0,0 +1,153 @@ +require "rails_helper" + +RSpec.describe CoPlan::WakeWebhookJob, type: :job do + include ActiveJob::TestHelper + + let(:user) { create(:coplan_user) } + let(:plan) { create(:plan, created_by_user: user) } + let(:token) { create(:api_token, user: user, raw_token: "wake-test-token", agent_name: "Orb") } + let(:session) do + CoPlan::AgentSession.create!( + plan_id: plan.id, api_token_id: token.id, agent_name: "Orb", state: "complete", + wake_url: "https://agents.example.com/hooks/wake", wake_secret: "s3cret" + ) + end + let(:event) do + CoPlan::AgentEvent.create!( + api_token_id: token.id, plan_id: plan.id, event_type: "comment.created", payload: {} + ) + end + + def deliver(code: "204") + http = instance_double(Net::HTTP) + response = instance_double(Net::HTTPResponse, code: code) + captured = nil + allow(Net::HTTP).to receive(:start) { |*_args, **_opts, &blk| blk.call(http) } + allow(http).to receive(:request) { |req| captured = req; response } + + described_class.new.perform(agent_session_id: session.id, agent_event_id: event.id) + captured + end + + it "pins the connection to the address the policy vetted" do + # The test env's permissive custom policy vets URIs, not addresses, so + # exercise the engine default: resolve once, connect to that answer. + original = CoPlan.configuration.wake_url_policy + CoPlan.configuration.wake_url_policy = nil + allow(Resolv).to receive(:getaddresses).with("agents.example.com").and_return([ "93.184.216.34" ]) + + http = instance_double(Net::HTTP) + response = instance_double(Net::HTTPResponse, code: "204") + allow(http).to receive(:request).and_return(response) + captured_options = nil + allow(Net::HTTP).to receive(:start) { |*_args, **opts, &blk| captured_options = opts; blk.call(http) } + + described_class.new.perform(agent_session_id: session.id, agent_event_id: event.id) + + # A rebinding host could answer the policy check publicly and the + # connection privately; pinning closes that window. + expect(captured_options[:ipaddr]).to eq("93.184.216.34") + ensure + CoPlan.configuration.wake_url_policy = original + end + + it "posts a signed ping, not the event payload" do + request = deliver + + expect(request.path).to eq("/hooks/wake") + body = JSON.parse(request.body) + # A ping tells the agent to come pull its inbox — the payload (and its + # authority context) stays behind the authenticated cursor API. + expect(body.keys).to contain_exactly("event_id", "event_type", "plan_id", "inbox") + expect(body["event_id"]).to eq(event.id) + + expected = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", "s3cret", request.body)}" + expect(request["X-CoPlan-Signature"]).to eq(expected) + expect(request["X-CoPlan-Event-Id"]).to eq(event.id) + end + + it "raises a retryable error when the receiver is unhappy" do + expect { deliver(code: "503") }.to raise_error(described_class::DeliveryFailed) + end + + # The URL can carry a capability token in its path, and DeliveryFailed + # messages land in logs and solid_queue_failed_executions. + it "keeps the wake URL out of error messages" do + expect { deliver(code: "503") }.to raise_error(described_class::DeliveryFailed) do |error| + expect(error.message).not_to include("agents.example.com") + expect(error.message).not_to include("/hooks/wake") + end + end + + it "wraps a connection torn down mid-response, not just clean refusals" do + allow(Net::HTTP).to receive(:start).and_raise(EOFError) + + expect { + described_class.new.perform(agent_session_id: session.id, agent_event_id: event.id) + }.to raise_error(described_class::DeliveryFailed) do |error| + expect(error.message).not_to include("agents.example.com") + end + end + + it "skips (without retrying) a URL the egress policy refuses" do + session; event # materialize before the stub — registration passed, policy tightened since + allow(CoPlan::WakeUrlPolicy).to receive(:vetted_addresses).and_return(nil) + + expect(Net::HTTP).not_to receive(:start) + expect { + described_class.new.perform(agent_session_id: session.id, agent_event_id: event.id) + }.not_to raise_error + end + + it "resets the failure count on a successful delivery" do + session.update!(wake_failures_count: 2) + + deliver + + expect(session.reload.wake_failures_count).to eq(0) + end + + describe "the dead-URL circuit breaker" do + def exhaust_retries + http = instance_double(Net::HTTP) + response = instance_double(Net::HTTPResponse, code: "503") + allow(Net::HTTP).to receive(:start) { |*_args, **_opts, &blk| blk.call(http) } + allow(http).to receive(:request).and_return(response) + + perform_enqueued_jobs do + described_class.perform_later(agent_session_id: session.id, agent_event_id: event.id) + end + end + + it "counts an exhausted retry run without unregistering yet" do + exhaust_retries + + expect(session.reload.wake_failures_count).to eq(1) + expect(session.wake_url).to be_present + end + + it "unregisters a URL that keeps eating whole retry ladders" do + session.update!(wake_failures_count: described_class::MAX_EXHAUSTIONS - 1) + + exhaust_retries + + session.reload + expect(session.wake_url).to be_nil + expect(session.wake_secret).to be_nil + end + end + + it "skips events already processed via another transport" do + event.update!(acked_at: Time.current) + + expect(Net::HTTP).not_to receive(:start) + described_class.new.perform(agent_session_id: session.id, agent_event_id: event.id) + end + + it "does nothing once the session unregistered" do + session.update!(wake_url: nil, wake_secret: nil) + + expect(Net::HTTP).not_to receive(:start) + described_class.new.perform(agent_session_id: session.id, agent_event_id: event.id) + end +end diff --git a/spec/models/agent_event_bus_spec.rb b/spec/models/agent_event_bus_spec.rb new file mode 100644 index 00000000..65246a3c --- /dev/null +++ b/spec/models/agent_event_bus_spec.rb @@ -0,0 +1,76 @@ +require "rails_helper" + +RSpec.describe CoPlan::AgentEventBus do + subject(:bus) { described_class.new(capacity: 2) } + + describe "#with_slot" do + it "grants slots up to capacity and refuses beyond it" do + bus.with_slot do |first| + bus.with_slot do |second| + bus.with_slot do |third| + expect([ first, second, third ]).to eq([ true, true, false ]) + end + end + end + end + + it "releases the slot when the block finishes" do + bus.with_slot { |granted| expect(granted).to be(true) } + bus.with_slot { |granted| expect(granted).to be(true) } + + expect(bus.held).to eq(0) + end + + it "releases the slot even when the block raises" do + expect { bus.with_slot { raise "boom" } }.to raise_error("boom") + + expect(bus.held).to eq(0) + end + end + + describe "#wait / #signal" do + it "returns as soon as the key is signalled rather than sleeping it out" do + started = Time.current + waiter = Thread.new { bus.wait("token-1", timeout: 5) } + + # Give the waiter a moment to actually block before signalling. + sleep 0.05 until bus.instance_variable_get(:@waiter_counts)["token-1"] == 1 + bus.signal("token-1") + waiter.join(5) + + expect(Time.current - started).to be < 2 + end + + it "does not block when the timeout has already elapsed" do + expect { bus.wait("token-1", timeout: 0) }.not_to raise_error + end + + it "wakes up on its own to catch out-of-process writes" do + started = Time.current + bus.wait("nobody-will-signal", timeout: 30) + + # Bounded by CROSS_PROCESS_INTERVAL, not by the caller's timeout. + expect(Time.current - started).to be < described_class::CROSS_PROCESS_INTERVAL + 2 + end + end + + describe ".default_capacity" do + it "reserves threads for ordinary web traffic" do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("COPLAN_MAX_AGENT_STREAMS").and_return(nil) + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with("RAILS_MAX_THREADS", 3).and_return("10") + + expect(described_class.default_capacity).to eq(10 - described_class::RESERVED_THREADS) + end + + it "never drops below one slot" do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("COPLAN_MAX_AGENT_STREAMS").and_return(nil) + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with("RAILS_MAX_THREADS", 3).and_return("1") + + expect(described_class.default_capacity).to eq(1) + end + end +end diff --git a/spec/models/agent_session_spec.rb b/spec/models/agent_session_spec.rb new file mode 100644 index 00000000..d4977139 --- /dev/null +++ b/spec/models/agent_session_spec.rb @@ -0,0 +1,216 @@ +require "rails_helper" + +RSpec.describe CoPlan::AgentSession, type: :model do + let(:user) { create(:coplan_user) } + let(:plan) { create(:plan, created_by_user: user) } + let(:api_token) { CoPlan::ApiToken.create_with_raw_token(user: user, name: "agent", agent_name: "Claude").first } + + def session(state:, last_activity_at:, **extra) + described_class.create!( + plan: plan, api_token: api_token, agent_name: "Claude", + state: state, last_activity_at: last_activity_at, **extra + ) + end + + describe ".visible" do + # Staleness is computed at read time so a dead agent process — or a + # job worker that never ran MarkStaleAgentSessionJob — can't leave a + # ghost pill promising an agent that isn't coming. + it "hides a pending session whose agent never reacted" do + session(state: "pending", last_activity_at: 2.minutes.ago) + + expect(described_class.visible).to be_empty + end + + it "shows a pending session that was just woken" do + session(state: "pending", last_activity_at: 5.seconds.ago) + + expect(described_class.visible.count).to eq(1) + end + + it "keeps an active session visible through a long turn" do + session(state: "active", last_activity_at: 2.minutes.ago) + + expect(described_class.visible.count).to eq(1) + end + + it "hides an active session whose process died" do + session(state: "active", last_activity_at: 10.minutes.ago) + + expect(described_class.visible).to be_empty + end + + it "keeps a watching session visible while its stream is alive" do + session(state: "watching", last_activity_at: 30.seconds.ago) + + expect(described_class.visible.count).to eq(1) + end + + it "hides a watching session once the stream stops heartbeating" do + session(state: "watching", last_activity_at: 5.minutes.ago) + + expect(described_class.visible).to be_empty + end + + it "hides completed sessions regardless of recency" do + session(state: "complete", last_activity_at: 1.second.ago) + + expect(described_class.visible).to be_empty + end + end + + describe "#stale?" do + it "falls back to updated_at when last_activity_at is missing" do + record = session(state: "pending", last_activity_at: nil) + + expect(record.stale?).to be(false) + end + end + + describe "#display_status" do + # Wakeability is demonstrated, never declared: until this session has + # answered a wake, a delivery is quietly a test and the pill keeps + # plain presence. + it "keeps plain presence through an unproven wake" do + record = session(state: "pending", last_activity_at: 5.seconds.ago) + + expect(record.display_status).to eq("Claude") + end + + it "promises the wake once one has been answered before" do + record = session(state: "pending", last_activity_at: 5.seconds.ago, wakes_answered_count: 1) + + expect(record.display_status).to eq("Waking Claude…") + end + + it "lets only the agent's own active state claim work" do + record = session(state: "active", last_activity_at: 5.seconds.ago) + + expect(record.display_status).to eq("Claude is working…") + end + end + + describe "wake proof" do + # The one observable proof that delivery became a model turn is the + # agent moving itself out of `pending`. + it "counts an agent-driven exit from pending as an answered wake" do + record = session(state: "pending", last_activity_at: 5.seconds.ago) + + record.transition!("active", detail: "reading your comment") + + expect(record.wakes_answered_count).to eq(1) + expect(record).to be_wake_proven + end + + it "does not count the server declaring the wake dead" do + record = session(state: "pending", last_activity_at: 5.seconds.ago) + + record.transition!("stale") + + expect(record.wakes_answered_count).to eq(0) + end + + it "does not count transitions that never left pending behind" do + record = session(state: "watching", last_activity_at: 5.seconds.ago) + + record.transition!("active") + + expect(record.wakes_answered_count).to eq(0) + end + + # Detach paths file `complete` mechanically (coplan-attach's at_exit), + # and supervising loops re-claim `watching` on restart — neither says a + # model turned the wake into work, so neither may prove wakeability. + it "does not count a mechanical detach out of pending" do + record = session(state: "pending", last_activity_at: 5.seconds.ago) + + record.transition!("complete") + + expect(record.wakes_answered_count).to eq(0) + end + + it "does not count a watcher re-claim out of pending" do + record = session(state: "pending", last_activity_at: 5.seconds.ago) + + record.transition!("watching") + + expect(record.wakes_answered_count).to eq(0) + end + end + + describe "wake_url validation" do + around do |example| + # The test env installs a permissive policy; these examples exercise + # how the model consults whatever policy is configured. + original = CoPlan.configuration.wake_url_policy + example.run + ensure + CoPlan.configuration.wake_url_policy = original + end + + it "refuses a URL the egress policy rejects" do + CoPlan.configuration.wake_url_policy = ->(uri) { false } + + record = described_class.new( + plan: plan, api_token: api_token, agent_name: "Claude", + state: "complete", wake_url: "https://agents.example.com/wake" + ) + + expect(record).not_to be_valid + expect(record.errors[:wake_url]).to be_present + end + + it "does not re-resolve an unchanged URL on state transitions" do + record = session(state: "pending", last_activity_at: 5.seconds.ago, + wake_url: "https://agents.example.com/wake") + # Policy tightens after registration: existing sessions must still be + # able to move through their state machine (the webhook job is the + # enforcement point at delivery time). + CoPlan.configuration.wake_url_policy = ->(uri) { raise "resolved during transition" } + + expect { record.transition!("active") }.not_to raise_error + end + end + + describe "parent cleanup" do + # Both collaboration tables FK the plan and the token; without the + # delete_all associations, destroying either parent raises. + it "is deleted with its plan, along with the plan's inbox rows" do + session(state: "watching", last_activity_at: Time.current) + CoPlan::AgentEvent.create!(api_token_id: api_token.id, plan_id: plan.id, event_type: "comment.created", payload: {}) + + # The current-version self-reference has to be detached before any + # plan can be destroyed (pre-existing, unrelated to agent rows) — + # this example is about the agent tables not blocking the delete. + plan.update_columns(current_plan_version_id: nil) + expect { plan.destroy! }.not_to raise_error + expect(described_class.where(plan_id: plan.id)).to be_empty + expect(CoPlan::AgentEvent.where(plan_id: plan.id)).to be_empty + end + + it "is deleted with its token, along with the token's inbox rows" do + session(state: "watching", last_activity_at: Time.current) + CoPlan::AgentEvent.create!(api_token_id: api_token.id, plan_id: plan.id, event_type: "comment.created", payload: {}) + + expect { api_token.destroy! }.not_to raise_error + expect(described_class.where(api_token_id: api_token.id)).to be_empty + expect(CoPlan::AgentEvent.where(api_token_id: api_token.id)).to be_empty + end + end + + describe "#transport_connected?" do + it "is true within the transport window" do + record = session(state: "watching", last_activity_at: Time.current, last_transport_at: 30.seconds.ago) + + expect(record.transport_connected?).to be(true) + end + + it "is false once the window lapses" do + expect(session(state: "watching", last_activity_at: Time.current, last_transport_at: 2.minutes.ago).transport_connected?).to be(false) + end + + it "is false without any touch at all" do + expect(session(state: "watching", last_activity_at: Time.current).transport_connected?).to be(false) + end + end +end diff --git a/spec/policies/coplan/wake_url_policy_spec.rb b/spec/policies/coplan/wake_url_policy_spec.rb new file mode 100644 index 00000000..1e953d43 --- /dev/null +++ b/spec/policies/coplan/wake_url_policy_spec.rb @@ -0,0 +1,92 @@ +require "rails_helper" + +RSpec.describe CoPlan::WakeUrlPolicy do + around do |example| + # The test env installs a permissive policy; nil it out so these + # examples exercise the engine default. + original = CoPlan.configuration.wake_url_policy + CoPlan.configuration.wake_url_policy = nil + example.run + ensure + CoPlan.configuration.wake_url_policy = original + end + + def allowed_for(addresses) + allow(Resolv).to receive(:getaddresses).with("agents.example.com").and_return(addresses) + described_class.allowed?(URI.parse("https://agents.example.com/wake")) + end + + it "allows a host resolving to public address space" do + expect(allowed_for([ "93.184.216.34" ])).to be(true) + end + + it "refuses loopback" do + expect(allowed_for([ "127.0.0.1" ])).to be(false) + end + + it "refuses RFC1918 space" do + expect(allowed_for([ "10.1.2.3" ])).to be(false) + expect(allowed_for([ "172.16.0.9" ])).to be(false) + expect(allowed_for([ "192.168.1.1" ])).to be(false) + end + + it "refuses the cloud metadata address" do + expect(allowed_for([ "169.254.169.254" ])).to be(false) + end + + it "refuses IPv6 loopback and private ranges" do + expect(allowed_for([ "::1" ])).to be(false) + expect(allowed_for([ "fd00::1" ])).to be(false) + expect(allowed_for([ "fe80::1" ])).to be(false) + end + + # One public A record must not launder a private one — the attacker + # controls the DNS answer, and the POST goes wherever it points. + it "refuses a host with any private address among its answers" do + expect(allowed_for([ "93.184.216.34", "10.0.0.5" ])).to be(false) + end + + it "refuses a host that does not resolve at all" do + expect(allowed_for([])).to be(false) + end + + it "refuses when resolution errors" do + allow(Resolv).to receive(:getaddresses).and_raise(Resolv::ResolvError) + + expect(described_class.allowed?(URI.parse("https://agents.example.com/wake"))).to be(false) + end + + it "refuses an unparseable resolver answer rather than excusing it" do + expect(allowed_for([ "not-an-address" ])).to be(false) + end + + it "defers entirely to a configured host policy" do + CoPlan.configuration.wake_url_policy = ->(uri) { uri.host == "trusted.internal" } + + expect(described_class.allowed?(URI.parse("http://trusted.internal/wake"))).to be(true) + expect(described_class.allowed?(URI.parse("http://other.internal/wake"))).to be(false) + end + + describe ".vetted_addresses" do + # Callers that go on to connect must pin to one of these — resolving + # again at connect time reopens the rebinding window. + it "returns the vetted addresses for a public host" do + allow(Resolv).to receive(:getaddresses).with("agents.example.com").and_return([ "93.184.216.34" ]) + + expect(described_class.vetted_addresses(URI.parse("https://agents.example.com/wake"))) + .to eq([ "93.184.216.34" ]) + end + + it "returns nil for a refused host" do + allow(Resolv).to receive(:getaddresses).with("agents.example.com").and_return([ "10.0.0.5" ]) + + expect(described_class.vetted_addresses(URI.parse("https://agents.example.com/wake"))).to be_nil + end + + it "returns :unpinned when a custom policy allows — it vets URIs, not addresses" do + CoPlan.configuration.wake_url_policy = ->(_uri) { true } + + expect(described_class.vetted_addresses(URI.parse("http://trusted.internal/wake"))).to eq(:unpinned) + end + end +end diff --git a/spec/requests/agent_tools_spec.rb b/spec/requests/agent_tools_spec.rb new file mode 100644 index 00000000..5e1896bf --- /dev/null +++ b/spec/requests/agent_tools_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +RSpec.describe "Agent tools", type: :request do + # The scripts are the front door for a new agent: downloadable with + # nothing but curl, no auth — same posture as /agent-instructions. + describe "GET /agent-tools/:tool" do + it "serves the attach script" do + get "/agent-tools/coplan-attach" + + expect(response).to have_http_status(:ok) + expect(response.content_type).to start_with("text/plain") + expect(response.body).to start_with("#!/usr/bin/env ruby") + expect(response.body).to include("--once") + end + + it "serves the session helper the attach script requires" do + get "/agent-tools/coplan_session.rb" + + expect(response).to have_http_status(:ok) + expect(response.body).to include("CoPlanSession") + end + + it "serves the bridge" do + get "/agent-tools/coplan-bridge" + + expect(response).to have_http_status(:ok) + expect(response.body).to include("--acp") + end + + it "refuses anything off the whitelist" do + get "/agent-tools/unknown-tool" + + expect(response).to have_http_status(:not_found) + end + + it "never treats the tool name as a path" do + # A bare ".." satisfies the route constraint, so it reaches the + # controller — the whitelist is what stops it. + get "/agent-tools/.." + expect(response).to have_http_status(:not_found) + + # Encoded slashes knock the request out of the agent-tools route + # entirely; the browse catchall picks it up like any garbage + # address. All that matters is that no file leaves the server. + get "/agent-tools/..%2F..%2Fconfig%2Froutes.rb" + expect(response).not_to have_http_status(:ok) + expect(response.body).not_to include("Engine.routes.draw") + end + end +end diff --git a/spec/requests/api/v1/agent_events_spec.rb b/spec/requests/api/v1/agent_events_spec.rb new file mode 100644 index 00000000..9edfb364 --- /dev/null +++ b/spec/requests/api/v1/agent_events_spec.rb @@ -0,0 +1,530 @@ +require "rails_helper" + +RSpec.describe "Api::V1::AgentEvents", type: :request do + let(:hampton) { create(:coplan_user, :admin) } + let(:agent_token) { create(:api_token, user: hampton, raw_token: "test-token-agent", agent_name: "Claude") } + let(:agent_headers) { { "Authorization" => "Bearer test-token-agent" } } + let(:other_token) { create(:api_token, user: hampton, raw_token: "test-token-other", agent_name: "Amp") } + let(:other_headers) { { "Authorization" => "Bearer test-token-other" } } + let(:plan) { create(:plan, :considering, created_by_user: hampton) } + + before do + agent_token + other_token + end + + describe "agent sessions" do + it "claims a session, drives states, and rejects bogus states" do + post api_v1_plan_agent_session_path(plan), params: { agent_name: "Claude" }, headers: agent_headers, as: :json + expect(response).to have_http_status(:created) + body = JSON.parse(response.body) + # Claiming means "attached", not "working" — the pill must not + # advertise activity that isn't happening. + expect(body["state"]).to eq("watching") + expect(body["agent_name"]).to eq("Claude") + + patch api_v1_plan_agent_session_path(plan), params: { state: "awaiting_input", detail: "asked about rollout" }, headers: agent_headers, as: :json + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)["state"]).to eq("awaiting_input") + + patch api_v1_plan_agent_session_path(plan), params: { state: "stale" }, headers: agent_headers, as: :json + expect(response).to have_http_status(:unprocessable_content) + end + + it "falls back to the token's agent_name when none is passed" do + post api_v1_plan_agent_session_path(plan), headers: agent_headers, as: :json + expect(JSON.parse(response.body)["agent_name"]).to eq("Claude") + end + + it "can claim straight into a working state when the caller means it" do + post api_v1_plan_agent_session_path(plan), params: { state: "active", detail: "reading your comment" }, headers: agent_headers, as: :json + + body = JSON.parse(response.body) + expect(body["state"]).to eq("active") + expect(body["state_detail"]).to eq("reading your comment") + end + + # A fresh claim into `awaiting_input` would park an "asked a question" + # pill for up to an hour on a session that never did anything — turn + # states have to be earned from inside the loop, via PATCH. + it "refuses to claim a turn state on arrival" do + post api_v1_plan_agent_session_path(plan), params: { state: "awaiting_input" }, headers: agent_headers, as: :json + + expect(response).to have_http_status(:unprocessable_content) + expect(CoPlan::AgentSession.where(plan_id: plan.id)).to be_empty + end + + # The server concludes `pending` (wake delivered) and `stale` (wake + # ignored) about the agent; an agent asserting either about itself + # would make the pill lie. + it "rejects server-owned states via PATCH" do + post api_v1_plan_agent_session_path(plan), headers: agent_headers, as: :json + + patch api_v1_plan_agent_session_path(plan), params: { state: "pending" }, headers: agent_headers, as: :json + expect(response).to have_http_status(:unprocessable_content) + end + + it "reads as presence rather than activity while watching" do + post api_v1_plan_agent_session_path(plan), params: { agent_name: "Claude" }, headers: agent_headers, as: :json + + session = CoPlan::AgentSession.find_by(plan_id: plan.id, api_token_id: agent_token.id) + expect(session.display_status).to eq("Claude") + end + + it "does not erase awaiting_input when the agent reattaches" do + post api_v1_plan_agent_session_path(plan), headers: agent_headers, as: :json + patch api_v1_plan_agent_session_path(plan), params: { state: "awaiting_input", detail: "asked about rollout" }, headers: agent_headers, as: :json + + post api_v1_plan_agent_session_path(plan), headers: agent_headers, as: :json + + body = JSON.parse(response.body) + expect(body["state"]).to eq("awaiting_input") + expect(body["state_detail"]).to eq("asked about rollout") + end + + it "moves a watching session to pending when an event arrives" do + post api_v1_plan_agent_session_path(plan), headers: agent_headers, as: :json + session = CoPlan::AgentSession.find_by(plan_id: plan.id, api_token_id: agent_token.id) + + session.wake! + + expect(session.reload.state).to eq("pending") + end + + it "is idempotent per (plan, token)" do + 2.times { post api_v1_plan_agent_session_path(plan), headers: agent_headers, as: :json } + expect(CoPlan::AgentSession.where(plan_id: plan.id, api_token_id: agent_token.id).count).to eq(1) + end + + describe "wake URL registration" do + it "mints the signing secret once and never re-sends it" do + post api_v1_plan_agent_session_path(plan), params: { wake_url: "https://agents.example.com/wake" }, headers: agent_headers, as: :json + + first = JSON.parse(response.body) + expect(first["wake_url"]).to eq("https://agents.example.com/wake") + expect(first["wake_secret"]).to be_present + + # Re-claiming must not churn the secret — the receiver verified + # signatures against the one it was given at registration. + post api_v1_plan_agent_session_path(plan), headers: agent_headers, as: :json + again = JSON.parse(response.body) + expect(again["wake_url"]).to eq("https://agents.example.com/wake") + expect(again).not_to have_key("wake_secret") + + session = CoPlan::AgentSession.find_by(plan_id: plan.id, api_token_id: agent_token.id) + expect(session.wake_secret).to eq(first["wake_secret"]) + end + + it "unregisters (and burns the secret) when the URL is cleared" do + post api_v1_plan_agent_session_path(plan), params: { wake_url: "https://agents.example.com/wake" }, headers: agent_headers, as: :json + post api_v1_plan_agent_session_path(plan), params: { wake_url: "" }, headers: agent_headers, as: :json + + session = CoPlan::AgentSession.find_by(plan_id: plan.id, api_token_id: agent_token.id) + expect(session.wake_url).to be_nil + expect(session.wake_secret).to be_nil + end + + it "rejects a wake URL that is not http(s)" do + post api_v1_plan_agent_session_path(plan), params: { wake_url: "file:///etc/passwd" }, headers: agent_headers, as: :json + + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe "event fan-out" do + let!(:session) { create_agent_collab_session(agent_token) } + let(:thread) { create(:comment_thread, plan: plan, plan_version: plan.current_plan_version, created_by_user: hampton) } + let(:comment) { create(:comment, comment_thread: thread, author_type: "human", author_id: hampton.id, body_markdown: "too formal!") } + + it "publishes comment events to subscribed agents and wakes the session" do + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: hampton.id, comment: comment, reason: "new_comment") + + events = CoPlan::AgentEvent.for_token(agent_token) + expect(events.count).to eq(1) + event = events.first + expect(event.event_type).to eq("comment.created") + expect(event.payload["comment_body"]).to eq("too formal!") + expect(event.payload["plan_title"]).to eq(plan.title) + + expect(session.reload.state).to eq("pending") + end + + describe "status changes without a comment" do + # A resolve/discard has no comment row, so self-suppression keys on + # the actor_api_token_id the caller passes through the job instead. + it "suppresses a status change performed by the subscribed token itself" do + CoPlan::Notifications::Create.call( + comment_thread: thread, actor_id: hampton.id, reason: "status_change", + actor_api_token_id: agent_token.id + ) + + expect(CoPlan::AgentEvent.for_token(agent_token)).to be_empty + end + + it "still fans a human's status change out to attached agents" do + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: hampton.id, reason: "status_change") + + expect(CoPlan::AgentEvent.for_token(agent_token).first.event_type).to eq("thread.status_changed") + end + + it "carries the acting token through the notifications job" do + CoPlan::CreateNotificationsJob.new.perform( + comment_thread_id: thread.id, actor_id: hampton.id, reason: "status_change", + actor_api_token_id: agent_token.id + ) + + expect(CoPlan::AgentEvent.for_token(agent_token)).to be_empty + end + end + + it "queues events for a detached session without giving it a pill" do + session.update!(state: "complete", last_activity_at: 1.minute.ago) + + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: hampton.id, comment: comment, reason: "new_comment") + + # The inbox is durable — it gets the backlog when it reattaches... + expect(CoPlan::AgentEvent.for_token(agent_token).count).to eq(1) + # ...but an abandoned session must not be resurrected into a pill. + expect(session.reload.state).to eq("complete") + expect(CoPlan::AgentSession.visible).to be_empty + end + + it "does not resurrect a session whose agent stopped responding" do + session.update!(state: "pending", last_activity_at: 10.minutes.ago) + + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: hampton.id, comment: comment, reason: "new_comment") + + expect(CoPlan::AgentSession.visible).to be_empty + end + + # A session whose connection is gone (and that registered no wake + # URL) has no path by which delivery could become action: flipping it + # to pending would start a 30-second countdown nothing can answer. + it "queues without a wake countdown when no connection is parked" do + session.update!(last_transport_at: 5.minutes.ago) + + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: hampton.id, comment: comment, reason: "new_comment") + + expect(CoPlan::AgentEvent.for_token(agent_token).count).to eq(1) + expect(session.reload.state).to eq("watching") + end + + describe "webhook wakes" do + before { allow(CoPlan::WakeWebhookJob).to receive(:perform_later) } + + it "pings the wake URL for each event" do + session.update!(wake_url: "https://agents.example.com/wake", wake_secret: "s3cret") + + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: hampton.id, comment: comment, reason: "new_comment") + + event = CoPlan::AgentEvent.for_token(agent_token).first + expect(CoPlan::WakeWebhookJob).to have_received(:perform_later) + .with(agent_session_id: session.id, agent_event_id: event.id) + end + + # A hosted agent holds no transport between turns — its session can + # look finished. Waking it back up is the point of registering. + it "pings even when the session looks complete" do + session.update!(state: "complete", last_activity_at: 1.hour.ago, + last_transport_at: nil, wake_url: "https://agents.example.com/wake", wake_secret: "s3cret") + + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: hampton.id, comment: comment, reason: "new_comment") + + expect(CoPlan::WakeWebhookJob).to have_received(:perform_later) + # The pill, though, is earned by reacting — not by being pinged. + expect(session.reload.state).to eq("complete") + end + end + + # Suppression keys on the comment's api_token_id (who *wrote* it), not + # the notification actor_id — that holds the human, per the attribution + # convention, and user ids must never be compared against token ids. + it "does not wake the agent for its own comments" do + own_comment = create(:comment, comment_thread: thread, author_type: "local_agent", + author_id: hampton.id, agent_name: "Claude", api_token_id: agent_token.id, body_markdown: "my own reply") + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: hampton.id, comment: own_comment, reason: "agent_response") + + expect(CoPlan::AgentEvent.for_token(agent_token).count).to eq(0) + end + + it "types the first comment on a thread as created even when an agent opened it" do + agent_comment = create(:comment, comment_thread: thread, author_type: "local_agent", author_id: other_token.id, agent_name: "Sara", body_markdown: "opening a thread") + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: other_token.id, comment: agent_comment, reason: "agent_response") + + expect(CoPlan::AgentEvent.for_token(agent_token).first.event_type).to eq("comment.created") + end + + it "types a later comment as replied regardless of author" do + create(:comment, comment_thread: thread, author_type: "human", author_id: hampton.id, body_markdown: "first") + second = create(:comment, comment_thread: thread, author_type: "human", author_id: hampton.id, body_markdown: "second") + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: hampton.id, comment: second, reason: "new_comment") + + expect(CoPlan::AgentEvent.for_token(agent_token).first.event_type).to eq("comment.replied") + end + + describe "authority" do + it "marks a comment from the token's own human as principal" do + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: hampton.id, comment: comment, reason: "new_comment") + + payload = CoPlan::AgentEvent.for_token(agent_token).first.payload + expect(payload["authority"]).to eq("principal") + expect(payload["from_principal"]).to be(true) + expect(payload["from_plan_author"]).to be(true) + end + + it "marks a comment from anyone else as a collaborator" do + sara = create(:coplan_user) + sara_comment = create(:comment, comment_thread: thread, author_type: "human", author_id: sara.id, body_markdown: "drive-by thought") + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: sara.id, comment: sara_comment, reason: "new_comment") + + payload = CoPlan::AgentEvent.for_token(agent_token).first.payload + expect(payload["authority"]).to eq("collaborator") + expect(payload["from_principal"]).to be(false) + expect(payload["from_plan_author"]).to be(false) + end + + # The two questions come apart: an agent can be attached to a plan + # somebody else wrote, and its own principal still outranks the author. + it "separates 'my human' from 'the plan's author'" do + sara = create(:coplan_user) + sara_plan = create(:plan, :considering, created_by_user: sara) + create_agent_collab_session(agent_token, plan: sara_plan) + sara_thread = create(:comment_thread, plan: sara_plan, plan_version: sara_plan.current_plan_version, created_by_user: sara) + hampton_comment = create(:comment, comment_thread: sara_thread, author_type: "human", author_id: hampton.id, body_markdown: "mine") + CoPlan::Notifications::Create.call(comment_thread: sara_thread, actor_id: hampton.id, comment: hampton_comment, reason: "new_comment") + + payload = CoPlan::AgentEvent.for_token(agent_token).where(plan_id: sara_plan.id).first.payload + expect(payload["from_principal"]).to be(true) + expect(payload["from_plan_author"]).to be(false) + end + + # An agent posting under a token carries that token's human, so a + # second agent working for Hampton speaks with Hampton's authority. + it "treats an agent comment as coming from the human behind its token" do + agent_comment = create(:comment, comment_thread: thread, author_type: "local_agent", author_id: hampton.id, agent_name: "Amp", body_markdown: "from another agent") + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: other_token.id, comment: agent_comment, reason: "agent_response") + + payload = CoPlan::AgentEvent.for_token(agent_token).first.payload + expect(payload["from_principal"]).to be(true) + end + end + + it "publishes content changes with changed section keys" do + CoPlan::Plans::ReplaceContent.call( + plan: plan, + new_content: "# Title\n\nBrand new body.\n", + base_revision: plan.current_revision, + actor_type: "human", + actor_id: hampton.id, + change_summary: "Rewrite" + ) + + event = CoPlan::AgentEvent.for_token(agent_token).where(event_type: "plan.content_changed").first + expect(event).to be_present + # ChangedSections::Result serializes as {"keys" => [...], "rewritten" => bool}. + expect(event.payload["changed_sections"]["keys"]).to be_an(Array) + expect(event.payload["changed_sections"]).to have_key("rewritten") + expect(event.payload["change_summary"]).to eq("Rewrite") + end + end + + describe "GET /api/v1/agent/events" do + let!(:session) { create_agent_collab_session(agent_token) } + let!(:other_session) { create_agent_collab_session(other_token) } + let(:thread) { create(:comment_thread, plan: plan, plan_version: plan.current_plan_version, created_by_user: hampton) } + + before do + comment = create(:comment, comment_thread: thread, author_type: "human", author_id: hampton.id, body_markdown: "hello agents") + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: hampton.id, comment: comment, reason: "new_comment") + end + + it "returns pending events for the calling token only" do + get api_v1_agent_events_path, params: { wait: 0 }, headers: agent_headers + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body["events"].length).to eq(1) + expect(body["events"].first["type"]).to eq("comment.created") + expect(body["cursor"]).to eq(body["events"].first["id"]) + end + + it "resumes from a cursor" do + get api_v1_agent_events_path, params: { wait: 0 }, headers: agent_headers + cursor = JSON.parse(response.body)["cursor"] + + get api_v1_agent_events_path, params: { wait: 0, cursor: cursor }, headers: agent_headers + expect(JSON.parse(response.body)["events"]).to be_empty + + reply = create(:comment, comment_thread: thread, author_type: "human", author_id: hampton.id, body_markdown: "and another") + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: hampton.id, comment: reply, reason: "reply") + + get api_v1_agent_events_path, params: { wait: 0, cursor: cursor }, headers: agent_headers + events = JSON.parse(response.body)["events"] + expect(events.length).to eq(1) + expect(events.first["type"]).to eq("comment.replied") + end + + # An attached agent holds a Rack thread for the life of its + # connection. Over budget we answer immediately instead of letting + # agents queue ahead of ordinary page requests. + it "degrades long-poll to a non-blocking read when at the connection budget" do + allow(CoPlan::AgentEventBus).to receive(:with_slot).and_yield(false) + + started = Time.current + get api_v1_agent_events_path, params: { wait: 30 }, headers: agent_headers + + expect(Time.current - started).to be < 5 + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)["throttled"]).to be(true) + end + + it "refuses a new stream over budget with Retry-After instead of blocking" do + allow(CoPlan::AgentEventBus).to receive(:with_slot).and_yield(false) + + get api_v1_agent_events_path, headers: agent_headers.merge("Accept" => "text/event-stream") + + expect(response).to have_http_status(:service_unavailable) + expect(response.headers["Retry-After"]).to eq("5") + expect(JSON.parse(response.body)["fallback"]).to include("long-poll") + end + + it "signals waiters when an event is published" do + allow(CoPlan::AgentEventBus).to receive(:signal) + + comment = create(:comment, comment_thread: thread, author_type: "human", author_id: hampton.id, body_markdown: "wake up") + CoPlan::Notifications::Create.call(comment_thread: thread, actor_id: hampton.id, comment: comment, reason: "reply") + + expect(CoPlan::AgentEventBus).to have_received(:signal).with(agent_token.id) + end + + # A parked long-poll is a held connection just like SSE: it must + # register as transport, or a faithfully-polling agent reads as + # absent and never gets a wake countdown. + it "counts a parked long-poll as transport" do + session.update!(last_transport_at: nil) + + get api_v1_agent_events_path, params: { wait: 1 }, headers: agent_headers + + expect(session.reload.last_transport_at).to be_present + end + + it "does not count a drive-by wait=0 read as transport" do + session.update!(last_transport_at: nil) + + get api_v1_agent_events_path, params: { wait: 0 }, headers: agent_headers + + expect(session.reload.last_transport_at).to be_nil + end + + it "acks up to a cursor" do + get api_v1_agent_events_path, params: { wait: 0 }, headers: agent_headers + cursor = JSON.parse(response.body)["cursor"] + + post api_v1_agent_events_ack_path, params: { cursor: cursor }, headers: agent_headers, as: :json + expect(JSON.parse(response.body)["acked"]).to eq(1) + + get api_v1_agent_events_path, params: { wait: 0 }, headers: agent_headers + expect(JSON.parse(response.body)["events"]).to be_empty + end + end + + describe "agent attribution ergonomics" do + # The token is the identity; the session is just presence on one plan. + # Every write path (comments, versions, events) resolves the name the + # same way — explicit param, then the token's agent_name, then its + # name — so one agent can't sign comments "Ada" while its versions + # say "Claude". + it "attributes writes to the token's name even when the session was claimed under another label" do + post api_v1_plan_agent_session_path(plan), params: { agent_name: "Ada" }, headers: agent_headers, as: :json + + post api_v1_plan_comments_path(plan), params: { body_markdown: "no name given" }, headers: agent_headers, as: :json + + expect(response).to have_http_status(:created) + expect(CoPlan::Comment.last.agent_name).to eq("Claude") + end + + it "falls back to the token's agent name when there is no session" do + post api_v1_plan_comments_path(plan), params: { body_markdown: "no session either" }, headers: agent_headers, as: :json + + expect(response).to have_http_status(:created) + expect(CoPlan::Comment.last.agent_name).to eq("Claude") + end + + it "truncates an over-long name instead of losing the comment" do + post api_v1_plan_comments_path(plan), + params: { body_markdown: "hi", agent_name: "Claude (this session, attached)" }, + headers: agent_headers, as: :json + + expect(response).to have_http_status(:created) + expect(CoPlan::Comment.last.agent_name.length).to eq(CoPlan::Comment::AGENT_NAME_LIMIT) + end + + it "returns id alongside comment_id so creates match the rest of the API" do + post api_v1_plan_comments_path(plan), params: { body_markdown: "first" }, headers: agent_headers, as: :json + thread_id = JSON.parse(response.body)["thread_id"] + + post reply_api_v1_plan_comment_path(plan, thread_id), params: { body_markdown: "second" }, headers: agent_headers, as: :json + + body = JSON.parse(response.body) + expect(body["id"]).to eq(body["comment_id"]) + expect(body["id"]).to be_present + end + + it "lets a comment be destroyed without tripping the notification foreign key" do + thread = create(:comment_thread, plan: plan, plan_version: plan.current_plan_version, created_by_user: hampton) + comment = create(:comment, comment_thread: thread, author_type: "human", author_id: hampton.id, body_markdown: "doomed") + CoPlan::Notification.create!(user_id: hampton.id, plan_id: plan.id, comment_thread_id: thread.id, comment_id: comment.id, reason: "new_comment") + + expect { comment.destroy! }.to change(CoPlan::Notification, :count).by(-1) + end + end + + describe "comment API ergonomics" do + let(:thread) { create(:comment_thread, plan: plan, plan_version: plan.current_plan_version, created_by_user: hampton) } + + before { create(:comment, comment_thread: thread, author_type: "human", author_id: hampton.id, body_markdown: "first") } + + it "fetches a single thread" do + get api_v1_plan_comment_path(plan, thread), headers: agent_headers + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body["id"]).to eq(thread.id) + expect(body["comments"].length).to eq(1) + end + + it "accepts dismiss as an alias for discard" do + patch dismiss_api_v1_plan_comment_path(plan, thread), headers: agent_headers, as: :json + expect(response).to have_http_status(:ok) + expect(thread.reload.status).to eq("discarded") + end + + it "does not leave an orphan thread when the first comment fails validation" do + expect { + # An empty body fails the comment, which must roll the thread back + # too — otherwise the plan keeps an empty thread with a live anchor. + post api_v1_plan_comments_path(plan), params: { body_markdown: "" }, headers: agent_headers, as: :json + }.not_to change(CoPlan::CommentThread, :count) + expect(response).to have_http_status(:unprocessable_content) + end + + it "gives the plan author's own API threads the todo initial status" do + post api_v1_plan_comments_path(plan), params: { body_markdown: "note to self", agent_name: "Claude" }, headers: agent_headers, as: :json + expect(response).to have_http_status(:created) + expect(JSON.parse(response.body)["status"]).to eq("todo") + end + end + + # An agent that has claimed a session and is genuinely attached — a + # connection has touched transport recently — which is the state + # fan-out actually cares about. + def create_agent_collab_session(token, plan: self.plan) + CoPlan::AgentSession.create!( + plan_id: plan.id, + api_token_id: token.id, + agent_name: token.agent_name, + state: "watching", + last_activity_at: Time.current, + last_transport_at: Time.current + ) + end +end diff --git a/spec/requests/api/v1/tokens_spec.rb b/spec/requests/api/v1/tokens_spec.rb index f4421858..ed10b379 100644 --- a/spec/requests/api/v1/tokens_spec.rb +++ b/spec/requests/api/v1/tokens_spec.rb @@ -144,4 +144,41 @@ def hook_auth_as(user) expect(response).to have_http_status(:unauthorized) end end + + # A token is also the unit of agent-collaboration identity: the agent + # session it claims and the event inbox it drains. + describe "minted tokens in the live-collaboration loop" do + let(:plan) { create(:plan, :considering, created_by_user: alice) } + + it "mints a session token usable for the rest of the API" do + post api_v1_tokens_path, params: { agent_name: "Claude refactor" }, headers: root_headers, as: :json + token = JSON.parse(response.body)["token"] + + post api_v1_plan_agent_session_path(plan), + headers: { "Authorization" => "Bearer #{token}" }, as: :json + + expect(response).to have_http_status(:created) + expect(JSON.parse(response.body)["agent_name"]).to eq("Claude refactor") + end + + it "gives each minted token its own event inbox" do + post api_v1_tokens_path, params: { agent_name: "One" }, headers: root_headers, as: :json + first = JSON.parse(response.body)["token"] + post api_v1_tokens_path, params: { agent_name: "Two" }, headers: root_headers, as: :json + second = JSON.parse(response.body)["token"] + + expect(first).not_to eq(second) + + post api_v1_plan_agent_session_path(plan), headers: { "Authorization" => "Bearer #{first}" }, as: :json + CoPlan::AgentEvents::Publish.call(plan: plan, event_type: "plan.content_changed") + + get api_v1_agent_events_path, headers: { "Authorization" => "Bearer #{first}" }, params: { wait: 0 } + expect(JSON.parse(response.body)["events"].length).to eq(1) + + # The second token never attached to that plan, so its inbox is empty + # — two agents on one machine don't steal each other's wakes. + get api_v1_agent_events_path, headers: { "Authorization" => "Bearer #{second}" }, params: { wait: 0 } + expect(JSON.parse(response.body)["events"]).to be_empty + end + end end diff --git a/spec/requests/dictations_spec.rb b/spec/requests/dictations_spec.rb index f3949c42..01fd0d26 100644 --- a/spec/requests/dictations_spec.rb +++ b/spec/requests/dictations_spec.rb @@ -125,6 +125,24 @@ expect(response).to have_http_status(:ok) end + it "forwards what was mid-screen so the model favors it for the span" do + expect(CoPlan::Ai).to receive(:call) do |user_content:, **| + expect(user_content).to include("They were reading this part") + expect(user_content).to match(/reading this part.*world domination/m) + { "text" => "Too grand.", "span" => nil }.to_json + end + + post plan_dictations_path(plan), + params: { + transcript: "too grand", + excerpt: "## Ambition\nOur goal is world domination by Q3.\nMore below the fold.", + focus: "Our goal is world domination by Q3." + }, + as: :json + + expect(response).to have_http_status(:ok) + end + it "falls back to the plan content when the client sends no excerpt" do expect(CoPlan::Ai).to receive(:call) do |user_content:, **| expect(user_content).to include(plan.current_content.truncate(50, omission: "")) diff --git a/spec/services/coplan/comments/interpret_dictation_spec.rb b/spec/services/coplan/comments/interpret_dictation_spec.rb index 8b169508..3d349276 100644 --- a/spec/services/coplan/comments/interpret_dictation_spec.rb +++ b/spec/services/coplan/comments/interpret_dictation_spec.rb @@ -192,6 +192,35 @@ def stub_ai(text:, span: nil) expect(described_class.call(excerpt: excerpt, transcript: " ").body).to eq("") end + # The excerpt accumulates everything that scrolled past during the + # take; the focus is what was mid-screen at the end. Without it the + # model kept anchoring on text just above the fold — what the person + # was reading a sentence ago. + describe "the focus — what was mid-screen when they finished" do + it "tells the model where the span most likely lives" do + expect(CoPlan::Ai).to receive(:call) do |user_content:, **| + expect(user_content).to include("They were reading this part") + expect(user_content).to match(/reading this part.*sidecar stays behind a flag/m) + { "text" => "Sounds too cautious.", "span" => nil }.to_json + end + + described_class.call( + excerpt: excerpt, + transcript: "sounds too cautious", + focus: "The higher-fidelity sidecar stays behind a flag until it is proven." + ) + end + + it "omits the section when the focus is the whole excerpt anyway" do + expect(CoPlan::Ai).to receive(:call) do |user_content:, **| + expect(user_content).not_to include("They were reading this part") + { "text" => "Sounds too cautious.", "span" => nil }.to_json + end + + described_class.call(excerpt: excerpt, transcript: "sounds too cautious", focus: excerpt) + end + end + # "Rename both of these" is one remark but two placements. The model # returns a comments array; each entry gets its own span vetting. describe "a remark about more than one passage" do diff --git a/spec/services/plans/replace_content_spec.rb b/spec/services/plans/replace_content_spec.rb index 9a462875..df043537 100644 --- a/spec/services/plans/replace_content_spec.rb +++ b/spec/services/plans/replace_content_spec.rb @@ -31,7 +31,8 @@ let(:new_content) { initial_content.sub("unit tests", "integration tests") } it "broadcasts the new content body so other tabs live-update" do - expect(CoPlan::Broadcaster).to receive(:replace_plan_content).with(plan).and_call_original + expect(CoPlan::Broadcaster).to receive(:replace_plan_content) + .with(plan, changed_sections: kind_of(CoPlan::Plans::ChangedSections::Result)).and_call_original allow(Turbo::StreamsChannel).to receive(:broadcast_stream_to) allow(Turbo::StreamsChannel).to receive(:broadcast_replace_to) diff --git a/voice/README.md b/voice/README.md new file mode 100644 index 00000000..124a1785 --- /dev/null +++ b/voice/README.md @@ -0,0 +1,89 @@ +# CoPlan voice sidecar + +Two voice tiers, one loop. Both turn speech into a plan comment, which +wakes the plan's agent through the event inbox (`GET /api/v1/agent/events` +— see `/agent-instructions` and `script/coplan-bridge`); the agent's reply +and edits arrive visually via the plan page's live broadcasts, with the +presence pill and diff flashes doing the "I heard you" work. + +## Tier 1 — zero-install (already on) + +The mic button on the plan page captures one of two ways, decided by +whether an AI provider is configured (`CoPlan::Ai.available?`, passed to +the Stimulus controller as `transcription-value`): + +- **Record and transcribe** (default when a provider is configured). + `MediaRecorder` captures Opus in WebM, or MP4/AAC in Safari, and posts + it to `POST /plans/:id/dictations`, which runs it through + `gpt-4o-transcribe` (override with `COPLAN_TRANSCRIBE_MODEL`). Works in + every browser with a microphone. The text that was on screen is passed + as the transcription prompt, which is what makes product names, jargon + and figures come back as themselves instead of phonetic guesses. + Costs a round trip, and there are no live captions while you talk. +- **Browser recognition** (fallback). Web Speech API, on-device in + Chrome 139+. Instant, free, interim text as you speak — and clearly + worse on anything domain-specific. Chrome only in practice; Safari's + implementation is unreliable. + +`speechSynthesis` handles the "Got it." / "Done — take a look." cues in +both cases. + +### Making tier 1 better + +Roughly in order of value per unit of work: + +1. **Both at once.** Run recognition for live captions *and* record for + the transcript that actually gets posted. Restores the "it's hearing + me" feedback the recording path gives up, at the cost of two mic + consumers in one page. +2. **A real hint, not just the viewport.** The prompt currently gets the + visible text. The plan title, section headings, and recent comment + text are all cheap to add and all full of the words being spoken. +3. **Streaming transcription.** `gpt-4o-transcribe` over a WebSocket, or + the tier 2 sidecar below, turns a 2–4s wait into a running transcript. +4. **Show the transcript before posting.** A two-second window to see + what it heard, with the comment posting itself if you say nothing — + catches the "that isn't what I said" case without adding a step. +5. **Silence trimming and a level meter.** Cheaper uploads, and a mic + that visibly responds to your voice is the clearest possible signal + that it is working. + +## Tier 2 — local OSS pipeline (this directory) + +Pipecat + Silero VAD + MLX Whisper (STT) + Kokoro-82M (TTS), browser ↔ +sidecar over serverless WebRTC. All local on Apple Silicon; expect +~0.5–1s from end-of-speech to the audible ack once models are warm +(~30s cold start). + +```bash +# 1. Kokoro TTS server (OpenAI-compatible on :8880) +uvx kokoro-fastapi # or: docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu + +# 2. The sidecar +python -m venv .venv && source .venv/bin/activate +pip install "pipecat-ai[webrtc,silero,whisper-mlx,openai]" aiohttp + +COPLAN_BASE=http://localhost:3223 \ +COPLAN_TOKEN= \ +COPLAN_PLAN_ID= \ +python bot.py +``` + +Point the browser client (Pipecat's `SmallWebRTCTransport` ships a JS SDK) +at the sidecar's offer endpoint. Barge-in is on (`allow_interruptions`); +pin pipecat past the ~0.0.62 SmallWebRTC audio regression. + +## The plugin seam + +- **Providers**: STT/TTS are Pipecat services — swapping MLX Whisper → + Deepgram or Kokoro → ElevenLabs is a one-line constructor change + (`OpenAITTSService` pointed at a different `base_url` already covers + any OpenAI-compatible vendor). +- **The brain**: `CoPlanAgentBackend` in `bot.py` is the only + CoPlan-aware piece — transcript in, fast-ack + comment out. Smarter + behavior (doc Q&A without waking the agent, richer spoken summaries + when the agent finishes) belongs there. + +Upgrade paths per the voice design plan: Kyutai STT 1B via MLX for true +streaming transcription; Kyutai TTS for token-streaming readbacks; Piper +for a sub-100ms ack voice. diff --git a/voice/bot.py b/voice/bot.py new file mode 100644 index 00000000..750dd078 --- /dev/null +++ b/voice/bot.py @@ -0,0 +1,97 @@ +"""CoPlan voice sidecar — fully local speech loop on macOS. + +Pipeline: browser mic --WebRTC--> Silero VAD + smart-turn --> MLX Whisper STT + --> CoPlanAgentBackend --> Kokoro TTS --WebRTC--> browser speaker + +The "brain" is deliberately NOT an LLM service: spoken feedback becomes a +CoPlan comment (same event inbox that wakes the plan's agent — see +script/coplan-bridge), and the pipeline speaks a fast acknowledgment +("Got it") within ~1s while the real agent works at its own pace. Doc +edits arrive visually through the plan page's live broadcasts, not through +this audio path. + +Providers are Pipecat services, so swapping STT/TTS for hosted ones +(Deepgram, ElevenLabs, ...) is a one-line constructor change — that's the +plugin seam. See voice/README.md for setup. +""" + +import asyncio +import os +import random + +import aiohttp +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import Frame, TranscriptionFrame, TTSSpeakFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.services.whisper.stt import WhisperSTTServiceMLX, MLXModel +from pipecat.services.openai.tts import OpenAITTSService +from pipecat.transports.network.small_webrtc import SmallWebRTCTransport + +COPLAN_BASE = os.environ.get("COPLAN_BASE", "http://localhost:3223") +COPLAN_TOKEN = os.environ["COPLAN_TOKEN"] +PLAN_ID = os.environ["COPLAN_PLAN_ID"] + +ACKS = ["Got it.", "On it.", "Sure thing.", "Okay, one sec."] + + +class CoPlanAgentBackend(FrameProcessor): + """The brain seam: final transcript in → fast ack + CoPlan comment out. + + Anything smarter (RAG over the doc, direct Q&A without waking the + agent) slots in here without touching VAD/turn-taking/barge-in. + """ + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + + if isinstance(frame, TranscriptionFrame) and frame.text.strip(): + # 1. Fast ack — speak before the real work starts. + await self.push_frame(TTSSpeakFrame(random.choice(ACKS))) + # 2. Feed the agent through the normal comment loop. + asyncio.create_task(self._post_comment(frame.text.strip())) + else: + await self.push_frame(frame, direction) + + async def _post_comment(self, text: str): + async with aiohttp.ClientSession() as session: + await session.post( + f"{COPLAN_BASE}/api/v1/plans/{PLAN_ID}/comments", + headers={"Authorization": f"Bearer {COPLAN_TOKEN}"}, + json={"body_markdown": f"🎙️ {text}", "agent_name": "Voice"}, + ) + + +async def main(): + transport = SmallWebRTCTransport( + params={"audio_in_enabled": True, "audio_out_enabled": True}, + vad_analyzer=SileroVADAnalyzer(), + ) + + stt = WhisperSTTServiceMLX(model=MLXModel.LARGE_V3_TURBO_Q4) + + # Kokoro-FastAPI exposes an OpenAI-compatible /v1/audio/speech — + # the same constructor pointed at api.openai.com is the hosted swap. + tts = OpenAITTSService( + api_key="local", + base_url=os.environ.get("KOKORO_URL", "http://localhost:8880/v1"), + voice="af_heart", + model="kokoro", + ) + + pipeline = Pipeline([ + transport.input(), + stt, + CoPlanAgentBackend(), + tts, + transport.output(), + ]) + + task = PipelineTask(pipeline, params=PipelineParams(allow_interruptions=True)) + await PipelineRunner().run(task) + + +if __name__ == "__main__": + asyncio.run(main())