@tt-a1i/openpi 0.6.0 → 0.6.1

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.
@@ -10,6 +10,7 @@ import type {
10
10
  Model,
11
11
  SimpleStreamOptions,
12
12
  TextContent,
13
+ ToolCall,
13
14
  } from "@earendil-works/pi-ai/compat";
14
15
  import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/compat";
15
16
  import { emptyUsage } from "../usage.ts";
@@ -35,6 +36,7 @@ import {
35
36
  CursorRuleSchema,
36
37
  CursorRuleTypeGlobalSchema,
37
38
  CursorRuleTypeSchema,
39
+ CursorToolCallSchema,
38
40
  ExecClientControlMessageSchema,
39
41
  ExecClientMessageSchema,
40
42
  ExecClientStreamCloseSchema,
@@ -44,6 +46,15 @@ import {
44
46
  KvClientMessageSchema,
45
47
  type KvServerMessage,
46
48
  KvServerMessageSchema,
49
+ McpArgsSchema,
50
+ McpImageContentSchema,
51
+ McpRejectedSchema,
52
+ McpResultSchema,
53
+ McpSuccessSchema,
54
+ McpTextContentSchema,
55
+ McpToolCallSchema,
56
+ McpToolResultContentItemSchema,
57
+ McpToolResultSchema,
47
58
  type ModelDetails,
48
59
  ModelDetailsSchema,
49
60
  RequestContextResultSchema,
@@ -59,8 +70,14 @@ import {
59
70
  UserMessageActionSchema,
60
71
  UserMessageSchema,
61
72
  } from "./proto.ts";
62
- import { create, fromBinary, toBinary } from "./protobuf.ts";
73
+ import { create, encodeJsonValue, fromBinary, toBinary } from "./protobuf.ts";
63
74
  import { connectCursorHttp2 } from "./proxy.ts";
75
+ import {
76
+ buildCursorTools,
77
+ CURSOR_PI_PROVIDER,
78
+ CURSOR_PI_TOOLS_SYSTEM_PROMPT,
79
+ decodeCursorTool,
80
+ } from "./tool-bridge.ts";
64
81
 
65
82
  const CONNECT_END_STREAM_FLAG = 0b00000010;
66
83
  const CONNECT_COMPRESSED_FLAG = 0b00000001;
@@ -199,22 +216,51 @@ function rootPromptContent(
199
216
 
200
217
  function assistantRootContent(
201
218
  message: Extract<Message, { role: "assistant" }>,
219
+ results: Map<string, Extract<Message, { role: "toolResult" }>>,
202
220
  ) {
203
221
  const content: Array<Record<string, unknown>> = [];
204
222
  for (const item of message.content) {
205
223
  if (item.type === "text" && item.text) {
206
224
  content.push({ type: "text", text: item.text });
225
+ } else if (
226
+ item.type === "toolCall" &&
227
+ results.get(item.id)?.toolName === item.name
228
+ ) {
229
+ content.push({
230
+ type: "tool-call",
231
+ toolCallId: item.id,
232
+ toolName: item.name,
233
+ args: item.arguments,
234
+ });
207
235
  }
208
236
  }
209
237
  return content;
210
238
  }
211
239
 
240
+ function pairedToolResults(messages: Message[], end: number) {
241
+ const calls = new Map<string, string>();
242
+ const results = new Map<string, Extract<Message, { role: "toolResult" }>>();
243
+ for (const message of messages.slice(0, end < 0 ? undefined : end)) {
244
+ if (message.role === "assistant") {
245
+ for (const part of message.content)
246
+ if (part.type === "toolCall") calls.set(part.id, part.name);
247
+ } else if (
248
+ message.role === "toolResult" &&
249
+ calls.get(message.toolCallId) === message.toolName
250
+ ) {
251
+ results.set(message.toolCallId, message);
252
+ }
253
+ }
254
+ return results;
255
+ }
256
+
212
257
  function buildHistoryRootPrompt(
213
258
  messages: Message[],
214
259
  store: CursorBlobStore,
215
260
  activeUserIndex: number,
216
261
  ): Uint8Array[] {
217
262
  const entries: Uint8Array[] = [];
263
+ const results = pairedToolResults(messages, activeUserIndex);
218
264
  for (let index = 0; index < messages.length; index++) {
219
265
  if (index === activeUserIndex) break;
220
266
  const message = messages[index];
@@ -224,13 +270,29 @@ function buildHistoryRootPrompt(
224
270
  if (content.length === 0) continue;
225
271
  value = { role: "user", content };
226
272
  } else if (message.role === "assistant") {
227
- const content = assistantRootContent(message);
273
+ const content = assistantRootContent(message, results);
228
274
  if (content.length === 0) continue;
229
275
  value = { role: "assistant", content };
230
276
  } else {
231
- // Chat-only mode never replays assistant tool calls. Replaying only the
232
- // matching tool result would create an invalid orphan in Cursor history.
233
- continue;
277
+ if (results.get(message.toolCallId) !== message) continue;
278
+ value = {
279
+ role: "tool",
280
+ id: message.toolCallId,
281
+ content: [
282
+ {
283
+ type: "tool-result",
284
+ toolCallId: message.toolCallId,
285
+ toolName: message.toolName,
286
+ result: message.content.some((part) => part.type === "image")
287
+ ? rootPromptContent(message.content)
288
+ : message.content
289
+ .filter((part) => part.type === "text")
290
+ .map((part) => part.text)
291
+ .join("\n"),
292
+ ...(message.isError ? { isError: true } : {}),
293
+ },
294
+ ],
295
+ };
234
296
  }
235
297
  entries.push(
236
298
  storeBlob(store, new TextEncoder().encode(JSON.stringify(value))),
@@ -242,13 +304,17 @@ function buildHistoryRootPrompt(
242
304
  function buildSystemPrompt(
243
305
  systemPrompt: Context["systemPrompt"],
244
306
  store: CursorBlobStore,
307
+ hasTools = false,
245
308
  ): Uint8Array[] {
246
309
  const prompts = systemPrompt
247
310
  ? Array.isArray(systemPrompt)
248
311
  ? systemPrompt
249
312
  : [systemPrompt]
250
313
  : ["You are a helpful assistant."];
251
- return [...prompts, CURSOR_CHAT_ONLY_SYSTEM_PROMPT].map((prompt) =>
314
+ return [
315
+ ...prompts,
316
+ hasTools ? CURSOR_PI_TOOLS_SYSTEM_PROMPT : CURSOR_CHAT_ONLY_SYSTEM_PROMPT,
317
+ ].map((prompt) =>
252
318
  storeBlob(
253
319
  store,
254
320
  new TextEncoder().encode(
@@ -260,11 +326,12 @@ function buildSystemPrompt(
260
326
 
261
327
  /**
262
328
  * Cursor asks for these rules over the exec channel before generating text.
263
- * They are global rules only; the chat-only provider intentionally returns an
264
- * empty MCP tool list and never forwards `context.tools`.
329
+ * These rules keep Cursor-native tools disabled. The request-context response
330
+ * advertises only the active Pi tools through the MCP protocol bridge.
265
331
  */
266
332
  export function buildCursorRequestContextRules(
267
333
  systemPrompt: Context["systemPrompt"],
334
+ hasTools = false,
268
335
  ): CursorRule[] {
269
336
  const rules: CursorRule[] = systemPrompt?.trim()
270
337
  ? [
@@ -283,8 +350,10 @@ export function buildCursorRequestContextRules(
283
350
  : [];
284
351
  rules.push(
285
352
  create(CursorRuleSchema, {
286
- fullPath: "/pi/cursor-chat-only.mdc",
287
- content: CURSOR_CHAT_ONLY_SYSTEM_PROMPT,
353
+ fullPath: hasTools ? "/pi/cursor-tools.mdc" : "/pi/cursor-chat-only.mdc",
354
+ content: hasTools
355
+ ? CURSOR_PI_TOOLS_SYSTEM_PROMPT
356
+ : CURSOR_CHAT_ONLY_SYSTEM_PROMPT,
288
357
  source: 2,
289
358
  type: create(CursorRuleTypeSchema, {
290
359
  type: { case: "global", value: create(CursorRuleTypeGlobalSchema, {}) },
@@ -300,6 +369,7 @@ function buildHistoryTurns(
300
369
  activeUserIndex: number,
301
370
  ): Uint8Array[] {
302
371
  const turns: Uint8Array[] = [];
372
+ const results = pairedToolResults(messages, activeUserIndex);
303
373
  const end = activeUserIndex >= 0 ? activeUserIndex : messages.length;
304
374
  let index = 0;
305
375
  while (index < end) {
@@ -335,6 +405,68 @@ function buildHistoryTurns(
335
405
  ),
336
406
  ),
337
407
  );
408
+ } else if (item.type === "toolCall") {
409
+ const result = results.get(item.id);
410
+ if (!result || result.toolName !== item.name) continue;
411
+ const args = Object.fromEntries(
412
+ Object.entries(item.arguments).map(([key, value]) => [
413
+ key,
414
+ encodeJsonValue(JSON.parse(JSON.stringify(value))),
415
+ ]),
416
+ );
417
+ const tool = create(CursorToolCallSchema, {
418
+ toolCallId: item.id,
419
+ tool: {
420
+ case: "mcpToolCall",
421
+ value: create(McpToolCallSchema, {
422
+ args: create(McpArgsSchema, {
423
+ name: item.name,
424
+ toolName: item.name,
425
+ providerIdentifier: CURSOR_PI_PROVIDER,
426
+ toolCallId: item.id,
427
+ args,
428
+ }),
429
+ result: create(McpToolResultSchema, {
430
+ result: {
431
+ case: "success",
432
+ value: create(McpSuccessSchema, {
433
+ isError: result.isError,
434
+ content: result.content.map((part) =>
435
+ create(McpToolResultContentItemSchema, {
436
+ content:
437
+ part.type === "text"
438
+ ? {
439
+ case: "text",
440
+ value: create(McpTextContentSchema, {
441
+ text: part.text,
442
+ }),
443
+ }
444
+ : {
445
+ case: "image",
446
+ value: create(McpImageContentSchema, {
447
+ data: Buffer.from(part.data, "base64"),
448
+ mimeType: part.mimeType,
449
+ }),
450
+ },
451
+ }),
452
+ ),
453
+ }),
454
+ },
455
+ }),
456
+ }),
457
+ },
458
+ });
459
+ steps.push(
460
+ storeBlob(
461
+ store,
462
+ toBinary(
463
+ ConversationStepSchema,
464
+ create(ConversationStepSchema, {
465
+ message: { case: "toolCall", value: tool },
466
+ }),
467
+ ),
468
+ ),
469
+ );
338
470
  }
339
471
  }
340
472
  }
@@ -414,11 +546,14 @@ export async function buildCursorRequest(
414
546
  options?: SimpleStreamOptions,
415
547
  ): Promise<CursorRequestBuild> {
416
548
  const store: CursorBlobStore = new Map();
417
- const activeIndex = lastUserIndex(context.messages);
549
+ const activeIndex =
550
+ context.messages.at(-1)?.role === "user"
551
+ ? lastUserIndex(context.messages)
552
+ : -1;
418
553
  const active = activeIndex >= 0 ? context.messages[activeIndex] : undefined;
419
554
  const activeContent = active?.role === "user" ? active.content : undefined;
420
555
  const rootPromptMessagesJson = [
421
- ...buildSystemPrompt(context.systemPrompt, store),
556
+ ...buildSystemPrompt(context.systemPrompt, store, !!context.tools?.length),
422
557
  ...buildHistoryRootPrompt(context.messages, store, activeIndex),
423
558
  ];
424
559
  const state = create(ConversationStateStructureSchema, {
@@ -554,7 +689,7 @@ function isAbortError(
554
689
  );
555
690
  }
556
691
 
557
- /** Cursor AgentService/Run, deliberately chat-only (no context.tools advertisement or Pi tool calls). */
692
+ /** Cursor AgentService/Run with Pi-owned tool execution across provider turns. */
558
693
  export function streamCursor(
559
694
  model: Model<Api>,
560
695
  context: Context,
@@ -586,6 +721,8 @@ export function streamCursor(
586
721
  let turnEnded = false;
587
722
  let terminalError: Error | undefined;
588
723
  let finished = false;
724
+ const pendingCalls = new Map<string, ToolCall>();
725
+ let handingOffTools = false;
589
726
 
590
727
  const closeBlocks = () => {
591
728
  if (currentText) {
@@ -698,6 +835,7 @@ export function streamCursor(
698
835
  };
699
836
  let frameBuffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
700
837
  const processFrame = (flags: number, bytes: Uint8Array) => {
838
+ if (handingOffTools) return;
701
839
  if ((flags & CONNECT_COMPRESSED_FLAG) !== 0) {
702
840
  throw new Error("Compressed Cursor Connect frames are unsupported");
703
841
  }
@@ -715,8 +853,11 @@ export function streamCursor(
715
853
  case: "success",
716
854
  value: create(RequestContextSuccessSchema, {
717
855
  requestContext: create(RequestContextSchema, {
718
- rules: buildCursorRequestContextRules(context.systemPrompt),
719
- tools: [],
856
+ rules: buildCursorRequestContextRules(
857
+ context.systemPrompt,
858
+ !!context.tools?.length,
859
+ ),
860
+ tools: buildCursorTools(context.tools),
720
861
  }),
721
862
  }),
722
863
  },
@@ -734,6 +875,60 @@ export function streamCursor(
734
875
  );
735
876
  return;
736
877
  }
878
+ if (exec.message.case === "mcpArgs") {
879
+ const args = exec.message.value;
880
+ if (args.smartModeApprovalOnly) {
881
+ // A probe must not become a tool call or preauthorize Pi execution.
882
+ const reply = create(AgentClientMessageSchema, {
883
+ message: {
884
+ case: "execClientMessage",
885
+ value: create(ExecClientMessageSchema, {
886
+ id: exec.id,
887
+ execId: exec.execId,
888
+ message: {
889
+ case: "mcpResult",
890
+ value: create(McpResultSchema, {
891
+ result: {
892
+ case: "rejected",
893
+ value: create(McpRejectedSchema, {
894
+ reason:
895
+ "Pi must evaluate permissions when executing the tool; approval-only probes cannot authorize execution.",
896
+ }),
897
+ },
898
+ }),
899
+ },
900
+ }),
901
+ },
902
+ });
903
+ h2Request?.write(
904
+ frameConnectMessage(toBinary(AgentClientMessageSchema, reply)),
905
+ );
906
+ return;
907
+ }
908
+ const call = decodeCursorTool(args, context.tools);
909
+ if (
910
+ context.messages.some((message) =>
911
+ message.role === "toolResult"
912
+ ? message.toolCallId === call.id
913
+ : message.role === "assistant" &&
914
+ message.content.some(
915
+ (part) => part.type === "toolCall" && part.id === call.id,
916
+ ),
917
+ )
918
+ ) {
919
+ throw new Error(
920
+ "Cursor attempted to replay a tool call identity already present in Pi history",
921
+ );
922
+ }
923
+ const previous = pendingCalls.get(call.id);
924
+ if (previous && JSON.stringify(previous) !== JSON.stringify(call)) {
925
+ throw new Error(
926
+ "Cursor repeated a tool call identity with different arguments",
927
+ );
928
+ }
929
+ pendingCalls.set(call.id, call);
930
+ return;
931
+ }
737
932
  const throwReply = create(AgentClientMessageSchema, {
738
933
  message: {
739
934
  case: "execClientControlMessage",
@@ -742,8 +937,9 @@ export function streamCursor(
742
937
  case: "throw",
743
938
  value: create(ExecClientThrowSchema, {
744
939
  id: exec.id,
745
- error:
746
- "Cursor tools are not available in this chat-only provider",
940
+ error: context.tools?.length
941
+ ? "Cursor-native execution is unavailable; use advertised Pi MCP tools"
942
+ : "Cursor tools are not available in this chat-only provider",
747
943
  errorCode: "UNIMPLEMENTED",
748
944
  }),
749
945
  },
@@ -762,7 +958,9 @@ export function streamCursor(
762
958
  },
763
959
  });
764
960
  const error = new Error(
765
- "Cursor requested a tool that is unavailable in chat-only mode",
961
+ context.tools?.length
962
+ ? "Cursor requested unsupported native execution outside Pi"
963
+ : "Cursor requested a tool that is unavailable in chat-only mode",
766
964
  );
767
965
  terminalError = error;
768
966
  if (!h2Request) {
@@ -784,10 +982,32 @@ export function streamCursor(
784
982
  }
785
983
  if (message.message.case === "interactionQuery") {
786
984
  throw new Error(
787
- `Cursor interaction query ${message.message.value.query.case ?? "unknown"} is unavailable in chat-only mode`,
985
+ `Cursor interaction query ${message.message.value.query.case ?? "unknown"} is unavailable ${context.tools?.length ? "outside Pi's interaction lifecycle" : "in chat-only mode"}`,
788
986
  );
789
987
  }
790
988
  if (message.message.case !== "interactionUpdate") return;
989
+ const update = message.message.value;
990
+ if (
991
+ context.tools?.length &&
992
+ (update.message.case === "partialToolCall" ||
993
+ update.message.case === "toolCallStarted" ||
994
+ update.message.case === "toolCallCompleted")
995
+ ) {
996
+ const preview = update.message.value.toolCall;
997
+ if (preview && preview.tool.case !== "mcpToolCall") {
998
+ throw new Error(
999
+ "Cursor-native tools are unavailable; use the advertised Pi MCP tools",
1000
+ );
1001
+ }
1002
+ // Only exec mcpArgs is an invocation. UI previews may be partial,
1003
+ // duplicated, or emitted for approval probes, and never execute.
1004
+ return;
1005
+ }
1006
+ if (context.tools?.length && update.message.case === "toolCallDelta") {
1007
+ throw new Error(
1008
+ "Cursor-native tool deltas are unavailable; use the advertised Pi MCP tools",
1009
+ );
1010
+ }
791
1011
  processInteraction(
792
1012
  message.message.value,
793
1013
  output,
@@ -830,6 +1050,37 @@ export function streamCursor(
830
1050
  frameBuffer = frameBuffer.subarray(size + 5);
831
1051
  processFrame(flags, data);
832
1052
  }
1053
+ if (pendingCalls.size > 0 && !handingOffTools) {
1054
+ if (terminalError) throw terminalError;
1055
+ if (options?.signal?.aborted)
1056
+ throw new Error("Cursor request aborted");
1057
+ closeBlocks();
1058
+ for (const call of pendingCalls.values()) {
1059
+ const contentIndex = output.content.length;
1060
+ output.content.push(call);
1061
+ stream.push({
1062
+ type: "toolcall_start",
1063
+ contentIndex,
1064
+ partial: output,
1065
+ });
1066
+ stream.push({
1067
+ type: "toolcall_delta",
1068
+ contentIndex,
1069
+ delta: JSON.stringify(call.arguments),
1070
+ partial: output,
1071
+ });
1072
+ stream.push({
1073
+ type: "toolcall_end",
1074
+ contentIndex,
1075
+ toolCall: call,
1076
+ partial: output,
1077
+ });
1078
+ }
1079
+ output.stopReason = "toolUse";
1080
+ handingOffTools = true;
1081
+ turnEnded = true;
1082
+ settle();
1083
+ }
833
1084
  };
834
1085
 
835
1086
  h2Client = await connectCursorHttp2(baseUrl, {
@@ -967,7 +1218,12 @@ export function streamCursor(
967
1218
  output.usage.totalTokens = output.usage.input + output.usage.output;
968
1219
  stream.push({
969
1220
  type: "done",
970
- reason: output.stopReason === "length" ? "length" : "stop",
1221
+ reason:
1222
+ output.stopReason === "toolUse"
1223
+ ? "toolUse"
1224
+ : output.stopReason === "length"
1225
+ ? "length"
1226
+ : "stop",
971
1227
  message: output,
972
1228
  });
973
1229
  stream.end();
@@ -0,0 +1,68 @@
1
+ /** Translate Cursor MCP requests into Pi calls; execution stays in Pi's loop. */
2
+ import type { Context, ToolCall } from "@earendil-works/pi-ai/compat";
3
+ import { type McpArgs, McpToolDefinitionSchema } from "./proto.ts";
4
+ import {
5
+ create,
6
+ decodeJsonValue,
7
+ encodeJsonValue,
8
+ type JsonValue,
9
+ } from "./protobuf.ts";
10
+
11
+ export const CURSOR_PI_PROVIDER = "openpi";
12
+ export const CURSOR_PI_TOOLS_SYSTEM_PROMPT =
13
+ "Use only the provided openpi MCP tools. These are the active Pi tools and Pi owns their execution and permissions. Do not use Cursor-native filesystem, shell, editing, web, task, or interaction tools. When tool results appear in conversation history, continue from those results. Do not repeat a completed tool call.";
14
+
15
+ export function buildCursorTools(tools: Context["tools"]) {
16
+ return (tools ?? []).map((tool) => {
17
+ const schema: JsonValue = JSON.parse(JSON.stringify(tool.parameters));
18
+ return create(McpToolDefinitionSchema, {
19
+ name: tool.name,
20
+ providerIdentifier: CURSOR_PI_PROVIDER,
21
+ toolName: tool.name,
22
+ description: tool.description,
23
+ inputSchema: encodeJsonValue(schema),
24
+ inputSchemaJson: JSON.stringify(schema),
25
+ });
26
+ });
27
+ }
28
+
29
+ export function decodeCursorTool(
30
+ args: McpArgs,
31
+ tools: Context["tools"],
32
+ ): ToolCall {
33
+ const name = args.toolName || args.name;
34
+ if (
35
+ args.providerIdentifier !== CURSOR_PI_PROVIDER ||
36
+ (args.serverIdentifier && args.serverIdentifier !== CURSOR_PI_PROVIDER) ||
37
+ !name ||
38
+ (args.name && args.name !== name) ||
39
+ !tools?.some((tool) => tool.name === name)
40
+ ) {
41
+ throw new Error("Cursor requested an unadvertised Pi tool identity");
42
+ }
43
+ if (!args.toolCallId.trim())
44
+ throw new Error("Cursor MCP tool call has no identity");
45
+ const values: Record<string, unknown> = {};
46
+ for (const [key, value] of Object.entries(args.args)) {
47
+ // google.protobuf.Value, not JSON text. Define own properties so keys such
48
+ // as __proto__ cannot alter the decoded argument object's prototype.
49
+ if (!value.length || ![8, 17, 26, 32, 42, 50].includes(value[0]!)) {
50
+ throw new Error("Cursor MCP argument is not a protobuf JSON value");
51
+ }
52
+ const decoded = decodeJsonValue(value);
53
+ const validateJson = (item: JsonValue): void => {
54
+ if (typeof item === "number" && !Number.isFinite(item))
55
+ throw new Error("Cursor MCP argument contains a non-finite number");
56
+ if (item && typeof item === "object")
57
+ for (const child of Object.values(item)) validateJson(child);
58
+ };
59
+ validateJson(decoded);
60
+ Object.defineProperty(values, key, {
61
+ value: decoded,
62
+ enumerable: true,
63
+ configurable: true,
64
+ writable: true,
65
+ });
66
+ }
67
+ return { type: "toolCall", id: args.toolCallId, name, arguments: values };
68
+ }
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Adds OAuth-backed Google Antigravity and Cursor model providers. Both are
5
5
  * inert until the user logs in and selects one of their models. Cursor uses
6
- * AgentService/Run in deliberately chat-only mode: Cursor-native coding tools
7
- * are not exposed or executed by this extension.
6
+ * AgentService/Run with an experimental bridge to normal Pi tool calls.
7
+ * Cursor-native coding tools are not exposed or executed by this extension.
8
8
  *
9
9
  * Wire protocol: Cloud Code Assist `v1internal:streamGenerateContent` over
10
10
  * SSE (see antigravity/provider.ts). Reference implementation: oh-my-pi's
@@ -13,7 +13,6 @@
13
13
 
14
14
  import { createProvider, type ProviderStreams } from "@earendil-works/pi-ai";
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
- import { createOAuthAuth } from "./oauth-adapter.ts";
17
16
  import { encodeApiKey } from "./antigravity/credentials.ts";
18
17
  import { fetchAntigravityModels } from "./antigravity/discovery.ts";
19
18
  import {
@@ -31,6 +30,7 @@ import { transformCursorImageInput } from "./cursor/input-images.ts";
31
30
  import { CURSOR_MODELS } from "./cursor/models.ts";
32
31
  import { loginCursor, refreshCursorToken } from "./cursor/oauth.ts";
33
32
  import { streamCursor } from "./cursor/provider.ts";
33
+ import { createOAuthAuth } from "./oauth-adapter.ts";
34
34
 
35
35
  function providerStreams(
36
36
  streamSimple: ProviderStreams["streamSimple"],
@@ -550,6 +550,20 @@ export function effectiveChildToolAllowlist(tools?: readonly string[]) {
550
550
  );
551
551
  }
552
552
 
553
+ /** Project the parent's active surface into a child; a role can only narrow it.
554
+ * Active tools are a visibility choice, not a filesystem/network sandbox.
555
+ * Inactive tools are not implicitly activated by delegation.
556
+ */
557
+ export function inheritedChildToolAllowlist(
558
+ parentTools: readonly string[],
559
+ roleTools?: readonly string[],
560
+ ) {
561
+ const allowed = roleTools === undefined ? undefined : new Set(roleTools);
562
+ return effectiveChildToolAllowlist([...new Set(parentTools)])!.filter(
563
+ (name) => allowed === undefined || allowed.has(name),
564
+ );
565
+ }
566
+
553
567
  export function childToolPolicy(tools?: readonly string[]) {
554
568
  const effectiveTools = effectiveChildToolAllowlist(tools);
555
569
  return {
@@ -57,6 +57,7 @@ import {
57
57
  } from "../shared/below-editor-navigation.ts";
58
58
  import {
59
59
  effectiveChildToolAllowlist,
60
+ inheritedChildToolAllowlist,
60
61
  resolveStandaloneChildProjectTrust,
61
62
  } from "../shared/child-session.ts";
62
63
  import { formatContextUtilization } from "../shared/context-utilization.ts";
@@ -144,6 +145,7 @@ import { createSubagentResultDelivery } from "./src/result-delivery.ts";
144
145
  import {
145
146
  createSubagentRuntime,
146
147
  runTool,
148
+ SubagentToolInterruptedError,
147
149
  type SubagentRuntime,
148
150
  } from "./src/runtime.ts";
149
151
  import { openSubagentPicker, openSubagentTakeover } from "./src/ui/takeover.ts";
@@ -886,6 +888,7 @@ export default function (
886
888
  if (
887
889
  planning &&
888
890
  agentType &&
891
+ !agentType.planningCompatible &&
889
892
  !planModeAllowsDeclaredTools(declaredChildTools)
890
893
  ) {
891
894
  throw new Error(
@@ -934,7 +937,10 @@ export default function (
934
937
  const requestedChildTools = planning
935
938
  ? planModeChildTools(declaredChildTools)
936
939
  : declaredChildTools;
937
- const childTools = effectiveChildToolAllowlist(requestedChildTools);
940
+ const childTools = inheritedChildToolAllowlist(
941
+ pi.getActiveTools(),
942
+ requestedChildTools,
943
+ );
938
944
  // Read at spawn time so `/openpi-setup` changes affect the next child
939
945
  // without reloading this extension. Undefined preserves parent-model
940
946
  // inheritance in the backend.
@@ -980,11 +986,22 @@ export default function (
980
986
  interruptMessage: "Subagent spawn aborted.",
981
987
  });
982
988
  } catch (error) {
983
- // The session scope owns reclamation, but it never opened, so this
984
- // worktree would otherwise be orphaned on disk.
989
+ // Known startup failures can reclaim their empty checkout. Interrupted
990
+ // startup must preserve it while asynchronous acquisition may continue.
985
991
  if (worktree) {
986
992
  const spawnError =
987
993
  error instanceof Error ? error.message : String(error);
994
+ // Cancelling Effect acquisition does not prove an asynchronous
995
+ // factory or extension hook has quiesced. It may still use this cwd.
996
+ if (
997
+ signal?.aborted ||
998
+ error instanceof SubagentToolInterruptedError
999
+ ) {
1000
+ throw new Error(
1001
+ `${spawnError}; startup quiescence is unknown; checkout preserved at ${worktree.path} (branch ${worktree.branch})`,
1002
+ { cause: error },
1003
+ );
1004
+ }
988
1005
  let cleanupWarning: string | undefined;
989
1006
  let cleanupError: unknown;
990
1007
  try {