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);