pi-cursor-sdk 0.1.41 → 0.1.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.42 - 2026-06-10
6
+
7
+ ### Added
8
+
9
+ - Surface Cursor SDK `task` activity as `Cursor subagent` replay/lifecycle cards by default, including description, subagent kind, model, short safe agent ID metadata, task-only expand hints, and returned `conversationSteps` summaries when the SDK provides them.
10
+ - Add `PI_CURSOR_TASK_PRESENTATION=task` as an escape hatch for the older explicit `Cursor task` wording.
11
+
12
+ ### Fixed
13
+
14
+ - Suppress unsafe nested subagent path displays, including out-of-workspace absolute paths, traversal, home aliases, URI-shaped paths, and Windows drive forms, while preserving in-workspace relative summaries.
15
+
5
16
  ## 0.1.41 - 2026-06-09
6
17
 
7
18
  ### Changed
package/README.md CHANGED
@@ -417,6 +417,8 @@ pi shows **Cursor web search** / **Cursor web fetch** activity cards only when t
417
417
 
418
418
  Known SDK boundary: some local Cursor web search activity is not emitted through live `onDelta`, `onStep`, or `run.stream()` tool events. When that happens, pi can only reconstruct a card from the local agent transcript after `run.wait()` finishes, so the **Cursor web search** card may appear after assistant text rather than as a live in-progress card. Buffering all assistant text until `run.wait()` would make the ordering prettier but would break normal streaming, so pi does not do that.
419
419
 
420
+ Known SDK boundary: Cursor SDK `task` activity is shown as **Cursor subagent** because it represents Cursor-spawned child-agent work, but the SDK does not always emit a live nested subagent action stream. Pi shows the subagent start, final output, kind/model/short-ID metadata, and any `conversationSteps` tool-call summaries Cursor returns. If Cursor only returns final subagent text, pi cannot show the subagent's internal read/shell/MCP steps.
421
+
420
422
  Many runs never expose web activity as replayable SDK tool completions or local transcript web tool records. The model may still answer from internal Cursor web tooling or only mention search in assistant text/thinking. In that case pi cannot render a tool card because there is no completed SDK tool-call payload to replay. Capture a run with `npm run debug:provider-events` when investigating; if `on-delta.jsonl`, `on-step.jsonl`, `stream-events.jsonl`, `coordinator-events.jsonl`, and `display-decisions.jsonl` have no completed or transcript web tool data, the limitation is on the Cursor SDK surface, not pi replay registration.
421
423
 
422
424
  **Web fetch:** `pi-cursor-sdk` can display `webFetchToolCall` transcript records and web-fetch-shaped MCP/host completions when Cursor reports them. It cannot make Cursor expose or execute a `WebFetch` tool. If Cursor's current local SDK tool set does not include WebFetch, pi cannot fetch a URL through Cursor web fetch; use an allowed browser/shell/MCP tool instead.
@@ -29,7 +29,7 @@ Current implementation notes:
29
29
  - Cursor SDK MCP tool calls use a guarded timeout override because installed `@cursor/sdk` 1.0.18 has a 60-second MCP request default with no public per-server timeout option. The extension extends the verified Cursor SDK MCP `callTool` timeout path to 3600 seconds by default and shortens the verified first-send MCP initialize/listTools timeout paths to 10 seconds by default so unavailable configured MCP servers do not block the first reply for a full minute; unknown MCP protocol timeout stacks keep the SDK default. Users can override tool-call timeouts with `PI_CURSOR_MCP_TOOL_TIMEOUT_MS` or `PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS`, and initialize/listTools timeouts with `PI_CURSOR_MCP_CONNECT_TIMEOUT_MS` or `PI_CURSOR_MCP_CONNECT_TIMEOUT_SECONDS`.
30
30
  - Bridge diagnostics are opt-in only: `PI_CURSOR_PI_TOOL_BRIDGE_DEBUG=1` writes typed, allowlisted, scrubbed single-line JSONL records to `process.stderr` with prefix `[pi-cursor-sdk:bridge]`. Diagnostics are scrubbed operational logs, not anonymous telemetry. They intentionally include tool names, safe correlation IDs, run lifecycle, exposed pi↔MCP name pairs, queued requests, result resolution, rejection, cancellation, and pending counts. Correlation IDs are generated independently from the tokenized endpoint path, and Cursor MCP call IDs are hashed before serialization. Diagnostics must not include endpoint paths/URLs/path components/tokens, API keys, bearer tokens, cookies, session credentials, raw args/results, stdout/stderr payloads, file contents, Cursor settings output, or local private session paths in tracked docs, and they must not call pi UI status, notification, or footer APIs. If tool names themselves are unacceptable for a release target, bridge debug diagnostics are not safe for shared logs under the current contract.
31
31
  - This repo does not provide a generic desktop-automation, browser-driver, or CDP recipe. Provider docs should describe pi-cursor-sdk's Cursor provider/bridge contract only.
32
- - Cursor internal tool activity is recorded from SDK events and scrubbed. Maintainer reference for all 16 `@cursor/sdk@1.0.18` `ToolType` values, runtime alias normalization, and intentional mapping/fallback rules: [Cursor native tool replay — SDK ToolType replay matrix](./cursor-native-tool-replay.md#sdk-tooltype-replay-matrix) (official SDK docs: https://cursor.com/docs/sdk/typescript). In TUI sessions and structured JSON/RPC modes, supported completed `read`, `bash`, `grep`, `find`, `ls`, `edit`, `write`, diagnostics, delete, todo/plan, task, image generation, MCP, semantic search, and screen recording activity is replayed through pi's native tool-call rendering path with recorded Cursor results, so users and JSON/RPC consumers can see native-looking cards/events without rerunning Cursor's reads/shell commands/file edits. Cursor `glob` activity is replayed through native `find` cards. Cursor write activity is replayed through native-looking `write` cards, and Cursor StrReplace/edit activity uses native-looking `edit` only when recorded arguments truthfully satisfy pi's `edit` schema; path-only Cursor edit and notebook edit replay falls back to neutral Cursor activity before pi validation. Diagnostics, delete, todos/plans, task, image, and MCP activity use neutral Cursor activity cards with pi's default success/error shell. Neutral Cursor activity calls include `activityTitle` and, when available, `activitySummary` so partial/collapsed cards preserve identity such as `Cursor plan`, `Cursor todos`, `Cursor MCP`, or `Cursor edit`. For long-running or externally meaningful Cursor tools (`task`, `shell`, `mcp`, `generateImage`, `recordScreen`, `semSearch`, web search/fetch, plan/todo), the provider may surface one low-noise deferred in-progress thinking line such as `Cursor MCP: external_search` from bounded, scrubbed SDK args; fast local tools (`read`, `grep`, `glob`, and similar) skip lifecycle lines when completion follows immediately, and pi bridge MCP calls are excluded because pi already shows real pi tool execution ([lifecycle visibility](./cursor-native-tool-replay.md#low-noise-tool-lifecycle-visibility)). Replay-only tools display recorded Cursor results, normalize workspace-local paths/diff headers for display, use pi diff colors for edit previews and path-inferred syntax highlighting for write previews, and fail closed if called without a recorded result. Native replay wrappers are registered only for tool names not already owned by another extension; conflicting tools use the bounded scrubbed transcript fallback. Cursor workflow tools such as mode/task/todo/plan activity are not pi workflow controls; reported todo/plan events are displayed as Cursor activity only. Plan/todo replay cards can be followed by Cursor's final plan text, selected from `run.wait().result` when Cursor provides one and trimmed against already-emitted text. Started Cursor SDK tool calls that never receive a completion event are surfaced with bounded user-visible labels/traces (neutral activity cards when native replay routing allows, otherwise the same inactive or transcript trace fallbacks used for completed replay) instead of being silently discarded when the run failed/aborted, produced no assistant text, or involved external/side-effectful tools; incomplete fast local discovery starts (`read`, `grep`, `glob`, `ls`) remain maintainer-debug-only after successful text-producing runs so stale SDK start events do not create red post-answer cards. Explicit failures remain visible when Cursor reports them through completed tool calls or step results. Pi bridge MCP starts remain excluded from duplicate incomplete Cursor cards because pi already shows real pi tool execution. `PI_CURSOR_NATIVE_TOOL_DISPLAY=0` disables native replay, and `PI_CURSOR_REGISTER_NATIVE_TOOLS=0` is a registration-only opt-out that keeps the transcript fallback without shadowing pi tool names. When bridge or native replay cards are emitted, the provider mirrors Codex's turn shape as Cursor SDK activity arrives: assistant `toolUse`, pi `toolResult`s, live post-tool Cursor thinking/text, any later tool batches as further `toolUse` turns, then Cursor's final assistant answer. For shell replay, completed `stdout` / `stderr` are primary; unambiguous `shell-output-delta` data is also shown as bounded live progress while one shell call is active and used as display-only fallback for empty successful shell completions, while overlapping shell calls drop ambiguous deltas instead of guessing. Print mode keeps bounded scrubbed transcript output instead, preserving `pi -p` assistant text output. Cursor text deltas stream live when no live-run turn split is active.
32
+ - Cursor internal tool activity is recorded from SDK events and scrubbed. Maintainer reference for all 16 `@cursor/sdk@1.0.18` `ToolType` values, runtime alias normalization, and intentional mapping/fallback rules: [Cursor native tool replay — SDK ToolType replay matrix](./cursor-native-tool-replay.md#sdk-tooltype-replay-matrix) (official SDK docs: https://cursor.com/docs/sdk/typescript). In TUI sessions and structured JSON/RPC modes, supported completed `read`, `bash`, `grep`, `find`, `ls`, `edit`, `write`, diagnostics, delete, todo/plan, task, image generation, MCP, semantic search, and screen recording activity is replayed through pi's native tool-call rendering path with recorded Cursor results, so users and JSON/RPC consumers can see native-looking cards/events without rerunning Cursor's reads/shell commands/file edits. Cursor `glob` activity is replayed through native `find` cards. Cursor write activity is replayed through native-looking `write` cards, and Cursor StrReplace/edit activity uses native-looking `edit` only when recorded arguments truthfully satisfy pi's `edit` schema; path-only Cursor edit and notebook edit replay falls back to neutral Cursor activity before pi validation. Diagnostics, delete, todos/plans, task/subagent, image, and MCP activity use neutral Cursor activity cards with pi's default success/error shell. Cursor SDK `task` activity is labeled **Cursor subagent** by default because it represents Cursor-spawned child-agent work; the card summary includes description plus subagent kind/model/short ID when Cursor reports them, and `PI_CURSOR_TASK_PRESENTATION=task` restores the older **Cursor task** wording for comparison. This is visibility over Cursor SDK task events, not a native pi subagent session: pi shows start/final output plus any `conversationSteps` tool-call summaries Cursor returns, but cannot show a live nested read/shell/MCP trail when the SDK only returns final subagent text. Neutral Cursor activity calls include `activityTitle` and, when available, `activitySummary` so partial/collapsed cards preserve identity such as `Cursor plan`, `Cursor todos`, `Cursor subagent`, `Cursor MCP`, or `Cursor edit`. For long-running or externally meaningful Cursor tools (`task`, `shell`, `mcp`, `generateImage`, `recordScreen`, `semSearch`, web search/fetch, plan/todo), the provider may surface one low-noise deferred in-progress thinking line such as `Cursor MCP: external_search` from bounded, scrubbed SDK args; fast local tools (`read`, `grep`, `glob`, and similar) skip lifecycle lines when completion follows immediately, and pi bridge MCP calls are excluded because pi already shows real pi tool execution ([lifecycle visibility](./cursor-native-tool-replay.md#low-noise-tool-lifecycle-visibility)). Replay-only tools display recorded Cursor results, normalize workspace-local paths/diff headers for display, use pi diff colors for edit previews and path-inferred syntax highlighting for write previews, and fail closed if called without a recorded result. Native replay wrappers are registered only for tool names not already owned by another extension; conflicting tools use the bounded scrubbed transcript fallback. Cursor workflow tools such as mode/task/todo/plan activity are not pi workflow controls; reported todo/plan events are displayed as Cursor activity only. Plan/todo replay cards can be followed by Cursor's final plan text, selected from `run.wait().result` when Cursor provides one and trimmed against already-emitted text. Started Cursor SDK tool calls that never receive a completion event are surfaced with bounded user-visible labels/traces (neutral activity cards when native replay routing allows, otherwise the same inactive or transcript trace fallbacks used for completed replay) instead of being silently discarded when the run failed/aborted, produced no assistant text, or involved external/side-effectful tools; incomplete fast local discovery starts (`read`, `grep`, `glob`, `ls`) remain maintainer-debug-only after successful text-producing runs so stale SDK start events do not create red post-answer cards. Explicit failures remain visible when Cursor reports them through completed tool calls or step results. Pi bridge MCP starts remain excluded from duplicate incomplete Cursor cards because pi already shows real pi tool execution. `PI_CURSOR_NATIVE_TOOL_DISPLAY=0` disables native replay, and `PI_CURSOR_REGISTER_NATIVE_TOOLS=0` is a registration-only opt-out that keeps the transcript fallback without shadowing pi tool names. When bridge or native replay cards are emitted, the provider mirrors Codex's turn shape as Cursor SDK activity arrives: assistant `toolUse`, pi `toolResult`s, live post-tool Cursor thinking/text, any later tool batches as further `toolUse` turns, then Cursor's final assistant answer. For shell replay, completed `stdout` / `stderr` are primary; unambiguous `shell-output-delta` data is also shown as bounded live progress while one shell call is active and used as display-only fallback for empty successful shell completions, while overlapping shell calls drop ambiguous deltas instead of guessing. Print mode keeps bounded scrubbed transcript output instead, preserving `pi -p` assistant text output. Cursor text deltas stream live when no live-run turn split is active.
33
33
  - Cursor native replay uses one neutral replay tool name, `cursor`, plus native-compatible card names when renderer-compatible (`read`, `bash`, `grep`, `find`, `ls`, `edit`, `write`). Neutral replay identity lives in `activityTitle`, `activitySummary`, and typed replay details, not in extra registered tool names. Bridge MCP names such as `pi__sem_reindex` are MCP-only; pi session output uses real pi tool names.
34
34
  - Cursor SDK usage events report cumulative internal agent/tool/cache work, not the replayable pi prompt context. The extension does not copy raw Cursor SDK usage into pi usage or compaction. For Cursor assistant messages, `usage.output` estimates visible assistant output, `usage.input` estimates the replayable Cursor context before that output, cache fields are zero, and `usage.totalTokens = input + output + cacheRead + cacheWrite`. The input/total estimate is derived from the same `buildCursorPrompt()` path used for `Agent.send` so context display and compaction keep using replayable Cursor context sizing while pi session summaries remain additive. `src/cursor-usage-accounting.ts` owns this usage policy.
35
35
  - Audit observation, 2026-05-19, superseded by the 2026-05-21 replay pass and #68 incomplete visibility, then narrowed by the 2026-05-26 fast-local suppression: a missing-file read with Composer 2.5 emitted `tool-call-started` for Cursor `read`, then streamed final text `Error: File not found`, but did not emit `tool-call-completed` or an `onStep` `toolCall` error result. Leftover external/side-effectful started calls are surfaced at run completion through the same native replay routing as completed tools (activity cards when allowed, otherwise inactive/transcript traces), while fast local discovery starts are debug-only after a successful text-producing run. Cursor-reported completed/step errors remain visible.
@@ -64,7 +64,7 @@ Cursor `glob` activity is displayed through native `find` cards.
64
64
 
65
65
  For the full `@cursor/sdk@1.0.18` `ToolType` set, disposition matrix, and runtime alias normalization, see [SDK ToolType replay matrix](#sdk-tooltype-replay-matrix) below. Official SDK reference: https://cursor.com/docs/sdk/typescript
66
66
 
67
- Edit and write activity replays through pi-facing `edit` and `write` cards only when replay arguments truthfully satisfy the matching pi schema, but still uses recorded Cursor results only. The adapter passes through truthful Cursor paths, content when Cursor reported it, and recorded diff/details; it does not pretend Cursor's editing schema is pi's schema and it fails closed if a recorded replay result is missing. Cursor `StrReplace` with recorded replacement text displays as native-looking `edit`; path-only Cursor `edit` and notebook edit activity fall back to neutral Cursor activity so pi does not reject the replay before recorded-result handling. Cursor `write` displays as native-looking `write`. Diagnostics, delete, todos/plans, task, image, MCP, semantic search, screen recording, and web search/fetch activity use neutral Cursor activity cards with pi's default success/error tool shell. MCP completions whose `toolName` is `WebSearch` / `web_search` / `WebFetch` / similar are labeled **Cursor web search** or **Cursor web fetch** instead of generic **Cursor MCP**. Neutral Cursor activity cards carry display metadata such as `activityTitle` and `activitySummary`, so partial/collapsed cards can say `Cursor plan`, `Cursor todos`, `Cursor MCP`, `Cursor semantic search`, `Cursor screen recording`, `Cursor web search`, `Cursor web fetch`, or `Cursor edit` instead of only `Cursor activity`. These replay tools only display recorded Cursor results; they never mutate files or execute tool work directly. Replay paths are normalized to workspace-relative paths when possible. Most collapsed replay cards include bounded previews for diffs and text details so small edits, todos, task output, and MCP results are visible without expanding; web search/fetch activity stays summary-only while collapsed because those cards often arrive after final text and can otherwise bury the answer. Ctrl+O expansion shows the recorded details. Edit previews omit raw unified diff headers and show compact numbered changed/context lines using pi's native diff added/removed/context colors, and write previews use syntax highlighting when pi can infer a language from the path. Image generation replay cards show the saved image path in the collapsed summary and render the image inline when pi terminal image display is enabled and the generated file is still readable.
67
+ Edit and write activity replays through pi-facing `edit` and `write` cards only when replay arguments truthfully satisfy the matching pi schema, but still uses recorded Cursor results only. The adapter passes through truthful Cursor paths, content when Cursor reported it, and recorded diff/details; it does not pretend Cursor's editing schema is pi's schema and it fails closed if a recorded replay result is missing. Cursor `StrReplace` with recorded replacement text displays as native-looking `edit`; path-only Cursor `edit` and notebook edit activity fall back to neutral Cursor activity so pi does not reject the replay before recorded-result handling. Cursor `write` displays as native-looking `write`. Diagnostics, delete, todos/plans, task/subagent, image, MCP, semantic search, screen recording, and web search/fetch activity use neutral Cursor activity cards with pi's default success/error tool shell. Cursor SDK `task` activity is labeled **Cursor subagent** by default because it represents Cursor-spawned child-agent work; `PI_CURSOR_TASK_PRESENTATION=task` keeps the older **Cursor task** wording for comparison. MCP completions whose `toolName` is `WebSearch` / `web_search` / `WebFetch` / similar are labeled **Cursor web search** or **Cursor web fetch** instead of generic **Cursor MCP**. Neutral Cursor activity cards carry display metadata such as `activityTitle` and `activitySummary`, so partial/collapsed cards can say `Cursor plan`, `Cursor todos`, `Cursor subagent`, `Cursor MCP`, `Cursor semantic search`, `Cursor screen recording`, `Cursor web search`, `Cursor web fetch`, or `Cursor edit` instead of only `Cursor activity`. These replay tools only display recorded Cursor results; they never mutate files or execute tool work directly. Replay paths are normalized to workspace-relative paths when possible. Most collapsed replay cards include bounded previews for diffs and text details so small edits, todos, task output, and MCP results are visible without expanding; web search/fetch activity stays summary-only while collapsed because those cards often arrive after final text and can otherwise bury the answer. Ctrl+O expansion shows the recorded details. Edit previews omit raw unified diff headers and show compact numbered changed/context lines using pi's native diff added/removed/context colors, and write previews use syntax highlighting when pi can infer a language from the path. Image generation replay cards show the saved image path in the collapsed summary and render the image inline when pi terminal image display is enabled and the generated file is still readable.
68
68
 
69
69
  ## SDK ToolType replay matrix
70
70
 
@@ -89,7 +89,7 @@ This matrix covers **Cursor native tool replay only**. It does not describe the
89
89
  | `readLints` | neutral activity | `cursor` | Collapsed label **Cursor diagnostics** |
90
90
  | `updateTodos` | neutral activity | `cursor` | Collapsed label **Cursor todos**; display-only, does not drive pi todos, including in Cursor SDK `plan` mode |
91
91
  | `createPlan` | neutral activity | `cursor` | Collapsed label **Cursor plan**; display-only, does not drive pi plan mode, including in Cursor SDK `plan` mode |
92
- | `task` | neutral activity | `cursor` | Collapsed label **Cursor task** |
92
+ | `task` | neutral activity | `cursor` | Collapsed label **Cursor subagent** by default; summary includes description plus subagent kind/model/short ID when Cursor reports them; `PI_CURSOR_TASK_PRESENTATION=task` restores **Cursor task** wording |
93
93
  | `generateImage` | neutral activity | `cursor` | Collapsed label **Cursor image generation** |
94
94
  | `mcp` | neutral activity | `cursor` | Collapsed label **Cursor MCP** for non-web MCP completions; web search/fetch MCP `toolName` values reclassify to the rows below |
95
95
  | `semSearch` | neutral activity | `cursor` | Collapsed label **Cursor semantic search**; semantic codebase search, not web search |
@@ -162,13 +162,17 @@ These are integration boundaries, not pi replay bugs:
162
162
 
163
163
  Maintainer debug (`PI_CURSOR_SDK_EVENT_DEBUG=1`) still records the same discarded started-call events in `coordinator-events.jsonl` under phase `discarded-incomplete-started-tool-call` for investigation (**#52**), including fast local starts suppressed from successful text-producing runs. User-visible incomplete cards and debug artifacts are complementary: cards explain actionable gaps in the TUI; debug files retain normalized tool names and scrubbed call-id hashes without changing default stderr behavior.
164
164
 
165
+ ### Cursor subagent visibility limits
166
+
167
+ Cursor SDK `task` activity is surfaced as **Cursor subagent** because it represents Cursor-spawned child-agent work. The card can show the subagent start, final result text, subagent kind/model/short-ID metadata, and compact `conversationSteps` tool-call summaries when the SDK includes them. It is not a native pi subagent session and does not guarantee a live nested action stream. If Cursor only returns final subagent text and no nested tool calls, pi cannot show the subagent's internal read/shell/MCP steps.
168
+
165
169
  ## Low-noise tool lifecycle visibility
166
170
 
167
171
  Most Cursor tool visibility is completion-based: the completed replay card (or bounded transcript trace) is the source of truth for recorded results. For long-running or externally meaningful tools, the provider may also surface one low-noise in-progress line while Cursor is still waiting on the tool.
168
172
 
169
173
  Lifecycle rules:
170
174
 
171
- - Eligible tools include `task`, `shell`, `mcp`, `generateImage`, `recordScreen`, `semSearch`, web search/fetch activity, and plan/todo activity. Fast local tools such as `read`, `grep`, and `glob` do not get lifecycle lines in normal cases.
175
+ - Eligible tools include `task` (shown as **Cursor subagent** by default), `shell`, `mcp`, `generateImage`, `recordScreen`, `semSearch`, web search/fetch activity, and plan/todo activity. Fast local tools such as `read`, `grep`, and `glob` do not get lifecycle lines in normal cases.
172
176
  - Lifecycle text is emitted as a single bounded, scrubbed thinking line such as `Cursor MCP: external_search` or `Cursor shell: npm test`. Shell pending labels show a scrubbed/truncated command preview, matching pi's native bash UX; the completed replay card remains the source of truth for recorded shell results. Lifecycle lines are not separate permanent replay cards and do not rerun tools.
173
177
  - A short defer window coalesces fast start+complete pairs: if a tool completes before the defer elapses, only the completed replay card/trace is shown.
174
178
  - pi bridge MCP calls (`pi__*`) are excluded because pi already shows the real pi tool execution path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cursor-sdk",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
4
4
  "description": "pi provider extension backed by @cursor/sdk local agents",
5
5
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
6
6
  "license": "MIT",
@@ -0,0 +1,81 @@
1
+ import { isAbsolute, relative, win32 } from "node:path";
2
+ import { asRecord, getRecord, getString } from "./cursor-record-utils.js";
3
+ import { formatDisplayPath } from "./cursor-transcript-utils.js";
4
+
5
+ export interface CursorCompactToolSummaryOptions {
6
+ cwd?: string;
7
+ }
8
+
9
+ function isWindowsAbsolutePath(path: string): boolean {
10
+ return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\");
11
+ }
12
+
13
+ export function formatCursorCompactToolPath(path: string | undefined, options: CursorCompactToolSummaryOptions): string | undefined {
14
+ const trimmed = path?.trim();
15
+ if (!trimmed) return undefined;
16
+ const normalized = trimmed.replace(/\\/g, "/");
17
+ if (normalized === "~" || normalized.startsWith("~/") || /^~[^/]+(?:\/|$)/.test(normalized)) return undefined;
18
+ if (normalized.split("/").includes("..")) return undefined;
19
+ if (/^[A-Za-z]:(?!\/)/.test(normalized)) return undefined;
20
+ if (isWindowsAbsolutePath(trimmed)) {
21
+ const cwd = options.cwd;
22
+ if (!cwd || !isWindowsAbsolutePath(cwd)) return undefined;
23
+ const relativePath = win32.relative(cwd, trimmed);
24
+ if (!relativePath || relativePath.startsWith("..") || isWindowsAbsolutePath(relativePath)) return undefined;
25
+ return relativePath.replace(/\\/g, "/");
26
+ }
27
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(normalized)) return undefined;
28
+ if (isAbsolute(trimmed)) {
29
+ const cwd = options.cwd;
30
+ if (!cwd) return undefined;
31
+ const relativePath = relative(cwd, trimmed);
32
+ if (!relativePath || relativePath.startsWith("..") || isAbsolute(relativePath)) return undefined;
33
+ return relativePath.replace(/\\/g, "/");
34
+ }
35
+ return formatDisplayPath(normalized, options.cwd);
36
+ }
37
+
38
+ function getNestedRecord(record: Record<string, unknown> | undefined, ...keys: string[]): Record<string, unknown> | undefined {
39
+ let current = record;
40
+ for (const key of keys) {
41
+ current = getRecord(current, key);
42
+ if (!current) return undefined;
43
+ }
44
+ return current;
45
+ }
46
+
47
+ function summarizeShellTool(args: Record<string, unknown> | undefined, resultValue: Record<string, unknown> | undefined): string {
48
+ const command = getString(args, "command");
49
+ const stdout = getString(resultValue, "stdout");
50
+ const stderr = getString(resultValue, "stderr");
51
+ return [command ? `$ ${command}` : "shell", stdout, stderr].filter((part): part is string => Boolean(part)).join("\n");
52
+ }
53
+
54
+ export function summarizeCursorCompactToolCall(
55
+ toolName: string | undefined,
56
+ args: Record<string, unknown> | undefined,
57
+ result: Record<string, unknown> | undefined,
58
+ options: CursorCompactToolSummaryOptions,
59
+ ): string | undefined {
60
+ if (!toolName) return undefined;
61
+ const compactName = toolName.replace(/\s+/g, " ").trim() || "unknown";
62
+ if (compactName === "shell") return summarizeShellTool(args, getNestedRecord(result, "value"));
63
+
64
+ const path = formatCursorCompactToolPath(getString(args, "path"), options);
65
+ if (path) return `${compactName} ${path}`;
66
+ const query = getString(args, "query") ?? getString(args, "pattern");
67
+ if (query) return `${compactName} ${query}`;
68
+ return compactName;
69
+ }
70
+
71
+ export function summarizeCursorCompactConversationToolCall(step: unknown, options: CursorCompactToolSummaryOptions): string | undefined {
72
+ const record = asRecord(step);
73
+ if (getString(record, "type") !== "toolCall") return undefined;
74
+ const message = getRecord(record, "message");
75
+ return summarizeCursorCompactToolCall(
76
+ getString(message, "type"),
77
+ getRecord(message, "args"),
78
+ getRecord(message, "result"),
79
+ options,
80
+ );
81
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
- import { getLanguageFromPath, highlightCode, type ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import { getLanguageFromPath, highlightCode, keyHint, type ToolDefinition } from "@earendil-works/pi-coding-agent";
4
4
  import { Image, Text, type Component } from "@earendil-works/pi-tui";
5
5
  import { Type } from "typebox";
6
6
  import { resolveCursorEditDiff } from "./cursor-edit-diff.js";
@@ -9,6 +9,7 @@ import { LOCAL_READ_PREVIEW_NOTICE, isLocalReadPreviewContent } from "./cursor-t
9
9
  import {
10
10
  CURSOR_REPLAY_ACTIVITY_TOOL_NAME,
11
11
  getCursorReplayCallSummary,
12
+ shouldShowCursorReplayCollapsedExpandHint,
12
13
  type CursorReplayToolName,
13
14
  } from "./cursor-tool-presentation-registry.js";
14
15
  import {
@@ -423,6 +424,14 @@ function hasCursorReplayDisplayTitle(details: CursorReplayToolDetails | undefine
423
424
  return isCursorReplayActivityDetails(details) || isCursorReplayGenerateImageDetails(details);
424
425
  }
425
426
 
427
+ function formatCursorReplayExpandHint(): string {
428
+ try {
429
+ return keyHint("app.tools.expand", "to expand");
430
+ } catch {
431
+ return "Ctrl+O to expand";
432
+ }
433
+ }
434
+
426
435
  function renderExpandableCursorReplayResult(
427
436
  title: string,
428
437
  details: CursorReplayExpandableResultDetails,
@@ -434,8 +443,10 @@ function renderExpandableCursorReplayResult(
434
443
  ): Component {
435
444
  const text = firstContentText(result);
436
445
  const summary = details.summary ?? text.split("\n").find((line) => line.trim()) ?? "completed";
437
- let rendered = `${theme.fg("toolTitle", theme.bold(title))} ${theme.fg(isError ? "error" : "success", summary)}`;
438
446
  const expandedText = details.expandedText ?? (text.includes("\n") ? text : undefined);
447
+ const showExpandHint = expandedText && !options.expanded && shouldShowCursorReplayCollapsedExpandHint(details.sourceToolName);
448
+ const expandHint = showExpandHint ? theme.fg("dim", ` (${formatCursorReplayExpandHint()})`) : "";
449
+ let rendered = `${theme.fg("toolTitle", theme.bold(title))} ${theme.fg(isError ? "error" : "success", summary)}${expandHint}`;
439
450
  if (expandedText && (options.expanded || !details.collapseDetailsByDefault)) {
440
451
  const preview = formatCursorReplayActivityPreview(
441
452
  details,
@@ -21,12 +21,12 @@ import {
21
21
  readMcpDisplayResult,
22
22
  getReadLintDiagnostics,
23
23
  getReadLintPaths,
24
- getTaskDescription,
25
24
  getTodoItems,
26
25
  getTodoTotalCount,
27
26
  inferImageMimeType,
28
27
  } from "./cursor-tool-result-display-readers.js";
29
28
  import { extractWebFetchTarget, extractWebSearchQuery } from "./cursor-web-tool-args.js";
29
+ import { formatCursorTaskAgentId, formatCursorTaskKind, getCursorTaskDescription, getCursorTaskPresentationMode, readCursorTaskMetadata } from "./cursor-task-presentation.js";
30
30
 
31
31
  export interface CursorReplayActivityBuildContext {
32
32
  args: Record<string, unknown>;
@@ -88,12 +88,20 @@ export function buildCreatePlanReplaySummaryArgs({ args, result }: CursorReplayA
88
88
  };
89
89
  }
90
90
 
91
- export function buildTaskReplaySummaryArgs({ args, result }: CursorReplayActivityBuildContext): CursorReplayTaskSummaryArgs {
92
- const description = getTaskDescription(args, result);
93
- const preview = firstNonEmptyLine(collectTaskText(result));
91
+ export function buildTaskReplaySummaryArgs({ args, result, options }: CursorReplayActivityBuildContext): CursorReplayTaskSummaryArgs {
92
+ const description = getCursorTaskDescription(args, result.value);
93
+ const preview = firstNonEmptyLine(collectTaskText(result, options));
94
+ const metadata = readCursorTaskMetadata(args, result.value);
95
+ const displayAgentId = formatCursorTaskAgentId(metadata.agentId);
96
+ const includeMetadata = getCursorTaskPresentationMode() === "subagent-meta";
94
97
  return {
95
98
  description: truncateArg(description),
96
99
  ...(preview ? { preview: truncateArg(preview) } : {}),
100
+ ...(includeMetadata && metadata.subagentName ? { subagentName: truncateArg(metadata.subagentName) } : {}),
101
+ ...(includeMetadata && metadata.subagentKind ? { subagentKind: truncateArg(formatCursorTaskKind(metadata.subagentKind) ?? metadata.subagentKind) } : {}),
102
+ ...(includeMetadata && metadata.model ? { model: truncateArg(metadata.model) } : {}),
103
+ ...(includeMetadata && displayAgentId ? { agentId: truncateArg(displayAgentId) } : {}),
104
+ ...(includeMetadata && metadata.isBackground === true ? { isBackground: true } : {}),
97
105
  };
98
106
  }
99
107
 
@@ -31,6 +31,11 @@ export interface CursorReplayPlanSummaryArgs extends CursorReplayTodoSummaryArgs
31
31
  export interface CursorReplayTaskSummaryArgs extends CursorReplayActivitySummaryOverride {
32
32
  description?: string;
33
33
  preview?: string;
34
+ subagentName?: string;
35
+ subagentKind?: string;
36
+ model?: string;
37
+ agentId?: string;
38
+ isBackground?: boolean;
34
39
  }
35
40
 
36
41
  export interface CursorReplayGenerateImageSummaryArgs extends CursorReplayActivitySummaryOverride {
@@ -150,8 +155,22 @@ export function summarizeReplayPlan(args: CursorReplayPlanSummaryArgs | undefine
150
155
  export function summarizeReplayTask(args: CursorReplayTaskSummaryArgs | undefined): string | undefined {
151
156
  const description = readCursorReplaySummaryString(args, "description");
152
157
  const preview = readCursorReplaySummaryString(args, "preview");
153
- if (description && preview && preview !== description) return `${description}: ${preview}`;
154
- return description ?? preview;
158
+ const subagentName = readCursorReplaySummaryString(args, "subagentName");
159
+ const subagentKind = readCursorReplaySummaryString(args, "subagentKind");
160
+ const model = readCursorReplaySummaryString(args, "model");
161
+ const agentId = readCursorReplaySummaryString(args, "agentId");
162
+ const metadataParts = [
163
+ subagentKind,
164
+ model,
165
+ agentId ? `ID: ${agentId}` : undefined,
166
+ args?.isBackground === true ? "backgrounded" : undefined,
167
+ ].filter((part): part is string => Boolean(part));
168
+ const subjectParts = [description].filter((part): part is string => Boolean(part));
169
+ const subject = subjectParts.length > 0 ? subjectParts.join(" · ") : undefined;
170
+ const head = metadataParts.length > 0 ? [subject, ...metadataParts].filter(Boolean).join(" · ") : subject;
171
+ if (metadataParts.length > 0) return head;
172
+ if (head && preview && preview !== description && preview !== subagentName) return `${head}: ${preview}`;
173
+ return head ?? preview;
155
174
  }
156
175
 
157
176
  export function summarizeReplayMcp(args: CursorReplayMcpSummaryArgs | undefined): string | undefined {
@@ -0,0 +1,77 @@
1
+ import { asRecord, getBoolean, getRecord, getString } from "./cursor-record-utils.js";
2
+
3
+ export const CURSOR_TASK_PRESENTATION_ENV = "PI_CURSOR_TASK_PRESENTATION";
4
+
5
+ export type CursorTaskPresentationMode = "task" | "subagent" | "subagent-meta";
6
+
7
+ const VALID_CURSOR_TASK_PRESENTATION_MODES = new Set<CursorTaskPresentationMode>([
8
+ "task",
9
+ "subagent",
10
+ "subagent-meta",
11
+ ]);
12
+
13
+ export function getCursorTaskPresentationMode(env: NodeJS.ProcessEnv = process.env): CursorTaskPresentationMode {
14
+ const raw = env[CURSOR_TASK_PRESENTATION_ENV]?.trim();
15
+ return raw && VALID_CURSOR_TASK_PRESENTATION_MODES.has(raw as CursorTaskPresentationMode)
16
+ ? (raw as CursorTaskPresentationMode)
17
+ : "subagent-meta";
18
+ }
19
+
20
+ export interface CursorTaskMetadata {
21
+ description?: string;
22
+ subagentKind?: string;
23
+ subagentName?: string;
24
+ model?: string;
25
+ agentId?: string;
26
+ isBackground?: boolean;
27
+ }
28
+
29
+ export function getCursorTaskDescription(args: Record<string, unknown>, resultValue?: unknown): string {
30
+ return getString(args, "description") ?? getString(asRecord(resultValue), "description") ?? "task";
31
+ }
32
+
33
+ export function readCursorTaskMetadata(args: Record<string, unknown>, resultValue?: unknown): CursorTaskMetadata {
34
+ const subagentType = getRecord(args, "subagentType");
35
+ const result = asRecord(resultValue);
36
+ return {
37
+ description: getCursorTaskDescription(args, resultValue),
38
+ subagentKind: getString(subagentType, "kind"),
39
+ subagentName: getString(subagentType, "name"),
40
+ model: getString(args, "model"),
41
+ agentId: getString(args, "agentId") ?? getString(result, "agentId"),
42
+ isBackground: getBoolean(result, "isBackground"),
43
+ };
44
+ }
45
+
46
+ function cleanMetadataValue(value: string | undefined): string | undefined {
47
+ const trimmed = value?.trim();
48
+ return trimmed || undefined;
49
+ }
50
+
51
+ export function getCursorTaskActivityTitle(): string {
52
+ return getCursorTaskPresentationMode() === "task" ? "Cursor task" : "Cursor subagent";
53
+ }
54
+
55
+ export function getCursorTaskTranscriptHeader(args: Record<string, unknown>, resultValue?: unknown): string {
56
+ const metadata = readCursorTaskMetadata(args, resultValue);
57
+ const description = cleanMetadataValue(metadata.description) ?? "task";
58
+ const mode = getCursorTaskPresentationMode();
59
+ if (mode === "task") return `task ${description}`;
60
+ if (mode === "subagent-meta") {
61
+ const subagentName = cleanMetadataValue(metadata.subagentName);
62
+ return subagentName ? `subagent ${subagentName} ${description}` : `subagent ${description}`;
63
+ }
64
+ return `subagent ${description}`;
65
+ }
66
+
67
+ export function formatCursorTaskKind(value: string | undefined): string | undefined {
68
+ const cleaned = cleanMetadataValue(value);
69
+ if (!cleaned) return undefined;
70
+ return cleaned.slice(0, 1).toUpperCase() + cleaned.slice(1);
71
+ }
72
+
73
+ export function formatCursorTaskAgentId(value: string | undefined): string | undefined {
74
+ const cleaned = cleanMetadataValue(value);
75
+ if (!cleaned || !/^[A-Za-z0-9_.:-]+$/.test(cleaned)) return undefined;
76
+ return cleaned.length > 12 ? cleaned.slice(0, 8) : cleaned;
77
+ }
@@ -23,6 +23,7 @@ import {
23
23
  type CursorReplayWebSearchSummaryArgs,
24
24
  } from "./cursor-replay-summary-args.js";
25
25
  import { truncateCursorDisplayLine } from "./cursor-display-text.js";
26
+ import { getCursorTaskActivityTitle } from "./cursor-task-presentation.js";
26
27
  import {
27
28
  buildCreatePlanReplaySummaryArgs,
28
29
  buildDeleteReplayDetailFields,
@@ -75,6 +76,10 @@ export interface CursorToolVisibilityPolicy {
75
76
  fastLocalDiscovery?: boolean;
76
77
  }
77
78
 
79
+ export interface CursorToolReplayDisplayPolicy {
80
+ showCollapsedExpandHint?: boolean;
81
+ }
82
+
78
83
  export interface CursorToolActivityReplaySpec<TArgs extends CursorReplaySummaryArgs = CursorReplaySummaryArgs> {
79
84
  buildActivityArgs: (context: CursorReplayActivityBuildContext) => TArgs;
80
85
  buildDetails: (context: CursorReplayActivityBuildContext, contentText: string) => CursorReplayActivityDetailFields;
@@ -90,7 +95,9 @@ export interface CursorToolPresentationSpec {
90
95
  /** Raw SDK/host names that resolve to this tool via {@link normalizeCursorToolName}. */
91
96
  nameAliases?: readonly string[];
92
97
  displayLabel: string;
98
+ getActivityTitle?: () => string;
93
99
  visibility: CursorToolVisibilityPolicy;
100
+ replayDisplay?: CursorToolReplayDisplayPolicy;
94
101
  webKind?: CursorWebToolKind;
95
102
  /** Regexes matched against lowercased trimmed tool names for {@link classifyCursorWebToolKind}. */
96
103
  webNamePatterns?: readonly RegExp[];
@@ -209,8 +216,10 @@ export const CURSOR_TOOL_PRESENTATION_SPECS = [
209
216
  },
210
217
  {
211
218
  normalizedName: "task",
212
- displayLabel: "Cursor task",
219
+ displayLabel: "Cursor subagent",
220
+ getActivityTitle: getCursorTaskActivityTitle,
213
221
  visibility: { lifecycleEligible: true },
222
+ replayDisplay: { showCollapsedExpandHint: true },
214
223
  lifecycleLabelKind: "task",
215
224
  replayCallSummary: withActivitySummaryFallback(summarizeReplayTask),
216
225
  activityReplay: {
@@ -381,7 +390,7 @@ export function getCursorReplayPromptLabel(toolName: string): string {
381
390
  export function getCursorReplayActivityTitle(toolName: string): string | undefined {
382
391
  const spec = getCursorToolPresentationSpec(toolName);
383
392
  if (!spec || !hasNeutralActivityTitle(spec)) return undefined;
384
- return spec.displayLabel;
393
+ return spec.getActivityTitle?.() ?? spec.displayLabel;
385
394
  }
386
395
 
387
396
  function buildCursorGenericActivityTitle(displayName: string): string {
@@ -418,6 +427,11 @@ export function getCursorToolGenerateImageReplaySpec(normalizedKey: string): Cur
418
427
  return getCursorToolPresentationSpecByNormalizedKey(normalizedKey)?.generateImageReplay;
419
428
  }
420
429
 
430
+ export function shouldShowCursorReplayCollapsedExpandHint(normalizedKey: string | undefined): boolean {
431
+ if (!normalizedKey) return false;
432
+ return getCursorToolPresentationSpecByNormalizedKey(normalizedKey)?.replayDisplay?.showCollapsedExpandHint === true;
433
+ }
434
+
421
435
  export function getCursorReplayCallSummary(
422
436
  toolName: CursorReplayToolName | CursorReplaySourceToolName,
423
437
  args: CursorReplaySummaryArgs | undefined,
@@ -1,4 +1,5 @@
1
1
  import { asRecord, getArray, getNumber, getRecord, getString, stringifyUnknown } from "./cursor-record-utils.js";
2
+ import { summarizeCursorCompactConversationToolCall } from "./cursor-compact-tool-summary.js";
2
3
  import { scrubSensitiveText } from "./cursor-sensitive-text.js";
3
4
  import { firstNonEmptyLine, formatDisplayPath, truncateArg } from "./cursor-transcript-utils.js";
4
5
 
@@ -70,10 +71,6 @@ export function getTodoTotalCount(args: Record<string, unknown>, result: CursorT
70
71
  return getNumber(asRecord(result.value), "totalCount") ?? getNumber(args, "totalCount") ?? todos.length;
71
72
  }
72
73
 
73
- export function getTaskDescription(args: Record<string, unknown>, result: CursorToolResultLike): string {
74
- return getString(args, "description") ?? getString(asRecord(result.value), "description") ?? "task";
75
- }
76
-
77
74
  function getNestedRecord(record: Record<string, unknown> | undefined, ...keys: string[]): Record<string, unknown> | undefined {
78
75
  let current = record;
79
76
  for (const key of keys) {
@@ -83,16 +80,24 @@ function getNestedRecord(record: Record<string, unknown> | undefined, ...keys: s
83
80
  return current;
84
81
  }
85
82
 
86
- export function collectTaskText(result: CursorToolResultLike): string {
83
+ function readConversationStepAssistantText(step: unknown): string | undefined {
84
+ const record = asRecord(step);
85
+ const legacyText = getString(getRecord(record, "assistantMessage"), "text");
86
+ if (legacyText) return legacyText;
87
+ if (getString(record, "type") !== "assistantMessage") return undefined;
88
+ return getString(getRecord(record, "message"), "text");
89
+ }
90
+
91
+ export function collectTaskText(result: CursorToolResultLike, options: CursorToolResultReaderOptions = {}): string {
87
92
  const value = asRecord(result.value);
88
93
  const success = getNestedRecord(value, "result", "success");
89
94
  const command = getString(success, "command");
90
95
  const stdout = getString(success, "stdout");
91
96
  const interleavedOutput = getString(success, "interleavedOutput");
92
- const assistantMessages = (getArray(value, "conversationSteps") ?? [])
93
- .map((step) => getString(getRecord(asRecord(step), "assistantMessage"), "text"))
97
+ const conversationParts = (getArray(value, "conversationSteps") ?? [])
98
+ .map((step) => summarizeCursorCompactConversationToolCall(step, options) ?? readConversationStepAssistantText(step))
94
99
  .filter((entry): entry is string => Boolean(entry));
95
- const parts = [command ? `$ ${command}` : undefined, stdout || interleavedOutput, ...assistantMessages].filter((part): part is string => Boolean(part));
100
+ const parts = [command ? `$ ${command}` : undefined, stdout || interleavedOutput, ...conversationParts].filter((part): part is string => Boolean(part));
96
101
  return parts.join("\n");
97
102
  }
98
103
 
@@ -9,13 +9,13 @@ import {
9
9
  getRecord,
10
10
  getString,
11
11
  } from "./cursor-record-utils.js";
12
+ import { getCursorTaskTranscriptHeader } from "./cursor-task-presentation.js";
12
13
  import {
13
14
  collectTaskText,
14
15
  getGenerateImageDisplayPath,
15
16
  readMcpDisplayResult,
16
17
  getReadLintDiagnostics,
17
18
  getReadLintPaths,
18
- getTaskDescription,
19
19
  getTodoItems,
20
20
  } from "./cursor-tool-result-display-readers.js";
21
21
 
@@ -487,10 +487,10 @@ export function formatPlan(args: Record<string, unknown>, result: NormalizedResu
487
487
  }
488
488
 
489
489
  export function formatTask(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
490
- const description = getTaskDescription(args, result);
491
- if (result.status === "error") return joinSections(`task ${description}`, formatError(result.error));
492
- const taskText = collectTaskText(result);
493
- return joinSections(`task ${description}`, limitText(taskText || stringifyUnknown(result.value), options));
490
+ const header = getCursorTaskTranscriptHeader(args, result.value);
491
+ if (result.status === "error") return joinSections(header, formatError(result.error));
492
+ const taskText = collectTaskText(result, options);
493
+ return joinSections(header, limitText(taskText || stringifyUnknown(result.value), options));
494
494
  }
495
495
 
496
496
  export function formatGenerateImage(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {