When the agent waits on a backgrounded shell (the AwaitShell-style tool, protobuf case awaitToolCall), that wait is invisible to every public observability surface. The SDK explicitly maps awaitToolCall to null in the interaction-update converter, and the

Feature request for product/service

@cursor/sdk (TypeScript, local/self-hosted agents) + Agent hooks

Describe the request

Summary: when the agent waits on a backgrounded shell (the AwaitShell-style tool, protobuf case awaitToolCall), that wait is invisible to every public observability surface. The SDK explicitly maps awaitToolCall to null in the interaction-update converter, and the generic tool hooks never fire for it either. A wait therefore appears to an integrator as an unexplained gap between two unrelated tool calls, which is indistinguishable from the agent’s model stream stalling. Please expose it on at least one surface.

Reproduces on the latest published @cursor/[email protected].

What works today

We run Cursor agents headlessly (self-hosted Node process, @cursor/sdk local agent) and rebuild a per-run timeline for an internal dashboard. Almost everything we need is already there:

  • onDelta gives us tool-call-started / partial-tool-call / tool-call-completed with a stable callId, which is what we key the timeline on.
  • onStep gives us step-started / step-completed.
  • preToolUse / postToolUse / postToolUseFailure hooks give us an independent, out-of-band record of tool activity with tool_name and tool_input.

For Shell, Read, Write, Grep, Task and MCP tools, these three agree with each other and the timeline is complete.

The gap

1. The SDK drops the await tool call on purpose. In dist/esm/357.js (1.0.30), the converter that turns a protobuf tool call into a public toolCall object has a large fall-through group ending in return null, and awaitToolCall is in it:

case "awaitToolCall":
case "blameByFilePathToolCall":
// ... ~25 more cases ...
case void 0:
  return null;
default:
  return e.tool

The caller then discards the entire update when that conversion is null:

return r ? { type: e, callId: t.callId, toolCall: r, modelCallId: t.modelCallId } : null

Because all three update types run through this same helper:

case "toolCallStarted":   return s("tool-call-started",   e.message.value);
case "partialToolCall":   return s("partial-tool-call",   e.message.value);
case "toolCallCompleted": return s("tool-call-completed", e.message.value);

…an await emits no tool-call-started, no partial-tool-call and no tool-call-completed. Returning a null update is how this module suppresses an event entirely — case "heartbeat" in the enclosing switch does the same thing — so the drop reads as deliberate rather than an accident of payload shape. Also worth flagging: callId is already in hand at the point the update is discarded, so the identity we need is computed and then thrown away.

2. The hooks don’t cover it either, which contradicts the docs. cursor.com/docs/agent/hooks says:

preToolUse / postToolUse / postToolUseFailure - Generic tool use hooks (fires for all tools)

and

Called before any tool execution. This is a generic hook that fires for all tool types (Shell, Read, Write, MCP, Task, etc.).

In practice the await tool never appears. We ran an agent with an unconditional postToolUse hook (no matcher, so it logs every tool the hook layer reports) and had it start a background shell and then wait for it. Over the whole session the hook layer reported exactly three distinct tool_name values:

{"Shell": 9, "Read": 63, "Grep": 1}      # zero await-shaped entries

Meanwhile the SDK timeline for that same session showed a 70.5-second gap between two adjacent tool calls, and the agent itself reported that it had called the await tool and blocked for 65042 ms.

Because the census is unconditional it is also exhaustive: there is no fourth name, so this is not a spelling or casing mismatch that a matcher could be pointed at. Relatedly, the documented matcher value list for these hooks —

Filter by tool type. Values include Shell, Read, Write, Grep, Delete, Task, and MCP tools using the MCP:<tool_name> format.

— has no entry for the await tool, and the string AwaitShell does not appear anywhere in the published SDK bundle. The tool is also absent from cursor.com/docs/agent/tools.

One more observation that may help you locate it: during the wait window, the hook layer logged 58 Read calls at roughly one-second intervals that never appear on the SDK timeline, while the timeline shows no Read at all in that window. So the two layers disagree in both directions — the wait seems to be decomposed into another tool’s polling calls at the hook layer rather than surfaced as one call. We can’t prove that from outside, but it means no “capture one await call by tool name” approach can work today.

Minimal repro

// hooks.json (unconditional postToolUse — logs every tool the hook layer sees)
// { "version": 1, "hooks": { "postToolUse": [{ "command": "./log-tool.sh" }] } }

const agent = await Agent.create({ local: true, apiKey, model });
await agent.send({
  prompt: "Start `sleep 60` as a background shell, then wait for it to finish before replying.",
  onDelta: u => { if (u.type?.startsWith("tool-call")) console.log("DELTA", u.type, u.callId, u.toolCall?.type); },
  onStep:  s => console.log("STEP", s.type),
});

Observed: the background Shell call is reported normally, then a multi-second wall-clock gap with no tool-call-* update and no hook entry naming the await tool. Expected: the wait is observable on at least one of the two surfaces.

What we’re asking for

In descending order of preference — any one of these unblocks us:

  1. Stop suppressing awaitToolCall in the interaction-update converter. Emit it like any other tool call: tool-call-started with the callId you already have, then tool-call-completed when the wait resolves. Even an opaque or minimal toolCall payload ({ type: "await" }) is enough, because the value for us is the callId plus the start/end boundary. If some field is sensitive, please redact the field rather than dropping the whole update.
  2. Make the generic tool hooks actually fire for it, as the docs already promise, and publish the tool_name value plus its matcher token so we can target it. If it should carry the target shell’s id, including that in tool_input would let us attribute a wait to a specific background command.
  3. If neither is possible, emit a typed wait marker — something like { type: "external-wait-started" | "external-wait-completed", callId, shellId? } — so a wait is at least distinguishable from a stalled stream.
  4. If this exclusion is intentional and permanent, please document it. Naming which protobuf tool cases are deliberately dropped from interaction updates, and correcting “fires for all tools” in the hooks docs to list the exclusions, would at least stop integrators from misattributing the gap. Also please confirm whether the same suppression applies to the Cloud Agents SSE surface, where the public tool_call event’s name enum (read_file, run_terminal_cmd, edit_file, mcp) likewise has no await entry.

Why this matters

We measure where agent wall-clock time goes, in order to separate “the agent is legitimately waiting on a long build or test” from “the engine is stalled and the run needs recovery”. Those two need opposite responses: the first should be left alone, the second should be retried or killed.

Today they are the same observation — a hole between two step-completed events. In one recent audit we attributed roughly 35 hours across five runs to “engine stalled”, and on drill-down a large share was actually the agent correctly waiting on backgrounded work. Exposing the await on any one of the surfaces above turns that from guesswork into a measurement, and it would also let automated stall-recovery stop firing on healthy runs.

We already tried to work around this with the hooks (option 2 above) and hit the wall described in the evidence section, which is why we’re asking upstream rather than shipping a local shim.

Related, but not the same issue

  • Missing terminal tool_call when custom tool throws (topic 168072) — also a missing tool_call, but for a thrown custom tool; ours is a built-in tool suppressed at conversion time even on the success path.
  • Nested run accepts then starves before first content event (topic 168069) — the “no events for a long window” symptom overlaps, but that one is a retry/stall story; ours is a deliberate null mapping.

Environment

  • @cursor/[email protected] (latest published at time of writing), Node 22, Linux container, local/self-hosted agent via Agent.create({ local: true })
  • Agent hooks configured in ~/.cursor/hooks.json, version: 1
  • Behaviour observed on the SDK’s own onDelta / onStep callbacks and on an unconditional postToolUse hook