diff --git a/app/src/components/intelligence/HarnessGlyph.test.tsx b/app/src/components/intelligence/HarnessGlyph.test.tsx index d06dd1a941..eaa10563c2 100644 --- a/app/src/components/intelligence/HarnessGlyph.test.tsx +++ b/app/src/components/intelligence/HarnessGlyph.test.tsx @@ -8,6 +8,8 @@ describe('HarnessGlyph', () => { ['claude', 'C'], ['codex', 'Cx'], ['gemini', 'G'], + ['cursor', 'Cu'], + ['windsurf', 'Ws'], ['openhuman', 'OH'], ])('renders the %s mark', (harness, label) => { render(); diff --git a/app/src/components/intelligence/HarnessGlyph.tsx b/app/src/components/intelligence/HarnessGlyph.tsx index 212214f514..3d819651f2 100644 --- a/app/src/components/intelligence/HarnessGlyph.tsx +++ b/app/src/components/intelligence/HarnessGlyph.tsx @@ -20,6 +20,8 @@ const GLYPH: Record = { claude: { label: 'C', tone: 'bg-[#c96442] text-white' }, codex: { label: 'Cx', tone: 'bg-content text-surface' }, gemini: { label: 'G', tone: 'bg-ocean-500 text-white' }, + cursor: { label: 'Cu', tone: 'bg-slate-800 text-white' }, + windsurf: { label: 'Ws', tone: 'bg-teal-500 text-white' }, openhuman: { label: 'OH', tone: 'bg-sage-500 text-white' }, }; diff --git a/app/src/components/intelligence/TinyPlaceRoster.test.tsx b/app/src/components/intelligence/TinyPlaceRoster.test.tsx index ecfd537c4f..34918d679f 100644 --- a/app/src/components/intelligence/TinyPlaceRoster.test.tsx +++ b/app/src/components/intelligence/TinyPlaceRoster.test.tsx @@ -46,6 +46,20 @@ describe('TinyPlaceRoster', () => { expect(screen.getByTestId('instance-card-u1')).toBeInTheDocument(); }); + it('groups cursor and windsurf sessions under their own headers', () => { + const sessions = [ + session({ sessionId: 'cu1', harnessType: 'cursor', source: 'cursor' }), + session({ sessionId: 'ws1', harnessType: 'windsurf', source: 'windsurf' }), + ]; + render(); + expect(screen.getByText('Cursor')).toBeInTheDocument(); + expect(screen.getByText('Windsurf')).toBeInTheDocument(); + expect(screen.getByTestId('instance-card-cu1')).toBeInTheDocument(); + expect(screen.getByTestId('instance-card-ws1')).toBeInTheDocument(); + // Neither falls into the Other catch-all. + expect(screen.queryByText('tinyplaceOrchestration.roster.other')).toBeNull(); + }); + it('marks the selected instance and forwards selection', () => { const onSelect = vi.fn(); const sessions = [session({ sessionId: 'c1' }), session({ sessionId: 'c2' })]; diff --git a/app/src/components/intelligence/TinyPlaceRoster.tsx b/app/src/components/intelligence/TinyPlaceRoster.tsx index 709b15d2e9..66727aa352 100644 --- a/app/src/components/intelligence/TinyPlaceRoster.tsx +++ b/app/src/components/intelligence/TinyPlaceRoster.tsx @@ -24,6 +24,8 @@ const HARNESS_GROUPS: Array<{ key: HarnessType; label: string }> = [ { key: 'claude', label: 'Claude' }, { key: 'codex', label: 'Codex' }, { key: 'gemini', label: 'Gemini' }, + { key: 'cursor', label: 'Cursor' }, + { key: 'windsurf', label: 'Windsurf' }, ]; export default function TinyPlaceRoster({ diff --git a/app/src/lib/orchestration/orchestrationClient.ts b/app/src/lib/orchestration/orchestrationClient.ts index 0d9fe969c0..7ae9906b18 100644 --- a/app/src/lib/orchestration/orchestrationClient.ts +++ b/app/src/lib/orchestration/orchestrationClient.ts @@ -21,7 +21,7 @@ export { PaymentRequiredError }; export type OrchestrationChatKind = 'master' | 'subconscious' | 'session'; /** External agent harness that emits a session (drives the roster grouping). */ -export type HarnessType = 'claude' | 'codex' | 'gemini'; +export type HarnessType = 'claude' | 'codex' | 'gemini' | 'cursor' | 'windsurf'; /** * Coarse instance status for the roster dot. Peer instances carry no true diff --git a/docs/superpowers/plans/2026-07-09-cursor-windsurf-harness.md b/docs/superpowers/plans/2026-07-09-cursor-windsurf-harness.md new file mode 100644 index 0000000000..a0db748a7a --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-cursor-windsurf-harness.md @@ -0,0 +1,250 @@ +# Cursor & Windsurf Harness Recognition — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make OpenHuman's orchestration engine recognize `cursor` and `windsurf` as harness providers so their sessions render with a first-class roster group and brand glyph instead of the "Other" catch-all. + +**Architecture:** Widen the single backend recognition gate (`harness_type_for()` in the Rust orchestration crate), then widen the frontend `HarnessType` union it feeds — the TypeScript type-checker forces the two UI lookup tables (`HarnessGlyph` glyph map, `TinyPlaceRoster` groups) to stay complete. No changes to ingest, pairing, relay, or attention. + +**Tech Stack:** Rust (`openhuman` crate, `cargo test`), TypeScript/React (`app/`, `vitest`, `tsc`). + +**Design doc:** `docs/superpowers/specs/2026-07-09-cursor-windsurf-harness-design.md` + +## Global Constraints + +- **Scope is recognition-only.** Do NOT create any `sdk/plugin-tinyplace/adapters/*` file or touch ingest/pairing/relay/attention. Only the recognition gate and the two UI tables it feeds. +- **Anchor edits on exact code strings, not line numbers.** This repo has concurrent activity and line numbers drift. Every edit below quotes the exact `old` string to match. +- **Glyph labels:** Cursor → `Cu`, Windsurf → `Ws` (two-letter, parallel to Codex's `Cx`). +- **Glyph tones (confirmed present in `app/tailwind.config.js`):** Cursor → `bg-slate-800 text-white`; Windsurf → `bg-teal-500 text-white`. +- **Roster order:** append Cursor then Windsurf after Gemini, before the implicit "Other" group. +- **Provider ordering in Rust match / everywhere:** `claude`, `codex`, `gemini`, `cursor`, `windsurf`. +- **Working branch:** `feat/cursor-windsurf-harness` (already created; the design-doc commit `2af56b1e0` is its tip). Do NOT commit on `main`. Never `git add -A`; stage only the exact paths listed. + +--- + +### Task 1: Rust core — widen the recognition gate + +**Files:** +- Modify: `src/openhuman/orchestration/schemas.rs` (fn `harness_type_for`, its two doc comments, and the test `harness_type_only_for_known_providers`) + +**Interfaces:** +- Consumes: nothing (leaf change). +- Produces: `harness_type_for(source: &str) -> Option` now returns `Some(source)` for `"cursor"` and `"windsurf"` in addition to `"claude" | "codex" | "gemini"`. This is the value that populates the `harnessType` field on the session summary consumed by the frontend (Task 2). + +- [ ] **Step 1: Extend the failing test** + +In `src/openhuman/orchestration/schemas.rs`, find the test `fn harness_type_only_for_known_providers()`. It contains these assertions: + +```rust + assert_eq!(harness_type_for("claude").as_deref(), Some("claude")); + assert_eq!(harness_type_for("codex").as_deref(), Some("codex")); + assert_eq!(harness_type_for("gemini").as_deref(), Some("gemini")); +``` + +Add two lines immediately after the `gemini` assertion: + +```rust + assert_eq!(harness_type_for("cursor").as_deref(), Some("cursor")); + assert_eq!(harness_type_for("windsurf").as_deref(), Some("windsurf")); +``` + +Leave the existing `None` assertions (`master` / `user_created` / `orchestration`) unchanged — they guard against over-broadening. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p openhuman harness_type_only_for_known_providers` +Expected: FAIL — `assertion \`left == right\` failed` with `left: None, right: Some("cursor")` (the gate doesn't recognize `cursor` yet). + +- [ ] **Step 3: Widen the gate** + +In the same file, find the function body: + +```rust + matches!(source, "claude" | "codex" | "gemini").then(|| source.to_string()) +``` + +Replace it with: + +```rust + matches!(source, "claude" | "codex" | "gemini" | "cursor" | "windsurf") + .then(|| source.to_string()) +``` + +- [ ] **Step 4: Refresh the doc comments (same file)** + +There are two doc comments naming the provider set as `(claude/codex/gemini)`. Update both to `(claude/codex/gemini/cursor/windsurf)`: + +Doc comment above the `HarnessSession`/`source` field — find: +```rust + /// The emitting harness (claude/codex/gemini) when this is an external agent +``` +Replace `(claude/codex/gemini)` → `(claude/codex/gemini/cursor/windsurf)`. + +Doc comment above `fn harness_type_for` — find: +```rust +/// windows persist the emitting harness (claude/codex/gemini) in `source` (see +``` +Replace `(claude/codex/gemini)` → `(claude/codex/gemini/cursor/windsurf)`. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cargo test -p openhuman harness_type_only_for_known_providers` +Expected: PASS (`test result: ok. 1 passed`). + +- [ ] **Step 6: Commit** + +```bash +git add src/openhuman/orchestration/schemas.rs +git commit -m "feat(orchestration): recognize cursor & windsurf harness providers" +``` + +--- + +### Task 2: Frontend — widen the harness type and its UI tables + +**Files:** +- Modify: `app/src/lib/orchestration/orchestrationClient.ts` (the `HarnessType` union) +- Modify: `app/src/components/intelligence/HarnessGlyph.tsx` (the `GLYPH` record) +- Modify: `app/src/components/intelligence/TinyPlaceRoster.tsx` (the `HARNESS_GROUPS` list) +- Test: `app/src/components/intelligence/HarnessGlyph.test.tsx` +- Test: `app/src/components/intelligence/TinyPlaceRoster.test.tsx` + +**Interfaces:** +- Consumes: the widened backend gate from Task 1 (a session whose `source` is `cursor`/`windsurf` now arrives with `harnessType: 'cursor' | 'windsurf'`). +- Produces: `HarnessType` union widened to `'claude' | 'codex' | 'gemini' | 'cursor' | 'windsurf'`; `GLYPH` and `HARNESS_GROUPS` gain matching entries. No new exported functions. + +- [ ] **Step 1: Extend the failing tests** + +In `app/src/components/intelligence/HarnessGlyph.test.tsx`, the `it.each` table is: + +```tsx + it.each<[GlyphKind, string]>([ + ['claude', 'C'], + ['codex', 'Cx'], + ['gemini', 'G'], + ['openhuman', 'OH'], + ])('renders the %s mark', (harness, label) => { +``` + +Add two rows after `['gemini', 'G'],`: + +```tsx + ['cursor', 'Cu'], + ['windsurf', 'Ws'], +``` + +In `app/src/components/intelligence/TinyPlaceRoster.test.tsx`, add a new test after the existing `groups instances by harness ...` test (insert before the `marks the selected instance` test): + +```tsx + it('groups cursor and windsurf sessions under their own headers', () => { + const sessions = [ + session({ sessionId: 'cu1', harnessType: 'cursor', source: 'cursor' }), + session({ sessionId: 'ws1', harnessType: 'windsurf', source: 'windsurf' }), + ]; + render(); + expect(screen.getByText('Cursor')).toBeInTheDocument(); + expect(screen.getByText('Windsurf')).toBeInTheDocument(); + expect(screen.getByTestId('instance-card-cu1')).toBeInTheDocument(); + expect(screen.getByTestId('instance-card-ws1')).toBeInTheDocument(); + // Neither falls into the Other catch-all. + expect(screen.queryByText('tinyplaceOrchestration.roster.other')).toBeNull(); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run from the `app/` directory: `pnpm test src/components/intelligence/HarnessGlyph.test.tsx src/components/intelligence/TinyPlaceRoster.test.tsx` +Expected: FAIL — `HarnessGlyph` throws destructuring `undefined` for `GLYPH['cursor']`, and `TinyPlaceRoster` cannot find the `Cursor`/`Windsurf` headers (sessions currently land in "Other"). + +- [ ] **Step 3: Widen the `HarnessType` union** + +In `app/src/lib/orchestration/orchestrationClient.ts`, find: + +```ts +export type HarnessType = 'claude' | 'codex' | 'gemini'; +``` + +Replace with: + +```ts +export type HarnessType = 'claude' | 'codex' | 'gemini' | 'cursor' | 'windsurf'; +``` + +- [ ] **Step 4: Add the glyph entries** + +In `app/src/components/intelligence/HarnessGlyph.tsx`, find the `GLYPH` record: + +```ts +const GLYPH: Record = { + claude: { label: 'C', tone: 'bg-[#c96442] text-white' }, + codex: { label: 'Cx', tone: 'bg-content text-surface' }, + gemini: { label: 'G', tone: 'bg-ocean-500 text-white' }, + openhuman: { label: 'OH', tone: 'bg-sage-500 text-white' }, +}; +``` + +Insert the two new entries after `gemini`: + +```ts + cursor: { label: 'Cu', tone: 'bg-slate-800 text-white' }, + windsurf: { label: 'Ws', tone: 'bg-teal-500 text-white' }, +``` + +- [ ] **Step 5: Add the roster groups** + +In `app/src/components/intelligence/TinyPlaceRoster.tsx`, find the `HARNESS_GROUPS` list: + +```ts +const HARNESS_GROUPS: Array<{ key: HarnessType; label: string }> = [ + { key: 'claude', label: 'Claude' }, + { key: 'codex', label: 'Codex' }, + { key: 'gemini', label: 'Gemini' }, +``` + +Add the two new groups after the `gemini` entry (before the closing `];`): + +```ts + { key: 'cursor', label: 'Cursor' }, + { key: 'windsurf', label: 'Windsurf' }, +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run from the `app/` directory: `pnpm test src/components/intelligence/HarnessGlyph.test.tsx src/components/intelligence/TinyPlaceRoster.test.tsx` +Expected: PASS (both files green; the parametrized `HarnessGlyph` cases now include `cursor`/`windsurf`). + +- [ ] **Step 7: Type-check the frontend** + +Run from the `app/` directory: `pnpm tsc --noEmit` (or the project's `typecheck` script if present — check `app/package.json`). +Expected: PASS with no errors. This confirms the widened union left no exhaustive consumer incomplete. + +- [ ] **Step 8: Commit** + +```bash +git add app/src/lib/orchestration/orchestrationClient.ts \ + app/src/components/intelligence/HarnessGlyph.tsx \ + app/src/components/intelligence/HarnessGlyph.test.tsx \ + app/src/components/intelligence/TinyPlaceRoster.tsx \ + app/src/components/intelligence/TinyPlaceRoster.test.tsx +git commit -m "feat(orchestration): render cursor & windsurf harness glyphs and roster groups" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Design change 1 (widen `harness_type_for`) → Task 1 Steps 3. ✅ +- Design change 2 (Rust test) → Task 1 Steps 1–2, 5. ✅ +- Design doc-comment refresh → Task 1 Step 4. ✅ +- Design change 3 (`HarnessType` union) → Task 2 Step 3. ✅ +- Design change 4 (glyph entries, `bg-slate-800` / `bg-teal-500`) → Task 2 Step 4. ✅ +- Design change 5 (roster groups) → Task 2 Step 5. ✅ +- Design tests 6 (`HarnessGlyph.test.tsx`) → Task 2 Step 1. ✅ +- Design tests 7 (`TinyPlaceRoster.test.tsx`) → Task 2 Step 1. ✅ +- Verification (cargo test, vitest, tsc) → Task 1 Step 5, Task 2 Steps 6–7. ✅ +- Out-of-scope (no adapter, no ingest/attention change) → Global Constraints. ✅ + +**Placeholder scan:** No TBD/TODO/"handle edge cases"; every code step shows exact strings. ✅ + +**Type consistency:** Provider order `claude, codex, gemini, cursor, windsurf` and labels `Cu`/`Ws` are identical across the Rust match, the TS union, the glyph map, the roster groups, and both test files. ✅ diff --git a/docs/superpowers/specs/2026-07-09-cursor-windsurf-harness-design.md b/docs/superpowers/specs/2026-07-09-cursor-windsurf-harness-design.md new file mode 100644 index 0000000000..29822c8872 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-cursor-windsurf-harness-design.md @@ -0,0 +1,140 @@ +# Design: Recognize Cursor & Windsurf as first-class harnesses + +> Status: **approved** — 2026-07-09. Scope locked to OpenHuman-side recognition only. + +## Goal + +Teach OpenHuman's orchestration engine to recognize `cursor` and `windsurf` as +harness providers, so their sessions render with a proper roster group, a brand +glyph, and a typed `harnessType` instead of falling into the generic **"Other"** +catch-all group. + +This is **recognition-only**. It does not add a session-origination adapter (the +`sdk/plugin-tinyplace/adapters/*` piece that would make a real Cursor/Windsurf +CLI emit these sessions). See [Out of scope](#out-of-scope). + +## Background: how harness recognition works today + +A coding agent sends a `SessionEnvelopeV1` over the tiny.place Signal relay with a +`harness.provider` field (e.g. `"codex"`). OpenHuman's orchestration ingest +(`src/openhuman/orchestration/ingest.rs`) decrypts it and copies that field +**verbatim** into the session's `source` — there is no provider allowlist at +ingest (`ingest.rs:187`, `ingest.rs:213`). + +The single gate that decides whether a `source` is a *known* harness is +`harness_type_for()` in `src/openhuman/orchestration/schemas.rs:276`: + +```rust +fn harness_type_for(source: &str) -> Option { + matches!(source, "claude" | "codex" | "gemini").then(|| source.to_string()) +} +``` + +It is called when building the session summary (`schemas.rs:339`) to populate the +`harnessType` field sent to the UI. An unrecognized `source` yields `None`, and +the roster drops that session into the "Other" group with no glyph. + +On the frontend, `harnessType` keys three surfaces: + +- `app/src/lib/orchestration/orchestrationClient.ts:24` — the `HarnessType` union. +- `app/src/components/intelligence/HarnessGlyph.tsx:20` — a `Record` + mapping each harness to a `{ label, tone }` brand glyph. +- `app/src/components/intelligence/TinyPlaceRoster.tsx:23` — `HARNESS_GROUPS`, the + ordered list of roster group headers. + +Because `GLYPH` is a `Record`, widening `HarnessType` is +**compile-forced**: the type-checker fails until every new variant has a glyph. + +## Data flow (unchanged by this change) + +``` +Cursor/Windsurf CLI → harness.provider = "cursor" | "windsurf" + → relay → ingest.rs (source = provider, verbatim) + → harness_type_for(source) ← THE GATE (widened here) + → session summary { harnessType } + → HarnessGlyph + TinyPlaceRoster group ← follows via the type +``` + +No change to ingest, pairing, attention, or the relay. Only the recognition gate +and the two UI lookup tables it feeds. + +## Changes + +### Rust core — `src/openhuman/orchestration/` + +1. **`schemas.rs:277`** — widen the match: + ```rust + matches!(source, "claude" | "codex" | "gemini" | "cursor" | "windsurf") + ``` + Refresh the `(claude/codex/gemini)` doc comments at `schemas.rs:192` and + `schemas.rs:273` to include the new providers. + +2. **`schemas.rs:1113` (test)** — extend the `harness_type_for` round-trip test: + ```rust + assert_eq!(harness_type_for("cursor").as_deref(), Some("cursor")); + assert_eq!(harness_type_for("windsurf").as_deref(), Some("windsurf")); + ``` + The existing `None` cases (`master`/`user_created`/`orchestration`) stay. + +### Frontend — `app/src/` + +3. **`lib/orchestration/orchestrationClient.ts:24`**: + ```ts + export type HarnessType = 'claude' | 'codex' | 'gemini' | 'cursor' | 'windsurf'; + ``` + +4. **`components/intelligence/HarnessGlyph.tsx:20`** — add two `GLYPH` entries: + ```ts + cursor: { label: 'Cu', tone: 'bg-slate-800 text-white' }, + windsurf: { label: 'Ws', tone: 'bg-teal-500 text-white' }, + ``` + Labels `Cu` / `Ws` parallel Codex's two-letter `Cx` and avoid colliding with + Claude's `C` / Gemini's `G`. Tones: Cursor slate/near-black, Windsurf teal. + **Exact tone classes to be confirmed against the palette classes actually + available in the app** (the existing glyphs mix literal `bg-[#c96442]` and + named `bg-ocean-500`/`bg-sage-500`); the values above are the intent, final + classes chosen from what the Tailwind config exposes. + +5. **`components/intelligence/TinyPlaceRoster.tsx:23`** — append to `HARNESS_GROUPS` + (after Gemini, before the implicit "Other"): + ```ts + { key: 'cursor', label: 'Cursor' }, + { key: 'windsurf', label: 'Windsurf' }, + ``` + +### Tests + +6. **`components/intelligence/HarnessGlyph.test.tsx`** — add label cases + `['cursor', 'Cu']`, `['windsurf', 'Ws']` to the existing parametrized table. + +7. **`components/intelligence/TinyPlaceRoster.test.tsx`** — add a session with + `harnessType: 'cursor'` (and one `'windsurf'`), assert it groups under the + `Cursor` / `Windsurf` header and **not** under "Other". + +## Testing / verification + +- **Rust:** `cargo test` for the orchestration schemas module (the widened + `harness_type_for` test). +- **Frontend:** the vitest suites for `HarnessGlyph`, `TinyPlaceRoster`, and + `InstanceCard` (the last already exercises `harnessType`/`source`). +- **Type-check:** widening `HarnessType` is the safety net — `tsc` fails if any + exhaustive consumer (notably the `GLYPH` `Record`) is left incomplete. + +Regression posture: each new variant is asserted both in Rust (`harness_type_for`) +and in the two UI tables' tests — failing before the change, passing after. + +## Out of scope + +- **Session origination.** A `sdk/plugin-tinyplace/adapters/cursor.mjs` / + `windsurf.mjs` that wraps a real Cursor/Windsurf CLI and emits + `harness.provider = "cursor" | "windsurf"` is **not** built here. Feasibility + depends on those agents exposing a wrappable headless CLI, which is unverified. + Until such an adapter exists, this recognition is correct but exercised only by + tests, not live traffic. This is an intentional, separately-scoped follow-up. +- No changes to ingest, pairing/consent, attention/unread, or the relay. + +## Risks + +- **Low.** The change is additive and type-checked end to end. The only judgment + call is the glyph tone classes, which are cosmetic and confirmed against the + available palette during implementation. diff --git a/docs/superpowers/specs/2026-07-10-cursor-windsurf-adapter-spike.md b/docs/superpowers/specs/2026-07-10-cursor-windsurf-adapter-spike.md new file mode 100644 index 0000000000..5351c1a286 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-cursor-windsurf-adapter-spike.md @@ -0,0 +1,52 @@ +# Spike: Cursor & Windsurf tiny.place adapters — feasibility + +> Status: **findings** — 2026-07-10. Time-boxed research spike (web, cited). Precedes any adapter code. +> Companion to the recognition slice (`2026-07-09-cursor-windsurf-harness-design.md`), which is the prerequisite Layer 1. + +## Question + +Can **Cursor** and **Windsurf** each host the tiny.place `plugin-tinyplace` stdio MCP server *inside* their agent, be observed, and be driven headlessly for an auto-responder — i.e. can we build `adapters/cursor.mjs` and `adapters/windsurf.mjs` mirroring `adapters/codex.mjs`? + +## Headline + +**Both are _likely_ feasible as thin observe+respond adapters, contingent on the two empirical spike-tests (A and B) below.** The make-or-break capability — a headless agent CLI (the `codex exec` equivalent) — **exists for both** (Cursor `cursor-agent -p`, Devin `devin -p`). But the remaining unknowns must pass before this is a firm yes: what env actually reaches the MCP subprocess (test A) and whether an inbound DM can be surfaced into a live turn (test B). The inbound `server→live-turn` push limitation stands regardless of those tests. + +⚠️ **Windsurf has rebranded:** as of 2026-06-02 it is **Devin Desktop** (Cognition); **Cascade reached EOL 2026-07-01**, replaced by **Devin Local**. On-disk config paths (`~/.codeium/windsurf/mcp_config.json`) and a `windsurf` CLI carried forward, but the headless target is now the **`devin`** CLI. A "windsurf" adapter today actually targets Devin Desktop. Devin Desktop also natively speaks **ACP (Agent Client Protocol)** — a possible cleaner integration surface than a bespoke adapter. + +## Feasibility matrix (adapter-contract field → harness) + +| Contract field | Cursor | Windsurf / Devin Desktop | +|---|---|---| +| **MCP stdio hosting** | 🟢 `.cursor/mcp.json` (project) / `~/.cursor/mcp.json` (global); `mcpServers` {command,args,env}. **40-tool cap** (our ~20 fine). | 🟢 `~/.codeium/windsurf/mcp_config.json`; `mcpServers` {command,args,env}. Remote entries accept **`serverUrl`** (and newer builds also `url`) — moot for us since our adapter is stdio {command,args,env}. | +| **Headless responder (CRITICAL)** | 🟢 `cursor-agent -p "" --model --force --approve-mcps --output-format json`. Model-pinnable. | 🟢 `devin -p "" --model --permission-mode bypass`. `--resume`/`--continue` for continuity. | +| **Session-id env to MCP subprocess** | 🟡 None ambient/documented. `conversation_id` only in **hook** payload. Inject a sentinel via `mcp.json` `env`. | 🟡 None documented. Session id in **hook** payload. `DEVIN_PROJECT_DIR` set for hooks. | +| **Workspace-dir** | 🟡 `${workspaceFolder}` interpolation into `mcp.json` `env`. | 🟡 `DEVIN_PROJECT_DIR` (hooks) / `${env:...}` injection. | +| **Inbound push (server→live turn)** | 🔴 No async server push. Elicitation (mid-tool only) + `tools/list_changed`. Surface on next tool call or a fresh headless turn. | 🔴 Undocumented/unverified. Route inbound via **hook `additionalContext`** or an agent-polled tool. | +| **Hooks (surfacing/observe)** | 🟢 Cursor 1.7+: `sessionStart`, `beforeSubmitPrompt`, `stop`, `pre/postToolUse`, `before/afterMCPExecution`. Payload has `conversation_id`, `workspace_roots`; env `CURSOR_PROJECT_DIR`. | 🟢 `SessionStart/End`, `UserPromptSubmit`, `Stop`, `Pre/PostToolUse`. stdin JSON; `hookSpecificOutput.additionalContext` injects live context. | +| **Launch + MCP install** | 🟡 No per-launch MCP-config flag (open FR). Install = write/merge `mcp.json`. `cursor ` / `cursor-agent --workspace`. | 🟡 No per-launch flag. Install = write `mcp_config.json`. `windsurf ` opens workspace. | +| **Detection ("am I inside X?")** | 🟡 No confirmed `CURSOR_*` in MCP child (only in hook env). Self-provision `TINYPLACE_HOST=cursor` sentinel in `mcp.json` `env`. | 🟡 No `CODEX_HOME` analogue for MCP. Self-provision sentinel. | + +## What this changes vs. the codex/claude adapters + +1. **`foregroundInject` (tmux) is dead for both** — they're GUI IDEs, not terminal panes. Inbound leans on hooks + headless turns, not tmux `send-keys`. +2. **No async server→agent push** on either — the `inbound.push` channel (Claude's `claude/channel`) has no equivalent. Model inbound as: surface-on-next-tool-call, hook `additionalContext`, or spawn a headless `-p` turn. +3. **Install model differs** — codex writes an *isolated home*; here `launch.prepare()` becomes *"merge our server into the well-known `mcp.json`/`mcp_config.json`"* (no per-invocation injection flag exists). Must be careful to merge, not clobber, the user's existing MCP config. +4. **Session id** comes from the **hook payload**, not MCP env → correlating a hook's `conversation_id` to the live MCP process is the piece to prototype. + +## Two empirical spike-tests before writing code + +- **A. What env actually reaches the MCP subprocess?** Register a trivial MCP server in each IDE that dumps `process.env`; confirm whether any session/workspace var leaks (docs say no — verify). +- **B. Inbound delivery path.** Confirm whether hook `additionalContext` (Devin) / a `stop`+`beforeSubmitPrompt` hook (Cursor) can reliably surface an inbound DM into the next turn, vs. relying on an agent-polled `inbox` tool. + +## Recommendation + +- **Both adapters are buildable today.** Proceed — but with a GUI-IDE-shaped adapter model (env-injection install, hook-based inbound, headless-`-p` responder), not a copy of codex's isolated-home/tmux model. +- **Sequence Cursor first:** cleaner, well-documented (`cursor-agent`, hooks, 40-tool cap), no rebrand churn. +- **Then Windsurf → target `devin`/Devin Local**, watch the churn, and evaluate the **ACP** route (Devin speaks ACP; may insulate from further rebrands and reuse ACP patterns). +- **Naming decision (resolved → `windsurf`):** the recognition slice baked in `harness.provider = "windsurf"`; the product is now "Devin Desktop." We keep the harness key **`windsurf`** — it matches the on-disk brand that actually carried forward (`~/.codeium/windsurf/mcp_config.json`, the `windsurf` CLI shim) and keeps the recognition gate and adapter `provider` aligned with what's observable on disk. Revisit only if/when the config path itself rebrands to `devin`. +- **Land the recognition slice (Layer 1) regardless** — it's the prerequisite; without it, a working adapter's sessions render as "Other." + +## Sources (selected) + +Cursor: `cursor.com/docs/mcp`, `cursor.com/docs/cli/headless`, `cursor.com/docs/cli/reference/parameters`, `cursor.com/docs/hooks`, `cursor.com/changelog/1-5`. +Windsurf/Devin: `docs.devin.ai/desktop/cascade/mcp`, `docs.devin.ai/cli/reference/commands`, `docs.devin.ai/cli/extensibility/hooks/overview`, `devin.ai/blog/windsurf-is-now-devin-desktop/`, `docs.devin.ai/desktop/changelog`. diff --git a/docs/superpowers/specs/2026-07-11-cursor-observer-findings.md b/docs/superpowers/specs/2026-07-11-cursor-observer-findings.md new file mode 100644 index 0000000000..b7099cbbbc --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-cursor-observer-findings.md @@ -0,0 +1,104 @@ +# Cursor observer prototype — empirical findings (spike-tests A & B) + +> Date: 2026-07-11 +> Companion to `2026-07-10-cursor-windsurf-adapter-spike.md`, which flagged two +> empirical spike-tests (A: what env reaches the MCP subprocess; B: can an +> inbound DM be surfaced into a live turn) as the gates before a firm feasibility +> yes. This doc records what a throwaway prototype actually demonstrated against +> **staging** (`https://staging-api.tiny.place`). + +## What was built (and then removed) + +A throwaway **Cursor hooks observer** — not the real adapter, just enough to +prove the observe→stream half end-to-end: + +- `~/.cursor/hooks.json` wired `beforeSubmitPrompt` (user turn) and + `afterAgentResponse` (assistant turn) to a small Node script. +- The script normalized each turn into a tinyplace `SessionEnvelopeV1` + (`harness.provider = "cursor"`, `scope.harness_session_id = conversation_id`) + and sent it as a Signal-E2E DM to the running OpenHuman identity. +- OpenHuman's orchestration ingest decrypted it, classified it as `cursor` + (`harness_type_for`, the gate widened in the recognition slice), and rendered + it under a **Runtime · cursor** session. + +All prototype artifacts (the hooks file, the observer scripts, the throwaway +`cursorbridge`/`cursoragent` wallets, the `~/.tinyplace-cursorbridge` store) were +removed after the demo. The shippable output is the recognition slice (PR #4775). + +> **Correction (2026-07-13).** A first attempt to "fix" a bundle 404 by minting +> a slash-free tiny.place identity (PR #4779) was **closed as wrong** on review. +> The `/keys/:cryptoId/*` relay routes are keyed on the **base58** cryptoId (no +> `/`) and the backend already resolves both encodings, so the 404 only occurs +> when a *client* puts the base64 `identityKey` in the URL path. Every current +> send path already fetches by base58 (TS SDK ≥2.0.2 `deriveCryptoId`; OpenHuman +> `resolve_recipient_to_agent_id` → `crypto_id`). The observer's 404 was a stale +> `@tinyhumansai/tinyplace@1.0.1` build in the prototype, not a product defect — +> and remapping the identity would have broken the payment/wallet coupling +> (`signer.agent_id()` is the x402 `from` / registry `crypto_id`). Identity must +> stay `== funded Solana wallet`; the real follow-up is bumping the plugin's SDK +> dependency to ≥2.0.2 (see `tiny.place#213`). + +## Result: observe → render works ✅ + +A **real, live Cursor chat** (a Cursor agent window open on `~/work/k8s`, +`conversation_id = 4c17e405…`) streamed both turns into OpenHuman and rendered as +a Cursor runtime session — with no polling, driven purely by Cursor's own hooks +firing on each turn. This confirms the observe path the adapter needs is real: +hooks give us `{prompt}` / `{text}` plus `conversation_id` / `workspace_roots`, +which is enough to build the envelope the ingest already understands. + +### Spike-test A (env to MCP subprocess) — partially answered + +The observer used **hooks**, not the MCP subprocess, so it did not need the +sanitized-env workaround. But the same run confirmed the constraint the spike +called out: Cursor hands MCP/hook subprocesses a **sanitized environment** (only +`HOME`/`PATH`/`SHELL`/…). The observer therefore had to inject +`TINYPLACE_CURSOR_HOME` / `OPENHUMAN_ADDR` / `TINYPLACE_API_URL` explicitly via a +wrapper `.sh`. **Implication for the real adapter:** its `launch.prepare()` must +bake every env var the MCP server needs into the written `mcp.json` `env` block — +it cannot rely on inheriting the parent shell's environment. (This matches what +the committed `adapters/cursor.mjs` already does with its `env` sentinel.) + +## Gaps found (both are the *respond/inbound* half, not observe) + +### Gap 1 — reply address is base58↔base64 mismatched + +When OpenHuman tries to **reply** into the runtime session, the send fails with +`No agent found for `. This is the same base58/base64 encoding family as the +correction above: the peer must be addressed and resolved by its **base58** +`crypto_id`, not the **base64** `identityKey`. No new crypto is needed — the fix +is base58 discipline on the reply/resolve boundary, and it is likely already +closed once the prototype/plugin is on SDK ≥2.0.2 (whose send path fetches by +`deriveCryptoId`). **Next step: re-test the reply path against a current-SDK +plugin build before assuming any OpenHuman-side change is required.** + +### Gap 2 — no Cursor GUI-chat injection API + +Even with Gap 1 fixed, an orchestrator reply can be *delivered* to the runtime, +but there is **no supported way to inject text back into Cursor's live GUI chat +window**. The headless `cursor-agent -p` path can *answer* a prompt (that's the +adapter's `responder`), but it is a separate, headless turn — it does not appear +in the human's open Cursor chat. Surfacing an inbound DM into the *live GUI* turn +(spike-test B) remains **unsolved** and is a research item, not a quick fix. The +observe→render→headless-respond loop works; observe→render→*GUI-inject* does not. + +### First-contact ratchet desync (SDK-level, recoverable) + +On the very first DM between two fresh identities, the Double Ratchet can desync +("No session for " → the recipient drops the decrypt). Recovery is +`reset_session(, rehandshake: true)` on the emitter followed by a resend; +after that the session is stable. This is a known SDK first-contact edge, not +specific to the harness work, but adapter onboarding flows should expect it and +either pre-warm the session or retry once on the first send. + +## Net feasibility read + +- **Observe → normalize → render as a Cursor runtime: proven live.** ✅ +- **Headless respond (`cursor-agent -p`): proven separately** in the adapter + live E2E (whoami, session label `cursor:1`, staging-verified). +- **Orchestrator → live GUI reply: blocked** on Gap 1 (encoding, fixable) and + Gap 2 (no injection API, open research). + +So the recognition slice + the adapter's observe and headless-respond surfaces +are sound; the remaining work is the reply-address encoding fix and the (harder) +question of GUI injection. diff --git a/src/openhuman/orchestration/schemas.rs b/src/openhuman/orchestration/schemas.rs index 424c0a1523..5f0152abcb 100644 --- a/src/openhuman/orchestration/schemas.rs +++ b/src/openhuman/orchestration/schemas.rs @@ -190,7 +190,7 @@ struct SessionSummary { session_id: String, agent_id: String, source: String, - /// The emitting harness (claude/codex/gemini) when this is an external agent + /// The emitting harness (claude/codex/gemini/cursor/windsurf) when this is an external agent /// instance; absent for the pinned master/subconscious/user-created windows. #[serde(skip_serializing_if = "Option::is_none")] harness_type: Option, @@ -286,11 +286,15 @@ fn is_active(last_message_at: &str) -> bool { } /// The harness provider for a session, when its `source` names one. Session -/// windows persist the emitting harness (claude/codex/gemini) in `source` (see +/// windows persist the emitting harness (claude/codex/gemini/cursor/windsurf) in `source` (see /// `ingest.rs`); the sentinel windows (master/subconscious/user_created/ /// orchestration) carry no harness and yield `None`. fn harness_type_for(source: &str) -> Option { - matches!(source, "claude" | "codex" | "gemini").then(|| source.to_string()) + matches!( + source, + "claude" | "codex" | "gemini" | "cursor" | "windsurf" + ) + .then(|| source.to_string()) } /// Coarse instance status for the roster dot. Reads the persisted v2 run-state @@ -1193,6 +1197,8 @@ mod tests { assert_eq!(harness_type_for("claude").as_deref(), Some("claude")); assert_eq!(harness_type_for("codex").as_deref(), Some("codex")); assert_eq!(harness_type_for("gemini").as_deref(), Some("gemini")); + assert_eq!(harness_type_for("cursor").as_deref(), Some("cursor")); + assert_eq!(harness_type_for("windsurf").as_deref(), Some("windsurf")); // Sentinel / origin sources are not harnesses. assert_eq!(harness_type_for("master"), None); assert_eq!(harness_type_for("user_created"), None);