@tt-a1i/openpi 0.6.0 → 0.7.0

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.
Files changed (60) hide show
  1. package/README.md +13 -11
  2. package/SETUP.md +2 -2
  3. package/THIRD_PARTY_NOTICES.md +242 -0
  4. package/extensions/ai-providers/README.md +12 -6
  5. package/extensions/ai-providers/cursor/connect-frame-reader.ts +76 -0
  6. package/extensions/ai-providers/cursor/input-images.ts +2 -3
  7. package/extensions/ai-providers/cursor/proto.ts +218 -11
  8. package/extensions/ai-providers/cursor/protobuf.ts +12 -2
  9. package/extensions/ai-providers/cursor/provider.ts +280 -37
  10. package/extensions/ai-providers/cursor/tool-bridge.ts +68 -0
  11. package/extensions/ai-providers/index.ts +3 -3
  12. package/extensions/file-mutation-display/index.ts +13 -9
  13. package/extensions/shared/agent-transcript.ts +3 -2
  14. package/extensions/shared/child-session.ts +14 -0
  15. package/extensions/subagents/index.ts +20 -3
  16. package/extensions/subagents/src/agent-types.ts +5 -17
  17. package/extensions/subagents/src/backends/pi.ts +70 -59
  18. package/extensions/subagents/src/backends/tool-preview.ts +29 -0
  19. package/extensions/subagents/src/manager.ts +2 -71
  20. package/extensions/subagents/src/prompt.ts +2 -2
  21. package/extensions/subagents/src/runtime.ts +10 -3
  22. package/extensions/web/index.ts +69 -5
  23. package/extensions/workflows/artifacts.ts +362 -23
  24. package/extensions/workflows/dashboard.ts +141 -21
  25. package/extensions/workflows/index.ts +62 -20
  26. package/extensions/workflows/progress-projection.ts +7 -1
  27. package/extensions/workflows/runner.ts +5 -162
  28. package/extensions/workflows/sandbox.ts +4 -0
  29. package/package.json +28 -4
  30. package/skills/subagents/REFERENCE.md +6 -7
  31. package/skills/subagents/SKILL.md +1 -1
  32. package/skills/workflows/REFERENCE.md +3 -1
  33. package/web/dist/app.js +87 -0
  34. package/web/dist/favicon.svg +9 -0
  35. package/web/dist/index.html +15 -0
  36. package/web/dist/styles.css +3 -0
  37. package/web/host/web-host.ts +6 -9
  38. package/web/ui/index.html +3 -131
  39. package/web/ui/public/favicon.svg +9 -0
  40. package/web/ui/src/app/App.tsx +134 -0
  41. package/web/ui/src/app/providers.tsx +38 -0
  42. package/web/ui/src/components/Markdown.tsx +58 -0
  43. package/web/ui/src/components/OpenPiLogo.tsx +41 -0
  44. package/web/ui/src/features/activity/ActivityBar.tsx +120 -0
  45. package/web/ui/src/features/composer/Composer.tsx +237 -0
  46. package/web/ui/src/features/sessions/SessionSidebar.tsx +418 -0
  47. package/web/ui/src/features/transcript/Transcript.tsx +860 -0
  48. package/web/ui/src/i18n.ts +159 -0
  49. package/web/ui/src/lib/format.ts +57 -0
  50. package/web/ui/src/main.tsx +16 -0
  51. package/web/ui/src/protocol/client.ts +199 -0
  52. package/web/ui/src/protocol/event-stream.ts +88 -0
  53. package/web/ui/src/store/web-store.ts +926 -0
  54. package/web/ui/src/styles.css +420 -0
  55. package/web/ui/tsconfig.json +12 -0
  56. package/web/ui/vite-env.d.ts +1 -0
  57. package/web/vite.config.mjs +21 -1
  58. package/web/host/static-assets.ts +0 -4
  59. package/web/ui/app.js +0 -1700
  60. package/web/ui/styles.css +0 -680
@@ -10,9 +10,11 @@ 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";
17
+ import { ConnectFrameReader } from "./connect-frame-reader.ts";
16
18
  import {
17
19
  CURSOR_API_URL,
18
20
  CURSOR_CLIENT_VERSION,
@@ -35,6 +37,7 @@ import {
35
37
  CursorRuleSchema,
36
38
  CursorRuleTypeGlobalSchema,
37
39
  CursorRuleTypeSchema,
40
+ CursorToolCallSchema,
38
41
  ExecClientControlMessageSchema,
39
42
  ExecClientMessageSchema,
40
43
  ExecClientStreamCloseSchema,
@@ -44,6 +47,15 @@ import {
44
47
  KvClientMessageSchema,
45
48
  type KvServerMessage,
46
49
  KvServerMessageSchema,
50
+ McpArgsSchema,
51
+ McpImageContentSchema,
52
+ McpRejectedSchema,
53
+ McpResultSchema,
54
+ McpSuccessSchema,
55
+ McpTextContentSchema,
56
+ McpToolCallSchema,
57
+ McpToolResultContentItemSchema,
58
+ McpToolResultSchema,
47
59
  type ModelDetails,
48
60
  ModelDetailsSchema,
49
61
  RequestContextResultSchema,
@@ -59,8 +71,14 @@ import {
59
71
  UserMessageActionSchema,
60
72
  UserMessageSchema,
61
73
  } from "./proto.ts";
62
- import { create, fromBinary, toBinary } from "./protobuf.ts";
74
+ import { create, encodeJsonValue, fromBinary, toBinary } from "./protobuf.ts";
63
75
  import { connectCursorHttp2 } from "./proxy.ts";
76
+ import {
77
+ buildCursorTools,
78
+ CURSOR_PI_PROVIDER,
79
+ CURSOR_PI_TOOLS_SYSTEM_PROMPT,
80
+ decodeCursorTool,
81
+ } from "./tool-bridge.ts";
64
82
 
65
83
  const CONNECT_END_STREAM_FLAG = 0b00000010;
66
84
  const CONNECT_COMPRESSED_FLAG = 0b00000001;
@@ -199,22 +217,51 @@ function rootPromptContent(
199
217
 
200
218
  function assistantRootContent(
201
219
  message: Extract<Message, { role: "assistant" }>,
220
+ results: Map<string, Extract<Message, { role: "toolResult" }>>,
202
221
  ) {
203
222
  const content: Array<Record<string, unknown>> = [];
204
223
  for (const item of message.content) {
205
224
  if (item.type === "text" && item.text) {
206
225
  content.push({ type: "text", text: item.text });
226
+ } else if (
227
+ item.type === "toolCall" &&
228
+ results.get(item.id)?.toolName === item.name
229
+ ) {
230
+ content.push({
231
+ type: "tool-call",
232
+ toolCallId: item.id,
233
+ toolName: item.name,
234
+ args: item.arguments,
235
+ });
207
236
  }
208
237
  }
209
238
  return content;
210
239
  }
211
240
 
241
+ function pairedToolResults(messages: Message[], end: number) {
242
+ const calls = new Map<string, string>();
243
+ const results = new Map<string, Extract<Message, { role: "toolResult" }>>();
244
+ for (const message of messages.slice(0, end < 0 ? undefined : end)) {
245
+ if (message.role === "assistant") {
246
+ for (const part of message.content)
247
+ if (part.type === "toolCall") calls.set(part.id, part.name);
248
+ } else if (
249
+ message.role === "toolResult" &&
250
+ calls.get(message.toolCallId) === message.toolName
251
+ ) {
252
+ results.set(message.toolCallId, message);
253
+ }
254
+ }
255
+ return results;
256
+ }
257
+
212
258
  function buildHistoryRootPrompt(
213
259
  messages: Message[],
214
260
  store: CursorBlobStore,
215
261
  activeUserIndex: number,
216
262
  ): Uint8Array[] {
217
263
  const entries: Uint8Array[] = [];
264
+ const results = pairedToolResults(messages, activeUserIndex);
218
265
  for (let index = 0; index < messages.length; index++) {
219
266
  if (index === activeUserIndex) break;
220
267
  const message = messages[index];
@@ -224,13 +271,29 @@ function buildHistoryRootPrompt(
224
271
  if (content.length === 0) continue;
225
272
  value = { role: "user", content };
226
273
  } else if (message.role === "assistant") {
227
- const content = assistantRootContent(message);
274
+ const content = assistantRootContent(message, results);
228
275
  if (content.length === 0) continue;
229
276
  value = { role: "assistant", content };
230
277
  } 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;
278
+ if (results.get(message.toolCallId) !== message) continue;
279
+ value = {
280
+ role: "tool",
281
+ id: message.toolCallId,
282
+ content: [
283
+ {
284
+ type: "tool-result",
285
+ toolCallId: message.toolCallId,
286
+ toolName: message.toolName,
287
+ result: message.content.some((part) => part.type === "image")
288
+ ? rootPromptContent(message.content)
289
+ : message.content
290
+ .filter((part) => part.type === "text")
291
+ .map((part) => part.text)
292
+ .join("\n"),
293
+ ...(message.isError ? { isError: true } : {}),
294
+ },
295
+ ],
296
+ };
234
297
  }
235
298
  entries.push(
236
299
  storeBlob(store, new TextEncoder().encode(JSON.stringify(value))),
@@ -242,13 +305,17 @@ function buildHistoryRootPrompt(
242
305
  function buildSystemPrompt(
243
306
  systemPrompt: Context["systemPrompt"],
244
307
  store: CursorBlobStore,
308
+ hasTools = false,
245
309
  ): Uint8Array[] {
246
310
  const prompts = systemPrompt
247
311
  ? Array.isArray(systemPrompt)
248
312
  ? systemPrompt
249
313
  : [systemPrompt]
250
314
  : ["You are a helpful assistant."];
251
- return [...prompts, CURSOR_CHAT_ONLY_SYSTEM_PROMPT].map((prompt) =>
315
+ return [
316
+ ...prompts,
317
+ hasTools ? CURSOR_PI_TOOLS_SYSTEM_PROMPT : CURSOR_CHAT_ONLY_SYSTEM_PROMPT,
318
+ ].map((prompt) =>
252
319
  storeBlob(
253
320
  store,
254
321
  new TextEncoder().encode(
@@ -260,11 +327,12 @@ function buildSystemPrompt(
260
327
 
261
328
  /**
262
329
  * 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`.
330
+ * These rules keep Cursor-native tools disabled. The request-context response
331
+ * advertises only the active Pi tools through the MCP protocol bridge.
265
332
  */
266
333
  export function buildCursorRequestContextRules(
267
334
  systemPrompt: Context["systemPrompt"],
335
+ hasTools = false,
268
336
  ): CursorRule[] {
269
337
  const rules: CursorRule[] = systemPrompt?.trim()
270
338
  ? [
@@ -283,8 +351,10 @@ export function buildCursorRequestContextRules(
283
351
  : [];
284
352
  rules.push(
285
353
  create(CursorRuleSchema, {
286
- fullPath: "/pi/cursor-chat-only.mdc",
287
- content: CURSOR_CHAT_ONLY_SYSTEM_PROMPT,
354
+ fullPath: hasTools ? "/pi/cursor-tools.mdc" : "/pi/cursor-chat-only.mdc",
355
+ content: hasTools
356
+ ? CURSOR_PI_TOOLS_SYSTEM_PROMPT
357
+ : CURSOR_CHAT_ONLY_SYSTEM_PROMPT,
288
358
  source: 2,
289
359
  type: create(CursorRuleTypeSchema, {
290
360
  type: { case: "global", value: create(CursorRuleTypeGlobalSchema, {}) },
@@ -300,6 +370,7 @@ function buildHistoryTurns(
300
370
  activeUserIndex: number,
301
371
  ): Uint8Array[] {
302
372
  const turns: Uint8Array[] = [];
373
+ const results = pairedToolResults(messages, activeUserIndex);
303
374
  const end = activeUserIndex >= 0 ? activeUserIndex : messages.length;
304
375
  let index = 0;
305
376
  while (index < end) {
@@ -335,6 +406,68 @@ function buildHistoryTurns(
335
406
  ),
336
407
  ),
337
408
  );
409
+ } else if (item.type === "toolCall") {
410
+ const result = results.get(item.id);
411
+ if (!result || result.toolName !== item.name) continue;
412
+ const args = Object.fromEntries(
413
+ Object.entries(item.arguments).map(([key, value]) => [
414
+ key,
415
+ encodeJsonValue(JSON.parse(JSON.stringify(value))),
416
+ ]),
417
+ );
418
+ const tool = create(CursorToolCallSchema, {
419
+ toolCallId: item.id,
420
+ tool: {
421
+ case: "mcpToolCall",
422
+ value: create(McpToolCallSchema, {
423
+ args: create(McpArgsSchema, {
424
+ name: item.name,
425
+ toolName: item.name,
426
+ providerIdentifier: CURSOR_PI_PROVIDER,
427
+ toolCallId: item.id,
428
+ args,
429
+ }),
430
+ result: create(McpToolResultSchema, {
431
+ result: {
432
+ case: "success",
433
+ value: create(McpSuccessSchema, {
434
+ isError: result.isError,
435
+ content: result.content.map((part) =>
436
+ create(McpToolResultContentItemSchema, {
437
+ content:
438
+ part.type === "text"
439
+ ? {
440
+ case: "text",
441
+ value: create(McpTextContentSchema, {
442
+ text: part.text,
443
+ }),
444
+ }
445
+ : {
446
+ case: "image",
447
+ value: create(McpImageContentSchema, {
448
+ data: Buffer.from(part.data, "base64"),
449
+ mimeType: part.mimeType,
450
+ }),
451
+ },
452
+ }),
453
+ ),
454
+ }),
455
+ },
456
+ }),
457
+ }),
458
+ },
459
+ });
460
+ steps.push(
461
+ storeBlob(
462
+ store,
463
+ toBinary(
464
+ ConversationStepSchema,
465
+ create(ConversationStepSchema, {
466
+ message: { case: "toolCall", value: tool },
467
+ }),
468
+ ),
469
+ ),
470
+ );
338
471
  }
339
472
  }
340
473
  }
@@ -414,11 +547,14 @@ export async function buildCursorRequest(
414
547
  options?: SimpleStreamOptions,
415
548
  ): Promise<CursorRequestBuild> {
416
549
  const store: CursorBlobStore = new Map();
417
- const activeIndex = lastUserIndex(context.messages);
550
+ const activeIndex =
551
+ context.messages.at(-1)?.role === "user"
552
+ ? lastUserIndex(context.messages)
553
+ : -1;
418
554
  const active = activeIndex >= 0 ? context.messages[activeIndex] : undefined;
419
555
  const activeContent = active?.role === "user" ? active.content : undefined;
420
556
  const rootPromptMessagesJson = [
421
- ...buildSystemPrompt(context.systemPrompt, store),
557
+ ...buildSystemPrompt(context.systemPrompt, store, !!context.tools?.length),
422
558
  ...buildHistoryRootPrompt(context.messages, store, activeIndex),
423
559
  ];
424
560
  const state = create(ConversationStateStructureSchema, {
@@ -554,7 +690,7 @@ function isAbortError(
554
690
  );
555
691
  }
556
692
 
557
- /** Cursor AgentService/Run, deliberately chat-only (no context.tools advertisement or Pi tool calls). */
693
+ /** Cursor AgentService/Run with Pi-owned tool execution across provider turns. */
558
694
  export function streamCursor(
559
695
  model: Model<Api>,
560
696
  context: Context,
@@ -586,6 +722,8 @@ export function streamCursor(
586
722
  let turnEnded = false;
587
723
  let terminalError: Error | undefined;
588
724
  let finished = false;
725
+ const pendingCalls = new Map<string, ToolCall>();
726
+ let handingOffTools = false;
589
727
 
590
728
  const closeBlocks = () => {
591
729
  if (currentText) {
@@ -696,8 +834,9 @@ export function streamCursor(
696
834
  h2Request?.close(http2.constants.NGHTTP2_CANCEL);
697
835
  }, requestTimeoutMs);
698
836
  };
699
- let frameBuffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
837
+ const frames = new ConnectFrameReader(MAX_CONNECT_FRAME_BYTES);
700
838
  const processFrame = (flags: number, bytes: Uint8Array) => {
839
+ if (handingOffTools) return;
701
840
  if ((flags & CONNECT_COMPRESSED_FLAG) !== 0) {
702
841
  throw new Error("Compressed Cursor Connect frames are unsupported");
703
842
  }
@@ -715,8 +854,11 @@ export function streamCursor(
715
854
  case: "success",
716
855
  value: create(RequestContextSuccessSchema, {
717
856
  requestContext: create(RequestContextSchema, {
718
- rules: buildCursorRequestContextRules(context.systemPrompt),
719
- tools: [],
857
+ rules: buildCursorRequestContextRules(
858
+ context.systemPrompt,
859
+ !!context.tools?.length,
860
+ ),
861
+ tools: buildCursorTools(context.tools),
720
862
  }),
721
863
  }),
722
864
  },
@@ -734,6 +876,60 @@ export function streamCursor(
734
876
  );
735
877
  return;
736
878
  }
879
+ if (exec.message.case === "mcpArgs") {
880
+ const args = exec.message.value;
881
+ if (args.smartModeApprovalOnly) {
882
+ // A probe must not become a tool call or preauthorize Pi execution.
883
+ const reply = create(AgentClientMessageSchema, {
884
+ message: {
885
+ case: "execClientMessage",
886
+ value: create(ExecClientMessageSchema, {
887
+ id: exec.id,
888
+ execId: exec.execId,
889
+ message: {
890
+ case: "mcpResult",
891
+ value: create(McpResultSchema, {
892
+ result: {
893
+ case: "rejected",
894
+ value: create(McpRejectedSchema, {
895
+ reason:
896
+ "Pi must evaluate permissions when executing the tool; approval-only probes cannot authorize execution.",
897
+ }),
898
+ },
899
+ }),
900
+ },
901
+ }),
902
+ },
903
+ });
904
+ h2Request?.write(
905
+ frameConnectMessage(toBinary(AgentClientMessageSchema, reply)),
906
+ );
907
+ return;
908
+ }
909
+ const call = decodeCursorTool(args, context.tools);
910
+ if (
911
+ context.messages.some((message) =>
912
+ message.role === "toolResult"
913
+ ? message.toolCallId === call.id
914
+ : message.role === "assistant" &&
915
+ message.content.some(
916
+ (part) => part.type === "toolCall" && part.id === call.id,
917
+ ),
918
+ )
919
+ ) {
920
+ throw new Error(
921
+ "Cursor attempted to replay a tool call identity already present in Pi history",
922
+ );
923
+ }
924
+ const previous = pendingCalls.get(call.id);
925
+ if (previous && JSON.stringify(previous) !== JSON.stringify(call)) {
926
+ throw new Error(
927
+ "Cursor repeated a tool call identity with different arguments",
928
+ );
929
+ }
930
+ pendingCalls.set(call.id, call);
931
+ return;
932
+ }
737
933
  const throwReply = create(AgentClientMessageSchema, {
738
934
  message: {
739
935
  case: "execClientControlMessage",
@@ -742,8 +938,9 @@ export function streamCursor(
742
938
  case: "throw",
743
939
  value: create(ExecClientThrowSchema, {
744
940
  id: exec.id,
745
- error:
746
- "Cursor tools are not available in this chat-only provider",
941
+ error: context.tools?.length
942
+ ? "Cursor-native execution is unavailable; use advertised Pi MCP tools"
943
+ : "Cursor tools are not available in this chat-only provider",
747
944
  errorCode: "UNIMPLEMENTED",
748
945
  }),
749
946
  },
@@ -762,7 +959,9 @@ export function streamCursor(
762
959
  },
763
960
  });
764
961
  const error = new Error(
765
- "Cursor requested a tool that is unavailable in chat-only mode",
962
+ context.tools?.length
963
+ ? "Cursor requested unsupported native execution outside Pi"
964
+ : "Cursor requested a tool that is unavailable in chat-only mode",
766
965
  );
767
966
  terminalError = error;
768
967
  if (!h2Request) {
@@ -784,10 +983,32 @@ export function streamCursor(
784
983
  }
785
984
  if (message.message.case === "interactionQuery") {
786
985
  throw new Error(
787
- `Cursor interaction query ${message.message.value.query.case ?? "unknown"} is unavailable in chat-only mode`,
986
+ `Cursor interaction query ${message.message.value.query.case ?? "unknown"} is unavailable ${context.tools?.length ? "outside Pi's interaction lifecycle" : "in chat-only mode"}`,
788
987
  );
789
988
  }
790
989
  if (message.message.case !== "interactionUpdate") return;
990
+ const update = message.message.value;
991
+ if (
992
+ context.tools?.length &&
993
+ (update.message.case === "partialToolCall" ||
994
+ update.message.case === "toolCallStarted" ||
995
+ update.message.case === "toolCallCompleted")
996
+ ) {
997
+ const preview = update.message.value.toolCall;
998
+ if (preview && preview.tool.case !== "mcpToolCall") {
999
+ throw new Error(
1000
+ "Cursor-native tools are unavailable; use the advertised Pi MCP tools",
1001
+ );
1002
+ }
1003
+ // Only exec mcpArgs is an invocation. UI previews may be partial,
1004
+ // duplicated, or emitted for approval probes, and never execute.
1005
+ return;
1006
+ }
1007
+ if (context.tools?.length && update.message.case === "toolCallDelta") {
1008
+ throw new Error(
1009
+ "Cursor-native tool deltas are unavailable; use the advertised Pi MCP tools",
1010
+ );
1011
+ }
791
1012
  processInteraction(
792
1013
  message.message.value,
793
1014
  output,
@@ -813,22 +1034,39 @@ export function streamCursor(
813
1034
  );
814
1035
  };
815
1036
  const processData = (chunk: Buffer) => {
816
- frameBuffer =
817
- frameBuffer.length === 0
818
- ? chunk
819
- : Buffer.concat([frameBuffer, chunk]);
820
- while (frameBuffer.length >= 5) {
821
- const size = frameBuffer.readUInt32BE(1);
822
- if (size > MAX_CONNECT_FRAME_BYTES) {
823
- throw new Error(
824
- `Cursor Connect frame exceeds ${MAX_CONNECT_FRAME_BYTES} bytes`,
825
- );
1037
+ frames.push(chunk, processFrame);
1038
+ // Preserve tool batching while a complete header awaits its body.
1039
+ if (frames.awaitingPayload) return;
1040
+ if (pendingCalls.size > 0 && !handingOffTools) {
1041
+ if (terminalError) throw terminalError;
1042
+ if (options?.signal?.aborted)
1043
+ throw new Error("Cursor request aborted");
1044
+ closeBlocks();
1045
+ for (const call of pendingCalls.values()) {
1046
+ const contentIndex = output.content.length;
1047
+ output.content.push(call);
1048
+ stream.push({
1049
+ type: "toolcall_start",
1050
+ contentIndex,
1051
+ partial: output,
1052
+ });
1053
+ stream.push({
1054
+ type: "toolcall_delta",
1055
+ contentIndex,
1056
+ delta: JSON.stringify(call.arguments),
1057
+ partial: output,
1058
+ });
1059
+ stream.push({
1060
+ type: "toolcall_end",
1061
+ contentIndex,
1062
+ toolCall: call,
1063
+ partial: output,
1064
+ });
826
1065
  }
827
- if (frameBuffer.length < size + 5) return;
828
- const flags = frameBuffer[0]!;
829
- const data = frameBuffer.subarray(5, size + 5);
830
- frameBuffer = frameBuffer.subarray(size + 5);
831
- processFrame(flags, data);
1066
+ output.stopReason = "toolUse";
1067
+ handingOffTools = true;
1068
+ turnEnded = true;
1069
+ settle();
832
1070
  }
833
1071
  };
834
1072
 
@@ -907,7 +1145,7 @@ export function streamCursor(
907
1145
  .then(() => {
908
1146
  if (!responseSeen)
909
1147
  throw new Error("Cursor response headers were not received");
910
- if (frameBuffer.length !== 0)
1148
+ if (frames.incomplete)
911
1149
  throw new Error("Incomplete Cursor Connect frame");
912
1150
  settle();
913
1151
  })
@@ -967,7 +1205,12 @@ export function streamCursor(
967
1205
  output.usage.totalTokens = output.usage.input + output.usage.output;
968
1206
  stream.push({
969
1207
  type: "done",
970
- reason: output.stopReason === "length" ? "length" : "stop",
1208
+ reason:
1209
+ output.stopReason === "toolUse"
1210
+ ? "toolUse"
1211
+ : output.stopReason === "length"
1212
+ ? "length"
1213
+ : "stop",
971
1214
  message: output,
972
1215
  });
973
1216
  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"],
@@ -27,15 +27,19 @@ function compact<TParams extends TSchema, TDetails, TState>(
27
27
  export default function fileMutationDisplay(pi: ExtensionAPI) {
28
28
  pi.on("session_start", (_event, ctx) => {
29
29
  const display = loadSetupConfig().ui;
30
- if (ctx.mode === "tui") {
31
- // Ctrl+O remains a temporary override. A new/reloaded session starts from
32
- // the persisted defaults instead of inheriting an old expanded toggle.
33
- ctx.ui.setToolsExpanded(
34
- display.subagentResultDisplay === "full" &&
35
- display.bashToolDisplay === "full" &&
36
- display.fileMutationDisplay === "full",
37
- );
38
- }
30
+ // This extension changes only the interactive TUI projection. Headless
31
+ // sessions must keep Pi's native definitions, especially bash: replacing
32
+ // it here would drop the SettingsManager-provided shellPath and can make
33
+ // Windows resolve the WSL System32 stub instead of the configured shell.
34
+ if (ctx.mode !== "tui") return;
35
+
36
+ // Ctrl+O remains a temporary override. A new/reloaded session starts from
37
+ // the persisted defaults instead of inheriting an old expanded toggle.
38
+ ctx.ui.setToolsExpanded(
39
+ display.subagentResultDisplay === "full" &&
40
+ display.bashToolDisplay === "full" &&
41
+ display.fileMutationDisplay === "full",
42
+ );
39
43
 
40
44
  pi.registerTool(
41
45
  compact(
@@ -409,7 +409,7 @@ function findResult(
409
409
  }
410
410
 
411
411
  /**
412
- * Caches finalized transcript items by identity and width. Live state remains
412
+ * Caches finalized transcript items by identity, width, and cwd. Live state remains
413
413
  * uncached because it changes on every stream tick; callers clear this cache
414
414
  * from their component's invalidate() when Pi changes theme.
415
415
  */
@@ -434,7 +434,8 @@ export class AgentTranscriptRenderer {
434
434
  for (let index = 0; index < document.items.length; index++) {
435
435
  const item = document.items[index];
436
436
  const context = itemContext(document.items, index, liveIds, pairing);
437
- const key = `${width}|${context.token}`;
437
+ // Fallback tool rows relativize paths against the document's cwd.
438
+ const key = JSON.stringify([width, context.token, document.cwd]);
438
439
  const cacheable = !document.toolRenderer || !itemHasTool(item);
439
440
  const cached = cacheable ? this.itemCache.get(item)?.get(key) : undefined;
440
441
  const lines =
@@ -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 {