@oh-my-pi/pi-coding-agent 6.8.0 → 6.8.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [6.8.1] - 2026-01-20
6
+
7
+ ### Fixed
8
+
9
+ - Fixed unhandled promise rejection when tool execution fails by adding missing `.catch()` to floating `.finally()` chain in `createAbortablePromise`
10
+
5
11
  ## [6.8.0] - 2026-01-20
6
12
 
7
13
  ### Added
@@ -247,16 +247,16 @@ export default function planModeExtension(pi: ExtensionAPI) {
247
247
  }
248
248
  }
249
249
 
250
- function togglePlanMode(ctx: ExtensionContext) {
250
+ async function togglePlanMode(ctx: ExtensionContext) {
251
251
  planModeEnabled = !planModeEnabled;
252
252
  executionMode = false;
253
253
  todoItems = [];
254
254
 
255
255
  if (planModeEnabled) {
256
- pi.setActiveTools(PLAN_MODE_TOOLS);
256
+ await pi.setActiveTools(PLAN_MODE_TOOLS);
257
257
  ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
258
258
  } else {
259
- pi.setActiveTools(NORMAL_MODE_TOOLS);
259
+ await pi.setActiveTools(NORMAL_MODE_TOOLS);
260
260
  ctx.ui.notify("Plan mode disabled. Full access restored.");
261
261
  }
262
262
  updateStatus(ctx);
@@ -266,7 +266,7 @@ export default function planModeExtension(pi: ExtensionAPI) {
266
266
  pi.registerCommand("plan", {
267
267
  description: "Toggle plan mode (read-only exploration)",
268
268
  handler: async (_args, ctx) => {
269
- togglePlanMode(ctx);
269
+ await togglePlanMode(ctx);
270
270
  },
271
271
  });
272
272
 
@@ -294,7 +294,7 @@ export default function planModeExtension(pi: ExtensionAPI) {
294
294
  pi.registerShortcut(Key.shift("p"), {
295
295
  description: "Toggle plan mode",
296
296
  handler: async (ctx) => {
297
- togglePlanMode(ctx);
297
+ await togglePlanMode(ctx);
298
298
  },
299
299
  });
300
300
 
@@ -417,7 +417,7 @@ Execute each step in order.`,
417
417
 
418
418
  executionMode = false;
419
419
  todoItems = [];
420
- pi.setActiveTools(NORMAL_MODE_TOOLS);
420
+ await pi.setActiveTools(NORMAL_MODE_TOOLS);
421
421
  updateStatus(ctx);
422
422
  }
423
423
  return;
@@ -470,7 +470,7 @@ Execute each step in order.`,
470
470
  if (choice?.startsWith("Execute")) {
471
471
  planModeEnabled = false;
472
472
  executionMode = hasTodos;
473
- pi.setActiveTools(NORMAL_MODE_TOOLS);
473
+ await pi.setActiveTools(NORMAL_MODE_TOOLS);
474
474
  updateStatus(ctx);
475
475
 
476
476
  // Simple execution message - context event filters old plan mode messages
@@ -519,7 +519,7 @@ Execute each step in order.`,
519
519
  }
520
520
 
521
521
  if (planModeEnabled) {
522
- pi.setActiveTools(PLAN_MODE_TOOLS);
522
+ await pi.setActiveTools(PLAN_MODE_TOOLS);
523
523
  }
524
524
  updateStatus(ctx);
525
525
  });
@@ -31,12 +31,12 @@ export default function toolsExtension(pi: ExtensionAPI) {
31
31
  }
32
32
 
33
33
  // Apply current tool selection
34
- function applyTools() {
35
- pi.setActiveTools(Array.from(enabledTools));
34
+ async function applyTools() {
35
+ await pi.setActiveTools(Array.from(enabledTools));
36
36
  }
37
37
 
38
38
  // Find the last tools-config entry in the current branch
39
- function restoreFromBranch(ctx: ExtensionContext) {
39
+ async function restoreFromBranch(ctx: ExtensionContext) {
40
40
  allTools = pi.getAllTools();
41
41
 
42
42
  // Get entries in current branch only
@@ -55,7 +55,7 @@ export default function toolsExtension(pi: ExtensionAPI) {
55
55
  if (savedTools) {
56
56
  // Restore saved tool selection (filter to only tools that still exist)
57
57
  enabledTools = new Set(savedTools.filter((t: string) => allTools.includes(t)));
58
- applyTools();
58
+ await applyTools();
59
59
  } else {
60
60
  // No saved state - sync with currently active tools
61
61
  enabledTools = new Set(pi.getActiveTools());
@@ -130,16 +130,16 @@ export default function toolsExtension(pi: ExtensionAPI) {
130
130
 
131
131
  // Restore state on session start
132
132
  pi.on("session_start", async (_event, ctx) => {
133
- restoreFromBranch(ctx);
133
+ await restoreFromBranch(ctx);
134
134
  });
135
135
 
136
136
  // Restore state when navigating the session tree
137
137
  pi.on("session_tree", async (_event, ctx) => {
138
- restoreFromBranch(ctx);
138
+ await restoreFromBranch(ctx);
139
139
  });
140
140
 
141
141
  // Restore state after branching
142
142
  pi.on("session_branch", async (_event, ctx) => {
143
- restoreFromBranch(ctx);
143
+ await restoreFromBranch(ctx);
144
144
  });
145
145
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "6.8.0",
3
+ "version": "6.8.1",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -40,11 +40,11 @@
40
40
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
41
41
  },
42
42
  "dependencies": {
43
- "@oh-my-pi/pi-agent-core": "6.8.0",
44
- "@oh-my-pi/pi-ai": "6.8.0",
45
- "@oh-my-pi/pi-git-tool": "6.8.0",
46
- "@oh-my-pi/pi-tui": "6.8.0",
47
- "@oh-my-pi/pi-utils": "6.8.0",
43
+ "@oh-my-pi/pi-agent-core": "6.8.1",
44
+ "@oh-my-pi/pi-ai": "6.8.1",
45
+ "@oh-my-pi/pi-git-tool": "6.8.1",
46
+ "@oh-my-pi/pi-tui": "6.8.1",
47
+ "@oh-my-pi/pi-utils": "6.8.1",
48
48
  "@openai/agents": "^0.3.7",
49
49
  "@sinclair/typebox": "^0.34.46",
50
50
  "ajv": "^8.17.1",
@@ -28,8 +28,11 @@ export async function selectSession(sessions: SessionInfo[]): Promise<string | n
28
28
  }
29
29
  },
30
30
  () => {
31
- ui.stop();
32
- process.exit(0);
31
+ if (!resolved) {
32
+ resolved = true;
33
+ ui.stop();
34
+ process.exit(0);
35
+ }
33
36
  },
34
37
  );
35
38
 
@@ -2226,7 +2226,7 @@ export class AgentSession {
2226
2226
  error: message,
2227
2227
  model: `${candidate.provider}/${candidate.id}`,
2228
2228
  });
2229
- await Bun.sleep(delayMs);
2229
+ await abortableSleep(delayMs, this._autoCompactionAbortController.signal);
2230
2230
  }
2231
2231
  }
2232
2232
 
@@ -2291,6 +2291,10 @@ export class AgentSession {
2291
2291
  }, 100);
2292
2292
  }
2293
2293
  } catch (error) {
2294
+ if (this._autoCompactionAbortController?.signal.aborted) {
2295
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
2296
+ return;
2297
+ }
2294
2298
  const errorMessage = error instanceof Error ? error.message : "compaction failed";
2295
2299
  this._emit({
2296
2300
  type: "auto_compaction_end",
@@ -107,10 +107,12 @@ function toBoolean(value: unknown): boolean | undefined {
107
107
  export class AuthStorage {
108
108
  private static readonly codexUsageCacheTtlMs = 60_000; // Cache usage data for 1 minute
109
109
  private static readonly defaultBackoffMs = 60_000; // Default backoff when no reset time available
110
+ private static readonly cacheCleanupIntervalMs = 300_000; // Clean expired cache every 5 minutes
110
111
 
111
112
  /** Provider -> credentials cache, populated from agent.db on reload(). */
112
113
  private data: Map<string, StoredCredential[]> = new Map();
113
114
  private storage: AgentStorage;
115
+ private lastCacheCleanup = 0;
114
116
  /** Resolved path to agent.db (derived from authPath or used directly if .db). */
115
117
  private dbPath: string;
116
118
  private runtimeOverrides: Map<string, string> = new Map();
@@ -153,6 +155,7 @@ export class AuthStorage {
153
155
  instance.sessionLastCredential = new Map();
154
156
  instance.credentialBackoff = new Map();
155
157
  instance.codexUsageCache = new Map();
158
+ instance.lastCacheCleanup = 0;
156
159
 
157
160
  for (const [provider, creds] of Object.entries(data.credentials)) {
158
161
  instance.data.set(
@@ -748,6 +751,11 @@ export class AuthStorage {
748
751
  const cacheKey = this.getCodexUsageCacheKey(accountId, normalizedBase);
749
752
  const now = Date.now();
750
753
 
754
+ if (now - this.lastCacheCleanup > AuthStorage.cacheCleanupIntervalMs) {
755
+ this.lastCacheCleanup = now;
756
+ this.storage.cleanExpiredCache();
757
+ }
758
+
751
759
  // Check in-memory cache first (fastest)
752
760
  const memCached = this.codexUsageCache.get(cacheKey);
753
761
  if (memCached && memCached.expiresAt > now) {
@@ -4,7 +4,7 @@
4
4
  * Provides unified bash execution for AgentSession.executeBash() and direct calls.
5
5
  */
6
6
 
7
- import { cspawn, Exception } from "@oh-my-pi/pi-utils";
7
+ import { cspawn, Exception, ptree } from "@oh-my-pi/pi-utils";
8
8
  import { getShellConfig } from "../utils/shell";
9
9
  import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
10
10
  import { OutputSink } from "./streaming-output";
@@ -63,7 +63,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
63
63
  // Exception covers NonZeroExitError, AbortError, TimeoutError
64
64
  if (err instanceof Exception) {
65
65
  if (err.aborted) {
66
- const isTimeout = err.message.includes("timed out");
66
+ const isTimeout = err instanceof ptree.TimeoutError || err.message.toLowerCase().includes("timed out");
67
67
  const annotation = isTimeout
68
68
  ? `Command timed out after ${Math.round((options?.timeout ?? 0) / 1000)} seconds`
69
69
  : undefined;
package/src/core/exec.ts CHANGED
@@ -41,14 +41,16 @@ export async function execCommand(
41
41
  signal: options?.signal,
42
42
  timeout: options?.timeout,
43
43
  });
44
+ // Read streams before awaiting exit to avoid data loss if streams close
45
+ const [stdoutText, stderrText] = await Promise.all([proc.stdout.text(), proc.stderr.text()]);
44
46
  try {
45
47
  await proc.exited;
46
48
  } catch {
47
49
  // ChildProcess rejects on non-zero exit; we handle it below
48
50
  }
49
51
  return {
50
- stdout: await proc.stdout.text(),
51
- stderr: await proc.stderr.text(),
52
+ stdout: stdoutText,
53
+ stderr: stderrText,
52
54
  code: proc.exitCode ?? 0,
53
55
  killed: proc.exitReason instanceof ptree.AbortError,
54
56
  };
@@ -757,7 +757,7 @@ export interface ExtensionAPI {
757
757
  getAllTools(): string[];
758
758
 
759
759
  /** Set the active tools by name. */
760
- setActiveTools(toolNames: string[]): void;
760
+ setActiveTools(toolNames: string[]): Promise<void>;
761
761
 
762
762
  /** Set the current model. Returns false if no API key available. */
763
763
  setModel(model: Model<any>): Promise<boolean>;
@@ -685,12 +685,13 @@ export interface HookAPI {
685
685
  * @param message.content - Message content (string or TextContent/ImageContent array)
686
686
  * @param message.display - Whether to show in TUI (true = styled display, false = hidden)
687
687
  * @param message.details - Optional hook-specific metadata (not sent to LLM)
688
- * @param triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false.
689
- * If agent is streaming, message is queued and triggerTurn is ignored.
688
+ * @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false.
689
+ * If agent is streaming, message is queued and triggerTurn is ignored.
690
+ * @param options.deliverAs - How to deliver the message: "steer" or "followUp".
690
691
  */
691
692
  sendMessage<T = unknown>(
692
693
  message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
693
- triggerTurn?: boolean,
694
+ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
694
695
  ): void;
695
696
 
696
697
  /**
@@ -5,7 +5,7 @@
5
5
  * Based on MCP spec 2025-03-26.
6
6
  */
7
7
 
8
- import type { JsonRpcResponse, MCPHttpServerConfig, MCPSseServerConfig, MCPTransport } from "../types";
8
+ import type { JsonRpcMessage, JsonRpcResponse, MCPHttpServerConfig, MCPSseServerConfig, MCPTransport } from "../types";
9
9
 
10
10
  /** Generate unique request ID */
11
11
  function generateId(): string {
@@ -167,39 +167,47 @@ export class HttpTransport implements MCPTransport {
167
167
  throw new Error("No response body");
168
168
  }
169
169
 
170
- let result: T | undefined;
170
+ const timeout = this.config.timeout ?? 30000;
171
171
 
172
- for await (const event of readSseEvents(response.body)) {
173
- const data = event.data?.trim();
174
- if (!data || data === "[DONE]") continue;
175
- try {
176
- const message = JSON.parse(data) as JsonRpcResponse;
172
+ const parse = async (): Promise<T> => {
173
+ for await (const event of readSseEvents(response.body!)) {
174
+ const data = event.data?.trim();
175
+ if (!data || data === "[DONE]") continue;
177
176
 
178
- // Handle our response
179
- if ("id" in message && message.id === expectedId) {
180
- if (message.error) {
181
- throw new Error(`MCP error ${message.error.code}: ${message.error.message}`);
177
+ try {
178
+ const message = JSON.parse(data) as JsonRpcMessage;
179
+
180
+ if (
181
+ "id" in message &&
182
+ (message as JsonRpcResponse).id === expectedId &&
183
+ ("result" in message || "error" in message)
184
+ ) {
185
+ const response = message as JsonRpcResponse;
186
+ if (response.error) {
187
+ throw new Error(`MCP error ${response.error.code}: ${response.error.message}`);
188
+ }
189
+ return response.result as T;
190
+ }
191
+
192
+ if ("method" in message && !("id" in message)) {
193
+ this.onNotification?.(message.method, message.params);
194
+ }
195
+ } catch (error) {
196
+ if (error instanceof Error && error.message.startsWith("MCP error")) {
197
+ throw error;
182
198
  }
183
- result = message.result as T;
184
- }
185
- // Handle notifications
186
- else if ("method" in message && !("id" in message)) {
187
- const notification = message as { method: string; params?: unknown };
188
- this.onNotification?.(notification.method, notification.params);
189
- }
190
- } catch (error) {
191
- if (error instanceof Error && error.message.startsWith("MCP error")) {
192
- throw error;
193
199
  }
194
- // Ignore other parse errors
195
200
  }
196
- }
197
201
 
198
- if (result === undefined) {
199
- throw new Error("No response received");
200
- }
202
+ throw new Error(`No response received for request ID ${expectedId}`);
203
+ };
201
204
 
202
- return result;
205
+ return Promise.race([
206
+ parse(),
207
+ new Promise<never>((_, reject) =>
208
+ setTimeout(() => reject(new Error(`SSE response timeout after ${timeout}ms`)), timeout),
209
+ ),
210
+ ]);
203
211
  }
204
212
 
205
213
  async notify(method: string, params?: Record<string, unknown>): Promise<void> {
@@ -116,8 +116,8 @@ const DEFAULT_ENV_DENYLIST = new Set([
116
116
  const CASE_INSENSITIVE_ENV = process.platform === "win32";
117
117
  const ACTIVE_ENV_ALLOWLIST = CASE_INSENSITIVE_ENV ? WINDOWS_ENV_ALLOWLIST : DEFAULT_ENV_ALLOWLIST;
118
118
 
119
- const NORMALIZED_ALLOWLIST = new Set(
120
- Array.from(ACTIVE_ENV_ALLOWLIST, (key) => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
119
+ const NORMALIZED_ALLOWLIST = new Map(
120
+ Array.from(ACTIVE_ENV_ALLOWLIST, (key) => [CASE_INSENSITIVE_ENV ? key.toUpperCase() : key, key] as const),
121
121
  );
122
122
  const NORMALIZED_DENYLIST = new Set(
123
123
  Array.from(DEFAULT_ENV_DENYLIST, (key) => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
@@ -168,8 +168,9 @@ function filterEnv(env: Record<string, string | undefined>): Record<string, stri
168
168
  if (value === undefined) continue;
169
169
  const normalizedKey = normalizeEnvKey(key);
170
170
  if (NORMALIZED_DENYLIST.has(normalizedKey)) continue;
171
- if (NORMALIZED_ALLOWLIST.has(normalizedKey)) {
172
- filtered[key] = value;
171
+ const canonicalKey = NORMALIZED_ALLOWLIST.get(normalizedKey);
172
+ if (canonicalKey !== undefined) {
173
+ filtered[canonicalKey] = value;
173
174
  continue;
174
175
  }
175
176
  if (NORMALIZED_ALLOW_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix))) {
@@ -95,7 +95,7 @@ export async function executeSSH(
95
95
  return {
96
96
  exitCode: undefined,
97
97
  cancelled: true,
98
- ...sink.dump(`SSH command timed out after ${Math.round(options!.timeout! / 1000)} seconds`),
98
+ ...sink.dump(`SSH: ${err.message}`),
99
99
  };
100
100
  }
101
101
  if (err.aborted) {
@@ -64,6 +64,7 @@ export interface PythonToolDetails {
64
64
  images?: ImageContent[];
65
65
  /** Structured status events from prelude helpers */
66
66
  statusEvents?: PythonStatusEvent[];
67
+ isError?: boolean;
67
68
  }
68
69
 
69
70
  function formatJsonScalar(value: unknown): string {
@@ -343,7 +343,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
343
343
  let abortSent = false;
344
344
  let abortReason: AbortReason | undefined;
345
345
  let terminationScheduled = false;
346
- let pendingTerminationController: AbortController | null = null;
346
+ let terminated = false;
347
+ let terminationTimeoutId: ReturnType<typeof setTimeout> | null = null;
348
+ let pendingTerminationTimeoutId: ReturnType<typeof setTimeout> | null = null;
347
349
  let finalize: ((message: Extract<SubagentWorkerResponse, { type: "done" }>) => void) | null = null;
348
350
  const listenerController = new AbortController();
349
351
  const listenerSignal = listenerController.signal;
@@ -416,28 +418,25 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
416
418
  const scheduleTermination = () => {
417
419
  if (terminationScheduled) return;
418
420
  terminationScheduled = true;
419
- const timeoutSignal = AbortSignal.timeout(2000);
420
- timeoutSignal.addEventListener(
421
- "abort",
422
- () => {
423
- if (resolved) return;
424
- try {
425
- worker.terminate();
426
- } catch {
427
- // Ignore termination errors
428
- }
429
- if (finalize && !resolved) {
430
- finalize({
431
- type: "done",
432
- exitCode: 1,
433
- durationMs: Date.now() - startTime,
434
- error: abortReason === "signal" ? "Aborted" : "Worker terminated after tool completion",
435
- aborted: abortReason === "signal",
436
- });
437
- }
438
- },
439
- { once: true, signal: listenerSignal },
440
- );
421
+ terminationTimeoutId = setTimeout(() => {
422
+ terminationTimeoutId = null;
423
+ if (resolved || terminated) return;
424
+ terminated = true;
425
+ try {
426
+ worker.terminate();
427
+ } catch {
428
+ // Ignore termination errors
429
+ }
430
+ if (finalize && !resolved) {
431
+ finalize({
432
+ type: "done",
433
+ exitCode: 1,
434
+ durationMs: Date.now() - startTime,
435
+ error: abortReason === "signal" ? "Aborted" : "Worker terminated after tool completion",
436
+ aborted: abortReason === "signal",
437
+ });
438
+ }
439
+ }, 2000);
441
440
  };
442
441
 
443
442
  const requestAbort = (reason: AbortReason) => {
@@ -461,28 +460,25 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
461
460
  // Worker already terminated, nothing to do
462
461
  }
463
462
  // Cancel pending termination if it exists
464
- if (pendingTerminationController) {
465
- pendingTerminationController.abort();
466
- pendingTerminationController = null;
467
- }
463
+ cancelPendingTermination();
468
464
  scheduleTermination();
469
465
  };
470
466
 
471
467
  const schedulePendingTermination = () => {
472
- if (pendingTerminationController || abortSent || terminationScheduled || resolved) return;
473
- const readyController = new AbortController();
474
- pendingTerminationController = readyController;
475
- const pendingSignal = AbortSignal.any([AbortSignal.timeout(2000), readyController.signal]);
476
- pendingSignal.addEventListener(
477
- "abort",
478
- () => {
479
- pendingTerminationController = null;
480
- if (!resolved) {
481
- requestAbort("terminate");
482
- }
483
- },
484
- { once: true, signal: listenerSignal },
485
- );
468
+ if (pendingTerminationTimeoutId || abortSent || terminationScheduled || resolved) return;
469
+ pendingTerminationTimeoutId = setTimeout(() => {
470
+ pendingTerminationTimeoutId = null;
471
+ if (!resolved) {
472
+ requestAbort("terminate");
473
+ }
474
+ }, 2000);
475
+ };
476
+
477
+ const cancelPendingTermination = () => {
478
+ if (pendingTerminationTimeoutId) {
479
+ clearTimeout(pendingTerminationTimeoutId);
480
+ pendingTerminationTimeoutId = null;
481
+ }
486
482
  };
487
483
 
488
484
  // Handle abort signal
@@ -655,9 +651,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
655
651
  // Accumulate tokens for progress display
656
652
  progress.tokens += getUsageTokens(messageUsage);
657
653
  }
658
- // If pending termination, now we have tokens - terminate
659
- if (pendingTerminationController) {
660
- pendingTerminationController.abort();
654
+ // If pending termination, now we have tokens - terminate immediately
655
+ if (pendingTerminationTimeoutId) {
656
+ cancelPendingTermination();
657
+ requestAbort("terminate");
661
658
  }
662
659
  break;
663
660
  }
@@ -714,7 +711,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
714
711
 
715
712
  const done = await new Promise<Extract<SubagentWorkerResponse, { type: "done" }>>((resolve) => {
716
713
  const cleanup = () => {
717
- pendingTerminationController = null;
718
714
  listenerController.abort();
719
715
  };
720
716
  finalize = (message) => {
@@ -723,10 +719,18 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
723
719
  cleanup();
724
720
  resolve(message);
725
721
  };
722
+ const postMessageSafe = (message: unknown) => {
723
+ if (resolved || terminated) return;
724
+ try {
725
+ worker.postMessage(message);
726
+ } catch {
727
+ // Worker already terminated
728
+ }
729
+ };
726
730
  const handleMCPCall = async (request: MCPToolCallRequest) => {
727
731
  const mcpManager = options.mcpManager;
728
732
  if (!mcpManager) {
729
- worker.postMessage({
733
+ postMessageSafe({
730
734
  type: "mcp_tool_result",
731
735
  callId: request.callId,
732
736
  error: "MCP not available",
@@ -743,13 +747,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
743
747
  })(),
744
748
  request.timeoutMs,
745
749
  );
746
- worker.postMessage({
750
+ postMessageSafe({
747
751
  type: "mcp_tool_result",
748
752
  callId: request.callId,
749
753
  result: { content: result.content ?? [], isError: result.isError },
750
754
  });
751
755
  } catch (error) {
752
- worker.postMessage({
756
+ postMessageSafe({
753
757
  type: "mcp_tool_result",
754
758
  callId: request.callId,
755
759
  error: error instanceof Error ? error.message : String(error),
@@ -767,7 +771,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
767
771
 
768
772
  const handlePythonCall = async (request: PythonToolCallRequest) => {
769
773
  if (!pythonTool) {
770
- worker.postMessage({
774
+ postMessageSafe({
771
775
  type: "python_tool_result",
772
776
  callId: request.callId,
773
777
  error: "Python proxy not available",
@@ -785,7 +789,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
785
789
  request.params as { code: string; timeout?: number; workdir?: string; reset?: boolean },
786
790
  combinedSignal,
787
791
  );
788
- worker.postMessage({
792
+ postMessageSafe({
789
793
  type: "python_tool_result",
790
794
  callId: request.callId,
791
795
  result: { content: result.content ?? [], details: result.details },
@@ -797,7 +801,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
797
801
  : error instanceof Error
798
802
  ? error.message
799
803
  : String(error);
800
- worker.postMessage({
804
+ postMessageSafe({
801
805
  type: "python_tool_result",
802
806
  callId: request.callId,
803
807
  error: message,
@@ -816,7 +820,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
816
820
 
817
821
  const handleLspCall = async (request: LspToolCallRequest) => {
818
822
  if (!lspTool) {
819
- worker.postMessage({
823
+ postMessageSafe({
820
824
  type: "lsp_tool_result",
821
825
  callId: request.callId,
822
826
  error: "LSP proxy not available",
@@ -828,7 +832,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
828
832
  lspTool.execute(request.callId, request.params as LspParams, signal),
829
833
  request.timeoutMs,
830
834
  );
831
- worker.postMessage({
835
+ postMessageSafe({
832
836
  type: "lsp_tool_result",
833
837
  callId: request.callId,
834
838
  result: { content: result.content ?? [], details: result.details },
@@ -840,7 +844,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
840
844
  : error instanceof Error
841
845
  ? error.message
842
846
  : String(error);
843
- worker.postMessage({
847
+ postMessageSafe({
844
848
  type: "lsp_tool_result",
845
849
  callId: request.callId,
846
850
  error: message,
@@ -881,10 +885,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
881
885
  return;
882
886
  }
883
887
  if (message.type === "done") {
888
+ // Worker is exiting - mark as terminated to prevent calling terminate() on dead worker
889
+ terminated = true;
884
890
  finalize?.(message);
885
891
  }
886
892
  };
887
893
  const onError = (event: WorkerErrorEvent) => {
894
+ // Worker error likely means it's dead or dying
895
+ terminated = true;
888
896
  finalize?.({
889
897
  type: "done",
890
898
  exitCode: 1,
@@ -893,6 +901,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
893
901
  });
894
902
  };
895
903
  const onMessageError = () => {
904
+ // Message error may indicate worker is in bad state
905
+ terminated = true;
896
906
  finalize?.({
897
907
  type: "done",
898
908
  exitCode: 1,
@@ -902,6 +912,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
902
912
  };
903
913
  const onClose = () => {
904
914
  // Worker terminated unexpectedly (crashed or was killed without sending done)
915
+ // Mark as terminated since the worker is already dead - calling terminate() again would crash
916
+ terminated = true;
905
917
  const abortMessage =
906
918
  abortSent && abortReason === "signal"
907
919
  ? "Worker terminated after abort"
@@ -932,11 +944,19 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
932
944
  }
933
945
  });
934
946
 
935
- // Cleanup
936
- try {
937
- worker.terminate();
938
- } catch {
939
- // Ignore termination errors
947
+ // Cleanup - cancel any pending timeouts first
948
+ if (terminationTimeoutId) {
949
+ clearTimeout(terminationTimeoutId);
950
+ terminationTimeoutId = null;
951
+ }
952
+ cancelPendingTermination();
953
+ if (!terminated) {
954
+ terminated = true;
955
+ try {
956
+ worker.terminate();
957
+ } catch {
958
+ // Ignore termination errors
959
+ }
940
960
  }
941
961
 
942
962
  let exitCode = done.exitCode;
@@ -15,7 +15,7 @@
15
15
 
16
16
  import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
17
17
  import type { Api, Model } from "@oh-my-pi/pi-ai";
18
- import { logger, untilAborted } from "@oh-my-pi/pi-utils";
18
+ import { logger, postmortem, untilAborted } from "@oh-my-pi/pi-utils";
19
19
  import type { TSchema } from "@sinclair/typebox";
20
20
  import lspDescription from "../../../prompts/tools/lsp.md" with { type: "text" };
21
21
  import type { AgentSessionEvent } from "../../agent-session";
@@ -377,17 +377,29 @@ function createPythonProxyTool(): CustomTool<typeof pythonSchema> {
377
377
  description: getPythonToolDescription(),
378
378
  parameters: pythonSchema,
379
379
  execute: async (_toolCallId, params, _onUpdate, _ctx, signal) => {
380
- const timeoutMs = getPythonCallTimeoutMs(params as PythonToolParams);
381
- const result = await callPythonToolViaParent(params as PythonToolParams, signal, timeoutMs);
382
- return {
383
- content:
384
- result?.content?.map((c) =>
385
- c.type === "text"
386
- ? { type: "text" as const, text: c.text ?? "" }
387
- : { type: "text" as const, text: JSON.stringify(c) },
388
- ) ?? [],
389
- details: result?.details as PythonToolDetails | undefined,
390
- };
380
+ try {
381
+ const timeoutMs = getPythonCallTimeoutMs(params as PythonToolParams);
382
+ const result = await callPythonToolViaParent(params as PythonToolParams, signal, timeoutMs);
383
+ return {
384
+ content:
385
+ result?.content?.map((c) =>
386
+ c.type === "text"
387
+ ? { type: "text" as const, text: c.text ?? "" }
388
+ : { type: "text" as const, text: JSON.stringify(c) },
389
+ ) ?? [],
390
+ details: result?.details as PythonToolDetails | undefined,
391
+ };
392
+ } catch (error) {
393
+ return {
394
+ content: [
395
+ {
396
+ type: "text" as const,
397
+ text: `Python error: ${error instanceof Error ? error.message : String(error)}`,
398
+ },
399
+ ],
400
+ details: { isError: true } as PythonToolDetails,
401
+ };
402
+ }
391
403
  },
392
404
  };
393
405
  }
@@ -780,7 +792,14 @@ function handleAbort(): void {
780
792
  }
781
793
  }
782
794
 
783
- const reportFatal = (message: string): void => {
795
+ const reportFatal = async (message: string): Promise<void> => {
796
+ // Run postmortem cleanup first to ensure child processes are killed
797
+ try {
798
+ await postmortem.cleanup();
799
+ } catch {
800
+ // Ignore cleanup errors
801
+ }
802
+
784
803
  const runState = activeRun;
785
804
  if (runState) {
786
805
  runState.abortController.abort();
@@ -821,6 +840,16 @@ self.addEventListener("error", (event) => {
821
840
  self.addEventListener("unhandledrejection", (event) => {
822
841
  const reason = event.reason;
823
842
  const message = reason instanceof Error ? reason.stack || reason.message : String(reason);
843
+
844
+ // Avoid terminating active runs on tool-level errors that bubble as rejections.
845
+ if (activeRun) {
846
+ logger.error("Unhandled rejection in subagent worker", { error: message });
847
+ if ("preventDefault" in event && typeof event.preventDefault === "function") {
848
+ event.preventDefault();
849
+ }
850
+ return;
851
+ }
852
+
824
853
  reportFatal(`Unhandled rejection: ${message}`);
825
854
  });
826
855
 
@@ -4,8 +4,8 @@ import * as path from "node:path";
4
4
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
5
  import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
+ import { ptree } from "@oh-my-pi/pi-utils";
7
8
  import { type Static, Type } from "@sinclair/typebox";
8
- import { $ } from "bun";
9
9
  import { nanoid } from "nanoid";
10
10
  import { parse as parseHtml } from "node-html-parser";
11
11
  import { type Theme, theme } from "../../modes/interactive/theme/theme";
@@ -75,18 +75,58 @@ const CONVERTIBLE_EXTENSIONS = new Set([
75
75
  * Execute a command and return stdout
76
76
  */
77
77
 
78
+ type WritableLike = {
79
+ write: (chunk: string | Uint8Array) => unknown;
80
+ flush?: () => unknown;
81
+ end?: () => unknown;
82
+ };
83
+
84
+ const textEncoder = new TextEncoder();
85
+
86
+ async function writeStdin(handle: unknown, input: string | Buffer): Promise<void> {
87
+ if (!handle || typeof handle === "number") return;
88
+ if (typeof (handle as WritableStream<Uint8Array>).getWriter === "function") {
89
+ const writer = (handle as WritableStream<Uint8Array>).getWriter();
90
+ try {
91
+ const chunk = typeof input === "string" ? textEncoder.encode(input) : new Uint8Array(input);
92
+ await writer.write(chunk);
93
+ } finally {
94
+ await writer.close();
95
+ }
96
+ return;
97
+ }
98
+
99
+ const sink = handle as WritableLike;
100
+ sink.write(input);
101
+ if (sink.flush) sink.flush();
102
+ if (sink.end) sink.end();
103
+ }
104
+
78
105
  async function exec(
79
106
  cmd: string,
80
107
  args: string[],
81
108
  options?: { timeout?: number; input?: string | Buffer },
82
109
  ): Promise<{ stdout: string; stderr: string; ok: boolean }> {
83
- void options;
84
- const result = await $`${cmd} ${args}`.quiet().nothrow();
85
- const decoder = new TextDecoder();
110
+ const proc = ptree.cspawn([cmd, ...args], {
111
+ stdin: options?.input ? "pipe" : null,
112
+ timeout: options?.timeout,
113
+ });
114
+
115
+ if (options?.input) {
116
+ await writeStdin(proc.stdin, options.input);
117
+ }
118
+
119
+ const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]);
120
+ try {
121
+ await proc.exited;
122
+ } catch {
123
+ // Handle non-zero exit or timeout
124
+ }
125
+
86
126
  return {
87
- stdout: result.stdout ? decoder.decode(result.stdout) : "",
88
- stderr: result.stderr ? decoder.decode(result.stderr) : "",
89
- ok: result.exitCode === 0,
127
+ stdout,
128
+ stderr,
129
+ ok: proc.exitCode === 0,
90
130
  };
91
131
  }
92
132
 
package/src/main.ts CHANGED
@@ -92,11 +92,13 @@ async function runInteractiveMode(
92
92
 
93
93
  await mode.init();
94
94
 
95
- versionCheckPromise.then((newVersion) => {
96
- if (newVersion) {
97
- mode.showNewVersionNotification(newVersion);
98
- }
99
- });
95
+ versionCheckPromise
96
+ .then((newVersion) => {
97
+ if (newVersion) {
98
+ mode.showNewVersionNotification(newVersion);
99
+ }
100
+ })
101
+ .catch(() => {});
100
102
 
101
103
  mode.renderInitialMessages();
102
104
 
@@ -109,7 +109,9 @@ export class LoginDialogComponent extends Container {
109
109
  showManualInput(prompt: string): Promise<string> {
110
110
  this.contentContainer.addChild(new Spacer(1));
111
111
  this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
112
- this.contentContainer.addChild(this.input);
112
+ if (!this.contentContainer.children.includes(this.input)) {
113
+ this.contentContainer.addChild(this.input);
114
+ }
113
115
  this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0));
114
116
  this.tui.requestRender();
115
117
 
@@ -129,7 +131,9 @@ export class LoginDialogComponent extends Container {
129
131
  if (placeholder) {
130
132
  this.contentContainer.addChild(new Text(theme.fg("dim", `e.g., ${placeholder}`), 1, 0));
131
133
  }
132
- this.contentContainer.addChild(this.input);
134
+ if (!this.contentContainer.children.includes(this.input)) {
135
+ this.contentContainer.addChild(this.input);
136
+ }
133
137
  this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel, Enter to submit)"), 1, 0));
134
138
 
135
139
  this.input.setValue("");
@@ -260,6 +260,10 @@ export class ToolExecutionComponent extends Container {
260
260
  ): void {
261
261
  this.result = result;
262
262
  this.isPartial = isPartial;
263
+ // When tool is complete, ensure args are marked complete so spinner stops
264
+ if (!isPartial) {
265
+ this.argsComplete = true;
266
+ }
263
267
  this.updateSpinnerAnimation();
264
268
  this.updateDisplay();
265
269
  // Convert non-PNG images to PNG for Kitty protocol (async)
@@ -168,6 +168,9 @@ export class InteractiveMode implements InteractiveModeContext {
168
168
  this.editor.onAutocompleteCancel = () => {
169
169
  this.ui.requestRender(true);
170
170
  };
171
+ this.editor.onAutocompleteUpdate = () => {
172
+ this.ui.requestRender(true);
173
+ };
171
174
  try {
172
175
  this.historyStorage = HistoryStorage.open();
173
176
  this.editor.setHistoryStorage(this.historyStorage);