@oh-my-pi/pi-coding-agent 13.15.3 → 13.16.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +30 -16
  2. package/package.json +7 -7
  3. package/src/commit/agentic/tools/analyze-file.ts +1 -0
  4. package/src/config/model-registry.ts +215 -57
  5. package/src/config/settings-schema.ts +27 -0
  6. package/src/extensibility/custom-tools/types.ts +3 -0
  7. package/src/extensibility/extensions/runner.ts +7 -0
  8. package/src/extensibility/extensions/types.ts +10 -1
  9. package/src/extensibility/hooks/types.ts +1 -1
  10. package/src/internal-urls/docs-index.generated.ts +1 -1
  11. package/src/ipy/cancellation.ts +28 -0
  12. package/src/ipy/executor.ts +252 -77
  13. package/src/ipy/kernel.ts +181 -35
  14. package/src/ipy/modules.ts +39 -4
  15. package/src/modes/acp/acp-agent.ts +1 -0
  16. package/src/modes/components/hook-editor.ts +57 -8
  17. package/src/modes/components/model-selector.ts +48 -29
  18. package/src/modes/components/settings-defs.ts +10 -1
  19. package/src/modes/components/settings-selector.ts +92 -5
  20. package/src/modes/controllers/extension-ui-controller.ts +35 -4
  21. package/src/modes/controllers/input-controller.ts +4 -3
  22. package/src/modes/controllers/selector-controller.ts +2 -2
  23. package/src/modes/interactive-mode.ts +7 -2
  24. package/src/modes/print-mode.ts +1 -0
  25. package/src/modes/prompt-action-autocomplete.ts +5 -3
  26. package/src/modes/rpc/rpc-mode.ts +79 -30
  27. package/src/modes/rpc/rpc-types.ts +9 -1
  28. package/src/modes/theme/theme.ts +70 -0
  29. package/src/modes/types.ts +6 -1
  30. package/src/prompts/system/custom-system-prompt.md +5 -0
  31. package/src/prompts/system/system-prompt.md +6 -0
  32. package/src/prompts/tools/ask.md +1 -0
  33. package/src/prompts/tools/grep.md +1 -1
  34. package/src/prompts/tools/hashline.md +20 -5
  35. package/src/sdk.ts +26 -2
  36. package/src/session/agent-session.ts +18 -11
  37. package/src/system-prompt.ts +63 -2
  38. package/src/task/executor.ts +4 -0
  39. package/src/task/index.ts +2 -0
  40. package/src/tools/ask.ts +109 -61
  41. package/src/tools/ast-edit.ts +2 -16
  42. package/src/tools/ast-grep.ts +2 -17
  43. package/src/tools/browser.ts +35 -17
  44. package/src/tools/find.ts +1 -0
  45. package/src/tools/grep.ts +25 -34
  46. package/src/tools/index.ts +3 -0
  47. package/src/tools/path-utils.ts +7 -0
  48. package/src/tools/python.ts +3 -2
  49. package/src/tools/render-utils.ts +27 -0
  50. package/src/tui/tree-list.ts +51 -22
@@ -24,6 +24,8 @@ export interface PythonExecutorOptions {
24
24
  cwd?: string;
25
25
  /** Timeout in milliseconds */
26
26
  timeoutMs?: number;
27
+ /** Absolute wall-clock deadline in milliseconds since epoch */
28
+ deadlineMs?: number;
27
29
  /** Callback for streaming output chunks (already sanitized) */
28
30
  onChunk?: (chunk: string) => Promise<void> | void;
29
31
  /** AbortSignal for cancellation */
@@ -86,6 +88,151 @@ const kernelSessions = new Map<string, KernelSession>();
86
88
  let cachedPreludeDocs: PreludeHelper[] | null = null;
87
89
  let cleanupTimer: NodeJS.Timeout | null = null;
88
90
 
91
+ interface KernelSessionExecutionOptions {
92
+ useSharedGateway?: boolean;
93
+ sessionFile?: string;
94
+ signal?: AbortSignal;
95
+ deadlineMs?: number;
96
+ }
97
+
98
+ class PythonExecutionCancelledError extends Error {
99
+ readonly timedOut: boolean;
100
+
101
+ constructor(timedOut: boolean) {
102
+ super(timedOut ? "Command timed out" : "Command aborted");
103
+ this.name = timedOut ? "TimeoutError" : "AbortError";
104
+ this.timedOut = timedOut;
105
+ }
106
+ }
107
+
108
+ function getExecutionDeadlineMs(options?: Pick<PythonExecutorOptions, "deadlineMs" | "timeoutMs">): number | undefined {
109
+ if (options?.deadlineMs !== undefined) return options.deadlineMs;
110
+ if (options?.timeoutMs === undefined) return undefined;
111
+ return Date.now() + options.timeoutMs;
112
+ }
113
+
114
+ function getRemainingTimeoutMs(deadlineMs?: number): number | undefined {
115
+ if (deadlineMs === undefined) return undefined;
116
+ return deadlineMs - Date.now();
117
+ }
118
+
119
+ function requireRemainingTimeoutMs(deadlineMs?: number): number | undefined {
120
+ const remainingMs = getRemainingTimeoutMs(deadlineMs);
121
+ if (remainingMs === undefined) return undefined;
122
+ if (remainingMs <= 0) {
123
+ throw new PythonExecutionCancelledError(true);
124
+ }
125
+ return remainingMs;
126
+ }
127
+
128
+ function isCancellationError(error: unknown): boolean {
129
+ return (
130
+ error instanceof PythonExecutionCancelledError ||
131
+ (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) ||
132
+ (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError"))
133
+ );
134
+ }
135
+
136
+ function isTimedOutCancellation(error: unknown, signal?: AbortSignal): boolean {
137
+ if (error instanceof PythonExecutionCancelledError) return error.timedOut;
138
+ if (error instanceof DOMException) return error.name === "TimeoutError";
139
+ if (error instanceof Error && error.name === "TimeoutError") return true;
140
+ const reason = signal?.reason;
141
+ if (reason instanceof DOMException) return reason.name === "TimeoutError";
142
+ return reason instanceof Error ? reason.name === "TimeoutError" : false;
143
+ }
144
+
145
+ async function waitForQueueTurn(
146
+ queue: Promise<void>,
147
+ options: Pick<KernelSessionExecutionOptions, "signal" | "deadlineMs">,
148
+ ): Promise<void> {
149
+ if (options.signal?.aborted) {
150
+ throw new PythonExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal));
151
+ }
152
+
153
+ const remainingMs = getRemainingTimeoutMs(options.deadlineMs);
154
+ if (remainingMs !== undefined && remainingMs <= 0) {
155
+ throw new PythonExecutionCancelledError(true);
156
+ }
157
+
158
+ if (!options.signal && remainingMs === undefined) {
159
+ await queue;
160
+ return;
161
+ }
162
+
163
+ await new Promise<void>((resolve, reject) => {
164
+ const cleanups: Array<() => void> = [];
165
+ const finish = (callback: () => void) => {
166
+ while (cleanups.length > 0) {
167
+ cleanups.pop()?.();
168
+ }
169
+ callback();
170
+ };
171
+
172
+ const onAbort = () => {
173
+ finish(() =>
174
+ reject(new PythonExecutionCancelledError(isTimedOutCancellation(options.signal?.reason, options.signal))),
175
+ );
176
+ };
177
+
178
+ if (options.signal) {
179
+ options.signal.addEventListener("abort", onAbort, { once: true });
180
+ cleanups.push(() => options.signal?.removeEventListener("abort", onAbort));
181
+ }
182
+
183
+ if (remainingMs !== undefined) {
184
+ const timeout = setTimeout(() => {
185
+ finish(() => reject(new PythonExecutionCancelledError(true)));
186
+ }, remainingMs);
187
+ timeout.unref();
188
+ cleanups.push(() => clearTimeout(timeout));
189
+ }
190
+
191
+ queue.then(
192
+ () => finish(resolve),
193
+ error => finish(() => reject(error)),
194
+ );
195
+ });
196
+ }
197
+
198
+ function formatTimeoutAnnotation(timeoutMs?: number): string | undefined {
199
+ if (timeoutMs === undefined) return "Command timed out";
200
+ const secs = Math.max(1, Math.round(timeoutMs / 1000));
201
+ return `Command timed out after ${secs} seconds`;
202
+ }
203
+
204
+ function createCancelledPythonResult(timedOut: boolean, timeoutMs?: number): PythonResult {
205
+ const output = timedOut ? (formatTimeoutAnnotation(timeoutMs) ?? "Command timed out") : "";
206
+ const outputBytes = Buffer.byteLength(output, "utf-8");
207
+ const outputLines = output.length > 0 ? 1 : 0;
208
+ return {
209
+ output,
210
+ exitCode: undefined,
211
+ cancelled: true,
212
+ truncated: false,
213
+ totalLines: outputLines,
214
+ totalBytes: outputBytes,
215
+ outputLines,
216
+ outputBytes,
217
+ displayOutputs: [],
218
+ stdinRequested: false,
219
+ };
220
+ }
221
+
222
+ function buildKernelStartOptions(
223
+ cwd: string,
224
+ env: Record<string, string> | undefined,
225
+ options: KernelSessionExecutionOptions,
226
+ ) {
227
+ return {
228
+ cwd,
229
+ env,
230
+ useSharedGateway: options.useSharedGateway,
231
+ signal: options.signal,
232
+ deadlineMs: options.deadlineMs,
233
+ };
234
+ }
235
+
89
236
  interface PreludeCacheSource {
90
237
  path: string;
91
238
  hash: string;
@@ -247,13 +394,10 @@ export async function warmPythonEnvironment(
247
394
  const resolvedSessionId = sessionId ?? `session:${cwd}`;
248
395
  try {
249
396
  const docs = await logger.timeAsync("warmPython:withKernelSession", () =>
250
- withKernelSession(
251
- resolvedSessionId,
252
- cwd,
253
- async kernel => kernel.introspectPrelude(),
397
+ withKernelSession(resolvedSessionId, cwd, async kernel => kernel.introspectPrelude(), {
254
398
  useSharedGateway,
255
399
  sessionFile,
256
- ),
400
+ }),
257
401
  );
258
402
  cachedPreludeDocs = docs;
259
403
  if (!isTestEnv && docs.length > 0) {
@@ -306,21 +450,22 @@ async function recoverFromResourceExhaustion(): Promise<void> {
306
450
  async function createKernelSession(
307
451
  sessionId: string,
308
452
  cwd: string,
309
- useSharedGateway?: boolean,
310
- sessionFile?: string,
453
+ options: KernelSessionExecutionOptions = {},
311
454
  isRetry?: boolean,
312
455
  ): Promise<KernelSession> {
313
- const env: Record<string, string> | undefined = sessionFile ? { PI_SESSION_FILE: sessionFile } : undefined;
456
+ requireRemainingTimeoutMs(options.deadlineMs);
457
+ const env: Record<string, string> | undefined = options.sessionFile
458
+ ? { PI_SESSION_FILE: options.sessionFile }
459
+ : undefined;
460
+ const startOptions = buildKernelStartOptions(cwd, env, options);
314
461
 
315
462
  let kernel: PythonKernel;
316
463
  try {
317
- kernel = await logger.timeAsync("createKernelSession:PythonKernel.start", () =>
318
- PythonKernel.start({ cwd, useSharedGateway, env }),
319
- );
464
+ kernel = await logger.timeAsync("createKernelSession:PythonKernel.start", () => PythonKernel.start(startOptions));
320
465
  } catch (err) {
321
466
  if (!isRetry && isResourceExhaustionError(err)) {
322
467
  await recoverFromResourceExhaustion();
323
- return createKernelSession(sessionId, cwd, useSharedGateway, sessionFile, true);
468
+ return createKernelSession(sessionId, cwd, options, true);
324
469
  }
325
470
  throw err;
326
471
  }
@@ -347,20 +492,23 @@ async function createKernelSession(
347
492
  async function restartKernelSession(
348
493
  session: KernelSession,
349
494
  cwd: string,
350
- useSharedGateway?: boolean,
351
- sessionFile?: string,
495
+ options: KernelSessionExecutionOptions = {},
352
496
  ): Promise<void> {
353
497
  session.restartCount += 1;
354
498
  if (session.restartCount > 1) {
355
499
  throw new Error("Python kernel restarted too many times in this session");
356
500
  }
501
+ requireRemainingTimeoutMs(options.deadlineMs);
357
502
  try {
358
503
  await session.kernel.shutdown();
359
504
  } catch (err) {
360
505
  logger.warn("Failed to shutdown crashed kernel", { error: err instanceof Error ? err.message : String(err) });
361
506
  }
362
- const env: Record<string, string> | undefined = sessionFile ? { PI_SESSION_FILE: sessionFile } : undefined;
363
- const kernel = await PythonKernel.start({ cwd, useSharedGateway, env });
507
+ const env: Record<string, string> | undefined = options.sessionFile
508
+ ? { PI_SESSION_FILE: options.sessionFile }
509
+ : undefined;
510
+ const startOptions = buildKernelStartOptions(cwd, env, options);
511
+ const kernel = await PythonKernel.start(startOptions);
364
512
  session.kernel = kernel;
365
513
  session.dead = false;
366
514
  session.lastUsedAt = Date.now();
@@ -382,23 +530,18 @@ async function withKernelSession<T>(
382
530
  sessionId: string,
383
531
  cwd: string,
384
532
  handler: (kernel: PythonKernel) => Promise<T>,
385
- useSharedGateway?: boolean,
386
- sessionFile?: string,
533
+ options: KernelSessionExecutionOptions = {},
387
534
  ): Promise<T> {
388
535
  let session = kernelSessions.get(sessionId);
389
536
  if (!session) {
390
- // Evict oldest session if at capacity
391
537
  if (kernelSessions.size >= MAX_KERNEL_SESSIONS) {
392
538
  await evictOldestSession();
393
539
  }
394
- session = await logger.timeAsync(
395
- "kernel:createKernelSession",
396
- createKernelSession,
397
- sessionId,
398
- cwd,
399
- useSharedGateway,
400
- sessionFile,
401
- );
540
+ requireRemainingTimeoutMs(options.deadlineMs);
541
+ if (options.signal?.aborted) {
542
+ throw new PythonExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal));
543
+ }
544
+ session = await logger.timeAsync("kernel:createKernelSession", createKernelSession, sessionId, cwd, options);
402
545
  kernelSessions.set(sessionId, session);
403
546
  startCleanupTimer();
404
547
  }
@@ -406,14 +549,7 @@ async function withKernelSession<T>(
406
549
  const run = async (): Promise<T> => {
407
550
  session!.lastUsedAt = Date.now();
408
551
  if (session!.dead || !session!.kernel.isAlive()) {
409
- await logger.timeAsync(
410
- "kernel:restartKernelSession",
411
- restartKernelSession,
412
- session!,
413
- cwd,
414
- useSharedGateway,
415
- sessionFile,
416
- );
552
+ await logger.timeAsync("kernel:restartKernelSession", restartKernelSession, session!, cwd, options);
417
553
  }
418
554
  try {
419
555
  const result = await logger.timeAsync("kernel:withSession:handler", handler, session!.kernel);
@@ -423,26 +559,34 @@ async function withKernelSession<T>(
423
559
  if (!session!.dead && session!.kernel.isAlive()) {
424
560
  throw err;
425
561
  }
426
- await logger.timeAsync(
427
- "kernel:restartKernelSession",
428
- restartKernelSession,
429
- session!,
430
- cwd,
431
- useSharedGateway,
432
- sessionFile,
433
- );
562
+ await logger.timeAsync("kernel:restartKernelSession", restartKernelSession, session!, cwd, options);
434
563
  const result = await logger.timeAsync("kernel:postRestart:handler", handler, session!.kernel);
435
564
  session!.restartCount = 0;
436
565
  return result;
437
566
  }
438
567
  };
439
568
 
440
- const task = session.queue.then(run, run);
441
- session.queue = task.then(
442
- () => undefined,
443
- () => undefined,
444
- );
445
- return task;
569
+ const queue = session.queue;
570
+ let releaseTurn: (() => void) | undefined;
571
+ const turn = new Promise<void>(resolve => {
572
+ releaseTurn = resolve;
573
+ });
574
+ session.queue = queue
575
+ .then(
576
+ () => turn,
577
+ () => turn,
578
+ )
579
+ .then(
580
+ () => undefined,
581
+ () => undefined,
582
+ );
583
+
584
+ try {
585
+ await waitForQueueTurn(queue, options);
586
+ return await run();
587
+ } finally {
588
+ releaseTurn?.();
589
+ }
446
590
  }
447
591
 
448
592
  async function executeWithKernel(
@@ -456,19 +600,20 @@ async function executeWithKernel(
456
600
  artifactId: options?.artifactId,
457
601
  });
458
602
  const displayOutputs: KernelDisplayOutput[] = [];
603
+ const deadlineMs = getExecutionDeadlineMs(options);
604
+ let executionTimeoutMs: number | undefined;
459
605
 
460
606
  try {
607
+ executionTimeoutMs = requireRemainingTimeoutMs(deadlineMs);
461
608
  const result = await kernel.execute(code, {
462
609
  signal: options?.signal,
463
- timeoutMs: options?.timeoutMs,
610
+ timeoutMs: executionTimeoutMs,
464
611
  onChunk: text => sink.push(text),
465
612
  onDisplay: output => void displayOutputs.push(output),
466
613
  });
467
614
 
468
615
  if (result.cancelled) {
469
- const secs = options?.timeoutMs ? Math.round(options.timeoutMs / 1000) : undefined;
470
- const annotation =
471
- result.timedOut && secs !== undefined ? `Command timed out after ${secs} seconds` : undefined;
616
+ const annotation = result.timedOut ? formatTimeoutAnnotation(executionTimeoutMs) : undefined;
472
617
  return {
473
618
  exitCode: undefined,
474
619
  cancelled: true,
@@ -497,6 +642,16 @@ async function executeWithKernel(
497
642
  ...(await sink.dump()),
498
643
  };
499
644
  } catch (err) {
645
+ if (isCancellationError(err) || options?.signal?.aborted) {
646
+ const timedOut = isTimedOutCancellation(err, options?.signal);
647
+ return {
648
+ exitCode: undefined,
649
+ cancelled: true,
650
+ displayOutputs,
651
+ stdinRequested: false,
652
+ ...(await sink.dump(timedOut ? formatTimeoutAnnotation(executionTimeoutMs) : undefined)),
653
+ };
654
+ }
500
655
  const error = err instanceof Error ? err : new Error(String(err));
501
656
  logger.error("Python execution failed", { error: error.message });
502
657
  throw error;
@@ -513,34 +668,54 @@ export async function executePythonWithKernel(
513
668
 
514
669
  export async function executePython(code: string, options?: PythonExecutorOptions): Promise<PythonResult> {
515
670
  const cwd = options?.cwd ?? getProjectDir();
516
- await ensureKernelAvailable(cwd);
671
+ const deadlineMs = getExecutionDeadlineMs(options);
672
+ const executionOptions: PythonExecutorOptions = {
673
+ ...(options ?? {}),
674
+ deadlineMs,
675
+ };
676
+
677
+ try {
678
+ requireRemainingTimeoutMs(deadlineMs);
679
+ if (executionOptions.signal?.aborted) {
680
+ throw new PythonExecutionCancelledError(
681
+ isTimedOutCancellation(executionOptions.signal.reason, executionOptions.signal),
682
+ );
683
+ }
517
684
 
518
- const kernelMode = options?.kernelMode ?? "session";
519
- const useSharedGateway = options?.useSharedGateway;
520
- const sessionFile = options?.sessionFile;
685
+ await ensureKernelAvailable(cwd);
521
686
 
522
- if (kernelMode === "per-call") {
523
- const env: Record<string, string> | undefined = sessionFile ? { PI_SESSION_FILE: sessionFile } : undefined;
524
- const kernel = await PythonKernel.start({ cwd, useSharedGateway, env });
525
- try {
526
- return await executeWithKernel(kernel, code, options);
527
- } finally {
528
- await kernel.shutdown();
687
+ const kernelMode = executionOptions.kernelMode ?? "session";
688
+ const sessionFile = executionOptions.sessionFile;
689
+
690
+ if (kernelMode === "per-call") {
691
+ const env: Record<string, string> | undefined = sessionFile ? { PI_SESSION_FILE: sessionFile } : undefined;
692
+ requireRemainingTimeoutMs(deadlineMs);
693
+ const startOptions = buildKernelStartOptions(cwd, env, executionOptions);
694
+ const kernel = await PythonKernel.start(startOptions);
695
+ try {
696
+ return await executeWithKernel(kernel, code, executionOptions);
697
+ } finally {
698
+ await kernel.shutdown();
699
+ }
529
700
  }
530
- }
531
701
 
532
- const sessionId = options?.sessionId ?? `session:${cwd}`;
533
- if (options?.reset) {
534
- const existing = kernelSessions.get(sessionId);
535
- if (existing) {
536
- await disposeKernelSession(existing);
702
+ const sessionId = executionOptions.sessionId ?? `session:${cwd}`;
703
+ if (executionOptions.reset) {
704
+ const existing = kernelSessions.get(sessionId);
705
+ if (existing) {
706
+ await disposeKernelSession(existing);
707
+ }
708
+ }
709
+ return await withKernelSession(
710
+ sessionId,
711
+ cwd,
712
+ async kernel => executeWithKernel(kernel, code, executionOptions),
713
+ executionOptions,
714
+ );
715
+ } catch (err) {
716
+ if (isCancellationError(err) || executionOptions.signal?.aborted) {
717
+ return createCancelledPythonResult(isTimedOutCancellation(err, executionOptions.signal));
537
718
  }
719
+ throw err;
538
720
  }
539
- return await withKernelSession(
540
- sessionId,
541
- cwd,
542
- async kernel => executeWithKernel(kernel, code, options),
543
- useSharedGateway,
544
- sessionFile,
545
- );
546
721
  }