@oh-my-pi/pi-coding-agent 16.2.11 → 16.2.13

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 (89) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/cli.js +2982 -2825
  3. package/dist/types/cli/bench-cli.d.ts +0 -5
  4. package/dist/types/cli/dry-balance-cli.d.ts +0 -5
  5. package/dist/types/cli/models-cli.d.ts +1 -1
  6. package/dist/types/config/inline-tool-descriptors-mode.d.ts +1 -2
  7. package/dist/types/config/model-registry.d.ts +1 -23
  8. package/dist/types/config/model-resolver.d.ts +10 -15
  9. package/dist/types/config/models-config-schema.d.ts +12 -6
  10. package/dist/types/config/models-config.d.ts +9 -6
  11. package/dist/types/config/settings-schema.d.ts +31 -1
  12. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +15 -0
  14. package/dist/types/lsp/client.d.ts +9 -3
  15. package/dist/types/modes/controllers/tool-args-reveal.d.ts +0 -5
  16. package/dist/types/task/spawn-policy.d.ts +17 -0
  17. package/dist/types/task/spawn-policy.test.d.ts +1 -0
  18. package/dist/types/task/types.d.ts +8 -2
  19. package/dist/types/tools/__tests__/eval-description.test.d.ts +1 -0
  20. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +2 -2
  21. package/dist/types/tools/browser/registry.d.ts +2 -0
  22. package/dist/types/tools/browser/run-cancellation.d.ts +4 -0
  23. package/dist/types/tools/browser/tab-supervisor.d.ts +30 -1
  24. package/dist/types/tools/eval.d.ts +3 -6
  25. package/dist/types/tools/write.d.ts +1 -1
  26. package/dist/types/utils/image-vision-fallback.d.ts +1 -1
  27. package/package.json +12 -12
  28. package/src/advisor/__tests__/advisor.test.ts +1 -1
  29. package/src/auto-thinking/classifier.ts +1 -1
  30. package/src/cli/bench-cli.ts +3 -12
  31. package/src/cli/dry-balance-cli.ts +1 -6
  32. package/src/cli/models-cli.ts +3 -81
  33. package/src/commands/models.ts +1 -2
  34. package/src/commit/model-selection.ts +4 -4
  35. package/src/config/inline-tool-descriptors-mode.ts +1 -2
  36. package/src/config/model-discovery.ts +32 -6
  37. package/src/config/model-registry.ts +11 -225
  38. package/src/config/model-resolver.ts +23 -135
  39. package/src/config/models-config-schema.ts +13 -22
  40. package/src/config/prompt-templates.ts +20 -0
  41. package/src/config/settings-schema.ts +35 -1
  42. package/src/config/settings.ts +60 -24
  43. package/src/eval/__tests__/agent-bridge.test.ts +17 -3
  44. package/src/eval/agent-bridge.ts +7 -9
  45. package/src/eval/completion-bridge.ts +1 -1
  46. package/src/eval/py/__tests__/runner-shell-output.test.ts +157 -0
  47. package/src/eval/py/runner.py +169 -18
  48. package/src/extensibility/extensions/model-api.ts +1 -3
  49. package/src/extensibility/extensions/runner.ts +62 -5
  50. package/src/lsp/client.ts +162 -45
  51. package/src/lsp/index.ts +31 -21
  52. package/src/main.ts +0 -1
  53. package/src/mcp/transports/http.ts +19 -17
  54. package/src/mcp/transports/stdio.test.ts +51 -1
  55. package/src/mcp/transports/stdio.ts +19 -9
  56. package/src/memories/index.ts +0 -1
  57. package/src/mnemopi/backend.ts +1 -1
  58. package/src/modes/components/model-selector.ts +32 -186
  59. package/src/modes/controllers/event-controller.ts +8 -39
  60. package/src/modes/controllers/selector-controller.ts +0 -1
  61. package/src/modes/controllers/tool-args-reveal.ts +0 -12
  62. package/src/prompts/system/subagent-system-prompt.md +2 -2
  63. package/src/prompts/system/tool-call-loop-redirect.md +8 -0
  64. package/src/prompts/tools/eval.md +2 -2
  65. package/src/prompts/tools/ssh.md +1 -0
  66. package/src/prompts/tools/task.md +1 -1
  67. package/src/sdk.ts +2 -9
  68. package/src/session/agent-session.ts +89 -8
  69. package/src/session/session-context.ts +2 -1
  70. package/src/session/session-manager.ts +2 -1
  71. package/src/session/unexpected-stop-classifier.ts +1 -1
  72. package/src/task/index.ts +23 -27
  73. package/src/task/spawn-policy.test.ts +63 -0
  74. package/src/task/spawn-policy.ts +58 -0
  75. package/src/task/types.ts +77 -6
  76. package/src/tools/__tests__/eval-description.test.ts +19 -0
  77. package/src/tools/browser/cmux/cmux-tab.ts +66 -24
  78. package/src/tools/browser/registry.ts +25 -0
  79. package/src/tools/browser/run-cancellation.ts +47 -0
  80. package/src/tools/browser/tab-supervisor.ts +120 -7
  81. package/src/tools/browser/tab-worker.ts +22 -10
  82. package/src/tools/browser.ts +1 -0
  83. package/src/tools/eval.ts +15 -10
  84. package/src/tools/inspect-image.ts +1 -1
  85. package/src/tools/ssh.ts +8 -1
  86. package/src/tools/write.ts +49 -9
  87. package/src/utils/commit-message-generator.ts +0 -1
  88. package/src/utils/image-vision-fallback.ts +2 -3
  89. package/src/utils/title-generator.ts +1 -1
@@ -196,15 +196,29 @@ describe("runEvalAgent", () => {
196
196
  await expect(runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "" }) })).rejects.toThrow(
197
197
  "spawns disabled",
198
198
  );
199
- await expect(runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "reviewer" }) })).rejects.toThrow(
200
- "Allowed: reviewer",
201
- );
199
+ await expect(
200
+ runEvalAgent({ prompt: "hello", agent: "task" }, { session: makeSession({ spawns: "reviewer" }) }),
201
+ ).rejects.toThrow("Allowed: reviewer");
202
202
  await expect(
203
203
  runEvalAgent({ prompt: "hello" }, { session: makeSession({ depth: EVAL_AGENT_MAX_DEPTH }) }),
204
204
  ).rejects.toThrow("maximum depth");
205
205
  expect(runSpy).not.toHaveBeenCalled();
206
206
  });
207
207
 
208
+ it("defaults to the first allowed spawn under restricted eval policies", async () => {
209
+ mockAgents();
210
+ const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
211
+ singleResult(options, {
212
+ output: options.agent.name,
213
+ }),
214
+ );
215
+
216
+ const result = await runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "reviewer,task" }) });
217
+
218
+ expect(result.text).toBe("reviewer");
219
+ expect(runSpy.mock.calls[0]?.[0].agent.name).toBe("reviewer");
220
+ });
221
+
208
222
  it("throws instead of spawning from plan mode", async () => {
209
223
  mockAgents();
210
224
  const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
@@ -24,6 +24,7 @@ import {
24
24
  runIsolatedSubprocess,
25
25
  } from "../task/isolation-runner";
26
26
  import { AgentOutputManager } from "../task/output-manager";
27
+ import { resolveSpawnPolicy } from "../task/spawn-policy";
27
28
  import type { AgentDefinition, AgentProgress, SingleResult } from "../task/types";
28
29
  import { type NestedRepoPatch, parseIsolationMode } from "../task/worktree";
29
30
  import type { ToolSession } from "../tools";
@@ -39,7 +40,6 @@ export const EVAL_AGENT_BRIDGE_NAME = "__agent__";
39
40
  /** Hard recursion limit for eval-driven subagents. */
40
41
  export const EVAL_AGENT_MAX_DEPTH = 3;
41
42
 
42
- const DEFAULT_AGENT_TYPE = "task";
43
43
  const DEFAULT_AGENT_LABEL = "EvalAgent";
44
44
 
45
45
  const agentArgsSchema = type({
@@ -139,14 +139,12 @@ function assertDepthAllowed(session: ToolSession): void {
139
139
  }
140
140
 
141
141
  function assertSpawnAllowed(session: ToolSession, agentName: string): void {
142
- const parentSpawns = session.getSessionSpawns() ?? "*";
143
- if (parentSpawns === "*") return;
144
- if (parentSpawns === "") {
145
- throw new ToolError(`Cannot spawn '${agentName}'. Allowed: none (spawns disabled for this agent)`);
142
+ const spawnPolicy = resolveSpawnPolicy(session.getSessionSpawns());
143
+ if (!spawnPolicy.enabled) {
144
+ throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}`);
146
145
  }
147
- const allowedSpawns = parentSpawns.split(",").map(spawn => spawn.trim());
148
- if (!allowedSpawns.includes(agentName)) {
149
- throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${parentSpawns}`);
146
+ if (spawnPolicy.allowedAgents !== null && !spawnPolicy.allowedAgents.includes(agentName)) {
147
+ throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}`);
150
148
  }
151
149
  }
152
150
 
@@ -283,7 +281,7 @@ function buildSubagentFailureMessage(agentName: string, result: SingleResult): s
283
281
  */
284
282
  export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOptions): Promise<EvalAgentResult> {
285
283
  const parsed = parseAgentArgs(args);
286
- const agentName = parsed.agent ?? DEFAULT_AGENT_TYPE;
284
+ const agentName = parsed.agent ?? resolveSpawnPolicy(options.session.getSessionSpawns()).defaultAgent;
287
285
  const structured = Object.hasOwn(parsed, "schema");
288
286
 
289
287
  assertNotPlanMode(options.session);
@@ -75,7 +75,7 @@ function resolveTierModel(tier: CompletionTier, session: ToolSession): Model<Api
75
75
  const resolve = (pattern: string | undefined): Model<Api> | undefined => {
76
76
  if (!pattern) return undefined;
77
77
  const expanded = expandRoleAlias(pattern, session.settings);
78
- return resolveModelFromString(expanded, available, matchPreferences, modelRegistry);
78
+ return resolveModelFromString(expanded, available, matchPreferences);
79
79
  };
80
80
 
81
81
  if (tier === "default") {
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import * as path from "node:path";
3
+
4
+ interface RunnerFrame {
5
+ type?: string;
6
+ id?: string;
7
+ data?: string;
8
+ status?: string;
9
+ }
10
+
11
+ const pythonPath = Bun.env.PYTHON ?? "python3";
12
+ const runnerPath = path.resolve(import.meta.dir, "..", "runner.py");
13
+ const repoRoot = path.resolve(import.meta.dir, "../../../../../..");
14
+ const encoder = new TextEncoder();
15
+
16
+ function shellQuote(value: string): string {
17
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
18
+ }
19
+
20
+ async function runCell(code: string): Promise<RunnerFrame[]> {
21
+ const proc = Bun.spawn([pythonPath, "-u", runnerPath], {
22
+ cwd: repoRoot,
23
+ stdin: "pipe",
24
+ stdout: "pipe",
25
+ stderr: "pipe",
26
+ env: {
27
+ ...process.env,
28
+ PYTHONUNBUFFERED: "1",
29
+ PYTHONIOENCODING: "utf-8",
30
+ },
31
+ });
32
+ const stderr = new Response(proc.stderr).text();
33
+ const reader = proc.stdout.getReader();
34
+ const decoder = new TextDecoder();
35
+ let pending = "";
36
+ const frames: RunnerFrame[] = [];
37
+
38
+ async function readFrame(): Promise<RunnerFrame> {
39
+ while (true) {
40
+ const newline = pending.indexOf("\n");
41
+ if (newline >= 0) {
42
+ const line = pending.slice(0, newline);
43
+ pending = pending.slice(newline + 1);
44
+ return JSON.parse(line) as RunnerFrame;
45
+ }
46
+ const { value, done } = await reader.read();
47
+ if (done) {
48
+ throw new Error(`Python runner exited before done frame: ${await stderr}`);
49
+ }
50
+ pending += decoder.decode(value, { stream: true });
51
+ }
52
+ }
53
+
54
+ try {
55
+ proc.stdin.write(encoder.encode(`${JSON.stringify({ id: "r1", code })}\n`));
56
+ proc.stdin.flush();
57
+ while (true) {
58
+ const frame = await readFrame();
59
+ frames.push(frame);
60
+ if (frame.type === "done") break;
61
+ }
62
+ proc.stdin.write(encoder.encode(`${JSON.stringify({ type: "exit" })}\n`));
63
+ proc.stdin.end();
64
+ const exitCode = await proc.exited;
65
+ if (exitCode !== 0) {
66
+ throw new Error(`Python runner exited ${exitCode}: ${await stderr}`);
67
+ }
68
+ return frames;
69
+ } finally {
70
+ try {
71
+ reader.releaseLock();
72
+ } catch {
73
+ // Reader may already be released by stream closure.
74
+ }
75
+ try {
76
+ proc.kill("SIGKILL");
77
+ } catch {
78
+ // Process already exited.
79
+ }
80
+ }
81
+ }
82
+
83
+ describe("Python runner shell output streaming", () => {
84
+ it("streams !cmd output chunks before the child process exits", async () => {
85
+ const child = [
86
+ "import sys,time",
87
+ "sys.stdout.write('first\\n')",
88
+ "sys.stdout.flush()",
89
+ "time.sleep(0.2)",
90
+ "sys.stdout.write('second\\n')",
91
+ "sys.stdout.flush()",
92
+ ].join(";");
93
+ const frames = await runCell(
94
+ [
95
+ `result = !${pythonPath} -c ${shellQuote(child)}`,
96
+ "print('return=' + str(result.returncode) + ' lines=' + repr(list(result)))",
97
+ ].join("\n"),
98
+ );
99
+ const stdout = frames.filter(frame => frame.type === "stdout").map(frame => frame.data);
100
+
101
+ expect(stdout[0]).toBe("first\n");
102
+ expect(stdout.join("")).toContain("second\n");
103
+ expect(stdout.join("")).toContain("return=0 lines=['first', 'second']");
104
+ });
105
+
106
+ it("caps !cmd output and captured result by line count with a truncation notice", async () => {
107
+ const child = ["import sys", "sys.stdout.write(('x' + chr(10)) * 3100)", "sys.stdout.flush()"].join(";");
108
+ const frames = await runCell(
109
+ [
110
+ `result = !${pythonPath} -c ${shellQuote(child)}`,
111
+ "print('captured=' + str(len(result)) + ' return=' + str(result.returncode))",
112
+ ].join("\n"),
113
+ );
114
+ const stdout = frames
115
+ .filter(frame => frame.type === "stdout")
116
+ .map(frame => frame.data)
117
+ .join("");
118
+
119
+ expect(stdout).toContain("[output truncated: shell helper exceeded");
120
+ expect(stdout).toContain("captured=3000 return=0");
121
+ expect(stdout).not.toContain("captured=3100");
122
+ });
123
+
124
+ it("caps newline-free !cmd output by bytes with a truncation notice", async () => {
125
+ const child = ["import sys", "sys.stdout.write('z' * (1024 * 1024 + 17))", "sys.stdout.flush()"].join(";");
126
+ const frames = await runCell(
127
+ [
128
+ `result = !${pythonPath} -c ${shellQuote(child)}`,
129
+ "print('capturedChars=' + str(len(result.n)) + ' return=' + str(result.returncode))",
130
+ ].join("\n"),
131
+ );
132
+ const stdout = frames
133
+ .filter(frame => frame.type === "stdout")
134
+ .map(frame => frame.data)
135
+ .join("");
136
+
137
+ expect(stdout).toContain("[output truncated: shell helper exceeded");
138
+ expect(stdout).toContain("capturedChars=1048576 return=0");
139
+ expect(stdout).not.toContain("capturedChars=1048593");
140
+ });
141
+
142
+ it("streams newline-free %%bash output without waiting for EOF", async () => {
143
+ const child = [
144
+ "import sys,time",
145
+ "sys.stdout.write('first')",
146
+ "sys.stdout.flush()",
147
+ "time.sleep(0.2)",
148
+ "sys.stdout.write('second')",
149
+ "sys.stdout.flush()",
150
+ ].join(";");
151
+ const frames = await runCell(`%%bash\n${pythonPath} -c ${shellQuote(child)}`);
152
+ const stdout = frames.filter(frame => frame.type === "stdout").map(frame => frame.data);
153
+
154
+ expect(stdout[0]).toBe("first");
155
+ expect(stdout.join("")).toBe("firstsecond");
156
+ });
157
+ });
@@ -26,14 +26,16 @@ when installed.
26
26
 
27
27
  from __future__ import annotations
28
28
 
29
- import asyncio
30
29
  import ast
31
- import contextvars
30
+ import asyncio
32
31
  import base64
33
32
  import builtins
33
+ import codecs
34
+ import contextvars
34
35
  import inspect
35
36
  import io
36
37
  import json
38
+ import locale
37
39
  import os
38
40
  import re
39
41
  import runpy
@@ -45,7 +47,7 @@ import threading
45
47
  import time
46
48
  import traceback
47
49
  from pathlib import Path
48
- from typing import Any
50
+ from typing import Any, Callable
49
51
 
50
52
  # ---------------------------------------------------------------------------
51
53
  # Frame writer
@@ -396,6 +398,158 @@ def _emit_status(op: str, **data: Any) -> None:
396
398
  return
397
399
  _emit({"type": "display", "id": rid, "bundle": bundle})
398
400
 
401
+ _SHELL_READ_CHUNK_BYTES = 8192
402
+ _SHELL_OUTPUT_MAX_BYTES = 1024 * 1024
403
+ _SHELL_OUTPUT_MAX_LINES = 3000
404
+ _SHELL_RESULT_CAPTURE_BYTES = _SHELL_OUTPUT_MAX_BYTES
405
+ _PIP_LINE_SCAN_CHARS = 64 * 1024
406
+ _SHELL_TRUNCATION_NOTICE = (
407
+ f"[output truncated: shell helper exceeded {_SHELL_OUTPUT_MAX_BYTES} bytes "
408
+ f"or {_SHELL_OUTPUT_MAX_LINES} lines; remaining output discarded]\n"
409
+ )
410
+
411
+
412
+ def _process_output_encoding() -> str:
413
+ return locale.getpreferredencoding(False) or "utf-8"
414
+
415
+
416
+ def _process_output_decoder(encoding: str) -> codecs.IncrementalDecoder:
417
+ return codecs.getincrementaldecoder(encoding)(errors="strict")
418
+
419
+
420
+ def _take_prefix_by_lines(text: str, max_lines: int) -> str:
421
+ if max_lines <= 0:
422
+ return ""
423
+ cursor = 0
424
+ for _ in range(max_lines):
425
+ newline = text.find("\n", cursor)
426
+ if newline < 0:
427
+ return text
428
+ cursor = newline + 1
429
+ return text[:cursor]
430
+
431
+
432
+ def _take_prefix_by_encoded_bytes(text: str, max_bytes: int, encoding: str) -> str:
433
+ if max_bytes <= 0:
434
+ return ""
435
+ if len(text.encode(encoding, errors="strict")) <= max_bytes:
436
+ return text
437
+ lo = 0
438
+ hi = len(text)
439
+ while lo < hi:
440
+ mid = (lo + hi + 1) // 2
441
+ if len(text[:mid].encode(encoding, errors="strict")) <= max_bytes:
442
+ lo = mid
443
+ else:
444
+ hi = mid - 1
445
+ return text[:lo]
446
+
447
+
448
+ class _ShellOutputLimiter:
449
+ def __init__(self, *, max_bytes: int, max_lines: int, encoding: str) -> None:
450
+ self._remaining_bytes = max_bytes
451
+ self._remaining_lines = max_lines
452
+ self._encoding = encoding
453
+ self._truncated = False
454
+ self._at_line_start = True
455
+
456
+ def write(self, text: str) -> None:
457
+ if not text or self._truncated:
458
+ return
459
+ limited = _take_prefix_by_lines(text, self._remaining_lines)
460
+ truncated = limited != text
461
+ byte_limited = _take_prefix_by_encoded_bytes(limited, self._remaining_bytes, self._encoding)
462
+ truncated = truncated or byte_limited != limited
463
+ if byte_limited:
464
+ sys.stdout.write(byte_limited)
465
+ sys.stdout.flush()
466
+ self._remaining_bytes -= len(byte_limited.encode(self._encoding, errors="strict"))
467
+ self._remaining_lines -= byte_limited.count("\n")
468
+ self._at_line_start = byte_limited.endswith("\n")
469
+ if truncated:
470
+ self._emit_truncation_notice()
471
+
472
+ def _emit_truncation_notice(self) -> None:
473
+ if self._truncated:
474
+ return
475
+ prefix = "" if self._at_line_start else "\n"
476
+ sys.stdout.write(prefix + _SHELL_TRUNCATION_NOTICE)
477
+ sys.stdout.flush()
478
+ self._truncated = True
479
+
480
+
481
+ def _stream_process_output(proc: subprocess.Popen, on_text: Callable[[str], None] | None = None) -> None:
482
+ assert proc.stdout is not None
483
+ encoding = _process_output_encoding()
484
+ decoder = _process_output_decoder(encoding)
485
+ limiter = _ShellOutputLimiter(
486
+ max_bytes=_SHELL_OUTPUT_MAX_BYTES,
487
+ max_lines=_SHELL_OUTPUT_MAX_LINES,
488
+ encoding=encoding,
489
+ )
490
+ while True:
491
+ chunk = os.read(proc.stdout.fileno(), _SHELL_READ_CHUNK_BYTES)
492
+ if not chunk:
493
+ break
494
+ text = decoder.decode(chunk)
495
+ if text:
496
+ limiter.write(text)
497
+ if on_text is not None:
498
+ on_text(text)
499
+ tail = decoder.decode(b"", final=True)
500
+ if tail:
501
+ limiter.write(tail)
502
+ if on_text is not None:
503
+ on_text(tail)
504
+
505
+
506
+ class _BoundedTextCapture:
507
+ def __init__(self, max_bytes: int, max_lines: int, encoding: str) -> None:
508
+ self._remaining_bytes = max_bytes
509
+ self._remaining_lines = max_lines
510
+ self._encoding = encoding
511
+ self._parts: list[str] = []
512
+
513
+ def add(self, text: str) -> None:
514
+ if self._remaining_bytes <= 0 or self._remaining_lines <= 0:
515
+ return
516
+ line_limited = _take_prefix_by_lines(text, self._remaining_lines)
517
+ part = _take_prefix_by_encoded_bytes(line_limited, self._remaining_bytes, self._encoding)
518
+ if not part:
519
+ return
520
+ self._parts.append(part)
521
+ self._remaining_bytes -= len(part.encode(self._encoding, errors="strict"))
522
+ self._remaining_lines -= part.count("\n")
523
+
524
+ def text(self) -> str:
525
+ return "".join(self._parts)
526
+
527
+
528
+ class _BoundedLineScanner:
529
+ def __init__(self, max_chars: int, on_line: Callable[[str], None]) -> None:
530
+ self._max_chars = max_chars
531
+ self._on_line = on_line
532
+ self._partial = ""
533
+
534
+ def add(self, text: str) -> None:
535
+ data = self._partial + text
536
+ lines = data.splitlines(keepends=True)
537
+ if not lines:
538
+ return
539
+ if lines[-1].endswith(("\n", "\r")):
540
+ self._partial = ""
541
+ else:
542
+ self._partial = lines.pop()
543
+ for line in lines:
544
+ self._on_line(line)
545
+ if len(self._partial) > self._max_chars:
546
+ self._partial = self._partial[-self._max_chars :]
547
+
548
+ def finish(self) -> None:
549
+ if self._partial:
550
+ self._on_line(self._partial)
551
+ self._partial = ""
552
+
399
553
 
400
554
  @line_magic("pip")
401
555
  def _magic_pip(args: str) -> None:
@@ -405,19 +559,20 @@ def _magic_pip(args: str) -> None:
405
559
  cmd,
406
560
  stdout=subprocess.PIPE,
407
561
  stderr=subprocess.STDOUT,
408
- text=True,
409
- bufsize=1,
410
562
  )
411
563
  installed_packages: list[str] = []
412
- assert proc.stdout is not None
413
- for raw_line in proc.stdout:
414
- sys.stdout.write(raw_line)
564
+
565
+ def scan_pip_line(raw_line: str) -> None:
415
566
  m = re.search(r"Successfully installed\s+(.+)$", raw_line)
416
567
  if m:
417
568
  for token in m.group(1).split():
418
569
  # Token is name-version; drop the version suffix.
419
570
  pkg = token.rsplit("-", 1)[0]
420
571
  installed_packages.append(pkg.replace("_", "-"))
572
+
573
+ scanner = _BoundedLineScanner(_PIP_LINE_SCAN_CHARS, scan_pip_line)
574
+ _stream_process_output(proc, scanner.add)
575
+ scanner.finish()
421
576
  proc.wait()
422
577
  if installed_packages:
423
578
  import importlib
@@ -599,12 +754,8 @@ def _run_shell_body(body: str, *, shell_arg: str) -> int:
599
754
  [shell_arg, "-c", body],
600
755
  stdout=subprocess.PIPE,
601
756
  stderr=subprocess.STDOUT,
602
- text=True,
603
- bufsize=1,
604
757
  )
605
- assert proc.stdout is not None
606
- for raw_line in proc.stdout:
607
- sys.stdout.write(raw_line)
758
+ _stream_process_output(proc)
608
759
  proc.wait()
609
760
  return proc.returncode
610
761
 
@@ -640,16 +791,16 @@ class _ShellResult(list):
640
791
 
641
792
 
642
793
  def __omp_shell(cmd: str) -> _ShellResult:
643
- proc = subprocess.run(
794
+ proc = subprocess.Popen(
644
795
  cmd,
645
796
  shell=True,
646
797
  stdout=subprocess.PIPE,
647
798
  stderr=subprocess.STDOUT,
648
- text=True,
649
799
  )
650
- if proc.stdout:
651
- sys.stdout.write(proc.stdout)
652
- lines = [line for line in (proc.stdout or "").splitlines()]
800
+ capture = _BoundedTextCapture(_SHELL_RESULT_CAPTURE_BYTES, _SHELL_OUTPUT_MAX_LINES, _process_output_encoding())
801
+ _stream_process_output(proc, capture.add)
802
+ proc.wait()
803
+ lines = [line for line in capture.text().splitlines()]
653
804
  return _ShellResult(lines, proc.returncode)
654
805
 
655
806
 
@@ -33,9 +33,7 @@ export function createExtensionModelQuery(
33
33
  resolveModelRoleValue(spec, modelRegistry.getAvailable(), {
34
34
  settings,
35
35
  matchPreferences: getModelMatchPreferences(settings),
36
- modelRegistry,
37
36
  }).model,
38
- family: (model: Model<Api>): string =>
39
- modelFamilyToken(modelRegistry.getCanonicalId(model) ?? model.id) || model.provider.toLowerCase(),
37
+ family: (model: Model<Api>): string => modelFamilyToken(model.id) || model.provider.toLowerCase(),
40
38
  };
41
39
  }
@@ -98,6 +98,32 @@ function handlerTimeoutForEvent(eventType: string): number {
98
98
 
99
99
  const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
100
100
 
101
+ /**
102
+ * Race `work` against a `timeoutMs` budget, clearing the pending timer the
103
+ * instant the work settles.
104
+ *
105
+ * We deliberately avoid `Bun.sleep(timeoutMs).then(...)` here: that leaves an
106
+ * uncancellable timer registered with the event loop, so every successful
107
+ * handler race leaks a timer that keeps the process alive until the deadline
108
+ * fires — up to the default 30s cap, which stalls non-interactive CLI exit
109
+ * after any subscribed `tool_call`/`tool_result` handler runs (issue #3948
110
+ * review, `chatgpt-codex-connector[bot]`). `setTimeout` returns a handle we
111
+ * can `clearTimeout` on the winning branch.
112
+ */
113
+ async function raceHandlerWithTimeout<T>(
114
+ work: Promise<T>,
115
+ timeoutMs: number,
116
+ ): Promise<T | typeof EXTENSION_HANDLER_TIMEOUT> {
117
+ const { promise: timeoutPromise, resolve: resolveTimeout } =
118
+ Promise.withResolvers<typeof EXTENSION_HANDLER_TIMEOUT>();
119
+ const timer = setTimeout(() => resolveTimeout(EXTENSION_HANDLER_TIMEOUT), timeoutMs);
120
+ try {
121
+ return await Promise.race([work, timeoutPromise]);
122
+ } finally {
123
+ clearTimeout(timer);
124
+ }
125
+ }
126
+
101
127
  const MAX_PENDING_CREDENTIAL_DISABLED = 32;
102
128
 
103
129
  /**
@@ -555,10 +581,7 @@ export class ExtensionRunner {
555
581
  timeoutMs: number,
556
582
  ): Promise<TResult | undefined> {
557
583
  try {
558
- const handlerResult = await Promise.race([
559
- Promise.resolve(handler(event, ctx)),
560
- Bun.sleep(timeoutMs).then(() => EXTENSION_HANDLER_TIMEOUT),
561
- ]);
584
+ const handlerResult = await raceHandlerWithTimeout(Promise.resolve(handler(event, ctx)), timeoutMs);
562
585
  if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
563
586
  const error = `handler timed out after ${timeoutMs}ms`;
564
587
  logger.warn("Extension handler timed out", {
@@ -693,8 +716,24 @@ export class ExtensionRunner {
693
716
  };
694
717
  }
695
718
 
719
+ /**
720
+ * Emit a `tool_call` event to every subscribed extension before the tool executes.
721
+ *
722
+ * Each handler is bounded by `extensionHandlerTimeoutMs` (default 30s). This
723
+ * matches the timeout policy already applied to `emitToolResult` and every
724
+ * other handler routed through `#runHandlerWithTimeout`; without it a single
725
+ * hung extension (unresolved `await`, network call with no timeout) would
726
+ * park `ExtensionToolWrapper.execute` indefinitely and freeze tool
727
+ * dispatch — see issue #3948.
728
+ *
729
+ * On-timeout policy: **fail-closed** (return `{ block: true }`). This is
730
+ * symmetric with the existing error path below and safer for a
731
+ * pre-execution gate — an unresponsive extension MUST NOT be treated as
732
+ * silent consent to run the tool.
733
+ */
696
734
  async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
697
735
  const ctx = this.createContext();
736
+ const timeoutMs = extensionHandlerTimeoutMs;
698
737
  let result: ToolCallEventResult | undefined;
699
738
 
700
739
  for (const ext of this.extensions) {
@@ -703,7 +742,25 @@ export class ExtensionRunner {
703
742
 
704
743
  for (const handler of handlers) {
705
744
  try {
706
- const handlerResult = await handler(event, ctx);
745
+ const handlerResult = await raceHandlerWithTimeout(Promise.resolve(handler(event, ctx)), timeoutMs);
746
+
747
+ if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
748
+ const error = `handler timed out after ${timeoutMs}ms`;
749
+ logger.warn("Extension handler timed out", {
750
+ extensionPath: ext.path,
751
+ event: "tool_call",
752
+ timeoutMs,
753
+ });
754
+ this.emitError({
755
+ extensionPath: ext.path,
756
+ event: "tool_call",
757
+ error,
758
+ });
759
+ return {
760
+ block: true,
761
+ reason: `Extension ${ext.path} timed out after ${timeoutMs}ms`,
762
+ };
763
+ }
707
764
 
708
765
  if (handlerResult) {
709
766
  result = handlerResult as ToolCallEventResult;