Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

- **Cursor subagent transcripts in the TUI subagent view.** The child session
created for a Cursor subagent (`task` tool) is now seeded with the subagent's
own activity — its assistant text, thinking, and tool calls with args and
results — rendered from Cursor's `conversationSteps`, plus the final answer
and duration. Previously only a post-completion activity summary appeared.
Steps arrive as raw protobuf JSON, where `agent.v1.ConversationStep`'s `message`
oneof serialises to a single camelCase key (`{ assistantMessage: … }`,
`{ toolCall: { shellToolCall: … } }`) rather than the `{ type, message }` shape
of the SDK's public type; both are accepted. Transcript content is never
truncated — the child session carries the subagent's full output.
- **Live activity on the Cursor subagent card.** The SDK streams a local
subagent's nested activity via `taskUpdate` payloads on the parent task's
`tool-call-delta` updates (text, thinking, tool-start/tool-result with
id + name + input). Those events now write real `tool` parts into the child
session via `part.update` (an upsert — `session/processor.ts` creates parts
the same way), so the `task` card shows a live `↳ <Tool> <title>` subtitle
while the subagent runs (the TUI builds that line purely from `tool` parts
in the child session — `tui/routes/session/index.tsx:2227-2279`).
The child session is created up-front when the `task` call starts and the
task card's `state.metadata.sessionId` is stamped while the subagent is still
running (via opencode's `part.update` endpoint, mirroring the native task
tool's execute-time metadata publication), so the card is clickable /
`ctrl+x`-navigable live. Tool calls complete when their tool-result event
arrives; any call left open is completed at finalize.
`cursor_delegate` also creates a child session seeded with its transcript,
discoverable via the TUI's subagent panel.

## [0.7.1] — 2026-08-05

The skills bridge (#90), per-model context limits and pricing (#89), and the
Expand Down
986 changes: 986 additions & 0 deletions docs/superpowers/plans/2026-08-06-cursor-subagent-live-transcript.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Source findings: native-like Cursor subagent cards without an upstream PR

Research against the **installed** opencode source, v1.18.18 (sparse clone:
`/tmp/opencode-src`, sparse = packages/opencode/src, packages/tui/src,
packages/plugin/src, packages/sdk). The old checkout at
`~/workspace/opencode` is the 2025 Go-TUI architecture — **do not trust it**;
its task tool used `metadata.summary` and it has no PATCH part endpoint.

Also researched: `pi-cursor-sdk` + `@cursor/sdk`
(`~/.pi/agent/npm/node_modules/…`) for comparable patterns.

## Verified mechanism chain (v1.18.18)

1. **Native task tool stamps the link itself.** `tool/task.ts:167-176` creates
the child session (`parentID: ctx.sessionID`), then `:185-193` calls
`ctx.metadata({ title, metadata: { sessionId: nextSession.id, … } })`
*before* executing. The link exists from the start of the run; there is no
pending/running window to race.
2. **TUI card reads** `metadata.sessionId` (`tui/src/routes/session/index.tsx:2238`),
syncs the child session on mount (`:2235-2238`), and renders the subtitle
from `tool` parts in the child session: `tools()` memo `:2244-2248` (no role
filter), `current()` `:2250-2252` (last part with status running/completed
and a title), subtitle `:2279-2291` = `↳ <Tool> <title>`.
3. **PATCH part is an upsert.** `httpapi/handlers/session.ts:397-412` validates
id/messageID/sessionID match the path and calls `session.updatePart`;
`session.ts:637-646` publishes `SessionV1.Event.PartUpdated` → SSE
`message.part.updated`. The TUI event handler (`tui/src/…/sync.tsx:165,376`)
filters by **directory only** — child-session part updates reach the parent
view live.
4. **`noReply` exists** (`session/prompt.ts:1504` schema, `:1069` skips the LLM
loop) — a plugin can seed a message in a synthesized child session without
invoking a model.
5. **Plugin surface** (`plugin/index.ts`): `event` hook fires for every
directory event (`:257`); plugin gets a full SDK `client` (`:144,158-167`).

## Key improvement over the current plan: providerMetadata, not PATCH-stamping

`session/processor.ts:337-356` (tool-call) merges the AI SDK stream part's
`providerMetadata` into the tool part's top-level `metadata` — the exact field
the TUI card reads for `sessionId` (`:249` also sets metadata for
providerExecuted tools). Since this plugin **authors the stream**
(`stream-map.ts`), it can stamp `{ sessionId: childId }` as `providerMetadata`
on the task `tool-call` part inline. That:

- eliminates the Task-1 race entirely (no event-hook + re-read + PATCH),
- matches how the link is stored natively (same `metadata.sessionId` key),
- works while `pending`→`running` because the processor writes metadata on the
tool-call event itself.

**Must verify empirically:** whether the processor stores `providerMetadata`
flat or namespaced by provider (`{ cursor: { sessionId } }` would not satisfy
`metadata.sessionId`). Read `processor.ts:337-356` closely and log one real
part. If namespaced, fall back to the plan's PATCH-stamp path.

## Cursor SDK side (pi-cursor-sdk comparison)

- Confirmed: `@cursor/sdk` `SendOptions` has only `onDelta`/`onStep`
(`agent.d.ts:31-39`). No `onSubagent`, no nested stream events. Subagent
activity lands only in the task result's `conversationSteps[]` +
`transcriptPath` (both post-completion).
- pi-cursor-sdk does **no** live subagent streaming — it summarizes
`conversationSteps` after completion
(`cursor-tool-result-display-readers.ts:94-100`) and never reads
`transcriptPath`. Our transcript-tail approach goes beyond it; no pattern to
copy, but also no contradiction.
- Reusable detail: task args carry `subagentType: { kind, name }` — good for
display naming of the card.

## Resulting architecture (addon-only, no upstream PR)

1. Child session created at task tool-call time (existing bridge code).
2. `sessionId` stamped via `providerMetadata` on the streamed tool-call part
(new; replaces the racy PATCH stamp) — pending empirical check above.
3. Live activity: tail Cursor's on-disk subagent transcript (full-rewrite
semantics, completed-line counting — as planned), upsert `tool` parts into
the child session via the PATCH upsert endpoint. TUI receives the SSE part
updates because filtering is by directory.
4. Final result still flows through the existing finalize path.

## Open verifications

- [ ] providerMetadata flat-vs-namespaced (see above) — decide stamp path.
- [ ] Transcript checkpoint cadence mid-run (plan Task 1 Step 3).
- [ ] noReply message shape: confirm which message id (user vs assistant) the
response returns, for anchoring synthesized tool parts.
22 changes: 22 additions & 0 deletions src/plugin/cursor-tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin";
import { runCloudAgent } from "../provider/cloud-agent.js";
import { runDelegate } from "../provider/delegate.js";
import { linkDelegateSession } from "../provider/subagent-bridge.js";

const s = tool.schema;

Expand Down Expand Up @@ -207,6 +208,27 @@ export function buildCursorTools(deps: CursorToolDeps): Record<string, ToolDefin
`${result.toolActivity.some((t) => t.isError) ? ", some failed" : ""})`
: "";

// Surface the delegate's work in a child session so it's discoverable
// in the TUI's subagent panel. Best-effort: a failed link never breaks
// the turn. The result card itself stays a tool block (a custom tool
// can't render a navigable `task` part), so the child session is
// reached via the subagent panel, not by clicking the result.
if (context.sessionID) {
const transcript = [
result.text || "(no text output)",
...(result.reasoning ? [`\n> ${result.reasoning}`] : []),
...(result.toolActivity.length > 0
? [`\n(${result.toolActivity.length} tool call(s))`]
: []),
].join("\n");
await linkDelegateSession({
parentSessionID: context.sessionID,
title: `Cursor delegate (${args.model})`,
prompt: args.prompt,
transcript,
});
}

return {
title: `Cursor delegate (${args.model})`,
output: (result.text || "(no text output)") + toolNote,
Expand Down
103 changes: 69 additions & 34 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,18 @@ import {
translateMcpServers,
} from "./mcp-config.js";
import { buildCursorTools } from "./cursor-tools.js";
import { getLocalVersion, getLatestVersion, clearVersionCache, PLUGIN_CACHE_PATH } from "../version-check.js";
import {
getLocalVersion,
getLatestVersion,
clearVersionCache,
PLUGIN_CACHE_PATH,
} from "../version-check.js";
import { removeSystemRule } from "../provider/system-rule.js";
import { clearLogBridge, pluginLog, setLogBridge } from "../provider/log-bridge.js";
import {
clearLogBridge,
pluginLog,
setLogBridge,
} from "../provider/log-bridge.js";
import {
writeSkillMirror,
removeSkillMirror,
Expand All @@ -29,6 +38,8 @@ import {
import {
clearSubagentBridge,
setSubagentBridge,
subagentCallChildId,
stampTaskPartSessionId,
} from "../provider/subagent-bridge.js";

function apiKeyFromAuth(auth: Auth | undefined): string | undefined {
Expand Down Expand Up @@ -62,17 +73,18 @@ export const CursorPlugin: Plugin = async (input) => {

// Surfaces the update notice in the UI (toast). Resolved once per plugin
// instance using the shared fetch above.
const _versionCheckPromise: Promise<{ local: string; latest: string } | null> = (async () => {
try {
if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) return null;
const local = getLocalVersion();
const latest = await _latestVersionPromise;
if (!local || !latest || !semver.gt(latest, local)) return null;
return { local, latest };
} catch {
return null;
}
})();
const _versionCheckPromise: Promise<{ local: string; latest: string } | null> =
(async () => {
try {
if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) return null;
const local = getLocalVersion();
const latest = await _latestVersionPromise;
if (!local || !latest || !semver.gt(latest, local)) return null;
return { local, latest };
} catch {
return null;
}
})();
let _toastShown = false;

// The Cursor API key resolved by opencode's auth loader, captured so the
Expand Down Expand Up @@ -108,7 +120,6 @@ export const CursorPlugin: Plugin = async (input) => {
})
.catch(() => {});


const directory = input?.directory;
// Publish the opencode client + directory so the provider stream layer can
// create a real child session for each Cursor subagent (making its `task`
Expand Down Expand Up @@ -180,10 +191,7 @@ export const CursorPlugin: Plugin = async (input) => {
const { models } = await discoverModels({});
config.provider ??= {};
const existing = config.provider[PROVIDER_ID] ?? {};
const existingOptions = (existing.options ?? {}) as Record<
string,
unknown
>;
const existingOptions = (existing.options ?? {}) as Record<string, unknown>;

// Forward opencode's configured MCP servers to the Cursor
// agent so it can use the same servers. Opt out via
Expand Down Expand Up @@ -340,10 +348,9 @@ export const CursorPlugin: Plugin = async (input) => {
// Cursor agent can't connect. Only those without a shareable
// client registration are skipped; ones with a clientId are
// forwarded with an `auth` block for the agent's own OAuth flow.
const unshareable = findUnshareableOAuthServers(
liveMcp,
status,
).filter((name) => !warnedOAuth.has(name));
const unshareable = findUnshareableOAuthServers(liveMcp, status).filter(
(name) => !warnedOAuth.has(name),
);
if (unshareable.length > 0) {
for (const name of unshareable) warnedOAuth.add(name);
const plural = unshareable.length > 1;
Expand Down Expand Up @@ -382,8 +389,7 @@ export const CursorPlugin: Plugin = async (input) => {
writeSkillMirror(resolvedCwd, resolved.skills, (msg) =>
pluginLog("warn", msg),
);
currentSkillsCatalogue =
buildSkillsCatalogue(resolved.skills) ?? "";
currentSkillsCatalogue = buildSkillsCatalogue(resolved.skills) ?? "";
lastSkillHash = hash;
}
} catch {
Expand All @@ -396,6 +402,31 @@ export const CursorPlugin: Plugin = async (input) => {
}
},

// Stamp the child session id on the RUNNING `task` part. The provider
// creates the child session when the Cursor subagent starts and
// publishes call→child on the bridge registry; when opencode's
// processor lands the task part (`message.part.updated`), patch it
// (`part.update`, the native `ctx.metadata` equivalent) so the TUI
// card carries `state.metadata.sessionId` from the start — matching
// the native task tool, which publishes the id at execute time. The
// processor emits a running-state part update for every streamed
// tool part, so this fires early; the stamp is idempotent.
event: async (input) => {
const evt = input.event;
if (evt.type !== "message.part.updated") return;
const part = evt.properties.part;
if (!part || part.type !== "tool" || part.tool !== "task") return;
const childId = subagentCallChildId(part.callID);
if (!childId) return;
void stampTaskPartSessionId({
sessionID: part.sessionID,
messageID: part.messageID,
partID: part.id,
part,
childId,
});
},

tool: {
cursor_update_plugin: {
description:
Expand All @@ -406,7 +437,11 @@ export const CursorPlugin: Plugin = async (input) => {
return {
title: "cursor plugin (checks disabled)",
output: "Update checks are disabled (CI or NO_UPDATE_NOTIFIER is set).",
metadata: { local: undefined, latest: undefined, status: "disabled" as const },
metadata: {
local: undefined,
latest: undefined,
status: "disabled" as const,
},
};
}

Expand All @@ -423,7 +458,8 @@ export const CursorPlugin: Plugin = async (input) => {
if (!latest || !semver.valid(latest)) {
return {
title: "cursor plugin (registry unavailable)",
output: "Could not fetch the latest version from npm. Check your network connection and try again.",
output:
"Could not fetch the latest version from npm. Check your network connection and try again.",
metadata: { local, latest, status: "failed" as const },
};
}
Expand All @@ -436,11 +472,12 @@ export const CursorPlugin: Plugin = async (input) => {
};
}

// Plugin is outdated — clear the opencode plugin cache so it re-fetches on next launch.
const cachePath = PLUGIN_CACHE_PATH;
const removeCommand = process.platform === "win32"
? `rmdir /s /q "${cachePath}"`
: `rm -rf ${cachePath}`;
// Plugin is outdated — clear the opencode plugin cache so it re-fetches on next launch.
const cachePath = PLUGIN_CACHE_PATH;
const removeCommand =
process.platform === "win32"
? `rmdir /s /q "${cachePath}"`
: `rm -rf ${cachePath}`;

try {
rmSync(cachePath, { recursive: true, force: true });
Expand Down Expand Up @@ -472,9 +509,7 @@ export const CursorPlugin: Plugin = async (input) => {
args: {},
execute: async () => {
const result = await discoverModels({ forceRefresh: true });
const lines = result.models.map(
(m) => `- ${m.id} — ${m.displayName}`,
);
const lines = result.models.map((m) => `- ${m.id} — ${m.displayName}`);
const header =
result.source === "live"
? `Refreshed ${result.models.length} Cursor models (live):`
Expand Down
Loading