Skip to content

Commit 0a76af8

Browse files
committed
feat: live Cursor subagent activity in the task card
A Cursor subagent's task card now behaves like a native opencode subagent card: clickable while running, with a live activity subtitle. - Child session is created up-front when the task call starts and state.metadata.sessionId is stamped on the RUNNING task part via part.update (the native task tool's execute-time equivalent), so the card is navigable from the start of the run. - Nested subagent activity (taskUpdate payloads on the parent task's tool-call-delta updates) is normalized into subagent-events and materialised as real tool parts in the child session (upserted via part.update): running on tool-start, completed on tool-result, with stragglers completed at finalize. This is what the TUI reads for its live activity subtitle. - The child session is seeded with the subagent's prompt and its rendered activity (text, thinking, tool calls, conversation steps, final answer, duration). - upsertToolPart treats a resolved { error } response as failure (hey-api's request does not throw on 4xx) and always sends state.metadata on completed parts, which the schema requires. - cursor_delegate also links a child session seeded with its transcript.
1 parent 121380d commit 0a76af8

15 files changed

Lines changed: 2542 additions & 140 deletions

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

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

936
The skills bridge (#90), per-model context limits and pricing (#89), and the

src/plugin/cursor-tools.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin";
22
import { runCloudAgent } from "../provider/cloud-agent.js";
33
import { runDelegate } from "../provider/delegate.js";
4+
import { linkDelegateSession } from "../provider/subagent-bridge.js";
45

56
const s = tool.schema;
67

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

211+
// Surface the delegate's work in a child session so it's discoverable
212+
// in the TUI's subagent panel. Best-effort: a failed link never breaks
213+
// the turn. The result card itself stays a tool block (a custom tool
214+
// can't render a navigable `task` part), so the child session is
215+
// reached via the subagent panel, not by clicking the result.
216+
if (context.sessionID) {
217+
const transcript = [
218+
result.text || "(no text output)",
219+
...(result.reasoning ? [`\n> ${result.reasoning}`] : []),
220+
...(result.toolActivity.length > 0
221+
? [`\n(${result.toolActivity.length} tool call(s))`]
222+
: []),
223+
].join("\n");
224+
await linkDelegateSession({
225+
parentSessionID: context.sessionID,
226+
title: `Cursor delegate (${args.model})`,
227+
prompt: args.prompt,
228+
transcript,
229+
});
230+
}
231+
210232
return {
211233
title: `Cursor delegate (${args.model})`,
212234
output: (result.text || "(no text output)") + toolNote,

src/plugin/index.ts

Lines changed: 69 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,18 @@ import {
1313
translateMcpServers,
1414
} from "./mcp-config.js";
1515
import { buildCursorTools } from "./cursor-tools.js";
16-
import { getLocalVersion, getLatestVersion, clearVersionCache, PLUGIN_CACHE_PATH } from "../version-check.js";
16+
import {
17+
getLocalVersion,
18+
getLatestVersion,
19+
clearVersionCache,
20+
PLUGIN_CACHE_PATH,
21+
} from "../version-check.js";
1722
import { removeSystemRule } from "../provider/system-rule.js";
18-
import { clearLogBridge, pluginLog, setLogBridge } from "../provider/log-bridge.js";
23+
import {
24+
clearLogBridge,
25+
pluginLog,
26+
setLogBridge,
27+
} from "../provider/log-bridge.js";
1928
import {
2029
writeSkillMirror,
2130
removeSkillMirror,
@@ -29,6 +38,8 @@ import {
2938
import {
3039
clearSubagentBridge,
3140
setSubagentBridge,
41+
subagentCallChildId,
42+
stampTaskPartSessionId,
3243
} from "../provider/subagent-bridge.js";
3344

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

6374
// Surfaces the update notice in the UI (toast). Resolved once per plugin
6475
// instance using the shared fetch above.
65-
const _versionCheckPromise: Promise<{ local: string; latest: string } | null> = (async () => {
66-
try {
67-
if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) return null;
68-
const local = getLocalVersion();
69-
const latest = await _latestVersionPromise;
70-
if (!local || !latest || !semver.gt(latest, local)) return null;
71-
return { local, latest };
72-
} catch {
73-
return null;
74-
}
75-
})();
76+
const _versionCheckPromise: Promise<{ local: string; latest: string } | null> =
77+
(async () => {
78+
try {
79+
if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) return null;
80+
const local = getLocalVersion();
81+
const latest = await _latestVersionPromise;
82+
if (!local || !latest || !semver.gt(latest, local)) return null;
83+
return { local, latest };
84+
} catch {
85+
return null;
86+
}
87+
})();
7688
let _toastShown = false;
7789

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

111-
112123
const directory = input?.directory;
113124
// Publish the opencode client + directory so the provider stream layer can
114125
// create a real child session for each Cursor subagent (making its `task`
@@ -180,10 +191,7 @@ export const CursorPlugin: Plugin = async (input) => {
180191
const { models } = await discoverModels({});
181192
config.provider ??= {};
182193
const existing = config.provider[PROVIDER_ID] ?? {};
183-
const existingOptions = (existing.options ?? {}) as Record<
184-
string,
185-
unknown
186-
>;
194+
const existingOptions = (existing.options ?? {}) as Record<string, unknown>;
187195

188196
// Forward opencode's configured MCP servers to the Cursor
189197
// agent so it can use the same servers. Opt out via
@@ -340,10 +348,9 @@ export const CursorPlugin: Plugin = async (input) => {
340348
// Cursor agent can't connect. Only those without a shareable
341349
// client registration are skipped; ones with a clientId are
342350
// forwarded with an `auth` block for the agent's own OAuth flow.
343-
const unshareable = findUnshareableOAuthServers(
344-
liveMcp,
345-
status,
346-
).filter((name) => !warnedOAuth.has(name));
351+
const unshareable = findUnshareableOAuthServers(liveMcp, status).filter(
352+
(name) => !warnedOAuth.has(name),
353+
);
347354
if (unshareable.length > 0) {
348355
for (const name of unshareable) warnedOAuth.add(name);
349356
const plural = unshareable.length > 1;
@@ -382,8 +389,7 @@ export const CursorPlugin: Plugin = async (input) => {
382389
writeSkillMirror(resolvedCwd, resolved.skills, (msg) =>
383390
pluginLog("warn", msg),
384391
);
385-
currentSkillsCatalogue =
386-
buildSkillsCatalogue(resolved.skills) ?? "";
392+
currentSkillsCatalogue = buildSkillsCatalogue(resolved.skills) ?? "";
387393
lastSkillHash = hash;
388394
}
389395
} catch {
@@ -396,6 +402,31 @@ export const CursorPlugin: Plugin = async (input) => {
396402
}
397403
},
398404

405+
// Stamp the child session id on the RUNNING `task` part. The provider
406+
// creates the child session when the Cursor subagent starts and
407+
// publishes call→child on the bridge registry; when opencode's
408+
// processor lands the task part (`message.part.updated`), patch it
409+
// (`part.update`, the native `ctx.metadata` equivalent) so the TUI
410+
// card carries `state.metadata.sessionId` from the start — matching
411+
// the native task tool, which publishes the id at execute time. The
412+
// processor emits a running-state part update for every streamed
413+
// tool part, so this fires early; the stamp is idempotent.
414+
event: async (input) => {
415+
const evt = input.event;
416+
if (evt.type !== "message.part.updated") return;
417+
const part = evt.properties.part;
418+
if (!part || part.type !== "tool" || part.tool !== "task") return;
419+
const childId = subagentCallChildId(part.callID);
420+
if (!childId) return;
421+
void stampTaskPartSessionId({
422+
sessionID: part.sessionID,
423+
messageID: part.messageID,
424+
partID: part.id,
425+
part,
426+
childId,
427+
});
428+
},
429+
399430
tool: {
400431
cursor_update_plugin: {
401432
description:
@@ -406,7 +437,11 @@ export const CursorPlugin: Plugin = async (input) => {
406437
return {
407438
title: "cursor plugin (checks disabled)",
408439
output: "Update checks are disabled (CI or NO_UPDATE_NOTIFIER is set).",
409-
metadata: { local: undefined, latest: undefined, status: "disabled" as const },
440+
metadata: {
441+
local: undefined,
442+
latest: undefined,
443+
status: "disabled" as const,
444+
},
410445
};
411446
}
412447

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

439-
// Plugin is outdated — clear the opencode plugin cache so it re-fetches on next launch.
440-
const cachePath = PLUGIN_CACHE_PATH;
441-
const removeCommand = process.platform === "win32"
442-
? `rmdir /s /q "${cachePath}"`
443-
: `rm -rf ${cachePath}`;
475+
// Plugin is outdated — clear the opencode plugin cache so it re-fetches on next launch.
476+
const cachePath = PLUGIN_CACHE_PATH;
477+
const removeCommand =
478+
process.platform === "win32"
479+
? `rmdir /s /q "${cachePath}"`
480+
: `rm -rf ${cachePath}`;
444481

445482
try {
446483
rmSync(cachePath, { recursive: true, force: true });
@@ -472,9 +509,7 @@ export const CursorPlugin: Plugin = async (input) => {
472509
args: {},
473510
execute: async () => {
474511
const result = await discoverModels({ forceRefresh: true });
475-
const lines = result.models.map(
476-
(m) => `- ${m.id}${m.displayName}`,
477-
);
512+
const lines = result.models.map((m) => `- ${m.id}${m.displayName}`);
478513
const header =
479514
result.source === "live"
480515
? `Refreshed ${result.models.length} Cursor models (live):`

src/provider/agent-events.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@ export interface CursorUsage {
1111
cacheWriteTokens: number;
1212
}
1313

14+
/**
15+
* A single nested update streamed from a Cursor subagent (the `task` tool).
16+
* The SDK surfaces these via the `tool-call-delta` interaction update; we
17+
* normalize the nested `taskUpdate` union into this small shape so the stream
18+
* layer can render it without depending on SDK internals.
19+
*/
20+
export type SubagentNestedEvent =
21+
| { type: "text"; text: string }
22+
| { type: "reasoning"; text: string }
23+
| { type: "tool-start"; id: string; name: string; input: unknown }
24+
| { type: "tool-result"; id: string; name: string; result: unknown; isError: boolean };
25+
1426
/** Normalized events bridged from the Cursor SDK's push callbacks. */
1527
export type CursorEvent =
1628
| { type: "text-delta"; text: string }
@@ -21,7 +33,13 @@ export type CursorEvent =
2133
| { type: "usage"; usage: CursorUsage }
2234
| { type: "reasoning-complete"; durationMs?: number }
2335
| { type: "compaction" }
24-
| { type: "finish"; text?: string };
36+
| { type: "finish"; text?: string }
37+
/**
38+
* A nested update from a Cursor subagent. `callId` is the parent's `task`
39+
* tool-call id, so the stream layer can route the event to the right child
40+
* session. Only one level of nesting is surfaced by the SDK.
41+
*/
42+
| { type: "subagent-event"; callId: string; event: SubagentNestedEvent };
2543

2644
export interface StreamAgentTurnOptions {
2745
mode: AgentModeOption;
@@ -36,6 +54,10 @@ export interface StreamAgentTurnOptions {
3654
usageBase?: CursorUsage;
3755
}
3856

57+
function isRecord(v: unknown): v is Record<string, unknown> {
58+
return typeof v === "object" && v !== null;
59+
}
60+
3961
/** Sum two usage reports (either may be absent). */
4062
export function addUsage(a?: CursorUsage, b?: CursorUsage): CursorUsage | undefined {
4163
if (!a) return b;
@@ -65,6 +87,56 @@ function toolDisplayName(toolCall: ({ type?: string } & Record<string, any>) | u
6587
return toolCall.type ?? "tool";
6688
}
6789

90+
/**
91+
* Normalize a nested subagent `taskUpdate` (from the SDK's `tool-call-delta`
92+
* interaction update) into a {@link SubagentNestedEvent}, or `undefined` when
93+
* the update carries nothing the stream layer renders (partials, step
94+
* bookkeeping, thinking-completed). The nested union is read defensively —
95+
* the SDK types are the contract, but the shape is opaque at runtime.
96+
*/
97+
function normalizeNestedTaskUpdate(update: unknown): SubagentNestedEvent | undefined {
98+
if (!isRecord(update)) return undefined;
99+
switch (update["type"]) {
100+
case "text-delta":
101+
return typeof update["text"] === "string"
102+
? { type: "text", text: update["text"] }
103+
: undefined;
104+
case "thinking-delta":
105+
return typeof update["text"] === "string"
106+
? { type: "reasoning", text: update["text"] }
107+
: undefined;
108+
case "tool-call-started": {
109+
const toolCall = isRecord(update["toolCall"]) ? update["toolCall"] : undefined;
110+
const id = typeof update["callId"] === "string" ? update["callId"] : "";
111+
return {
112+
type: "tool-start",
113+
id,
114+
name: toolDisplayName(toolCall),
115+
input: toolCall?.args ?? {},
116+
};
117+
}
118+
case "tool-call-completed": {
119+
const toolCall = isRecord(update["toolCall"]) ? update["toolCall"] : undefined;
120+
const id = typeof update["callId"] === "string" ? update["callId"] : "";
121+
const result = toolCall?.result;
122+
const resultValue = isRecord(result) ? result["value"] : undefined;
123+
const mcpError =
124+
toolCall?.type === "mcp" && isRecord(resultValue) && resultValue["isError"] === true;
125+
return {
126+
type: "tool-result",
127+
id,
128+
name: toolDisplayName(toolCall),
129+
result: result ?? null,
130+
isError: (isRecord(result) && result["status"] === "error") || mcpError,
131+
};
132+
}
133+
default:
134+
// partial-tool-call, thinking-completed, step-started, step-completed:
135+
// nothing to render (the final tool-call carries full args).
136+
return undefined;
137+
}
138+
}
139+
68140
/**
69141
* Node stores a timer delay in a signed 32-bit int; anything larger overflows
70142
* and is silently clamped to `1`. An operator following the tool-phase stall
@@ -203,6 +275,21 @@ export async function* streamAgentTurn(
203275
});
204276
break;
205277
}
278+
case "tool-call-delta": {
279+
// Nested updates from a Cursor subagent (the `task` tool). `callId` is
280+
// the parent task tool-call id; the stream layer routes the event to
281+
// the matching child session. Ignored when the nested update carries
282+
// nothing renderable.
283+
const nested = normalizeNestedTaskUpdate(update.taskUpdate);
284+
if (nested) {
285+
push({
286+
type: "subagent-event",
287+
callId: String(update.callId),
288+
event: nested,
289+
});
290+
}
291+
break;
292+
}
206293
case "turn-ended":
207294
// Reconcile: a dropped or differently-keyed `tool-call-completed`
208295
// would otherwise leave an entry pinned here, holding the turn on the

0 commit comments

Comments
 (0)