Skip to content

Commit 39bb303

Browse files
committed
feat: navigable Cursor subagents + release v0.6.0-next.0
Cursor's task tool now renders as a real, navigable opencode child session (parentID-linked, clickable, ctrl+x down) instead of a dead 'Unspecified Task' card. The plugin bridges its opencode client to the provider stream layer (src/provider/subagent-bridge.ts); on each completed subagent it creates a child session seeded with the prompt + Cursor's transcript and real duration, and stamps metadata.sessionId onto the task part. Best-effort: degrades to the prior non-navigable card when the client is unavailable. Also fixes the 'Unspecified Task' label (Cursor's proto zero-value kind is no longer forwarded; falls back to opencode's 'General Task' or the real name). Bump: 0.5.0-next.0 -> 0.6.0-next.0; CHANGELOG updated.
1 parent 3392d5d commit 39bb303

8 files changed

Lines changed: 464 additions & 19 deletions

File tree

CHANGELOG.md

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

55
## [Unreleased]
66

7+
## [0.6.0-next.0] — 2026-07-24
8+
9+
Native Cursor subagents: the Cursor agent's `task` tool now renders as a
10+
navigable opencode child session instead of a dead "Unspecified Task" card.
11+
12+
- **Cursor subagents are now navigable opencode child sessions.** When the Cursor
13+
agent runs its `task` tool, the plugin creates a real opencode child session
14+
(`parentID` = the current session) and links it to the task card, so it's
15+
clickable and reachable via `ctrl+x down` — like a native subagent. The child
16+
session is seeded with the subagent's prompt and Cursor's returned transcript
17+
plus a real duration line (posted as user-role messages via `noReply`; the
18+
public API can't synthesize assistant messages). Best-effort: if the opencode
19+
client is unavailable the card degrades to its previous, non-navigable form.
20+
The plugin hands its opencode client to the provider stream layer through an
21+
in-process bridge (`src/provider/subagent-bridge.ts`).
22+
- **Fixed: generic Cursor subagents rendered as "Unspecified Task".** Cursor's
23+
proto zero-value `subagentType.kind` (`"unspecified"`) is no longer forwarded
24+
as the agent label; the card now falls back to opencode's "General Task" (or
25+
the real subagent name when Cursor provides one).
26+
727
## [0.5.0] — 2026-07-24
828

929
Native-experience overhaul: in-process HTTP/1.1 transport under Bun, typed-error

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@stablekernel/opencode-cursor",
3-
"version": "0.5.0",
3+
"version": "0.6.0-next.0",
44
"description": "opencode provider plugin backed by the official Cursor SDK (@cursor/sdk) — adds a Cursor provider and lists its models",
55
"type": "module",
66
"license": "MIT",

src/plugin/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
import { buildCursorTools } from "./cursor-tools.js";
1414
import { warnIfStale } from "../version-check.js";
1515
import { removeSystemRule } from "../provider/system-rule.js";
16+
import {
17+
clearSubagentBridge,
18+
setSubagentBridge,
19+
} from "../provider/subagent-bridge.js";
1620

1721
function apiKeyFromAuth(auth: Auth | undefined): string | undefined {
1822
return auth?.type === "api" ? auth.key : undefined;
@@ -49,6 +53,11 @@ export const CursorPlugin: Plugin = async (input) => {
4953
// (reflecting mid-session enable/disable) rather than the startup snapshot.
5054
const client = input?.client;
5155
const directory = input?.directory;
56+
// Publish the opencode client + directory so the provider stream layer can
57+
// create a real child session for each Cursor subagent (making its `task`
58+
// card clickable / `ctrl+x`-navigable). Same-process handoff via a globalThis
59+
// registry; the provider degrades gracefully when it's absent.
60+
if (client) setSubagentBridge({ client, directory });
5261
// Canonical working directory for the generated system-prompt rule: the
5362
// provider writes `.cursor/rules/opencode.mdc` under this path and dispose
5463
// cleans it up from the same path. The config hook threads it into the
@@ -265,6 +274,7 @@ export const CursorPlugin: Plugin = async (input) => {
265274
// Uses the same canonical cwd the provider wrote to; sentinel-guarded,
266275
// so a user-owned opencode.mdc is never deleted.
267276
removeSystemRule(resolvedCwd);
277+
clearSubagentBridge();
268278
},
269279
};
270280
};

src/provider/language-model.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -498,11 +498,19 @@ export class CursorLanguageModel implements LanguageModelV3 {
498498
async doStream(options: LanguageModelV3CallOptions): Promise<{
499499
stream: ReadableStream<LanguageModelV3StreamPart>;
500500
}> {
501+
// Parent opencode session id (same source agentRun reads) so a Cursor
502+
// subagent's `task` part can be linked to a real opencode child session.
503+
const po = options.providerOptions?.[this.provider] as
504+
| Record<string, unknown>
505+
| undefined;
506+
const sessionID =
507+
typeof po?.["sessionID"] === "string"
508+
? (po["sessionID"] as string)
509+
: undefined;
501510
return {
502-
stream: cursorEventsToStream(
503-
this.agentRun(options),
504-
this.config.toolDisplay,
505-
),
511+
stream: cursorEventsToStream(this.agentRun(options), this.config.toolDisplay, {
512+
sessionID,
513+
}),
506514
};
507515
}
508516

src/provider/stream-map.ts

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,38 @@ import type {
55
LanguageModelV3Usage,
66
} from "@ai-sdk/provider";
77
import type { CursorEvent, CursorUsage } from "./agent-events.js";
8+
import { linkSubagentSession } from "./subagent-bridge.js";
9+
10+
/** Per-turn context threaded from the language model into the stream mapper. */
11+
export interface StreamContext {
12+
/** Parent opencode session id, used to link Cursor subagents as children. */
13+
sessionID?: string;
14+
}
15+
16+
/** Cursor's subagent tool name (the `task` tool-call/tool-result event name). */
17+
const TASK_TOOL_NAME = "task";
18+
19+
/**
20+
* Inject a linked child-session id into an already-folded `task` tool-result
21+
* part so the TUI renders the card as clickable / `ctrl+x`-navigable. The
22+
* folded result object lives on `part.result` ({@link nativeToolResult}); its
23+
* `metadata` is a fresh object per call, so mutation here is safe.
24+
*/
25+
function injectSubagentSessionId(
26+
parts: BlockToolPart[],
27+
sessionId: string,
28+
): void {
29+
for (const part of parts) {
30+
if (part.type !== "tool-result" || part.toolName !== "task") continue;
31+
const folded = (part as { result?: unknown }).result;
32+
if (!isRecord(folded)) continue;
33+
const metadata = isRecord(folded["metadata"])
34+
? (folded["metadata"] as Record<string, unknown>)
35+
: {};
36+
metadata["sessionId"] = sessionId;
37+
(folded as Record<string, unknown>)["metadata"] = metadata;
38+
}
39+
}
840

941
/**
1042
* How Cursor's internal tool activity (shell/read/edit/mcp/…) is surfaced to
@@ -158,6 +190,20 @@ function successValue(result: unknown): unknown {
158190
: undefined;
159191
}
160192

193+
/**
194+
* The opencode `task` input's `subagent_type` field, or `{}` to omit it. Prefers
195+
* Cursor's real subagent `name`; accepts a `kind` only when it isn't the proto
196+
* zero-value `"unspecified"` (which the TUI would titlecase to "Unspecified
197+
* Task"). Omitting the field makes the TUI fall back to "General Task".
198+
*/
199+
function subagentTypeField(args: unknown): { subagent_type?: string } {
200+
const sub = isRecord(args) ? args["subagentType"] : undefined;
201+
const name = strField(sub, "name");
202+
const kind = strField(sub, "kind");
203+
const subagent = name ?? (kind && kind !== "unspecified" ? kind : undefined);
204+
return subagent ? { subagent_type: subagent } : {};
205+
}
206+
161207
/** The `{title, metadata, output}` shape opencode folds into a tool part's state. */
162208
interface FoldedResult {
163209
title: string;
@@ -556,19 +602,16 @@ const NATIVE_ADAPTERS: Record<string, NativeToolAdapter> = {
556602
},
557603
},
558604
// Cursor `task` (subagent) → opencode `task` (agent card: name + description).
559-
// Non-clickable here — the subagent ran inside Cursor, not as an opencode
560-
// child session — but the native card reads far better than raw JSON.
605+
// Made navigable in the stream layer: when a parent session + the plugin
606+
// bridge are present, a real opencode child session is created and its id is
607+
// stamped onto this result's `metadata.sessionId` (see cursorEventsToStream's
608+
// tool-result path). Without that link it degrades to a non-navigable card.
561609
task: {
562610
tool: "task",
563-
input: (args) => {
564-
const description = strField(args, "description") ?? "";
565-
const sub = isRecord(args) ? args["subagentType"] : undefined;
566-
const subagent =
567-
strField(sub, "name") ?? strField(sub, "kind") ?? undefined;
568-
return subagent
569-
? { description, subagent_type: subagent }
570-
: { description };
571-
},
611+
input: (args) => ({
612+
description: strField(args, "description") ?? "",
613+
...subagentTypeField(args),
614+
}),
572615
result: (value, args) => {
573616
const description = strField(args, "description") ?? "";
574617
const suffix = strField(value, "resultSuffix");
@@ -1029,6 +1072,7 @@ const DANGLING_TOOL_RESULT = {
10291072
export function cursorEventsToStream(
10301073
events: AsyncIterable<CursorEvent>,
10311074
toolDisplay: ToolDisplay = "blocks",
1075+
ctx: StreamContext = {},
10321076
): ReadableStream<LanguageModelV3StreamPart> {
10331077
return new ReadableStream<LanguageModelV3StreamPart>({
10341078
async start(controller) {
@@ -1183,6 +1227,13 @@ export function cursorEventsToStream(
11831227
case "tool-result":
11841228
if (toolState.dropped.delete(event.id)) break;
11851229
if (toolDisplay === "blocks") {
1230+
// Capture the original call args before blockToolResultParts
1231+
// clears the open-tool entry — the subagent link needs the
1232+
// task description/prompt, which live on the call, not result.
1233+
const taskArgs =
1234+
event.name === TASK_TOOL_NAME
1235+
? toolState.open.get(event.id)?.args
1236+
: undefined;
11861237
const parts = blockToolResultParts(
11871238
event.id,
11881239
event.name,
@@ -1194,6 +1245,23 @@ export function cursorEventsToStream(
11941245
closeText();
11951246
closeReasoning();
11961247
}
1248+
// A Cursor subagent ran inside Cursor (no opencode child
1249+
// session). Create a real child session now and point the task
1250+
// card at it so it's clickable / ctrl+x-navigable. Best-effort:
1251+
// linkSubagentSession swallows all failures and returns
1252+
// undefined, leaving the card exactly as before.
1253+
if (
1254+
event.name === TASK_TOOL_NAME &&
1255+
!event.isError &&
1256+
ctx.sessionID
1257+
) {
1258+
const childId = await linkSubagentSession({
1259+
parentSessionID: ctx.sessionID,
1260+
args: taskArgs,
1261+
result: event.result,
1262+
});
1263+
if (childId) injectSubagentSessionId(parts, childId);
1264+
}
11971265
for (const part of parts) {
11981266
controller.enqueue(part);
11991267
}

0 commit comments

Comments
 (0)