@oh-my-pi/pi-coding-agent 5.4.2 → 5.6.7

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 (97) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/docs/python-repl.md +77 -0
  3. package/examples/hooks/snake.ts +7 -7
  4. package/package.json +5 -5
  5. package/src/bun-imports.d.ts +6 -0
  6. package/src/cli/args.ts +7 -0
  7. package/src/cli/setup-cli.ts +231 -0
  8. package/src/cli.ts +2 -0
  9. package/src/core/agent-session.ts +118 -15
  10. package/src/core/bash-executor.ts +3 -84
  11. package/src/core/compaction/compaction.ts +10 -5
  12. package/src/core/extensions/index.ts +2 -0
  13. package/src/core/extensions/loader.ts +13 -1
  14. package/src/core/extensions/runner.ts +50 -2
  15. package/src/core/extensions/types.ts +67 -2
  16. package/src/core/keybindings.ts +51 -1
  17. package/src/core/prompt-templates.ts +15 -0
  18. package/src/core/python-executor-display.test.ts +42 -0
  19. package/src/core/python-executor-lifecycle.test.ts +99 -0
  20. package/src/core/python-executor-mapping.test.ts +41 -0
  21. package/src/core/python-executor-per-call.test.ts +49 -0
  22. package/src/core/python-executor-session.test.ts +103 -0
  23. package/src/core/python-executor-streaming.test.ts +77 -0
  24. package/src/core/python-executor-timeout.test.ts +35 -0
  25. package/src/core/python-executor.lifecycle.test.ts +139 -0
  26. package/src/core/python-executor.result.test.ts +49 -0
  27. package/src/core/python-executor.test.ts +180 -0
  28. package/src/core/python-executor.ts +313 -0
  29. package/src/core/python-gateway-coordinator.ts +832 -0
  30. package/src/core/python-kernel-display.test.ts +54 -0
  31. package/src/core/python-kernel-env.test.ts +138 -0
  32. package/src/core/python-kernel-session.test.ts +87 -0
  33. package/src/core/python-kernel-ws.test.ts +104 -0
  34. package/src/core/python-kernel.lifecycle.test.ts +249 -0
  35. package/src/core/python-kernel.test.ts +549 -0
  36. package/src/core/python-kernel.ts +1178 -0
  37. package/src/core/python-prelude.py +889 -0
  38. package/src/core/python-prelude.test.ts +140 -0
  39. package/src/core/python-prelude.ts +3 -0
  40. package/src/core/sdk.ts +24 -6
  41. package/src/core/session-manager.ts +174 -82
  42. package/src/core/settings-manager-python.test.ts +23 -0
  43. package/src/core/settings-manager.ts +202 -0
  44. package/src/core/streaming-output.test.ts +26 -0
  45. package/src/core/streaming-output.ts +100 -0
  46. package/src/core/system-prompt.python.test.ts +17 -0
  47. package/src/core/system-prompt.ts +3 -1
  48. package/src/core/timings.ts +1 -1
  49. package/src/core/tools/bash.ts +13 -2
  50. package/src/core/tools/edit-diff.ts +9 -1
  51. package/src/core/tools/index.test.ts +50 -23
  52. package/src/core/tools/index.ts +83 -1
  53. package/src/core/tools/python-execution.test.ts +68 -0
  54. package/src/core/tools/python-fallback.test.ts +72 -0
  55. package/src/core/tools/python-renderer.test.ts +36 -0
  56. package/src/core/tools/python-tool-mode.test.ts +43 -0
  57. package/src/core/tools/python.test.ts +121 -0
  58. package/src/core/tools/python.ts +760 -0
  59. package/src/core/tools/renderers.ts +2 -0
  60. package/src/core/tools/schema-validation.test.ts +1 -0
  61. package/src/core/tools/task/executor.ts +146 -3
  62. package/src/core/tools/task/worker-protocol.ts +32 -2
  63. package/src/core/tools/task/worker.ts +182 -15
  64. package/src/index.ts +6 -0
  65. package/src/main.ts +136 -40
  66. package/src/modes/interactive/components/custom-editor.ts +16 -31
  67. package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
  68. package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
  69. package/src/modes/interactive/components/history-search.ts +5 -8
  70. package/src/modes/interactive/components/hook-editor.ts +3 -4
  71. package/src/modes/interactive/components/hook-input.ts +3 -3
  72. package/src/modes/interactive/components/hook-selector.ts +5 -15
  73. package/src/modes/interactive/components/index.ts +1 -0
  74. package/src/modes/interactive/components/keybinding-hints.ts +66 -0
  75. package/src/modes/interactive/components/model-selector.ts +53 -66
  76. package/src/modes/interactive/components/oauth-selector.ts +5 -5
  77. package/src/modes/interactive/components/session-selector.ts +29 -23
  78. package/src/modes/interactive/components/settings-defs.ts +404 -196
  79. package/src/modes/interactive/components/settings-selector.ts +14 -10
  80. package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
  81. package/src/modes/interactive/components/tool-execution.ts +8 -0
  82. package/src/modes/interactive/components/tree-selector.ts +29 -23
  83. package/src/modes/interactive/components/user-message-selector.ts +6 -17
  84. package/src/modes/interactive/controllers/command-controller.ts +86 -37
  85. package/src/modes/interactive/controllers/event-controller.ts +8 -0
  86. package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
  87. package/src/modes/interactive/controllers/input-controller.ts +42 -6
  88. package/src/modes/interactive/interactive-mode.ts +56 -30
  89. package/src/modes/interactive/theme/theme-schema.json +2 -2
  90. package/src/modes/interactive/types.ts +6 -1
  91. package/src/modes/interactive/utils/ui-helpers.ts +2 -1
  92. package/src/modes/print-mode.ts +23 -0
  93. package/src/modes/rpc/rpc-mode.ts +21 -0
  94. package/src/prompts/agents/reviewer.md +1 -1
  95. package/src/prompts/system/system-prompt.md +32 -1
  96. package/src/prompts/tools/python.md +91 -0
  97. package/src/prompts/tools/task.md +5 -1
@@ -17,6 +17,7 @@ import { lsToolRenderer } from "./ls";
17
17
  import { lspToolRenderer } from "./lsp/render";
18
18
  import { notebookToolRenderer } from "./notebook";
19
19
  import { outputToolRenderer } from "./output";
20
+ import { pythonToolRenderer } from "./python";
20
21
  import { readToolRenderer } from "./read";
21
22
  import { sshToolRenderer } from "./ssh";
22
23
  import { taskToolRenderer } from "./task/render";
@@ -41,6 +42,7 @@ type ToolRenderer = {
41
42
  export const toolRenderers: Record<string, ToolRenderer> = {
42
43
  ask: askToolRenderer as ToolRenderer,
43
44
  bash: bashToolRenderer as ToolRenderer,
45
+ python: pythonToolRenderer as ToolRenderer,
44
46
  calc: calculatorToolRenderer as ToolRenderer,
45
47
  edit: editToolRenderer as ToolRenderer,
46
48
  find: findToolRenderer as ToolRenderer,
@@ -378,6 +378,7 @@ describe("tool schema validation (post-sanitization)", () => {
378
378
  "lsp",
379
379
  "notebook",
380
380
  "output",
381
+ "python",
381
382
  "read",
382
383
  "task",
383
384
  "todo_write",
@@ -10,6 +10,9 @@ import type { EventBus } from "../../event-bus";
10
10
  import { callTool } from "../../mcp/client";
11
11
  import type { MCPManager } from "../../mcp/manager";
12
12
  import type { ModelRegistry } from "../../model-registry";
13
+ import { checkPythonKernelAvailability } from "../../python-kernel";
14
+ import type { ToolSession } from "..";
15
+ import { createPythonTool } from "../python";
13
16
  import { ensureArtifactsDir, getArtifactPaths } from "./artifacts";
14
17
  import { resolveModelPattern } from "./model-resolver";
15
18
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
@@ -26,6 +29,8 @@ import {
26
29
  import type {
27
30
  MCPToolCallRequest,
28
31
  MCPToolMetadata,
32
+ PythonToolCallCancel,
33
+ PythonToolCallRequest,
29
34
  SubagentWorkerRequest,
30
35
  SubagentWorkerResponse,
31
36
  } from "./worker-protocol";
@@ -52,7 +57,12 @@ export interface ExecutorOptions {
52
57
  mcpManager?: MCPManager;
53
58
  authStorage?: AuthStorage;
54
59
  modelRegistry?: ModelRegistry;
55
- settingsManager?: { serialize: () => import("../../settings-manager").Settings };
60
+ settingsManager?: {
61
+ serialize: () => import("../../settings-manager").Settings;
62
+ getPythonToolMode?: () => "ipy-only" | "bash-only" | "both";
63
+ getPythonKernelMode?: () => "session" | "per-call";
64
+ getPythonSharedGateway?: () => boolean;
65
+ };
56
66
  }
57
67
 
58
68
  /**
@@ -269,14 +279,34 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
269
279
  }
270
280
  }
271
281
 
282
+ const pythonToolMode = options.settingsManager?.getPythonToolMode?.() ?? "ipy-only";
283
+ if (toolNames?.includes("exec")) {
284
+ const expanded = toolNames.filter((name) => name !== "exec");
285
+ if (pythonToolMode === "bash-only") {
286
+ expanded.push("bash");
287
+ } else if (pythonToolMode === "ipy-only") {
288
+ expanded.push("python");
289
+ } else {
290
+ expanded.push("python", "bash");
291
+ }
292
+ toolNames = Array.from(new Set(expanded));
293
+ }
294
+
272
295
  const serializedSettings = options.settingsManager?.serialize();
273
296
  const availableModels = options.modelRegistry?.getAvailable().map((model) => `${model.provider}/${model.id}`);
274
297
 
275
298
  // Resolve and add model
276
299
  const resolvedModel = await resolveModelPattern(modelOverride || agent.model, availableModels, serializedSettings);
277
- const sessionFile = subtaskSessionFile ?? options.sessionFile ?? null;
300
+ const sessionFile = subtaskSessionFile ?? null;
278
301
  const spawnsEnv = agent.spawns === undefined ? "" : agent.spawns === "*" ? "*" : agent.spawns.join(",");
279
302
 
303
+ const pythonToolRequested = toolNames === undefined || toolNames.includes("python");
304
+ let pythonProxyEnabled = pythonToolRequested && pythonToolMode !== "bash-only";
305
+ if (pythonProxyEnabled) {
306
+ const availability = await checkPythonKernelAvailability(cwd);
307
+ pythonProxyEnabled = availability.ok;
308
+ }
309
+
280
310
  let worker: Worker;
281
311
  try {
282
312
  worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });
@@ -311,6 +341,49 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
311
341
  let finalize: ((message: Extract<SubagentWorkerResponse, { type: "done" }>) => void) | null = null;
312
342
  const listenerController = new AbortController();
313
343
  const listenerSignal = listenerController.signal;
344
+ const withTimeout = async <T>(promise: Promise<T>, timeoutMs?: number): Promise<T> => {
345
+ if (timeoutMs === undefined) return promise;
346
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
347
+ try {
348
+ return await Promise.race([
349
+ promise,
350
+ new Promise<T>((_resolve, reject) => {
351
+ timeoutId = setTimeout(() => {
352
+ reject(new Error(`Tool call timed out after ${timeoutMs}ms`));
353
+ }, timeoutMs);
354
+ }),
355
+ ]);
356
+ } finally {
357
+ if (timeoutId) clearTimeout(timeoutId);
358
+ }
359
+ };
360
+
361
+ const combineSignals = (signals: Array<AbortSignal | undefined>): AbortSignal | undefined => {
362
+ const filtered = signals.filter((value): value is AbortSignal => Boolean(value));
363
+ if (filtered.length === 0) return undefined;
364
+ if (filtered.length === 1) return filtered[0];
365
+ return AbortSignal.any(filtered);
366
+ };
367
+
368
+ const createTimeoutSignal = (timeoutMs?: number): AbortSignal | undefined => {
369
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
370
+ return undefined;
371
+ }
372
+ return AbortSignal.timeout(timeoutMs);
373
+ };
374
+
375
+ const pythonSessionFile = sessionFile ?? `subtask:${taskId}`;
376
+ const pythonToolSession: ToolSession = {
377
+ cwd,
378
+ hasUI: false,
379
+ enableLsp: false,
380
+ getSessionFile: () => pythonSessionFile,
381
+ getSessionSpawns: () => spawnsEnv,
382
+ settings: options.settingsManager as ToolSession["settings"],
383
+ settingsManager: options.settingsManager,
384
+ };
385
+ const pythonTool = pythonProxyEnabled ? createPythonTool(pythonToolSession) : null;
386
+ const pythonCallControllers = new Map<string, AbortController>();
314
387
 
315
388
  // Accumulate usage incrementally from message_end events (no memory for streaming events)
316
389
  const accumulatedUsage = {
@@ -360,6 +433,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
360
433
  if (resolved) return;
361
434
  abortSent = true;
362
435
  abortReason = reason;
436
+ for (const controller of pythonCallControllers.values()) {
437
+ controller.abort();
438
+ }
439
+ pythonCallControllers.clear();
363
440
  const abortMessage: SubagentWorkerRequest = { type: "abort" };
364
441
  try {
365
442
  worker.postMessage(abortMessage);
@@ -606,6 +683,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
606
683
  serializedModels: options.modelRegistry?.serialize(),
607
684
  serializedSettings,
608
685
  mcpTools: options.mcpManager ? extractMCPToolMetadata(options.mcpManager) : undefined,
686
+ pythonToolProxy: pythonProxyEnabled,
609
687
  },
610
688
  };
611
689
 
@@ -642,7 +720,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
642
720
  if (!parsed) throw new Error(`Invalid MCP tool name: ${request.toolName}`);
643
721
  const connection = mcpManager.getConnection(parsed.serverName);
644
722
  if (!connection) throw new Error(`MCP server not connected: ${parsed.serverName}`);
645
- const result = await callTool(connection, parsed.toolName, request.params);
723
+ const result = await withTimeout(callTool(connection, parsed.toolName, request.params), request.timeoutMs);
646
724
  worker.postMessage({
647
725
  type: "mcp_tool_result",
648
726
  callId: request.callId,
@@ -657,6 +735,63 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
657
735
  }
658
736
  };
659
737
 
738
+ const getPythonCallTimeoutMs = (params: { timeout?: number }): number | undefined => {
739
+ const timeout = params.timeout;
740
+ if (typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0) {
741
+ return Math.max(1000, Math.round(timeout * 1000) + 1000);
742
+ }
743
+ return undefined;
744
+ };
745
+
746
+ const handlePythonCall = async (request: PythonToolCallRequest) => {
747
+ if (!pythonTool) {
748
+ worker.postMessage({
749
+ type: "python_tool_result",
750
+ callId: request.callId,
751
+ error: "Python proxy not available",
752
+ });
753
+ return;
754
+ }
755
+ const callController = new AbortController();
756
+ pythonCallControllers.set(request.callId, callController);
757
+ const timeoutMs = getPythonCallTimeoutMs(request.params as { timeout?: number });
758
+ const timeoutSignal = createTimeoutSignal(timeoutMs);
759
+ const combinedSignal = combineSignals([signal, callController.signal, timeoutSignal]);
760
+ try {
761
+ const result = await pythonTool.execute(
762
+ request.callId,
763
+ request.params as { code: string; timeout?: number; workdir?: string; reset?: boolean },
764
+ combinedSignal,
765
+ );
766
+ worker.postMessage({
767
+ type: "python_tool_result",
768
+ callId: request.callId,
769
+ result: { content: result.content ?? [], details: result.details },
770
+ });
771
+ } catch (error) {
772
+ const message =
773
+ timeoutSignal?.aborted && timeoutMs !== undefined
774
+ ? `Python tool call timed out after ${timeoutMs}ms`
775
+ : error instanceof Error
776
+ ? error.message
777
+ : String(error);
778
+ worker.postMessage({
779
+ type: "python_tool_result",
780
+ callId: request.callId,
781
+ error: message,
782
+ });
783
+ } finally {
784
+ pythonCallControllers.delete(request.callId);
785
+ }
786
+ };
787
+
788
+ const handlePythonCancel = (request: PythonToolCallCancel) => {
789
+ const controller = pythonCallControllers.get(request.callId);
790
+ if (controller) {
791
+ controller.abort();
792
+ }
793
+ };
794
+
660
795
  const onMessage = (event: WorkerMessageEvent<SubagentWorkerResponse>) => {
661
796
  const message = event.data;
662
797
  if (!message || resolved) return;
@@ -664,6 +799,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
664
799
  handleMCPCall(message as MCPToolCallRequest);
665
800
  return;
666
801
  }
802
+ if (message.type === "python_tool_call") {
803
+ handlePythonCall(message as PythonToolCallRequest);
804
+ return;
805
+ }
806
+ if (message.type === "python_tool_cancel") {
807
+ handlePythonCancel(message as PythonToolCallCancel);
808
+ return;
809
+ }
667
810
  if (message.type === "event") {
668
811
  try {
669
812
  processEvent(message.event);
@@ -24,6 +24,7 @@ export interface MCPToolCallRequest {
24
24
  callId: string;
25
25
  toolName: string;
26
26
  params: Record<string, unknown>;
27
+ timeoutMs?: number;
27
28
  }
28
29
 
29
30
  /**
@@ -39,6 +40,30 @@ export interface MCPToolCallResponse {
39
40
  error?: string;
40
41
  }
41
42
 
43
+ export interface PythonToolCallRequest {
44
+ type: "python_tool_call";
45
+ callId: string;
46
+ params: Record<string, unknown>;
47
+ timeoutMs?: number;
48
+ }
49
+
50
+ export interface PythonToolCallResponse {
51
+ type: "python_tool_result";
52
+ callId: string;
53
+ result?: {
54
+ content: Array<{ type: string; text?: string; [key: string]: unknown }>;
55
+ details?: unknown;
56
+ isError?: boolean;
57
+ };
58
+ error?: string;
59
+ }
60
+
61
+ export interface PythonToolCallCancel {
62
+ type: "python_tool_cancel";
63
+ callId: string;
64
+ reason?: string;
65
+ }
66
+
42
67
  export interface SubagentWorkerStartPayload {
43
68
  cwd: string;
44
69
  task: string;
@@ -54,14 +79,19 @@ export interface SubagentWorkerStartPayload {
54
79
  serializedModels?: SerializedModelRegistry;
55
80
  serializedSettings?: Settings;
56
81
  mcpTools?: MCPToolMetadata[];
82
+ pythonToolProxy?: boolean;
57
83
  }
58
84
 
59
85
  export type SubagentWorkerRequest =
60
86
  | { type: "start"; payload: SubagentWorkerStartPayload }
61
87
  | { type: "abort" }
62
- | MCPToolCallResponse;
88
+ | MCPToolCallResponse
89
+ | PythonToolCallResponse
90
+ | PythonToolCallCancel;
63
91
 
64
92
  export type SubagentWorkerResponse =
65
93
  | { type: "event"; event: AgentEvent }
66
94
  | { type: "done"; exitCode: number; durationMs: number; error?: string; aborted?: boolean }
67
- | MCPToolCallRequest;
95
+ | MCPToolCallRequest
96
+ | PythonToolCallRequest
97
+ | PythonToolCallCancel;
@@ -19,15 +19,18 @@ import type { TSchema } from "@sinclair/typebox";
19
19
  import type { AgentSessionEvent } from "../../agent-session";
20
20
  import { AuthStorage } from "../../auth-storage";
21
21
  import type { CustomTool } from "../../custom-tools/types";
22
+ import { logger } from "../../logger";
22
23
  import { ModelRegistry } from "../../model-registry";
23
24
  import { parseModelPattern, parseModelString } from "../../model-resolver";
24
25
  import { createAgentSession, discoverAuthStorage, discoverModels } from "../../sdk";
25
26
  import { SessionManager } from "../../session-manager";
26
27
  import { SettingsManager } from "../../settings-manager";
27
28
  import { untilAborted } from "../../utils";
29
+ import { getPythonToolDescription, type PythonToolDetails, type PythonToolParams, pythonSchema } from "../python";
28
30
  import type {
29
31
  MCPToolCallResponse,
30
32
  MCPToolMetadata,
33
+ PythonToolCallResponse,
31
34
  SubagentWorkerRequest,
32
35
  SubagentWorkerResponse,
33
36
  SubagentWorkerStartPayload,
@@ -49,14 +52,26 @@ interface PendingMCPCall {
49
52
  timeoutId: ReturnType<typeof setTimeout>;
50
53
  }
51
54
 
55
+ interface PendingPythonCall {
56
+ resolve: (result: PythonToolCallResponse["result"]) => void;
57
+ reject: (error: Error) => void;
58
+ timeoutId?: ReturnType<typeof setTimeout>;
59
+ }
60
+
52
61
  const pendingMCPCalls = new Map<string, PendingMCPCall>();
62
+ const pendingPythonCalls = new Map<string, PendingPythonCall>();
53
63
  const MCP_CALL_TIMEOUT_MS = 60_000;
54
64
  let mcpCallIdCounter = 0;
65
+ let pythonCallIdCounter = 0;
55
66
 
56
67
  function generateMCPCallId(): string {
57
68
  return `mcp_${Date.now()}_${++mcpCallIdCounter}`;
58
69
  }
59
70
 
71
+ function generatePythonCallId(): string {
72
+ return `python_${Date.now()}_${++pythonCallIdCounter}`;
73
+ }
74
+
60
75
  function callMCPToolViaParent(
61
76
  toolName: string,
62
77
  params: Record<string, unknown>,
@@ -80,14 +95,16 @@ function callMCPToolViaParent(
80
95
  pendingMCPCalls.delete(callId);
81
96
  };
82
97
 
83
- signal?.addEventListener(
84
- "abort",
85
- () => {
86
- cleanup();
87
- reject(new Error("Aborted"));
88
- },
89
- { once: true },
90
- );
98
+ if (typeof signal?.addEventListener === "function") {
99
+ signal.addEventListener(
100
+ "abort",
101
+ () => {
102
+ cleanup();
103
+ reject(new Error("Aborted"));
104
+ },
105
+ { once: true },
106
+ );
107
+ }
91
108
 
92
109
  pendingMCPCalls.set(callId, {
93
110
  resolve: (result) => {
@@ -106,6 +123,72 @@ function callMCPToolViaParent(
106
123
  callId,
107
124
  toolName,
108
125
  params,
126
+ timeoutMs,
127
+ } as SubagentWorkerResponse);
128
+ });
129
+ }
130
+
131
+ function callPythonToolViaParent(
132
+ params: PythonToolParams,
133
+ signal?: AbortSignal,
134
+ timeoutMs?: number,
135
+ ): Promise<PythonToolCallResponse["result"]> {
136
+ return new Promise((resolve, reject) => {
137
+ const callId = generatePythonCallId();
138
+ if (signal?.aborted) {
139
+ reject(new Error("Aborted"));
140
+ return;
141
+ }
142
+
143
+ const sendCancel = (reason: string) => {
144
+ postMessageSafe({ type: "python_tool_cancel", callId, reason } as SubagentWorkerResponse);
145
+ };
146
+
147
+ const timeoutId =
148
+ typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
149
+ ? setTimeout(() => {
150
+ pendingPythonCalls.delete(callId);
151
+ sendCancel(`Python call timed out after ${timeoutMs}ms`);
152
+ reject(new Error(`Python call timed out after ${timeoutMs}ms`));
153
+ }, timeoutMs)
154
+ : undefined;
155
+
156
+ const cleanup = () => {
157
+ if (timeoutId) {
158
+ clearTimeout(timeoutId);
159
+ }
160
+ pendingPythonCalls.delete(callId);
161
+ };
162
+
163
+ if (typeof signal?.addEventListener === "function") {
164
+ signal.addEventListener(
165
+ "abort",
166
+ () => {
167
+ cleanup();
168
+ sendCancel("Aborted");
169
+ reject(new Error("Aborted"));
170
+ },
171
+ { once: true },
172
+ );
173
+ }
174
+
175
+ pendingPythonCalls.set(callId, {
176
+ resolve: (result) => {
177
+ cleanup();
178
+ resolve(result ?? { content: [] });
179
+ },
180
+ reject: (error) => {
181
+ cleanup();
182
+ reject(error);
183
+ },
184
+ timeoutId,
185
+ });
186
+
187
+ postMessageSafe({
188
+ type: "python_tool_call",
189
+ callId,
190
+ params,
191
+ timeoutMs,
109
192
  } as SubagentWorkerResponse);
110
193
  });
111
194
  }
@@ -120,6 +203,32 @@ function handleMCPToolResult(response: MCPToolCallResponse): void {
120
203
  }
121
204
  }
122
205
 
206
+ function handlePythonToolResult(response: PythonToolCallResponse): void {
207
+ const pending = pendingPythonCalls.get(response.callId);
208
+ if (!pending) return;
209
+ if (response.error) {
210
+ pending.reject(new Error(response.error));
211
+ } else {
212
+ pending.resolve(response.result);
213
+ }
214
+ }
215
+
216
+ function rejectPendingCalls(reason: string): void {
217
+ const error = new Error(reason);
218
+ const mcpCalls = Array.from(pendingMCPCalls.values());
219
+ const pythonCalls = Array.from(pendingPythonCalls.values());
220
+ pendingMCPCalls.clear();
221
+ pendingPythonCalls.clear();
222
+ for (const pending of mcpCalls) {
223
+ clearTimeout(pending.timeoutId);
224
+ pending.reject(error);
225
+ }
226
+ for (const pending of pythonCalls) {
227
+ clearTimeout(pending.timeoutId);
228
+ pending.reject(error);
229
+ }
230
+ }
231
+
123
232
  function createMCPProxyTool(metadata: MCPToolMetadata): CustomTool<TSchema> {
124
233
  return {
125
234
  name: metadata.name,
@@ -157,6 +266,36 @@ function createMCPProxyTool(metadata: MCPToolMetadata): CustomTool<TSchema> {
157
266
  };
158
267
  }
159
268
 
269
+ function getPythonCallTimeoutMs(params: PythonToolParams): number | undefined {
270
+ const timeout = params.timeout;
271
+ if (typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0) {
272
+ return Math.max(1000, Math.round(timeout * 1000) + 1000);
273
+ }
274
+ return undefined;
275
+ }
276
+
277
+ function createPythonProxyTool(): CustomTool<typeof pythonSchema> {
278
+ return {
279
+ name: "python",
280
+ label: "Python",
281
+ description: getPythonToolDescription(),
282
+ parameters: pythonSchema,
283
+ execute: async (_toolCallId, params, _onUpdate, _ctx, signal) => {
284
+ const timeoutMs = getPythonCallTimeoutMs(params as PythonToolParams);
285
+ const result = await callPythonToolViaParent(params as PythonToolParams, signal, timeoutMs);
286
+ return {
287
+ content:
288
+ result?.content?.map((c) =>
289
+ c.type === "text"
290
+ ? { type: "text" as const, text: c.text ?? "" }
291
+ : { type: "text" as const, text: JSON.stringify(c) },
292
+ ) ?? [],
293
+ details: result?.details as PythonToolDetails | undefined,
294
+ };
295
+ },
296
+ };
297
+ }
298
+
160
299
  interface WorkerMessageEvent<T> {
161
300
  data: T;
162
301
  }
@@ -284,8 +423,12 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
284
423
  checkAbort();
285
424
  }
286
425
 
287
- // Create MCP proxy tools if provided
288
- const mcpProxyTools = payload.mcpTools?.map(createMCPProxyTool) ?? [];
426
+ // Create MCP/python proxy tools if provided
427
+ const mcpProxyTools: CustomTool<TSchema>[] = payload.mcpTools?.map(createMCPProxyTool) ?? [];
428
+ const pythonProxyTools: CustomTool<TSchema>[] = payload.pythonToolProxy
429
+ ? [createPythonProxyTool() as unknown as CustomTool<TSchema>]
430
+ : [];
431
+ const proxyTools = [...mcpProxyTools, ...pythonProxyTools];
289
432
 
290
433
  // Resolve model override (equivalent to CLI's parseModelPattern with --model)
291
434
  const { model, thinkingLevel: modelThinkingLevel } = resolveModelOverride(payload.model, modelRegistry);
@@ -325,8 +468,8 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
325
468
  enableLsp: payload.enableLsp ?? true,
326
469
  // Disable local MCP discovery if using proxy tools
327
470
  enableMCP: !payload.mcpTools,
328
- // Add MCP proxy tools
329
- customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
471
+ // Add proxy tools
472
+ customTools: proxyTools.length > 0 ? proxyTools : undefined,
330
473
  });
331
474
 
332
475
  runState.session = session;
@@ -349,17 +492,24 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
349
492
  {
350
493
  sendMessage: (message, options) => {
351
494
  session.sendCustomMessage(message, options).catch((e) => {
352
- console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
495
+ logger.error("Extension sendMessage failed", {
496
+ error: e instanceof Error ? e.message : String(e),
497
+ });
353
498
  });
354
499
  },
355
500
  sendUserMessage: (content, options) => {
356
501
  session.sendUserMessage(content, options).catch((e) => {
357
- console.error(`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}`);
502
+ logger.error("Extension sendUserMessage failed", {
503
+ error: e instanceof Error ? e.message : String(e),
504
+ });
358
505
  });
359
506
  },
360
507
  appendEntry: (customType, data) => {
361
508
  session.sessionManager.appendCustomEntry(customType, data);
362
509
  },
510
+ setLabel: (targetId, label) => {
511
+ session.sessionManager.appendLabelChange(targetId, label);
512
+ },
363
513
  getActiveTools: () => session.getActiveToolNames(),
364
514
  getAllTools: () => session.getAllToolNames(),
365
515
  setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
@@ -379,10 +529,19 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
379
529
  abort: () => session.abort(),
380
530
  hasPendingMessages: () => session.queuedMessageCount > 0,
381
531
  shutdown: () => {},
532
+ getContextUsage: () => session.getContextUsage(),
533
+ compact: async (instructionsOrOptions) => {
534
+ const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
535
+ const options =
536
+ instructionsOrOptions && typeof instructionsOrOptions === "object"
537
+ ? instructionsOrOptions
538
+ : undefined;
539
+ await session.compact(instructions, options);
540
+ },
382
541
  },
383
542
  );
384
543
  extensionRunner.onError((err) => {
385
- console.error(`Extension error (${err.extensionPath}): ${err.error}`);
544
+ logger.error("Extension error", { path: err.extensionPath, error: err.error });
386
545
  });
387
546
  await extensionRunner.emit({ type: "session_start" });
388
547
  }
@@ -444,6 +603,7 @@ Call complete now.`;
444
603
  }
445
604
 
446
605
  sessionAbortController.abort();
606
+ rejectPendingCalls("Worker finished");
447
607
 
448
608
  if (runState.unsubscribe) {
449
609
  try {
@@ -485,8 +645,10 @@ function handleAbort(): void {
485
645
  const runState = activeRun;
486
646
  if (!runState) {
487
647
  pendingAbort = true;
648
+ rejectPendingCalls("Aborted");
488
649
  return;
489
650
  }
651
+ rejectPendingCalls("Aborted");
490
652
  runState.abortController.abort();
491
653
  if (runState.session) {
492
654
  void runState.session.abort();
@@ -556,6 +718,11 @@ globalThis.addEventListener("message", (event: WorkerMessageEvent<SubagentWorker
556
718
  return;
557
719
  }
558
720
 
721
+ if (message.type === "python_tool_result") {
722
+ handlePythonToolResult(message);
723
+ return;
724
+ }
725
+
559
726
  if (message.type === "start") {
560
727
  // Only allow one task per worker
561
728
  if (activeRun) return;
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  export { StringEnum } from "@oh-my-pi/pi-ai";
6
6
  // Re-export TUI components for custom tool rendering
7
7
  export { Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
8
+ export { getAgentDir, VERSION } from "./config";
8
9
  export {
9
10
  AgentSession,
10
11
  type AgentSessionConfig,
@@ -82,6 +83,8 @@ export type {
82
83
  ExtensionShortcut,
83
84
  ExtensionUIContext,
84
85
  ExtensionUIDialogOptions,
86
+ InputEvent,
87
+ InputEventResult,
85
88
  KeybindingsManager,
86
89
  LoadExtensionsResult,
87
90
  MessageRenderer,
@@ -107,6 +110,7 @@ export {
107
110
  } from "./core/extensions/index";
108
111
  // Hook system types (legacy re-export)
109
112
  export type * from "./core/hooks/index";
113
+ export { formatKeyHint, formatKeyHints } from "./core/keybindings";
110
114
  // Logging
111
115
  export { type Logger, logger } from "./core/logger";
112
116
  export { convertToLlm } from "./core/messages";
@@ -205,6 +209,7 @@ export {
205
209
  type LsOperations,
206
210
  type LsToolDetails,
207
211
  type LsToolOptions,
212
+ type PythonToolDetails,
208
213
  type ReadToolDetails,
209
214
  type TruncationOptions,
210
215
  type TruncationResult,
@@ -269,3 +274,4 @@ export {
269
274
  Theme,
270
275
  type ThemeColor,
271
276
  } from "./modes/interactive/theme/theme";
277
+ export { getShellConfig } from "./utils/shell";