Merge branch 'main' into fix-ask-option-descriptions

This commit is contained in:
Can Bölük
2026-06-01 18:14:42 +03:00
committed by GitHub
95 changed files with 3038 additions and 412 deletions
Generated
+6 -6
View File
@@ -2331,7 +2331,7 @@ dependencies = [
[[package]]
name = "pi-ast"
version = "15.7.4"
version = "15.7.5"
dependencies = [
"anyhow",
"ast-grep-core",
@@ -2399,7 +2399,7 @@ dependencies = [
[[package]]
name = "pi-iso"
version = "15.7.4"
version = "15.7.5"
dependencies = [
"async-trait",
"libc",
@@ -2411,7 +2411,7 @@ dependencies = [
[[package]]
name = "pi-natives"
version = "15.7.4"
version = "15.7.5"
dependencies = [
"anyhow",
"arboard",
@@ -2457,7 +2457,7 @@ dependencies = [
[[package]]
name = "pi-shell"
version = "15.7.4"
version = "15.7.5"
dependencies = [
"anyhow",
"brush-builtins",
@@ -3886,9 +3886,9 @@ dependencies = [
[[package]]
name = "tree-sitter-swift"
version = "0.7.2"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3b98fb6bc8e6a6a10023f401aa6a1858115e849dfaf7de57dd8b8ea0f257bd9"
checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c"
dependencies = [
"cc",
"tree-sitter-language",
+1 -1
View File
@@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"]
resolver = "3"
[workspace.package]
version = "15.7.4"
version = "15.7.5"
edition = "2024"
license = "MIT"
authors = ["Can Boluk"]
+23 -27
View File
@@ -15,7 +15,7 @@
},
"packages/agent": {
"name": "@oh-my-pi/pi-agent-core",
"version": "15.7.4",
"version": "15.7.5",
"dependencies": {
"@oh-my-pi/pi-ai": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
@@ -30,7 +30,7 @@
},
"packages/ai": {
"name": "@oh-my-pi/pi-ai",
"version": "15.7.4",
"version": "15.7.5",
"dependencies": {
"@anthropic-ai/sdk": "catalog:",
"@bufbuild/protobuf": "catalog:",
@@ -45,7 +45,7 @@
},
"packages/coding-agent": {
"name": "@oh-my-pi/pi-coding-agent",
"version": "15.7.4",
"version": "15.7.5",
"bin": {
"omp": "src/cli.ts",
},
@@ -85,7 +85,7 @@
},
"packages/hashline": {
"name": "@oh-my-pi/hashline",
"version": "15.7.4",
"version": "15.7.5",
"dependencies": {
"diff": "catalog:",
"lru-cache": "catalog:",
@@ -96,7 +96,7 @@
},
"packages/mnemopi": {
"name": "@oh-my-pi/pi-mnemopi",
"version": "15.7.4",
"version": "15.7.5",
"bin": {
"mnemopi": "src/cli.ts",
},
@@ -113,7 +113,7 @@
},
"packages/natives": {
"name": "@oh-my-pi/pi-natives",
"version": "15.7.4",
"version": "15.7.5",
"devDependencies": {
"@napi-rs/cli": "catalog:",
"@types/bun": "catalog:",
@@ -121,7 +121,7 @@
},
"packages/stats": {
"name": "@oh-my-pi/omp-stats",
"version": "15.7.4",
"version": "15.7.5",
"bin": {
"omp-stats": "./src/index.ts",
},
@@ -146,7 +146,7 @@
},
"packages/swarm-extension": {
"name": "@oh-my-pi/swarm-extension",
"version": "15.7.4",
"version": "15.7.5",
"bin": {
"omp-swarm": "src/cli.ts",
},
@@ -162,7 +162,7 @@
},
"packages/tui": {
"name": "@oh-my-pi/pi-tui",
"version": "15.7.4",
"version": "15.7.5",
"dependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
@@ -203,7 +203,7 @@
},
"packages/utils": {
"name": "@oh-my-pi/pi-utils",
"version": "15.7.4",
"version": "15.7.5",
"dependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"beautiful-mermaid": "catalog:",
@@ -244,15 +244,15 @@
"@huggingface/transformers": "^4.2.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.7.0",
"@oh-my-pi/hashline": "15.7.4",
"@oh-my-pi/omp-stats": "15.7.4",
"@oh-my-pi/pi-agent-core": "15.7.4",
"@oh-my-pi/pi-ai": "15.7.4",
"@oh-my-pi/pi-coding-agent": "15.7.4",
"@oh-my-pi/pi-mnemopi": "15.7.4",
"@oh-my-pi/pi-natives": "15.7.4",
"@oh-my-pi/pi-tui": "15.7.4",
"@oh-my-pi/pi-utils": "15.7.4",
"@oh-my-pi/hashline": "15.7.5",
"@oh-my-pi/omp-stats": "15.7.5",
"@oh-my-pi/pi-agent-core": "15.7.5",
"@oh-my-pi/pi-ai": "15.7.5",
"@oh-my-pi/pi-coding-agent": "15.7.5",
"@oh-my-pi/pi-mnemopi": "15.7.5",
"@oh-my-pi/pi-natives": "15.7.5",
"@oh-my-pi/pi-tui": "15.7.5",
"@oh-my-pi/pi-utils": "15.7.5",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/context-async-hooks": "^2.7.1",
"@opentelemetry/sdk-trace-base": "^2.7.1",
@@ -911,7 +911,7 @@
"duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
"electron-to-chromium": ["electron-to-chromium@1.5.361", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="],
"elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="],
@@ -921,7 +921,7 @@
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
"enhanced-resolve": ["enhanced-resolve@5.22.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A=="],
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@@ -1145,7 +1145,7 @@
"onnxruntime-web": ["onnxruntime-web@1.26.0-dev.20260416-b7804b056c", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw=="],
"openai": ["openai@6.39.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-O61LIsimY3acVabwvomwFhwrnN36yvHY2quIfy9keEcFytGgWeV35yLHQ6NVMLSBxRpHmcg2yuhCnlu2HT4pLQ=="],
"openai": ["openai@6.39.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A=="],
"option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
@@ -1247,7 +1247,7 @@
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -1403,8 +1403,6 @@
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
@@ -1419,8 +1417,6 @@
"fastembed/onnxruntime-node/tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="],
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
+1 -1
View File
@@ -68,5 +68,5 @@ use napi_derive::napi;
/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
/// `packages/natives/native/index.js` (which derives the name from
/// `package.json#version`).
#[napi(js_name = "__piNativesV15_7_4")]
#[napi(js_name = "__piNativesV15_7_5")]
pub const fn pi_natives_version_sentinel() {}
+10 -9
View File
@@ -190,15 +190,16 @@ OAuth host chain: `KIMI_CODE_OAUTH_HOST` → `KIMI_OAUTH_HOST` → `https://auth
### OpenAI Codex responses (feature/debug controls)
| Variable | Behavior |
| ------------------------------------ | ---------------------------------------------------- |
| `PI_CODEX_DEBUG` | `1`/`true` enables Codex provider debug logging |
| `PI_CODEX_WEBSOCKET` | `1`/`true` enables websocket transport preference |
| `PI_CODEX_WEBSOCKET_V2` | `1`/`true` enables websocket v2 path |
| `PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS` | Positive integer override (default 300000) |
| `PI_CODEX_WEBSOCKET_RETRY_BUDGET` | Non-negative integer override (default 5) |
| `PI_CODEX_WEBSOCKET_RETRY_DELAY_MS` | Positive integer base backoff override (default 500) |
| `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` | Positive integer OpenAI stream idle timeout override |
| Variable | Behavior |
| ------------------------------------------ | ---------------------------------------------------- |
| `PI_CODEX_DEBUG` | `1`/`true` enables Codex provider debug logging |
| `PI_CODEX_WEBSOCKET` | `1`/`true` enables websocket transport preference |
| `PI_CODEX_WEBSOCKET_V2` | `1`/`true` enables websocket v2 path |
| `PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS` | Positive integer override (default 300000) |
| `PI_CODEX_WEBSOCKET_RETRY_BUDGET` | Non-negative integer override (default 5) |
| `PI_CODEX_WEBSOCKET_RETRY_DELAY_MS` | Positive integer base backoff override (default 500) |
| `PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS` | Positive integer OpenAI first-event timeout override |
| `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` | Positive integer OpenAI stream idle timeout override |
### Cursor provider debug
+9 -9
View File
@@ -21,15 +21,15 @@
"@huggingface/transformers": "^4.2.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.7.0",
"@oh-my-pi/hashline": "15.7.4",
"@oh-my-pi/omp-stats": "15.7.4",
"@oh-my-pi/pi-agent-core": "15.7.4",
"@oh-my-pi/pi-ai": "15.7.4",
"@oh-my-pi/pi-coding-agent": "15.7.4",
"@oh-my-pi/pi-mnemopi": "15.7.4",
"@oh-my-pi/pi-natives": "15.7.4",
"@oh-my-pi/pi-tui": "15.7.4",
"@oh-my-pi/pi-utils": "15.7.4",
"@oh-my-pi/hashline": "15.7.5",
"@oh-my-pi/omp-stats": "15.7.5",
"@oh-my-pi/pi-agent-core": "15.7.5",
"@oh-my-pi/pi-ai": "15.7.5",
"@oh-my-pi/pi-coding-agent": "15.7.5",
"@oh-my-pi/pi-mnemopi": "15.7.5",
"@oh-my-pi/pi-natives": "15.7.5",
"@oh-my-pi/pi-tui": "15.7.5",
"@oh-my-pi/pi-utils": "15.7.5",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/context-async-hooks": "^2.7.1",
"@opentelemetry/sdk-trace-base": "^2.7.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-agent-core",
"version": "15.7.4",
"version": "15.7.5",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"homepage": "https://omp.sh",
"author": "Can Boluk",
+10
View File
@@ -2,6 +2,16 @@
## [Unreleased]
## [15.7.5] - 2026-06-01
### Added
- Added Anthropic task budget support, forwarding `taskBudget` as `output_config.task_budget` with the required `task-budgets-2026-03-13` beta header and accepting Anthropic gateway requests that send `output_config.task_budget`.
### Fixed
- Fixed OpenAI-family first-event timeouts so `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` cannot be undercut by a lower generic `PI_STREAM_FIRST_EVENT_TIMEOUT_MS` while local OpenAI-compatible servers are still processing large prompts. `PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS` is now available for an explicit OpenAI-specific first-event override. ([#1603](https://github.com/can1357/oh-my-pi/issues/1603))
## [15.7.4] - 2026-05-31
### Fixed
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-ai",
"version": "15.7.4",
"version": "15.7.5",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"homepage": "https://omp.sh",
"author": "Can Boluk",
+1
View File
@@ -141,6 +141,7 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort
if (options.reasoning !== undefined) opts.reasoning = options.reasoning;
if (options.disableReasoning !== undefined) opts.disableReasoning = options.disableReasoning;
if (options.hideThinkingSummary !== undefined) opts.hideThinkingSummary = options.hideThinkingSummary;
if (options.taskBudget !== undefined) opts.taskBudget = options.taskBudget;
if (options.serviceTier !== undefined) opts.serviceTier = options.serviceTier;
if (options.cacheRetention !== undefined) opts.cacheRetention = options.cacheRetention;
// Client-supplied `prompt_cache_key` wins; otherwise derive a stable
+10 -1
View File
@@ -1,5 +1,12 @@
import type { Effort } from "../model-thinking";
import type { AssistantMessage, AssistantMessageEventStream, CacheRetention, Context, ServiceTier } from "../types";
import type {
AssistantMessage,
AssistantMessageEventStream,
CacheRetention,
Context,
ServiceTier,
TokenTaskBudget,
} from "../types";
/**
* Wire types for the omp auth-gateway.
@@ -61,6 +68,8 @@ export interface AuthGatewayParsedRequestOptions {
thinkingBudgets?: Partial<Record<Effort, number>>;
/** Suppress the provider's reasoning summary stream. */
hideThinkingSummary?: boolean;
/** Anthropic `output_config.task_budget` advisory loop budget. */
taskBudget?: TokenTaskBudget;
// ── Service / routing ─────────────────────────────────────────────────
/** OpenAI service tier (auto|default|flex|scale|priority). */
File diff suppressed because it is too large Load Diff
@@ -189,6 +189,18 @@ export const thinkingConfigSchema = z.discriminatedUnion("type", [
}),
]);
const taskBudgetSchema = z.object({
type: z.literal("tokens"),
total: z.number(),
remaining: z.number().optional(),
});
const outputConfigSchema = z.object({
effort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional(),
task_budget: taskBudgetSchema.optional(),
format: z.unknown().optional(),
});
// ─── Top-level request ─────────────────────────────────────────────────────
export const anthropicMessagesRequestSchema = z.object({
@@ -204,6 +216,7 @@ export const anthropicMessagesRequestSchema = z.object({
stop_sequences: z.array(z.string()).optional(),
stream: z.boolean().optional(),
thinking: thinkingConfigSchema.optional(),
output_config: outputConfigSchema.optional(),
// Anthropic clients commonly send `metadata: { user_id }`; the walker
// surfaces it on `options.metadata` for downstream provider forwarding.
metadata: z.record(z.string(), z.unknown()).optional(),
@@ -344,6 +344,9 @@ export function parseRequest(body: unknown, headers?: Headers): ParsedRequest {
break;
}
}
if (data.output_config?.task_budget) {
options.taskBudget = data.output_config.task_budget;
}
const cacheRetention = deriveCacheRetention(data);
if (cacheRetention !== undefined) options.cacheRetention = cacheRetention;
// Anthropic clients commonly send `metadata: { user_id }`; forward verbatim
+28 -4
View File
@@ -47,6 +47,7 @@ import type {
StreamOptions,
TextContent,
ThinkingContent,
TokenTaskBudget,
Tool,
ToolCall,
ToolResultMessage,
@@ -123,6 +124,7 @@ const claudeCodeBetaDefaults = [
const fineGrainedToolStreamingBeta = "fine-grained-tool-streaming-2025-05-14";
const interleavedThinkingBeta = "interleaved-thinking-2025-05-14";
const fastModeBeta = "fast-mode-2026-02-01";
const taskBudgetBeta = "task-budgets-2026-03-13";
function getHeaderCaseInsensitive(headers: Record<string, string> | undefined, headerName: string): string | undefined {
if (!headers) return undefined;
@@ -217,6 +219,16 @@ type AnthropicSamplingParams = MessageCreateParamsStreaming & {
top_k?: number;
};
type AnthropicOutputConfig = NonNullable<MessageCreateParamsStreaming["output_config"]> & {
task_budget?: TokenTaskBudget | null;
};
function getAnthropicOutputConfig(params: MessageCreateParamsStreaming): AnthropicOutputConfig {
const outputConfig = (params.output_config ?? {}) as AnthropicOutputConfig;
params.output_config = outputConfig as typeof params.output_config;
return outputConfig;
}
const ANTHROPIC_STOP_SEQUENCES_MAX = 4;
let warnedStopSequencesTrim = false;
@@ -1150,6 +1162,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
if (wantsAnthropicPriority && !extraBetas.includes(fastModeBeta)) {
extraBetas.push(fastModeBeta);
}
if (options?.taskBudget && !extraBetas.includes(taskBudgetBeta)) {
extraBetas.push(taskBudgetBeta);
}
const created = createClient(model, {
model,
@@ -1779,8 +1794,14 @@ function createClient(
function disableThinkingIfToolChoiceForced(params: MessageCreateParamsStreaming): void {
const toolChoice = params.tool_choice;
if (!toolChoice) return;
if (toolChoice.type === "any" || toolChoice.type === "tool") {
delete params.thinking;
if (toolChoice.type !== "any" && toolChoice.type !== "tool") return;
delete params.thinking;
const outputConfig = params.output_config as AnthropicOutputConfig | undefined;
if (!outputConfig) return;
delete outputConfig.effort;
if (Object.keys(outputConfig).length === 0) {
delete params.output_config;
}
}
@@ -2107,7 +2128,7 @@ function buildParams(
if (effort) {
// SDK's OutputConfig.effort type is not yet widened to include the new "xhigh"
// level introduced with Claude Opus 4.7. Cast until the SDK catches up.
params.output_config = { effort } as typeof params.output_config;
getAnthropicOutputConfig(params).effort = effort;
}
} else {
params.thinking = {
@@ -2116,7 +2137,7 @@ function buildParams(
display: options.thinkingDisplay ?? "summarized",
} as typeof params.thinking;
if (mode === "anthropic-budget-effort" && effort) {
params.output_config = { effort } as typeof params.output_config;
getAnthropicOutputConfig(params).effort = effort;
}
}
} else if (options?.thinkingEnabled === false) {
@@ -2124,6 +2145,9 @@ function buildParams(
}
}
if (options?.taskBudget) {
getAnthropicOutputConfig(params).task_budget = options.taskBudget;
}
const metadataUserId = resolveAnthropicMetadataUserId(options?.metadata?.user_id, isOAuthToken);
if (metadataUserId) {
params.metadata = { user_id: metadataUserId };
@@ -22,8 +22,8 @@ import { createAbortSourceTracker } from "../utils/abort";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import {
getOpenAIStreamFirstEventTimeoutMs,
getOpenAIStreamIdleTimeoutMs,
getStreamFirstEventTimeoutMs,
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
@@ -122,7 +122,8 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
const params = buildParams(model, context, options, deploymentName, baseUrl);
options?.onPayload?.(params);
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const firstEventTimeoutMs =
options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
rawRequestDump = {
@@ -49,8 +49,8 @@ import {
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import {
getOpenAIStreamFirstEventTimeoutMs,
getOpenAIStreamIdleTimeoutMs,
getStreamFirstEventTimeoutMs,
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse";
@@ -603,7 +603,7 @@ function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): C
: requestAbortController.signal;
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
const websocketIdleTimeoutMs = options?.streamIdleTimeoutMs ?? getCodexWebSocketIdleTimeoutMs();
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
const websocketFirstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getCodexWebSocketFirstEventTimeoutMs();
const wrapCodexSseStream = (
source: AsyncGenerator<Record<string, unknown>>,
@@ -46,8 +46,8 @@ import {
rewriteCopilotError,
} from "../utils/http-inspector";
import {
getOpenAIStreamFirstEventTimeoutMs,
getOpenAIStreamIdleTimeoutMs,
getStreamFirstEventTimeoutMs,
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse";
@@ -421,10 +421,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const idleTimeoutMs =
options?.streamIdleTimeoutMs ??
getOpenAIStreamIdleTimeoutMs(getOpenAICompletionsStreamIdleTimeoutFallbackMs(model));
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const idleTimeoutFallbackMs = getOpenAICompletionsStreamIdleTimeoutFallbackMs(model);
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(idleTimeoutFallbackMs);
const firstEventTimeoutMs =
options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
const {
@@ -33,8 +33,8 @@ import { createAbortSourceTracker } from "../utils/abort";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
import {
getOpenAIStreamFirstEventTimeoutMs,
getOpenAIStreamIdleTimeoutMs,
getStreamFirstEventTimeoutMs,
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
@@ -228,7 +228,8 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
const { params } = buildParams(model, context, options, providerSessionState, baseUrl);
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const firstEventTimeoutMs =
options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
options?.onPayload?.(params);
@@ -418,7 +418,10 @@ export const streamGoogleGeminiCli = createLazyStream(
GOOGLE_GEMINI_CLI_LAZY_STREAM_LIMITS,
);
export const streamGoogleVertex = createLazyStream(loadGoogleVertexProviderModule);
export const streamOpenAICodexResponses = createLazyStream(loadOpenAICodexResponsesProviderModule);
export const streamOpenAICodexResponses = createLazyStream(
loadOpenAICodexResponsesProviderModule,
PROVIDER_HANDLED_STREAM_TIMEOUTS,
);
export const streamOpenAICompletions = createLazyStream(
loadOpenAICompletionsProviderModule,
PROVIDER_HANDLED_STREAM_TIMEOUTS,
+1
View File
@@ -727,6 +727,7 @@ function mapOptionsForApi<TApi extends Api>(
initiatorOverride: options?.initiatorOverride,
maxRetryDelayMs: options?.maxRetryDelayMs,
metadata: options?.metadata,
taskBudget: options?.taskBudget,
sessionId: options?.sessionId,
promptCacheKey: options?.promptCacheKey,
streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,
+16
View File
@@ -153,6 +153,12 @@ import type { Effort } from "./model-thinking";
/** Token budgets for each thinking level (token-based providers only) */
export type ThinkingBudgets = { [key in Effort]?: number };
export interface TokenTaskBudget {
type: "tokens";
total: number;
remaining?: number;
}
export type MessageAttribution = "user" | "agent";
export type ToolChoice =
@@ -319,6 +325,11 @@ export interface StreamOptions {
* For example, Anthropic uses `user_id` for abuse tracking and rate limiting.
*/
metadata?: Record<string, unknown>;
/**
* Advisory token budget for a full agentic loop. Anthropic encodes this as
* `output_config.task_budget` with the `task-budgets-2026-03-13` beta header.
*/
taskBudget?: TokenTaskBudget;
/**
* Optional session identifier for providers that support session-based
* routing, request affinity, or transport reuse. Providers may also use this
@@ -363,6 +374,11 @@ export interface StreamOptions {
* `0` to disable both layers for this request. After the first semantic
* event arrives, `streamIdleTimeoutMs` governs inter-event stalls. Falls
* back to `PI_STREAM_FIRST_EVENT_TIMEOUT_MS` and then to a 100s default.
* OpenAI-family transports additionally honor
* `PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS` as the most-specific override and
* floor the first-event budget at the resolved idle (per-call
* `streamIdleTimeoutMs` or `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS`) so slow local
* OpenAI-compatible servers are not undercut during prompt processing.
*
* Iterator-level honored by: every built-in provider (via the lazy-stream
* forwarder in `register-builtins`). SDK-request honored by:
+27
View File
@@ -58,6 +58,33 @@ export function getStreamFirstEventTimeoutMs(
return normalizeIdleTimeoutMs($env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS, fallback);
}
/**
* Returns the first-event timeout used for OpenAI-family streaming transports.
*
* Precedence: explicit `PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS` (including a
* `"0"` disable) wins outright. Otherwise the resolved idle (caller-supplied
* `idleTimeoutMs` — which itself already encompasses per-call
* `streamIdleTimeoutMs` or `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` resolved
* upstream) floors the first-event budget so slow local OpenAI-compatible
* servers are not undercut by a shorter `PI_STREAM_FIRST_EVENT_TIMEOUT_MS`
* or the global default during prompt processing.
*
* Returns `undefined` when an explicit env knob disables the watchdog.
*/
export function getOpenAIStreamFirstEventTimeoutMs(
idleTimeoutMs?: number,
fallbackMs: number = DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS,
): number | undefined {
const openAIFirstEventRaw = $env.PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS;
if (openAIFirstEventRaw !== undefined) {
return normalizeIdleTimeoutMs(openAIFirstEventRaw, fallbackMs);
}
const base = normalizeIdleTimeoutMs($env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS, fallbackMs);
if (base === undefined) return undefined;
if (idleTimeoutMs === undefined || idleTimeoutMs <= 0) return base;
return Math.max(base, idleTimeoutMs);
}
export interface IdleTimeoutIteratorOptions {
idleTimeoutMs?: number;
firstItemTimeoutMs?: number;
+1 -1
View File
@@ -11,7 +11,7 @@ const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
const TOKEN_URL = "https://api.anthropic.com/v1/oauth/token";
const CALLBACK_PORT = 54545;
const CALLBACK_PATH = "/callback";
const SCOPES = "org:create_api_key user:profile user:inference";
const SCOPES = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
function formatErrorDetails(error: unknown): string {
if (error instanceof Error) {
+82 -1
View File
@@ -18,7 +18,7 @@ import {
stripClaudeToolPrefix,
} from "@oh-my-pi/pi-ai/providers/anthropic";
import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream";
import type { Context, Model, TJsonSchema, Tool } from "@oh-my-pi/pi-ai/types";
import type { Context, Model, TJsonSchema, TokenTaskBudget, Tool } from "@oh-my-pi/pi-ai/types";
import * as z from "zod/v4";
import { withEnv } from "./helpers";
@@ -57,6 +57,8 @@ type CaptureAnthropicOptions = {
temperature?: number;
topP?: number;
topK?: number;
taskBudget?: TokenTaskBudget;
toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string };
};
function captureAnthropicPayload(
@@ -75,6 +77,8 @@ function captureAnthropicPayload(
temperature: options?.temperature,
topP: options?.topP,
topK: options?.topK,
taskBudget: options?.taskBudget,
toolChoice: options?.toolChoice,
onPayload: payload => resolve(payload),
});
return promise;
@@ -1047,6 +1051,83 @@ describe("Anthropic request fingerprint alignment", () => {
expect(payload.output_config).toEqual({ effort: "xhigh" });
});
it("sends task budgets through Anthropic output_config without dropping adaptive effort", async () => {
const payload = (await captureAnthropicPayload(
{
...ANTHROPIC_MODEL,
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
thinking: {
mode: "anthropic-adaptive",
minLevel: Effort.Minimal,
maxLevel: Effort.XHigh,
},
},
{
systemPrompt: ["Stay concise."],
messages: [{ role: "user", content: "Review this repo", timestamp: Date.now() }],
},
{
thinkingEnabled: true,
reasoning: Effort.High,
taskBudget: { type: "tokens", total: 64_000, remaining: 48_000 },
},
)) as {
output_config?: {
effort?: string;
task_budget?: TokenTaskBudget;
};
};
expect(payload.output_config).toEqual({
effort: "xhigh",
task_budget: { type: "tokens", total: 64_000, remaining: 48_000 },
});
});
it("preserves task budget when forced tool choice disables thinking", async () => {
const payload = (await captureAnthropicPayload(
{
...ANTHROPIC_MODEL,
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
thinking: {
mode: "anthropic-adaptive",
minLevel: Effort.Minimal,
maxLevel: Effort.XHigh,
},
},
{
systemPrompt: ["Stay concise."],
messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }],
tools: [
{
name: "lookup",
description: "Lookup a value",
parameters: { type: "object", properties: {}, additionalProperties: false },
},
],
},
{
thinkingEnabled: true,
reasoning: Effort.High,
taskBudget: { type: "tokens", total: 64_000 },
toolChoice: "any",
},
)) as {
thinking?: unknown;
output_config?: {
effort?: string;
task_budget?: TokenTaskBudget;
};
};
expect(payload.thinking).toBeUndefined();
expect(payload.output_config).toEqual({
task_budget: { type: "tokens", total: 64_000 },
});
});
it("treats tool prefix helpers as no-ops when prefix is empty", () => {
expect(applyClaudeToolPrefix("Read", "")).toBe("Read");
expect(stripClaudeToolPrefix("proxy_Read", "")).toBe("proxy_Read");
+3 -1
View File
@@ -20,7 +20,9 @@ describe("anthropic oauth alignment", () => {
const authUrl = new URL(url);
expect(authUrl.origin + authUrl.pathname).toBe("https://claude.ai/oauth/authorize");
expect(authUrl.searchParams.get("scope")).toBe("org:create_api_key user:profile user:inference");
expect(authUrl.searchParams.get("scope")).toBe(
"user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
);
expect(authUrl.searchParams.get("state")).toBe(state);
expect(authUrl.searchParams.get("redirect_uri")).toBe(redirectUri);
expect(authUrl.searchParams.get("code_challenge_method")).toBe("S256");
@@ -62,6 +62,7 @@ describe("anthropic-messages parseRequest", () => {
stop_sequences: ["\n\n"],
tool_choice: { type: "any" },
thinking: { type: "enabled", budget_tokens: 2048 },
output_config: { task_budget: { type: "tokens", total: 64_000, remaining: 60_000 } },
system: [
{ type: "text", text: "You are X" },
{ type: "text", text: "Be brief." },
@@ -119,6 +120,7 @@ describe("anthropic-messages parseRequest", () => {
expect(parsed.options.stopSequences).toEqual(["\n\n"]);
expect(parsed.options.toolChoice).toBe("required");
expect(parsed.options.explicitThinkingBudgetTokens).toBe(2048);
expect(parsed.options.taskBudget).toEqual({ type: "tokens", total: 64_000, remaining: 60_000 });
expect(parsed.options.extra).toBeUndefined();
expect(parsed.context.tools).toHaveLength(1);
@@ -320,6 +320,71 @@ describe("OpenAI-family first-event timeouts", () => {
);
});
it("lets PI_OPENAI_STREAM_IDLE_TIMEOUT_MS widen OpenAI responses first-event request setup", async () => {
const previousOpenAIIdleTimeout = Bun.env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS;
const previousGenericFirstEventTimeout = Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS;
const timeoutHeaders: string[] = [];
Bun.env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS = "1500";
Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS = "20";
global.fetch = createDelayedFetch(30, createOpenAIResponsesSuccessResponse, (input, init) => {
timeoutHeaders.push(getRequestHeader(input, init, "X-Stainless-Timeout") ?? "");
});
try {
const result = await streamOpenAIResponses(openAIResponsesModel, baseContext(), {
apiKey: "test-key",
}).result();
expect(result.stopReason).toBe("stop");
expect(getFirstTextContent(result)).toMatchObject({ type: "text", text: "Hello delayed" });
expect(timeoutHeaders).toContain("1");
} finally {
if (previousOpenAIIdleTimeout === undefined) {
delete Bun.env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS;
} else {
Bun.env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS = previousOpenAIIdleTimeout;
}
if (previousGenericFirstEventTimeout === undefined) {
delete Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS;
} else {
Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS = previousGenericFirstEventTimeout;
}
}
});
it("honors PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS even when caller pins streamIdleTimeoutMs", async () => {
const previousOpenAIFirstEventTimeout = Bun.env.PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS;
const previousGenericFirstEventTimeout = Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS;
const timeoutHeaders: string[] = [];
Bun.env.PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS = "1500";
Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS = "20";
global.fetch = createDelayedFetch(30, createOpenAIResponsesSuccessResponse, (input, init) => {
timeoutHeaders.push(getRequestHeader(input, init, "X-Stainless-Timeout") ?? "");
});
try {
const result = await streamOpenAIResponses(openAIResponsesModel, baseContext(), {
apiKey: "test-key",
streamIdleTimeoutMs: 5_000,
}).result();
expect(result.stopReason).toBe("stop");
expect(getFirstTextContent(result)).toMatchObject({ type: "text", text: "Hello delayed" });
expect(timeoutHeaders).toContain("1");
} finally {
if (previousOpenAIFirstEventTimeout === undefined) {
delete Bun.env.PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS;
} else {
Bun.env.PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS = previousOpenAIFirstEventTimeout;
}
if (previousGenericFirstEventTimeout === undefined) {
delete Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS;
} else {
Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS = previousGenericFirstEventTimeout;
}
}
});
it("times out OpenAI responses streams that only emit no-progress status events", async () => {
global.fetch = ((input: string | URL | Request, init?: RequestInit) =>
Promise.resolve(createNoProgressOpenAIResponsesStream(getRequestSignal(input, init)))) as typeof fetch;
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import {
getOpenAIStreamFirstEventTimeoutMs,
getOpenAIStreamIdleTimeoutMs,
getStreamFirstEventTimeoutMs,
getStreamIdleTimeoutMs,
@@ -19,6 +20,7 @@ const ENV_KEYS = [
"PI_STREAM_IDLE_TIMEOUT_MS",
"PI_OPENAI_STREAM_IDLE_TIMEOUT_MS",
"PI_STREAM_FIRST_EVENT_TIMEOUT_MS",
"PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS",
] as const;
const originalEnv: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
@@ -102,6 +104,34 @@ describe("getStreamFirstEventTimeoutMs(idleTimeoutMs, fallbackMs)", () => {
});
});
describe("getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs, fallbackMs)", () => {
it("floors the first-event budget at the caller-resolved idle when the generic env is lower", () => {
Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS = "20";
expect(getOpenAIStreamFirstEventTimeoutMs(1500, 100_000)).toBe(1500);
});
it("honors PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS even when caller pins per-call idle", () => {
Bun.env.PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS = "42";
Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS = "20";
expect(getOpenAIStreamFirstEventTimeoutMs(5_000, 100_000)).toBe(42);
});
it("treats PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS=0 as an explicit watchdog disable", () => {
Bun.env.PI_OPENAI_STREAM_FIRST_EVENT_TIMEOUT_MS = "0";
expect(getOpenAIStreamFirstEventTimeoutMs(1500, 100_000)).toBeUndefined();
});
it("falls back to the generic first-event env when OpenAI env vars are unset", () => {
Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS = "42";
expect(getOpenAIStreamFirstEventTimeoutMs(undefined, 300_000)).toBe(42);
});
it("respects PI_STREAM_FIRST_EVENT_TIMEOUT_MS=0 disable when no OpenAI override is set", () => {
Bun.env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS = "0";
expect(getOpenAIStreamFirstEventTimeoutMs(1500, 100_000)).toBeUndefined();
});
});
async function expectRejectsWithMessage(run: () => Promise<void>, message: string): Promise<void> {
let caught: unknown;
try {
+30 -2
View File
@@ -1,14 +1,42 @@
# Changelog
## [Unreleased]
### Added
- Added `ask` option descriptions so agents can keep short labels and render explanatory text as separate muted rows in the selector.
### Fixed
- Fixed long outlined `ask` selector options wrapping instead of truncating their tails.
- Fixed the `eval` tool aborting in-flight `agent()`/`parallel()` subagents and `llm()` requests by mistaking them for a stalled cell. The per-cell `timeout` is an *inactivity* budget that only re-arms on status events, but a host-side bridge call can legitimately run long stretches with no intermediate status (a subagent's time-to-first-token on a reasoning model, a long quiet nested tool, or an entire oneshot `llm()` request). Those calls now pump a lightweight heartbeat while they await, re-arming the idle watchdog through the existing status channel; the heartbeat is a pure keepalive and is never persisted or rendered, so a genuinely stalled cell is still interrupted once the call settles.
## [15.7.5] - 2026-06-01
### Fixed
- Fixed streaming assistant responses leaving duplicated tail rows in WSL/Windows Terminal scrollback by enabling eager native-scrollback rebuilds while assistant text is actively streaming ([#1615](https://github.com/can1357/oh-my-pi/issues/1615)).
### Fixed
- Fixed the `task` tool mangling subagent prompts when a model double-JSON-encodes a string argument: `context` and each task's `assignment`/`description` are now repaired when they arrive uniformly double-escaped (literal `\n`, `\"`, `\uXXXX`), so the subagent receives the intended prose and the call preview renders real newlines. The repair is guarded by a JSON-string round-trip and a double-encode signature, so legitimate backslashes/quotes (Windows paths, regexes, embedded quotes) are left untouched, and it is scoped to these natural-language fields only (never code-bearing tools).
### Fixed
- Fixed `pr://` PR views omitting formal review submissions and approvals when comments are enabled ([#1600](https://github.com/can1357/oh-my-pi/issues/1600)).
### Fixed
- Fixed subagent yield-reminder loop logging benign user/compaction aborts as `ERROR`. The catch around `session.prompt`/`waitForIdle` in `task/executor.ts` now demotes `ToolAbortError` and signal-aborted exits to `debug` and keeps `ERROR` for genuine prompt failures only ([#1623](https://github.com/can1357/oh-my-pi/issues/1623)).
### Fixed
- Fixed `read local://<file>` resolving to the wrong session's artifacts directory in multi-session ACP hosts (e.g. cmux). `LocalProtocolHandler.resolve` now honors `context.localProtocolOptions` supplied by the calling tool before falling back to the process-wide override or the first `main`-kind session in the global `AgentRegistry`; `read`, `find`, `search`, `ast_grep`, and `ast_edit` thread their session's options through so a `local://PLAN.md` lookup hits the calling session's `local` root instead of a sibling session's ([#1608](https://github.com/can1357/oh-my-pi/issues/1608)).
### Fixed
- Fixed `omp` segfaulting on exit on Windows after the tiny title/memory model loaded `onnxruntime-node` (issue [#1606](https://github.com/can1357/oh-my-pi/issues/1606)). The tiny model now runs in a Bun subprocess instead of a Worker thread, so the NAPI finalizer that crashes during shutdown never executes in the agent's address space; the subprocess is `SIGKILL`'d on dispose to skip every native destructor on every platform.
### Fixed
- Fixed unbounded MCP reconnect loop that could fork-bomb the host when a stdio MCP server completes the `initialize`/`tools/list` handshake and then exits. `MCPManager` now enforces a per-server crash circuit breaker (5 reconnects per 30 s window) on the automatic `transport.onClose` path; manual `/mcp reconnect` resets the window so users can recover after fixing the misconfiguration ([#1592](https://github.com/can1357/oh-my-pi/issues/1592)).
## [15.7.4] - 2026-05-31
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-coding-agent",
"version": "15.7.4",
"version": "15.7.5",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"homepage": "https://omp.sh",
"author": "Can Boluk",
@@ -56,7 +56,6 @@ async function main(): Promise<void> {
"../stats/src/sync-worker.ts",
"./src/tools/browser/tab-worker-entry.ts",
"./src/eval/js/worker-entry.ts",
"./src/tiny/worker.ts",
// Legacy pi-* extension compat entrypoints served by
// `legacy-pi-compat.ts`. These are reached via computed bunfs paths
// (which `--compile`'s static analyzer cannot trace), so each must be
+48
View File
@@ -54,12 +54,60 @@ async function runSmokeTest(): Promise<void> {
process.stdout.write("smoke-test: ok\n");
}
/**
* Hidden subcommand that boots the tiny-model worker inside this process
* over the parent's IPC channel. The agent's main process spawns the same
* binary with this flag so `onnxruntime-node` (loaded transitively by
* `@huggingface/transformers`) lives in a child address space. The parent
* `SIGKILL`s the child on shutdown so the NAPI finalizer never runs in
* either process — that finalizer segfaults Bun on Windows (issue #1606).
*/
async function runTinyWorker(): Promise<void> {
const { startTinyTitleWorker } = await import("./tiny/worker");
const { promise: shuttingDown, resolve: shutdown } = Promise.withResolvers<void>();
const send = (message: unknown): void => {
// `process.send` only exists when spawned with an IPC channel; the
// parent always spawns us that way. If it's missing, the parent
// vanished and there's no one to talk to.
const sender = (process as NodeJS.Process & { send?: (m: unknown) => boolean }).send;
if (!sender) {
shutdown();
return;
}
try {
sender.call(process, message);
} catch {
shutdown();
}
};
startTinyTitleWorker({
send,
onMessage(handler) {
const wrap = (data: unknown): void => handler(data as never);
process.on("message", wrap);
return () => {
process.off("message", wrap);
};
},
});
// Parent went away (crashed, SIGKILL, etc.) — commit suicide so we don't
// linger as an orphan. SIGKILL via `process.kill` keeps us symmetrical
// with the parent's hard-kill on shutdown: skip every JS/native finalizer.
process.on("disconnect", () => shutdown());
await shuttingDown;
process.kill(process.pid, "SIGKILL");
}
/** Run the CLI with the given argv (no `process.argv` prefix). */
export async function runCli(argv: string[]): Promise<void> {
if (argv[0] === "--smoke-test") {
await runSmokeTest();
return;
}
if (argv[0] === "--tiny-worker") {
await runTinyWorker();
return;
}
// --help and --version are handled by run() directly, don't rewrite those.
// Everything else that isn't a known subcommand routes to "launch".
const first = argv[0];
@@ -10,6 +10,8 @@ import { AgentOutputManager } from "../../task/output-manager";
import type { AgentDefinition, AgentProgress, SingleResult } from "../../task/types";
import type { ToolSession } from "../../tools";
import { EVAL_AGENT_MAX_DEPTH, runEvalAgent } from "../agent-bridge";
import { setBridgeHeartbeatIntervalMs } from "../heartbeat";
import { IdleTimeout } from "../idle-timeout";
import { disposeAllVmContexts } from "../js/context-manager";
import { executeJs } from "../js/executor";
import { disposeAllKernelSessions, executePython } from "../py/executor";
@@ -232,6 +234,7 @@ describe("runEvalAgent", () => {
describe("agent() through eval runtimes", () => {
afterEach(() => {
vi.restoreAllMocks();
setBridgeHeartbeatIntervalMs();
});
afterAll(async () => {
@@ -430,4 +433,32 @@ describe("agent() through eval runtimes", () => {
);
expect(displayAgentEvents.length).toBe(2);
});
it("keeps the idle watchdog armed while a quiet agent() runs past the budget", async () => {
using tempDir = TempDir.createSync("@omp-eval-agent-heartbeat-");
const { session } = makeEvalSession(tempDir, "js-agent-heartbeat");
mockAgents();
// Heartbeat cadence well under the idle budget so a working-but-silent
// subagent re-arms the watchdog several times before it could expire.
setBridgeHeartbeatIntervalMs(15);
// runSubprocess runs far past the budget and emits NO progress of its own
// — the only thing standing between the subagent and a spurious idle abort
// is the heartbeat keepalive the bridge pumps while it awaits.
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
await Bun.sleep(200);
return singleResult(options, { output: "done" });
});
// Mirror the eval tool's wiring: an IdleTimeout drives cancellation and
// every status event re-arms it.
using idle = new IdleTimeout(60);
const result = await runEvalAgent(
{ prompt: "investigate" },
{ session, signal: idle.signal, emitStatus: () => idle.bump() },
);
expect(idle.signal.aborted).toBe(false);
expect(result.text).toBe("done");
});
});
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it } from "bun:test";
import { EVAL_HEARTBEAT_OP, setBridgeHeartbeatIntervalMs, withBridgeHeartbeat } from "../heartbeat";
import type { JsStatusEvent } from "../js/shared/types";
describe("withBridgeHeartbeat", () => {
afterEach(() => {
setBridgeHeartbeatIntervalMs();
});
it("pumps heartbeat events on cadence while the operation is pending, then stops", async () => {
setBridgeHeartbeatIntervalMs(20);
const events: JsStatusEvent[] = [];
const value = await withBridgeHeartbeat(
event => events.push(event),
async () => {
await Bun.sleep(130);
return "done";
},
);
expect(value).toBe("done");
// ~6 ticks fit in 130ms at a 20ms cadence; assert it ticked repeatedly
// without pinning the exact count (scheduler jitter).
expect(events.length).toBeGreaterThanOrEqual(3);
expect(events.every(event => event.op === EVAL_HEARTBEAT_OP)).toBe(true);
// The interval is cleared once the operation settles: no further ticks.
const settledCount = events.length;
await Bun.sleep(80);
expect(events.length).toBe(settledCount);
});
it("runs the operation without emitting when no status sink is wired", async () => {
setBridgeHeartbeatIntervalMs(5);
let ran = 0;
const value = await withBridgeHeartbeat(undefined, async () => {
ran++;
await Bun.sleep(40);
return 42;
});
expect(value).toBe(42);
expect(ran).toBe(1);
});
it("clears the heartbeat even when the operation throws", async () => {
setBridgeHeartbeatIntervalMs(15);
const events: JsStatusEvent[] = [];
await expect(
withBridgeHeartbeat(
event => events.push(event),
async () => {
await Bun.sleep(60);
throw new Error("boom");
},
),
).rejects.toThrow("boom");
const afterThrow = events.length;
await Bun.sleep(60);
expect(events.length).toBe(afterThrow);
});
});
@@ -8,6 +8,8 @@ import type { ModelRegistry } from "../../config/model-registry";
import { Settings } from "../../config/settings";
import type { ToolSession } from "../../tools";
import { ToolError } from "../../tools/tool-errors";
import { setBridgeHeartbeatIntervalMs } from "../heartbeat";
import { IdleTimeout } from "../idle-timeout";
import { disposeAllVmContexts } from "../js/context-manager";
import { executeJs } from "../js/executor";
import { runEvalLlm } from "../llm-bridge";
@@ -97,6 +99,7 @@ function assistant(opts: {
describe("runEvalLlm", () => {
afterEach(() => {
vi.restoreAllMocks();
setBridgeHeartbeatIntervalMs();
});
it("resolves each tier to its expected model", async () => {
@@ -213,6 +216,26 @@ describe("runEvalLlm", () => {
ToolError,
);
});
it("keeps the idle watchdog armed while a slow llm() request is in flight", async () => {
// A oneshot completion emits no status until it returns; a slow request
// must not look like a stalled cell. The bridge pumps a heartbeat while it
// awaits, re-arming the watchdog through emitStatus.
setBridgeHeartbeatIntervalMs(15);
vi.spyOn(ai, "completeSimple").mockImplementation(async () => {
await Bun.sleep(200);
return assistant({ text: "the answer" });
});
using idle = new IdleTimeout(60);
const result = await runEvalLlm(
{ prompt: "q", model: "smol" },
{ session: makeSession(), signal: idle.signal, emitStatus: () => idle.bump() },
);
expect(idle.signal.aborted).toBe(false);
expect(result.text).toBe("the answer");
});
});
describe("llm() through eval runtimes", () => {
+44 -38
View File
@@ -16,6 +16,7 @@ import { AgentOutputManager } from "../task/output-manager";
import type { AgentDefinition, AgentProgress } from "../task/types";
import type { ToolSession } from "../tools";
import { ToolError } from "../tools/tool-errors";
import { withBridgeHeartbeat } from "./heartbeat";
import type { JsStatusEvent } from "./js/shared/types";
// Import review tools for side effects (registers subagent tool handlers).
import "../tools/review";
@@ -231,44 +232,49 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
const id = await outputManager.allocate(outputIdBase(parsed.label, agentName));
const assignment = parsed.prompt.trim();
const context = trimToUndefined(parsed.context);
const result = await taskExecutor.runSubprocess({
cwd: options.session.cwd,
agent: effectiveAgent,
task: renderSubagentPrompt(assignment),
assignment,
context,
description: trimToUndefined(parsed.label),
index: 0,
id,
taskDepth: options.session.taskDepth ?? 0,
modelOverride,
parentActiveModelPattern,
thinkingLevel: effectiveAgent.thinkingLevel,
outputSchema: structured ? parsed.schema : undefined,
sessionFile,
persistArtifacts: Boolean(sessionFile),
artifactsDir,
contextFile,
enableLsp: (options.session.enableLsp ?? true) && options.session.settings.get("task.enableLsp"),
signal: options.signal,
eventBus: options.session.eventBus,
onProgress: progress => emitProgressStatus(options.emitStatus, progress),
authStorage: options.session.authStorage,
modelRegistry: options.session.modelRegistry,
settings: options.session.settings,
mcpManager,
contextFiles,
skills: availableSkills,
autoloadSkills: resolvedAutoloadSkills,
workspaceTree: options.session.workspaceTree,
promptTemplates: options.session.promptTemplates,
localProtocolOptions,
parentArtifactManager,
parentHindsightSessionState: options.session.getHindsightSessionState?.(),
parentMnemopiSessionState: options.session.getMnemopiSessionState?.(),
parentTelemetry: options.session.getTelemetry?.(),
parentEvalSessionId,
});
// Pump a heartbeat while the subagent runs so the eval idle watchdog stays
// armed across quiet stretches (time-to-first-token, long nested tools)
// where `onProgress` would otherwise emit no status to re-arm it.
const result = await withBridgeHeartbeat(options.emitStatus, () =>
taskExecutor.runSubprocess({
cwd: options.session.cwd,
agent: effectiveAgent,
task: renderSubagentPrompt(assignment),
assignment,
context,
description: trimToUndefined(parsed.label),
index: 0,
id,
taskDepth: options.session.taskDepth ?? 0,
modelOverride,
parentActiveModelPattern,
thinkingLevel: effectiveAgent.thinkingLevel,
outputSchema: structured ? parsed.schema : undefined,
sessionFile,
persistArtifacts: Boolean(sessionFile),
artifactsDir,
contextFile,
enableLsp: (options.session.enableLsp ?? true) && options.session.settings.get("task.enableLsp"),
signal: options.signal,
eventBus: options.session.eventBus,
onProgress: progress => emitProgressStatus(options.emitStatus, progress),
authStorage: options.session.authStorage,
modelRegistry: options.session.modelRegistry,
settings: options.session.settings,
mcpManager,
contextFiles,
skills: availableSkills,
autoloadSkills: resolvedAutoloadSkills,
workspaceTree: options.session.workspaceTree,
promptTemplates: options.session.promptTemplates,
localProtocolOptions,
parentArtifactManager,
parentHindsightSessionState: options.session.getHindsightSessionState?.(),
parentMnemopiSessionState: options.session.getMnemopiSessionState?.(),
parentTelemetry: options.session.getTelemetry?.(),
parentEvalSessionId,
}),
);
if (result.exitCode !== 0 || result.error) {
const failureMessage =
@@ -0,0 +1,66 @@
/**
* Keepalive for in-flight host-side eval bridge calls.
*
* The eval idle watchdog ({@link ../tools/eval IdleTimeout}) treats a cell's
* `timeout` as an *inactivity* budget and only re-arms when a status event
* reaches it. Host-side bridge helpers — `agent()`/`parallel()` (via
* `runSubprocess`) and `llm()` (a single completion) — can legitimately run for
* long stretches with **no** intermediate status: a subagent's time-to-first
* token on a reasoning model, a long quiet nested tool, or the entire body of a
* oneshot `llm()` call. Without a keepalive the watchdog mistakes that work for
* a stall and aborts the cell mid-flight, killing the subagent.
*
* {@link withBridgeHeartbeat} fixes that by pumping a synthetic
* {@link EVAL_HEARTBEAT_OP} status event on a fixed cadence while the wrapped
* operation is pending. The event rides the same `emitStatus → onStatus` channel
* both runtimes already forward, so it re-arms the watchdog without any new
* plumbing. Consumers MUST treat the heartbeat as a pure keepalive: bump the
* watchdog and drop it (never persist or render it) — see the executor display
* sinks and the eval tool's `onStatus` handler.
*/
import type { JsStatusEvent } from "./js/shared/types";
/**
* Synthetic status op emitted purely to keep the eval idle watchdog alive while
* a host-side bridge call is in flight. Carries no payload.
*/
export const EVAL_HEARTBEAT_OP = "heartbeat";
/**
* Heartbeat cadence. Comfortably below the default 30s idle budget (and the
* larger budgets long fanouts run under), so a working bridge call always bumps
* the watchdog before it expires, while a genuine stall is still bounded once
* the call settles and the heartbeat stops.
*/
const HEARTBEAT_INTERVAL_MS = 5_000;
let heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS;
/**
* Test seam: override the heartbeat cadence so integration tests can exercise
* the keepalive within a sub-second idle budget. Pass no value to restore the
* production default.
*/
export function setBridgeHeartbeatIntervalMs(ms?: number): void {
heartbeatIntervalMs = ms === undefined ? HEARTBEAT_INTERVAL_MS : Math.max(1, Math.floor(ms));
}
/**
* Run {@link operation}, pumping {@link EVAL_HEARTBEAT_OP} status events through
* {@link emitStatus} on a fixed cadence until it settles. A no-op wrapper when
* no `emitStatus` sink is wired (the heartbeat would reach nobody).
*/
export async function withBridgeHeartbeat<T>(
emitStatus: ((event: JsStatusEvent) => void) | undefined,
operation: () => Promise<T>,
): Promise<T> {
if (!emitStatus) return operation();
const timer = setInterval(() => emitStatus({ op: EVAL_HEARTBEAT_OP }), heartbeatIntervalMs);
// Never keep the event loop alive for the heartbeat alone.
timer.unref?.();
try {
return await operation();
} finally {
clearInterval(timer);
}
}
@@ -1,6 +1,7 @@
import { DEFAULT_MAX_BYTES, OutputSink } from "../../session/streaming-output";
import type { ToolSession } from "../../tools";
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
import { EVAL_HEARTBEAT_OP } from "../heartbeat";
import { executeInVmContext, type JsDisplayOutput } from "./context-manager";
import type { JsStatusEvent } from "./shared/types";
@@ -105,8 +106,13 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
signal,
onText: chunk => outputSink.push(chunk),
onDisplay: output => {
if (output.type === "status") {
// Heartbeats are pure idle-watchdog keepalives: forward them so
// the eval tool re-arms its timer, but never store or render them.
options.onStatus?.(output.event);
if (output.event.op === EVAL_HEARTBEAT_OP) return;
}
displayOutputs.push(output);
if (output.type === "status") options.onStatus?.(output.event);
},
},
});
+20 -14
View File
@@ -18,6 +18,7 @@ import { extractTextContent, extractToolCall, parseJsonPayload } from "../commit
import { expandRoleAlias, formatModelString, resolveModelFromString } from "../config/model-resolver";
import type { ToolSession } from "../tools";
import { ToolError } from "../tools/tool-errors";
import { withBridgeHeartbeat } from "./heartbeat";
import type { JsStatusEvent } from "./js/shared/types";
/** Synthetic bridge name reserved for the `llm()` helper across both runtimes. */
@@ -131,20 +132,25 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
const telemetry = resolveTelemetry(options.session.getTelemetry?.(), options.session.getSessionId?.() ?? undefined);
const response = await instrumentedCompleteSimple(
model,
{
systemPrompt: system ? [system] : undefined,
messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
tools,
},
{
apiKey,
signal: options.signal,
reasoning: reasoningForTier(tier, model),
toolChoice: schema ? { type: "tool", name: STRUCTURED_TOOL_NAME } : undefined,
},
{ telemetry, oneshotKind: "eval_llm" },
// A oneshot completion emits no status until it returns, so pump a heartbeat
// while it runs to keep the eval idle watchdog armed across a slow (e.g.
// reasoning-tier) request that would otherwise look like a stalled cell.
const response = await withBridgeHeartbeat(options.emitStatus, () =>
instrumentedCompleteSimple(
model,
{
systemPrompt: system ? [system] : undefined,
messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
tools,
},
{
apiKey,
signal: options.signal,
reasoning: reasoningForTier(tier, model),
toolChoice: schema ? { type: "tool", name: STRUCTURED_TOOL_NAME } : undefined,
},
{ telemetry, oneshotKind: "eval_llm" },
),
);
if (response.stopReason === "error") {
@@ -5,6 +5,7 @@ import { Settings } from "../../config/settings";
import { OutputSink } from "../../session/streaming-output";
import type { ToolSession } from "../../tools";
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
import { EVAL_HEARTBEAT_OP } from "../heartbeat";
import type { JsStatusEvent } from "../js/shared/types";
import {
checkPythonKernelAvailability,
@@ -496,8 +497,13 @@ async function executeWithKernel(
// Collect every display output and, for status events, stream them live so
// long-running bridge helpers (e.g. `agent()`) surface progress mid-cell.
const collectDisplay = (output: KernelDisplayOutput) => {
if (output.type === "status") {
// Heartbeats are pure idle-watchdog keepalives: forward them so the
// eval tool re-arms its timer, but never store or render them.
options?.onStatus?.(output.event);
if (output.event.op === EVAL_HEARTBEAT_OP) return;
}
displayOutputs.push(output);
if (output.type === "status") options?.onStatus?.(output.event);
};
const emitStatus = options?.emitStatus ?? ((event: JsStatusEvent) => collectDisplay({ type: "status", event }));
const runId = `py-${crypto.randomUUID()}`;
@@ -5,7 +5,7 @@ import { isEnoent } from "@oh-my-pi/pi-utils";
import { AgentRegistry } from "../registry/agent-registry";
import { parseInternalUrl } from "./parse";
import { validateRelativePath } from "./skill-protocol";
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
export interface LocalProtocolOptions {
getArtifactsDir?: () => string | null;
@@ -164,13 +164,25 @@ export class LocalProtocolHandler implements ProtocolHandler {
* Returns the active local-protocol options.
*
* Resolution order:
* 1. Explicit override installed via {@link setOverride} (used by subagents
* that share their parent's root and by SDK consumers with a custom
* artifacts/session id mapping).
* 2. The main session in `AgentRegistry.global()`. Its `SessionManager`
* supplies both `getArtifactsDir` and `getSessionId`.
* 1. **Caller-supplied** `context.localProtocolOptions` (the actual session
* that initiated the `read`/`find`/`search`/`router.resolve` call). This
* is what keeps `local://` reads pinned to the calling session in
* multi-session hosts (cmux/ACP, embedded SDK consumers) where every
* session registers as `kind: "main"` and "first one wins" would route
* to the wrong artifacts directory.
* 2. Explicit process-global override installed via {@link setOverride}
* (used by SDK consumers with a custom artifacts/session-id mapping and
* by code paths that do not have a calling session, e.g. TUI hyperlink
* resolution).
* 3. The first `main`-kind session in `AgentRegistry.global()`. Its
* `SessionManager` supplies both `getArtifactsDir` and `getSessionId`.
* Last-resort fallback — every caller that has a session reference
* SHOULD thread it through `context` so this branch is never taken in
* multi-session setups.
*/
static resolveOptions(): LocalProtocolOptions | undefined {
static resolveOptions(context?: ResolveContext): LocalProtocolOptions | undefined {
const fromContext = context?.localProtocolOptions;
if (fromContext) return fromContext;
const override = LocalProtocolHandler.#override;
if (override) return override;
const main = AgentRegistry.global()
@@ -184,8 +196,8 @@ export class LocalProtocolHandler implements ProtocolHandler {
};
}
async resolve(url: InternalUrl): Promise<InternalResource> {
const opts = LocalProtocolHandler.resolveOptions();
async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
const opts = LocalProtocolHandler.resolveOptions(context);
if (!opts) {
throw new Error("No session - local:// unavailable");
}
@@ -247,8 +259,8 @@ export class LocalProtocolHandler implements ProtocolHandler {
};
}
async complete(): Promise<UrlCompletion[]> {
const opts = LocalProtocolHandler.resolveOptions();
async complete(_query?: string, context?: ResolveContext): Promise<UrlCompletion[]> {
const opts = LocalProtocolHandler.resolveOptions(context);
if (!opts) return [];
const localRoot = path.resolve(resolveLocalRoot(opts));
try {
@@ -5,6 +5,8 @@
* providing access to agent outputs and server resources without exposing filesystem paths.
*/
import type { LocalProtocolOptions } from "./local-protocol";
/**
* Raw resource payload returned by protocol handlers. The `immutable` flag is
* applied by the router from {@link ProtocolHandler.immutable}, so handlers do
@@ -77,6 +79,17 @@ export interface ResolveContext {
settings?: unknown;
/** Caller's abort signal. */
signal?: AbortSignal;
/**
* Calling session's `local://` root mapping. When present, the local-protocol
* handler resolves the URL against THIS session's artifacts dir instead of
* picking the first `main`-kind session from the global `AgentRegistry`.
*
* Required for correctness in multi-session hosts (cmux/ACP, embedded SDK
* consumers) where multiple sessions are registered as `main` and the
* "first one wins" lookup picks the wrong artifacts directory — see
* [#1608](https://github.com/can1357/oh-my-pi/issues/1608).
*/
localProtocolOptions?: LocalProtocolOptions;
}
/**
@@ -89,6 +102,8 @@ export interface WriteContext {
cwd?: string;
/** Caller's abort signal. */
signal?: AbortSignal;
/** Calling session's `local://` root mapping — see {@link ResolveContext.localProtocolOptions}. */
localProtocolOptions?: LocalProtocolOptions;
}
/**
+87 -4
View File
@@ -59,6 +59,27 @@ type TrackedPromise<T> = {
const STARTUP_TIMEOUT_MS = 250;
/**
* Per-server reconnect-storm circuit breaker.
*
* `transport.onClose` (wired in {@link MCPManager.connectServers} and
* {@link MCPManager.#connectAndWireServer}) fires `reconnectServer` on every
* clean process exit, so a stdio MCP server that completes the
* `initialize` + `tools/list` handshake and then exits will pull the agent
* into a fork loop with no rate limit. That pathology shipped in issue #1592
* (a `php`-shebang MCP fork-bombing macOS, parented directly to the agent's
* `bun` PID via shebang exec).
*
* We keep the sliding window short — older crashes age out so a single
* transient failure stays cheap — but cap the burst tightly enough that the
* agent never spawns more than `RECONNECT_BURST_LIMIT * #doReconnect retries`
* (≤ 25) processes per stuck server per window. Manual `/mcp reconnect`
* resets the window so users can recover after fixing the underlying
* misconfiguration.
*/
const RECONNECT_BURST_WINDOW_MS = 30_000;
const RECONNECT_BURST_LIMIT = 5;
function trackPromise<T>(promise: Promise<T>): TrackedPromise<T> {
const tracked: TrackedPromise<T> = { promise, status: "pending" };
promise.then(
@@ -166,6 +187,11 @@ export class MCPManager {
#pendingReconnections = new Map<string, Promise<MCPServerConnection | null>>();
/** Preserved configs for reconnection after connection loss. */
#serverConfigs = new Map<string, MCPServerConfig>();
/**
* Timestamps of recent `reconnectServer` invocations per server, used by the
* crash-storm circuit breaker (see {@link RECONNECT_BURST_LIMIT}).
*/
#reconnectHistory = new Map<string, number[]>();
/** Monotonic epoch incremented on disconnectAll to invalidate stale reconnections. */
#epoch = 0;
@@ -666,6 +692,7 @@ export class MCPManager {
this.#sources.delete(name);
this.#serverConfigs.delete(name);
this.#pendingResourceRefresh.delete(name);
this.#reconnectHistory.delete(name);
const connection = this.#connections.get(name);
@@ -714,24 +741,80 @@ export class MCPManager {
this.#connections.clear();
this.#tools = [];
this.#subscribedResources.clear();
this.#reconnectHistory.clear();
}
/**
* Reconnect to a server after a connection failure.
*
* Tears down the stale connection, re-resolves auth, establishes a new
* connection, reloads tools, and notifies consumers.
* Concurrent calls for the same server share one reconnection attempt.
* Returns the new connection, or null if reconnection failed.
* connection, reloads tools, and notifies consumers. Concurrent calls for
* the same server share one reconnection attempt. Returns the new
* connection, or `null` if reconnection failed or the per-server crash
* burst limit (see {@link RECONNECT_BURST_LIMIT}) is exceeded.
*
* @param options.manual - When `true`, resets the crash-burst window so a
* user-driven retry (e.g. `/mcp reconnect`) is never blocked by an
* earlier storm. Defaults to `false`; the transport `onClose` callback
* and the per-tool-call retry path in `tool-bridge` MUST NOT set it.
*/
async reconnectServer(name: string): Promise<MCPServerConnection | null> {
async reconnectServer(name: string, options?: { manual?: boolean }): Promise<MCPServerConnection | null> {
if (options?.manual) {
this.#reconnectHistory.delete(name);
}
const pending = this.#pendingReconnections.get(name);
if (pending) return pending;
if (this.#tripReconnectBreaker(name)) {
return null;
}
const attempt = this.#doReconnect(name);
this.#pendingReconnections.set(name, attempt);
return attempt.finally(() => this.#pendingReconnections.delete(name));
}
/**
* Record a reconnect attempt against the per-server crash window and report
* whether the circuit breaker is now open. Sliding window: entries older
* than {@link RECONNECT_BURST_WINDOW_MS} are pruned before the new
* timestamp is appended, so a single transient failure ages out cheaply
* but repeated rapid crashes accumulate until the limit is hit.
*/
#tripReconnectBreaker(name: string): boolean {
const now = Date.now();
const previous = this.#reconnectHistory.get(name) ?? [];
const recent = previous.filter(ts => now - ts < RECONNECT_BURST_WINDOW_MS);
recent.push(now);
this.#reconnectHistory.set(name, recent);
if (recent.length > RECONNECT_BURST_LIMIT) {
logger.error("MCP server crashed too many times; suspending automatic reconnects", {
path: `mcp:${name}`,
crashes: recent.length,
windowMs: RECONNECT_BURST_WINDOW_MS,
});
// Tear down the stale connection so `getConnectionStatus()` no
// longer reports it as "connected" and `waitForConnection()` does
// not hand a closed transport to callers. Tools stay registered
// in `#tools` — the user can recover with `/mcp reconnect <name>`
// once they've fixed the underlying misconfiguration. Mirrors the
// teardown in `#doReconnect`: detach `onClose` first so the
// transport's own `close()` cannot re-arm this path.
const stale = this.#connections.get(name);
if (stale) {
stale.transport.onClose = undefined;
void stale.transport.close().catch(() => {});
this.#connections.delete(name);
}
this.#pendingConnections.delete(name);
this.#pendingToolLoads.delete(name);
return true;
}
return false;
}
async #doReconnect(name: string): Promise<MCPServerConnection | null> {
const oldConnection = this.#connections.get(name);
const config = oldConnection?.config ?? this.#serverConfigs.get(name);
@@ -141,6 +141,14 @@ export interface ToolExecutionHandle {
setExpanded(expanded: boolean): void;
}
/** Drive pending-tool redraws at ~60fps so the animated border sweep is smooth.
* The TUI already throttles at its 16ms `MIN_RENDER_INTERVAL_MS`, so this is the
* natural upper bound and static frames diff to a no-op redraw at ~zero cost. */
const SPINNER_RENDER_INTERVAL_MS = 16;
/** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the
* 60fps render cadence (mirrors `Loader`). */
const SPINNER_GLYPH_ADVANCE_MS = 80;
/**
* Component that renders a tool call with its result (updateable)
*/
@@ -177,6 +185,7 @@ export class ToolExecutionComponent extends Container {
// Spinner animation for partial task results
#spinnerFrame?: number;
#spinnerInterval?: NodeJS.Timeout;
#lastSpinnerAdvanceAt = 0;
// Todo write completion strikethrough reveal animation
#todoStrikeInterval?: NodeJS.Timeout;
// Track if args are still being streamed (for edit/write spinner)
@@ -404,13 +413,20 @@ export class ToolExecutionComponent extends Container {
this.#isPartial && shimmerEnabled() && (this.#toolName === "bash" || this.#toolName === "eval");
const needsSpinner = isStreamingArgs || isPartialTask || isPendingExecBlock;
if (needsSpinner && !this.#spinnerInterval) {
this.#lastSpinnerAdvanceAt = performance.now();
this.#spinnerInterval = setInterval(() => {
const now = performance.now();
const frameCount = theme.spinnerFrames.length;
if (frameCount === 0) return;
this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
this.#renderState.spinnerFrame = this.#spinnerFrame;
// Redraw at ~60fps for a smooth border sweep, but only step the spinner
// glyph at its classic ~12.5fps cadence. The TUI throttles renders at
// 16ms and the differ drops no-op redraws, so the extra ticks are free.
if (frameCount > 0 && now - this.#lastSpinnerAdvanceAt >= SPINNER_GLYPH_ADVANCE_MS) {
this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
this.#renderState.spinnerFrame = this.#spinnerFrame;
this.#lastSpinnerAdvanceAt = now;
}
this.#ui.requestRender();
}, 80);
}, SPINNER_RENDER_INTERVAL_MS);
} else if (!needsSpinner && this.#spinnerInterval) {
clearInterval(this.#spinnerInterval);
this.#spinnerInterval = undefined;
@@ -25,12 +25,14 @@ type AgentSessionEventKind = AgentSessionEvent["type"];
const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
// Events that change which foreground tools are executing, or that reset a turn.
// The eager native-scrollback rebuild mode is recomputed only on these — other
// events (assistant text streaming, IRC, notices) leave it untouched so plain
// streaming keeps the no-yank deferral.
const TOOL_RENDER_MODE_EVENTS: Record<string, true> = {
// Events that change foreground streaming state, or that reset a turn. The TUI
// eager native-scrollback rebuild mode is recomputed only on these so unrelated
// IRC/notices/status refreshes do not toggle scrollback replay policy.
const STREAM_RENDER_MODE_EVENTS: Record<string, true> = {
agent_start: true,
agent_end: true,
message_start: true,
message_end: true,
tool_execution_start: true,
tool_execution_update: true,
tool_execution_end: true,
@@ -46,6 +48,7 @@ export class EventController {
#renderedCustomMessages = new Set<string>();
#lastIntent: string | undefined = undefined;
#backgroundToolCallIds = new Set<string>();
#assistantMessageStreaming = false;
#readToolCallArgs = new Map<string, Record<string, unknown>>();
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
#lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
@@ -169,24 +172,27 @@ export class EventController {
const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
await run(event);
// While a foreground tool is executing, its streaming result re-renders and can
// re-lay-out rows that already scrolled into native scrollback. Let the TUI
// rebuild history on those offscreen edits (a snap to the tail is acceptable
// mid-tool) instead of deferring, which would leave stale/duplicated rows.
// Background-running tools are excluded so their late async updates — and the
// assistant text that streams alongside them — keep the no-yank deferral;
// agent_start resets the mode at every turn boundary.
if (TOOL_RENDER_MODE_EVENTS[event.type]) {
// While assistant text or a foreground tool is streaming, rows above the
// viewport can re-layout after they have already entered native scrollback
// (Markdown fences, wrapping, previews). Let the TUI rebuild history on
// those offscreen edits instead of deferring, which otherwise leaves stale
// tail rows duplicated above the live viewport.
// Background-running tools are excluded so late async updates outside the
// active foreground stream keep the no-yank deferral; agent_start resets
// the mode at every turn boundary.
if (STREAM_RENDER_MODE_EVENTS[event.type]) {
this.#refreshToolRenderMode();
}
}
#refreshToolRenderMode(): void {
let foregroundToolActive = false;
for (const toolCallId of this.ctx.pendingTools.keys()) {
if (!this.#backgroundToolCallIds.has(toolCallId)) {
foregroundToolActive = true;
break;
let foregroundToolActive = this.#assistantMessageStreaming;
if (!foregroundToolActive) {
for (const toolCallId of this.ctx.pendingTools.keys()) {
if (!this.#backgroundToolCallIds.has(toolCallId)) {
foregroundToolActive = true;
break;
}
}
}
this.ctx.ui.setEagerNativeScrollbackRebuild(foregroundToolActive);
@@ -196,6 +202,7 @@ export class EventController {
this.#lastIntent = undefined;
this.#readToolCallArgs.clear();
this.#readToolCallAssistantComponents.clear();
this.#assistantMessageStreaming = false;
this.#lastAssistantComponent = undefined;
if (this.ctx.retryEscapeHandler) {
this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
@@ -268,6 +275,7 @@ export class EventController {
this.ctx.ui.requestRender();
} else if (event.message.role === "assistant") {
this.#lastThinkingCount = 0;
this.#assistantMessageStreaming = true;
this.#resetReadGroup();
this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock, () =>
this.ctx.ui.requestRender(),
@@ -414,6 +422,9 @@ export class EventController {
async #handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>): Promise<void> {
if (event.message.role === "user") return;
if (event.message.role === "assistant") {
this.#assistantMessageStreaming = false;
}
if (this.ctx.streamingComponent && event.message.role === "assistant") {
this.ctx.streamingMessage = event.message;
let errorMessage: string | undefined;
@@ -596,8 +607,8 @@ export class EventController {
}
}
}
async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
this.#assistantMessageStreaming = false;
if (this.ctx.loadingAnimation) {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
@@ -1425,7 +1425,7 @@ export class MCPCommandController {
this.#showMessage(["", theme.fg("muted", `Reconnecting to "${name}"...`), ""].join("\n"));
try {
const connection = await this.ctx.mcpManager.reconnectServer(name);
const connection = await this.ctx.mcpManager.reconnectServer(name, { manual: true });
if (connection) {
// refreshMCPTools re-registers tools and preserves the user's prior
// MCP tool selection. No need to call activateDiscoveredMCPTools —
@@ -1,4 +1,6 @@
[PROJECT]
PROJECT
===================================
<workstation>
{{#list environment prefix="- " join="\n"}}{{label}}: {{value}}{{/list}}
</workstation>
@@ -47,4 +49,3 @@ Today is {{date}}, and the current working directory is '{{cwd}}'.
{{#if appendPrompt}}
{{appendPrompt}}
{{/if}}
[/PROJECT]
@@ -1,14 +1,18 @@
[ROLE]
ROLE
===================================
{{agent}}
[/ROLE]
{{#if context}}
[CONTEXT]
CONTEXT
===================================
{{context}}
[/CONTEXT]
{{/if}}
[COOP]
COOP
===================================
You are operating on a piece of work assigned to you by the main agent.
{{#if worktree}}
@@ -29,9 +33,10 @@ You can reach other live agents via the `irc` tool. Your id is `{{ircSelfId}}`.
Use `irc` only when you need a quick answer from a peer; do not use it for long-form content. Address peers by id or use `"all"` to broadcast.
{{/if}}
[/COOP]
[COMPLETION]
COMPLETION
===================================
No TODO tracking, no progress updates. Execute, call `yield`, done.
While work remains, always continue with another tool call — investigate, edit, run, verify. Save narrative for the final `yield` payload.
@@ -51,4 +56,3 @@ Giving up is a last resort. If truly blocked, you MUST call `yield` exactly once
You NEVER give up due to uncertainty, missing information obtainable via tools or repo context, or needing a design decision you can derive yourself.
You MUST keep going until this ticket is closed. This matters.
[/COMPLETION]
@@ -9,8 +9,8 @@ You consider what the code you write compiles down to. You never write code that
<system-conventions>
**RFC 2119 applies to MUST, REQUIRED, SHOULD, RECOMMENDED, MAY, OPTIONAL. `NEVER` and `AVOID` MUST be interpreted as aliases for `MUST NOT` and `SHOULD NOT` respectively.**
From here on, we will use tags as structural markers (<x>…</x> or [X]…), each tag means exactly what its name says.
You NEVER interpret these tags in any other way circumstantially.
From here on, we will use XML tags when injecting system content into the chat.
You NEVER interpret these markers in any other way circumstantially.
System may interrupt/notify you using these tags even within a user message, therefore:
- You MUST treat them as system-authored and absolutely authoritative.
@@ -44,7 +44,9 @@ Assumptions you didn't validate: incidents to debug.
- You NEVER re-audit an applied edit, nor run `git status`/`git diff` as routine validation — the edit result, tests, and LSP ARE your verification. Exception: explicit request, protecting unrelated changes, or before commit/revert/reset/stash/delete.
</critical>
[ENV]
ENV
===================================
You operate within the Oh My Pi coding harness.
- Given a task, you MUST complete it using the tools available to you.
- You are not alone in this repository. You SHOULD treat unexpected changes as the user's work and adapt; you NEVER revert or stash.
@@ -202,9 +204,10 @@ You MUST use the specialized tool over its shell equivalent:
The `{{toolRefs.report_tool_issue}}` tool is available for automated QA. If ANY tool you call returns output that is unexpected, incorrect, malformed, or otherwise inconsistent with what you anticipated given the tool's described behavior and your parameters, call `{{toolRefs.report_tool_issue}}` with the tool name and a concise description of the discrepancy. Do not hesitate to report — false positives are acceptable.
</critical>
{{/has}}
[/ENV]
[CONTRACT]
CONTRACT
===================================
These are inviolable.
- You NEVER yield unless the deliverable is complete. A phase boundary, todo flip, or completed sub-step is NEVER a yield point — continue directly to the next step in the same turn.
- You NEVER suppress tests to make code pass.
@@ -265,4 +268,3 @@ Before declaring blocked:
- Do not test defaults: changing the default configuration, or a string, should not break the test. Assert logical behavior, not the current state.
- Aim at: conditional branches and edge values, invariants across fields, error handling on bad input vs silent broken results.
</workflow>
[/CONTRACT]
+14 -4
View File
@@ -633,7 +633,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
if (atMaxDepth && toolNames?.includes("task")) {
toolNames = toolNames.filter(name => name !== "task");
}
// IRC is always available; the [COOP] prompt advertises it, so a restricted
// IRC is always available; the COOP prompt section advertises it, so a restricted
// whitelist must still carry `irc` for the subagent to actually use it.
if (toolNames && !toolNames.includes("irc")) {
toolNames = [...toolNames, "irc"];
@@ -1446,9 +1446,19 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
);
await awaitAbortable(session.waitForIdle());
} catch (err) {
logger.error("Subagent prompt failed", {
error: err instanceof Error ? err.message : String(err),
});
if (abortSignal.aborted || err instanceof ToolAbortError) {
// Benign control-flow exit — user cancel (^C) or compaction aborting
// pending operations both surface here as ToolAbortError. The outer
// catch and finally already mark the run aborted; logging at ERROR
// would spam operator dashboards with non-failures.
logger.debug("Subagent prompt aborted", {
reason: abortReason ?? "signal",
});
} else {
logger.error("Subagent prompt failed", {
error: err instanceof Error ? err.message : String(err),
});
}
}
}
+3 -2
View File
@@ -48,6 +48,7 @@ import { runSubprocess } from "./executor";
import { AgentOutputManager } from "./output-manager";
import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
import { renderResult, renderCall as renderTaskCall } from "./render";
import { repairTaskParams } from "./repair-args";
import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
import {
applyNestedPatches,
@@ -247,7 +248,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
}
renderCall(args: unknown, options: Parameters<typeof renderTaskCall>[1], theme: Theme) {
return renderTaskCall(args as TaskParams, options, theme);
return renderTaskCall(repairTaskParams(args as TaskParams), options, theme);
}
/** Dynamic description that reflects current disabled-agent settings */
@@ -292,7 +293,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
): Promise<AgentToolResult<TaskToolDetails>> {
const params = rawParams as TaskParams;
const params = repairTaskParams(rawParams as TaskParams);
const simpleMode = this.#getTaskSimpleMode();
const validationError = validateTaskModeParams(simpleMode, params);
if (validationError) {
@@ -0,0 +1,117 @@
/**
* Repair double-encoded JSON string arguments for the task tool.
*
* Models occasionally JSON-escape a string value twice when emitting a
* `task` tool call, so a `context`/`assignment` that should read
*
* # Role
* You are a judge … "describe this" … return —
*
* arrives — after the one JSON decode the provider already applied — as the
* literal text
*
* # Role\nYou are a judge … \"describe this\" … return \u2014
*
* i.e. every newline, quote, and unicode character is still backslash-escaped.
* The subagent then receives that garbled prompt, and the call preview renders
* one long blob with visible `\n` / `\"` / `\uXXXX`.
*
* The *whole-arguments* form of this quirk (the entire `arguments` blob is a
* JSON string) is already auto-corrected by the validator's JSON-string
* coercion. This module handles the *per-field* form, where the object parses
* fine but an individual string value is double-encoded — the validator never
* fires there because a double-encoded string is still a structurally valid
* string.
*
* This is deliberately scoped to the task tool's natural-language fields
* (`context`, `assignment`, `description`). It is NOT applied to code-bearing
* tools (write/edit/bash/search), where a backslash or quote is load-bearing
* and a false-positive unescape would silently corrupt a file or command.
*/
import type { TaskItem, TaskParams } from "./types";
/** A backslash that escapes a structural char — `\"`, `\\`, `\/`, or `\uXXXX`. */
const STRUCTURAL_ESCAPE = /\\(?:["\\/]|u[0-9a-fA-F]{4})/;
/**
* Whether `value` carries the signature of whole-string double-encoding rather
* than an incidental escape mention. A lone `\n`/`\t` in an instruction (e.g.
* "split lines on \n") is far more likely a literal mention than a
* double-encoded document, so it is left alone; a structural escape (`\"`,
* `\\`, `\uXXXX`) or two-plus escape sequences indicates a re-escaped payload.
*/
function hasDoubleEncodeSignature(value: string): boolean {
if (STRUCTURAL_ESCAPE.test(value)) return true;
let count = 0;
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) === 0x5c /* \ */) {
count += 1;
if (count >= 2) return true;
i += 1; // skip the escaped char so `\\` counts once
}
}
return false;
}
/**
* Return the once-unescaped string when `value` is uniformly double-encoded
* JSON (a well-formed JSON string body that decodes to a different string);
* otherwise return `value` unchanged.
*
* The `JSON.parse(\`"${value}"\`)` round-trip is the safety net: it only
* succeeds when *every* backslash begins a valid JSON escape and no bare
* double-quote exists — exactly the signature of double-encoding. Genuine
* prose with a Windows path (`C:\Users`), a regex (`\d+`), an embedded quote,
* or a real (already-decoded) newline makes the parse throw, so the value is
* returned untouched.
*/
export function repairDoubleEncodedJsonString(value: string): string {
// Fast path: no backslash → nothing was escaped → the parse can never differ.
if (!value.includes("\\")) return value;
if (!hasDoubleEncodeSignature(value)) return value;
let decoded: unknown;
try {
decoded = JSON.parse(`"${value}"`);
} catch {
return value;
}
return typeof decoded === "string" && decoded !== value ? decoded : value;
}
/** Repair a single (possibly partial) task item's prose fields. */
function repairTaskItem(task: TaskItem): TaskItem {
if (task === null || typeof task !== "object") return task;
const assignment =
typeof task.assignment === "string" ? repairDoubleEncodedJsonString(task.assignment) : task.assignment;
const description =
typeof task.description === "string" ? repairDoubleEncodedJsonString(task.description) : task.description;
if (assignment === task.assignment && description === task.description) return task;
return { ...task, assignment, description };
}
/**
* Repair double-encoded prose in task-tool params (`context` and each task's
* `assignment`/`description`). Returns the same reference when nothing changed
* so callers can cheaply skip work. Defensive against partially-streamed args
* (missing/undefined fields, partial task arrays) so it is safe on the render
* path as well as on execution.
*/
export function repairTaskParams(params: TaskParams): TaskParams {
if (params === null || typeof params !== "object") return params;
const context = typeof params.context === "string" ? repairDoubleEncodedJsonString(params.context) : params.context;
let tasks = params.tasks;
if (Array.isArray(params.tasks)) {
let changed = false;
const repaired = params.tasks.map(task => {
const next = repairTaskItem(task);
if (next !== task) changed = true;
return next;
});
if (changed) tasks = repaired;
}
if (context === params.context && tasks === params.tasks) return params;
return { ...params, context, tasks };
}
+133 -43
View File
@@ -1,4 +1,6 @@
import * as path from "node:path";
import { $env, isCompiledBinary, logger } from "@oh-my-pi/pi-utils";
import type { Subprocess } from "bun";
import { settings } from "../config/settings";
import { tinyModelDeviceSettingToEnv } from "./device";
import { tinyModelDtypeSettingToEnv } from "./dtype";
@@ -12,6 +14,14 @@ import {
} from "./models";
import type { TinyTitleProgressEvent, TinyTitleWorkerInbound, TinyTitleWorkerOutbound } from "./title-protocol";
/**
* Abstraction over the tiny-model subprocess. Modelled as a worker interface
* so existing callers (titles, memory completions, downloads) compose the
* same way; the runtime implementation is a Bun child process so
* `onnxruntime-node`'s NAPI finalizer never runs inside the main agent
* address space — that destructor segfaults Bun on Windows during shutdown
* (issue #1606).
*/
interface WorkerHandle {
send(message: TinyTitleWorkerInbound): void;
onMessage(handler: (message: TinyTitleWorkerOutbound) => void): () => void;
@@ -31,6 +41,12 @@ export interface TinyTitleDownloadOptions {
const SMOKE_TEST_TIMEOUT_MS = 5_000;
/**
* Hidden subcommand on the main CLI that boots the tiny-model worker in the
* spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
*/
export const TINY_WORKER_ARG = "--tiny-worker";
function readTinyModelSetting(path: "providers.tinyModelDevice" | "providers.tinyModelDtype"): string | undefined {
try {
const value = settings.get(path);
@@ -66,49 +82,128 @@ export function tinyWorkerEnvOverlay(
}
/**
* Env handed to the tiny-model worker. The `PI_TINY_DEVICE` / `PI_TINY_DTYPE` env
* vars win; otherwise the persisted `providers.tinyModelDevice` /
* `providers.tinyModelDtype` settings are mapped onto those vars so the worker's
* env-based resolution picks them up. Resolved once at spawn (pipelines are cached).
* Env handed to the tiny-model subprocess. The `PI_TINY_DEVICE` / `PI_TINY_DTYPE`
* env vars win; otherwise the persisted `providers.tinyModelDevice` /
* `providers.tinyModelDtype` settings are mapped onto those vars so the
* subprocess's env-based resolution picks them up. Resolved once at spawn
* (pipelines are cached for the lifetime of the subprocess).
*/
function tinyWorkerEnv(): Record<string, string> | undefined {
function tinyWorkerEnv(): Record<string, string> {
const overlay = tinyWorkerEnvOverlay(
$env,
readTinyModelSetting("providers.tinyModelDevice"),
readTinyModelSetting("providers.tinyModelDtype"),
);
if (Object.keys(overlay).length === 0) return undefined;
return { ...($env as Record<string, string>), ...overlay };
const base = $env as Record<string, string | undefined>;
const merged: Record<string, string> = {};
for (const key in base) {
const value = base[key];
if (typeof value === "string") merged[key] = value;
}
for (const key in overlay) merged[key] = overlay[key];
return merged;
}
export function createTinyTitleWorker(): Worker {
const env = tinyWorkerEnv();
const options: WorkerOptions = env ? { type: "module", env } : { type: "module" };
return isCompiledBinary()
? new Worker("./packages/coding-agent/src/tiny/worker.ts", options)
: new Worker(new URL("./worker.ts", import.meta.url).href, options);
/**
* Resolve the argv used to relaunch the agent CLI into tiny-worker mode. In a
* compiled binary the entry point is the binary itself; in dev/source the
* spawned `bun` needs the absolute path to `cli.ts` so it can resolve module
* imports against the on-disk source tree.
*/
function tinyWorkerSpawnCmd(): string[] {
if (isCompiledBinary()) return [process.execPath, TINY_WORKER_ARG];
const cliPath = path.resolve(import.meta.dir, "..", "cli.ts");
return [process.execPath, cliPath, TINY_WORKER_ARG];
}
function wrapBunWorker(worker: Worker): WorkerHandle {
(worker as Worker & { unref?: () => void }).unref?.();
interface SpawnedSubprocess {
proc: Subprocess<"ignore", "inherit", "inherit">;
inbound: Set<(message: TinyTitleWorkerOutbound) => void>;
errors: Set<(error: Error) => void>;
/**
* Flipped to `true` by {@link wrapSubprocess}'s `terminate()` right
* before it SIGKILLs the child so `onExit` can distinguish the
* expected hard-kill from a crash/OOM/external signal. Only the
* latter is surfaced as a worker error.
*/
intentionalExit: { value: boolean };
}
/**
* Spawn the tiny-model worker as a subprocess. Exported for tests and the
* smoke probe; production callers go through {@link spawnTinyTitleWorker}
* which wraps the result in a {@link WorkerHandle}.
*/
export function createTinyTitleSubprocess(): SpawnedSubprocess {
const inbound = new Set<(message: TinyTitleWorkerOutbound) => void>();
const errors = new Set<(error: Error) => void>();
const intentionalExit = { value: false };
const proc = Bun.spawn({
cmd: tinyWorkerSpawnCmd(),
env: tinyWorkerEnv(),
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
serialization: "advanced",
windowsHide: true,
ipc(message) {
for (const handler of inbound) handler(message as TinyTitleWorkerOutbound);
},
onExit(_proc, exitCode, signalCode) {
// Clean exit. The child only exits via SIGKILL in practice, but
// treat code 0 as a no-op for symmetry.
if (exitCode === 0) return;
// `exitCode === null` + non-null `signalCode` covers both the
// expected SIGKILL from `terminate()` AND external kills
// (SIGSEGV from a native crash, SIGKILL from the OOM killer, an
// operator `kill -9`, etc.). Swallow only the expected one;
// every other signal exit is a real worker death that must
// fault every in-flight request so callers don't await forever.
if (exitCode === null && intentionalExit.value) return;
const reason = exitCode !== null ? `code ${exitCode}` : `signal ${signalCode ?? "unknown"}`;
const err = new Error(`tiny model subprocess exited with ${reason}`);
for (const handler of errors) handler(err);
},
});
// Don't keep the parent event loop alive on account of an idle worker; the
// agent dispose path calls `terminate()` explicitly when shutting down.
proc.unref();
return { proc, inbound, errors, intentionalExit };
}
function wrapSubprocess({ proc, inbound, errors, intentionalExit }: SpawnedSubprocess): WorkerHandle {
return {
send(message) {
worker.postMessage(message);
try {
proc.send(message);
} catch (error) {
logger.debug("tiny-title: send to subprocess failed", {
error: error instanceof Error ? error.message : String(error),
});
}
},
onMessage(handler) {
const wrap = (event: MessageEvent): void => handler(event.data as TinyTitleWorkerOutbound);
worker.addEventListener("message", wrap);
return () => worker.removeEventListener("message", wrap);
inbound.add(handler);
return () => inbound.delete(handler);
},
onError(handler) {
const wrap = (event: ErrorEvent): void => {
handler(event.error instanceof Error ? event.error : new Error(event.message || "tiny title worker error"));
};
worker.addEventListener("error", wrap);
return () => worker.removeEventListener("error", wrap);
errors.add(handler);
return () => errors.delete(handler);
},
async terminate() {
worker.terminate();
// SIGKILL: the whole point of the subprocess isolation is that the
// parent never runs `onnxruntime-node`'s NAPI finalizer. A polite
// SIGTERM lets the subprocess try to clean up, which is exactly the
// codepath that crashes Bun on Windows. Hard-kill instead — the
// model lives in process memory and the OS reclaims everything.
// Flip the intentional-exit flag *before* killing so `onExit` can
// tell this apart from a crash or external SIGKILL.
intentionalExit.value = true;
try {
proc.kill("SIGKILL");
} catch {
// Already gone.
}
},
};
}
@@ -126,10 +221,6 @@ function spawnInlineUnavailableWorker(error: unknown): WorkerHandle {
emit({ type: "pong", id: message.id });
return;
}
if (message.type === "close") {
emit({ type: "closed" });
return;
}
emit({ type: "error", id: message.id, error: errorMessage });
});
},
@@ -148,9 +239,9 @@ function spawnInlineUnavailableWorker(error: unknown): WorkerHandle {
function spawnTinyTitleWorker(): WorkerHandle {
try {
return wrapBunWorker(createTinyTitleWorker());
return wrapSubprocess(createTinyTitleSubprocess());
} catch (error) {
logger.warn("Tiny title Worker spawn failed; local titles disabled", {
logger.warn("Tiny title worker spawn failed; local titles disabled", {
error: error instanceof Error ? error.message : String(error),
});
return spawnInlineUnavailableWorker(error);
@@ -293,9 +384,9 @@ export class TinyTitleClient {
}
this.#pending.clear();
try {
worker?.send({ type: "close" });
await worker?.terminate();
} catch {
// Worker may already be gone.
// Already gone.
}
}
@@ -317,7 +408,6 @@ export class TinyTitleClient {
this.#emitProgress(message.event);
return;
}
if (message.type === "closed") return;
if (message.type === "pong") return;
const pending = this.#pending.get(message.id);
@@ -371,25 +461,25 @@ export async function smokeTestTinyTitleWorker({
}: {
timeoutMs?: number;
} = {}): Promise<void> {
const worker = createTinyTitleWorker();
const handle = wrapSubprocess(createTinyTitleSubprocess());
const { promise, resolve, reject } = Promise.withResolvers<void>();
const timer = setTimeout(() => reject(new Error(`tiny title worker did not pong within ${timeoutMs}ms`)), timeoutMs);
worker.onmessage = (event: MessageEvent<TinyTitleWorkerOutbound>) => {
const message = event.data;
const unsubscribeMessage = handle.onMessage(message => {
if (message.type === "pong") {
resolve();
return;
}
if (message.type === "log") return;
reject(new Error(`tiny title worker: expected pong, got ${JSON.stringify(message)}`));
};
worker.onerror = (event: ErrorEvent) => {
reject(event.error instanceof Error ? event.error : new Error(event.message || "tiny title worker error"));
};
});
const unsubscribeError = handle.onError(reject);
try {
worker.postMessage({ type: "ping", id: "smoke" } satisfies TinyTitleWorkerInbound);
handle.send({ type: "ping", id: "smoke" } satisfies TinyTitleWorkerInbound);
await promise;
} finally {
clearTimeout(timer);
worker.terminate();
unsubscribeMessage();
unsubscribeError();
await handle.terminate();
}
}
@@ -31,8 +31,7 @@ export type TinyTitleWorkerInbound =
| { type: "ping"; id: string }
| { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string }
| { type: "complete"; id: string; modelKey: TinyLocalModelKey; prompt: string; maxTokens?: number }
| { type: "download"; id: string; modelKey: TinyLocalModelKey }
| { type: "close" };
| { type: "download"; id: string; modelKey: TinyLocalModelKey };
export type TinyTitleWorkerOutbound =
| { type: "pong"; id: string }
@@ -41,11 +40,17 @@ export type TinyTitleWorkerOutbound =
| { type: "downloaded"; id: string }
| { type: "error"; id: string; error: string }
| { type: "progress"; id: string; event: TinyTitleProgressEvent }
| { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> }
| { type: "closed" };
| { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> };
/**
* Wire transport between the parent (`TinyTitleClient`) and the tiny-model
* subprocess. The parent owns the subprocess lifecycle (graceful work, hard
* kill on shutdown); the protocol therefore carries no explicit close
* handshake — once the parent decides to terminate, it signals the OS to
* reap the child so `onnxruntime-node`'s NAPI finalizer never runs in any
* shared address space. See `title-client.ts` for the spawn/kill glue.
*/
export interface TinyTitleTransport {
send(message: TinyTitleWorkerOutbound): void;
onMessage(handler: (message: TinyTitleWorkerInbound) => void): () => void;
close(): void;
}
+1 -44
View File
@@ -1,7 +1,6 @@
import * as fs from "node:fs/promises";
import { createRequire } from "node:module";
import * as path from "node:path";
import { parentPort } from "node:worker_threads";
import type {
ProgressInfo,
TextGenerationPipeline,
@@ -20,12 +19,7 @@ import {
type TinyTitleLocalModelSpec,
} from "./models";
import { formatTitleUserMessage, normalizeGeneratedTitle } from "./text";
import type {
TinyTitleProgressEvent,
TinyTitleTransport,
TinyTitleWorkerInbound,
TinyTitleWorkerOutbound,
} from "./title-protocol";
import type { TinyTitleProgressEvent, TinyTitleTransport, TinyTitleWorkerInbound } from "./title-protocol";
const TITLE_PREFILL = "<title>";
const TITLE_CLOSE = "</title>";
@@ -497,16 +491,6 @@ async function generateCompletion(
return generated === "" ? null : generated;
}
function releasePipelines(): void {
// Intentionally NOT calling `pipeline.dispose()`. transformers.js disposes the
// underlying onnxruntime InferenceSession, freeing native memory that Bun's
// worker/NAPI teardown then frees a second time — a double-free that aborts the
// process on quit ("malloc: pointer being freed was not allocated" /
// "NAPI FATAL ERROR"). The worker is torn down immediately after `close`, so the
// OS reclaims the model memory regardless; skipping dispose avoids the crash.
pipelines.clear();
}
function enqueueRequest(
transport: TinyTitleTransport,
request: Extract<TinyTitleWorkerInbound, { type: "generate" | "complete" | "download" }>,
@@ -555,33 +539,6 @@ export function startTinyTitleWorker(transport: TinyTitleTransport): void {
transport.send({ type: "pong", id: message.id });
return;
}
if (message.type === "close") {
releasePipelines();
transport.send({ type: "closed" });
transport.close();
return;
}
enqueueRequest(transport, message);
});
}
if (!parentPort) throw new Error("tiny-title-worker: missing parentPort");
const port = parentPort;
const transport: TinyTitleTransport = {
send: (message: TinyTitleWorkerOutbound) => port.postMessage(message),
onMessage: handler => {
const wrap = (data: unknown): void => handler(data as TinyTitleWorkerInbound);
port.on("message", wrap);
return () => port.off("message", wrap);
},
close: () => {
try {
port.close();
} catch {
// Already closed.
}
},
};
startTinyTitleWorker(transport);
@@ -230,6 +230,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
rawPaths: params.paths,
cwd: this.session.cwd,
internalUrlAction: "rewrite",
settings: this.session.settings,
signal,
localProtocolOptions: this.session.localProtocolOptions,
});
const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
@@ -155,6 +155,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
rawPaths: params.paths,
cwd: this.session.cwd,
internalUrlAction: "search",
settings: this.session.settings,
signal,
localProtocolOptions: this.session.localProtocolOptions,
});
const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
+6
View File
@@ -7,6 +7,7 @@ import * as z from "zod/v4";
import { settings } from "../config/settings";
import { jsBackend, pythonBackend } from "../eval";
import type { ExecutorBackend, ExecutorBackendResult } from "../eval/backend";
import { EVAL_HEARTBEAT_OP } from "../eval/heartbeat";
import { IdleTimeout } from "../eval/idle-timeout";
import { defaultEvalSessionId } from "../eval/session-id";
import type { EvalCellResult, EvalDisplayOutput, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
@@ -388,7 +389,12 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
outputSink!.push(chunk);
},
onStatus: event => {
// Every status event re-arms the inactivity watchdog. A
// heartbeat is a pure keepalive emitted while a host-side
// bridge call (agent()/llm()) runs: it bumps the timer but
// carries no payload, so don't persist or render it.
idle.bump();
if (event.op === EVAL_HEARTBEAT_OP) return;
cellResult.statusEvents ??= [];
upsertStatusEvent(cellResult.statusEvents, event);
pushUpdate();
+20 -6
View File
@@ -192,7 +192,12 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
if (hasGlobPathChars(rawPattern)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPattern}`);
}
const resource = await internalRouter.resolve(rawPattern);
const resource = await internalRouter.resolve(rawPattern, {
cwd: this.session.cwd,
settings: this.session.settings,
signal,
localProtocolOptions: this.session.localProtocolOptions,
});
if (!resource.sourcePath) {
throw new ToolError(`Cannot find internal URL without a backing file: ${rawPattern}`);
}
@@ -443,10 +448,14 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
// =============================================================================
interface FindRenderArgs {
paths?: string[];
paths?: string | string[];
limit?: number;
}
function formatFindRenderPaths(paths: FindRenderArgs["paths"]): string | undefined {
return Array.isArray(paths) ? paths.join(", ") : paths;
}
const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
export const findToolRenderer = {
@@ -456,7 +465,7 @@ export const findToolRenderer = {
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
const text = renderStatusLine(
{ icon: "pending", title: "Find", description: args.paths?.join(", ") || "*", meta },
{ icon: "pending", title: "Find", description: formatFindRenderPaths(args.paths) || "*", meta },
uiTheme,
);
return new Text(text, 0, 0);
@@ -493,7 +502,7 @@ export const findToolRenderer = {
{
icon: "success",
title: "Find",
description: args?.paths?.join(", "),
description: formatFindRenderPaths(args?.paths),
meta: [formatCount("file", lines.length)],
},
uiTheme,
@@ -528,7 +537,7 @@ export const findToolRenderer = {
if (fileCount === 0) {
const header = renderStatusLine(
{ icon: "warning", title: "Find", description: args?.paths?.join(", "), meta: ["0 files"] },
{ icon: "warning", title: "Find", description: formatFindRenderPaths(args?.paths), meta: ["0 files"] },
uiTheme,
);
const lines = [header, formatEmptyMessage("No files found", uiTheme)];
@@ -539,7 +548,12 @@ export const findToolRenderer = {
if (details?.scopePath) meta.push(`in ${details.scopePath}`);
if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
const header = renderStatusLine(
{ icon: truncated ? "warning" : "success", title: "Find", description: args?.paths?.join(", "), meta },
{
icon: truncated ? "warning" : "success",
title: "Find",
description: formatFindRenderPaths(args?.paths),
meta,
},
uiTheme,
);
+1
View File
@@ -75,6 +75,7 @@ const GH_PR_FIELDS = [
"labels",
"mergeStateStatus",
"number",
"reviews",
"reviewDecision",
"state",
"title",
+13 -2
View File
@@ -3,7 +3,7 @@ import * as os from "node:os";
import * as path from "node:path";
import * as url from "node:url";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { InternalUrlRouter } from "../internal-urls";
import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls";
import { ToolError } from "./tool-errors";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
@@ -740,6 +740,12 @@ export interface ToolScopeOptions {
surfaceExactFilePaths?: boolean;
/** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */
multipathStatHint?: string;
/** Calling session's settings — forwarded to the internal-URL router so caller-aware handlers (issue://, pr://) honor it. */
settings?: unknown;
/** Caller's abort signal — forwarded to the internal-URL router. */
signal?: AbortSignal;
/** Calling session's `local://` root mapping — pins resolutions to the calling session. */
localProtocolOptions?: LocalProtocolOptions;
}
export interface ToolScopeResolution {
@@ -778,7 +784,12 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
const resource = await internalRouter.resolve(rawPath, {
cwd,
settings: opts.settings,
signal: opts.signal,
localProtocolOptions: opts.localProtocolOptions,
});
if (!resource.sourcePath) {
throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);
}
+1
View File
@@ -2141,6 +2141,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
cwd: this.session.cwd,
settings: this.session.settings,
signal,
localProtocolOptions: this.session.localProtocolOptions,
});
const details: ReadToolDetails = { resolvedPath: resource.sourcePath, contentType: resource.contentType };
+12 -1
View File
@@ -10,6 +10,7 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
import { recordFileSnapshot } from "../edit/file-snapshot-store";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { LocalProtocolOptions } from "../internal-urls/local-protocol";
import { InternalUrlRouter } from "../internal-urls/router";
import type { InternalResource, ResolveContext } from "../internal-urls/types";
import type { Theme } from "../modes/theme/theme";
@@ -543,6 +544,7 @@ async function resolveInternalSearchInputs(opts: {
settings: unknown;
signal?: AbortSignal;
archiveDisplayMap: ReadonlyMap<string, string>;
localProtocolOptions?: LocalProtocolOptions;
}): Promise<InternalSearchInputResolution> {
const internalRouter = InternalUrlRouter.instance();
const paths = opts.resolvedPaths.slice();
@@ -551,7 +553,12 @@ async function resolveInternalSearchInputs(opts: {
const virtualInputIndexes = new Set<number>();
const immutableSourcePaths = new Set<string>();
let virtualScopePath: string | undefined;
const context: ResolveContext = { cwd: opts.cwd, settings: opts.settings, signal: opts.signal };
const context: ResolveContext = {
cwd: opts.cwd,
settings: opts.settings,
signal: opts.signal,
localProtocolOptions: opts.localProtocolOptions,
};
for (let idx = 0; idx < paths.length; idx++) {
const rawPath = paths[idx];
@@ -674,6 +681,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
settings: this.session.settings,
signal,
archiveDisplayMap,
localProtocolOptions: this.session.localProtocolOptions,
});
const searchablePaths = internalResolution.paths;
const { virtualResources, virtualPathSet, virtualInputIndexes } = internalResolution;
@@ -738,6 +746,9 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
trackImmutableSources: true,
surfaceExactFilePaths: true,
multipathStatHint: " (`paths` entries must each exist relative to cwd)",
settings: this.session.settings,
signal,
localProtocolOptions: this.session.localProtocolOptions,
});
searchPath = scope.searchPath;
isDirectory = scope.isDirectory;
@@ -19,7 +19,7 @@ export interface OutputBlockOptions {
animate?: boolean;
}
const BORDER_SHIMMER_TICK_MS = 50;
const BORDER_SHIMMER_TICK_MS = 16;
/** Duration of one full left↔right↔left bounce of the bottom-edge segment, in
* ms. Position is derived from the wall clock against this fixed cycle so a
* resize only nudges the segment proportionally instead of teleporting it. */
@@ -28,9 +28,10 @@ const BORDER_BOUNCE_MS = 3000;
const BORDER_SEGMENT_LEN = 8;
/**
* Monotonic frame counter for animated borders. Quantized coarse enough to
* coalesce multiple render passes inside one frame, fine enough to advance on
* every spinner interval so cached blocks re-render while the segment travels.
* Monotonic frame counter for animated borders, quantized to the TUI's ~16ms
* render cap so the cache key advances once per ~60fps frame — fine enough for a
* smooth segment sweep, coarse enough to coalesce multiple render passes that
* land inside the same frame.
*/
export function borderShimmerTick(): number {
return Math.floor(Date.now() / BORDER_SHIMMER_TICK_MS);
@@ -55,7 +55,7 @@ function createFixture(opts: {
const ctx = {
isInitialized: true,
init: vi.fn(async () => {}),
ui: { requestRender },
ui: { requestRender, setEagerNativeScrollbackRebuild: vi.fn() },
statusLine: { invalidate: vi.fn() },
updateEditorTopBorder: vi.fn(),
streamingComponent,
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bun
/**
* Test fixture: a minimal stdio MCP server that completes the initialize +
* tools/list handshake and then exits cleanly. Models a misconfigured PHP
* MCP server (e.g. Laravel Boost in a non-Laravel project) that successfully
* advertises tools and then dies on the very next event-loop tick.
*
* Reproduces issue #1592: without a crash circuit breaker, every exit fires
* `transport.onClose`, which triggers an unbounded reconnect storm — the
* spindump in the bug report shows 66 487 PHP processes parented to the
* agent's `bun` PID.
*
* Each invocation atomically appends the PID + timestamp to the path in
* `$OMP_TEST_SPAWN_LOG`, so the test can count spawns without racing.
*/
import * as fs from "node:fs";
import * as readline from "node:readline";
const spawnLog = Bun.env.OMP_TEST_SPAWN_LOG;
if (spawnLog) {
fs.appendFileSync(spawnLog, `${process.pid} ${Date.now()}\n`);
}
const rl = readline.createInterface({ input: process.stdin });
function send(message: Record<string, unknown>): void {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
rl.on("line", line => {
let message: { id?: number | string; method?: string };
try {
message = JSON.parse(line);
} catch {
return;
}
if (message.method === "initialize" && message.id !== undefined) {
send({
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: "2025-03-26",
capabilities: { tools: {} },
serverInfo: { name: "crash-after-init", version: "1.0.0" },
},
});
return;
}
if (message.method === "tools/list" && message.id !== undefined) {
send({ jsonrpc: "2.0", id: message.id, result: { tools: [] } });
// Exit on the next tick so the response is fully flushed before EOF.
setImmediate(() => process.exit(0));
return;
}
});
rl.on("close", () => process.exit(0));
@@ -463,11 +463,12 @@ function createEventControllerFixtureForE10() {
const ctx = {
isInitialized: true,
init: vi.fn(async () => {}),
ui: { requestRender },
ui: { requestRender, setEagerNativeScrollbackRebuild: vi.fn() },
statusLine: { invalidate: vi.fn() },
updateEditorTopBorder: vi.fn(),
addMessageToChat,
updatePendingMessagesDisplay,
pendingTools: new Map(),
session: {},
} as unknown as InteractiveModeContext;
@@ -66,7 +66,16 @@ function issuePayload(number: number, body: string, commentBodies: string[] = []
};
}
interface PrPayloadReview {
author: { login: string };
body: string;
commit: { oid: string };
state: string;
submittedAt: string;
}
function prPayload(number: number, body: string) {
const reviews: PrPayloadReview[] = [];
return {
number,
title: `PR #${number}`,
@@ -81,11 +90,34 @@ function prPayload(number: number, body: string) {
url: `https://github.com/owner/example/pull/${number}`,
labels: [],
files: [],
reviews: [],
reviews,
comments: [],
};
}
function requestedJsonFields(args: string[]): Set<string> {
const jsonIndex = args.indexOf("--json");
const fieldsArg = jsonIndex >= 0 ? args[jsonIndex + 1] : undefined;
return new Set((fieldsArg ?? "").split(",").filter(Boolean));
}
function prPayloadWithRequestedFields(args: string[], number: number, body: string) {
const payload = prPayload(number, body);
const fields = requestedJsonFields(args);
if (fields.has("reviews")) {
payload.reviews = [
{
author: { login: "approver" },
body: "Approved from the formal review flow.",
commit: { oid: "1234567890abcdef1234567890abcdef12345678" },
state: "APPROVED",
submittedAt: "2026-04-01T12:00:00Z",
},
];
}
return payload;
}
interface DiffFileSpec {
name: string;
adds?: number;
@@ -193,6 +225,22 @@ describe("pr:// protocol handler", () => {
expect(spy).toHaveBeenCalledTimes(2);
});
it("requests and renders formal reviews when comments are enabled", async () => {
vi.spyOn(git.github, "json").mockImplementation(async (_cwd, args) => {
if (args.includes("/repos/owner/example/pulls/78/comments")) {
return [] as never;
}
return prPayloadWithRequestedFields(args, 78, "pr body") as never;
});
const router = InternalUrlRouter.instance();
const resource = await router.resolve("pr://owner/example/78");
expect(resource.content).toContain("## Reviews (1)");
expect(resource.content).toContain("### @approver - 2026-04-01T12:00:00Z [APPROVED]");
expect(resource.content).toContain("Approved from the formal review flow.");
});
it("rejects invalid pr:// URLs with a friendly message", async () => {
const router = InternalUrlRouter.instance();
await expect(router.resolve("pr://owner/example/foo/bar")).rejects.toThrow(/Invalid pr:\/\/ URL/);
@@ -110,4 +110,65 @@ describe("LocalProtocolHandler", () => {
await expect(router.resolve("local://linked/secret.txt")).rejects.toThrow("local:// URL escapes local root");
});
});
it("prefers caller-supplied context.localProtocolOptions over the installed override", async () => {
await withTempDir(async tempDir => {
const overrideArtifactsDir = path.join(tempDir, "override-artifacts");
const callerArtifactsDir = path.join(tempDir, "caller-artifacts");
await fs.mkdir(path.join(overrideArtifactsDir, "local"), { recursive: true });
await fs.mkdir(path.join(callerArtifactsDir, "local"), { recursive: true });
await Bun.write(path.join(overrideArtifactsDir, "local", "PLAN.md"), "# wrong session");
await Bun.write(path.join(callerArtifactsDir, "local", "PLAN.md"), "# caller session");
// Process-global override points at the WRONG session (simulates a
// stale override leaked from a prior subagent, or the multi-`main`
// AgentRegistry case in cmux/ACP where "first one wins" lookup
// picks a sibling session's artifacts dir — issue #1608).
LocalProtocolHandler.setOverride({
getArtifactsDir: () => overrideArtifactsDir,
getSessionId: () => "stale-session",
});
const router = InternalUrlRouter.instance();
const resource = await router.resolve("local://PLAN.md", {
localProtocolOptions: {
getArtifactsDir: () => callerArtifactsDir,
getSessionId: () => "caller-session",
},
});
const expectedSourcePath = await fs.realpath(path.join(callerArtifactsDir, "local", "PLAN.md"));
expect(resource.content).toBe("# caller session");
// `sourcePath` is canonicalized by the handler after symlink escape checks.
// On macOS this may turn `/var/...` into `/private/var/...`.
expect(resource.sourcePath).toBe(expectedSourcePath);
});
});
it("surfaces ENOENT against the caller's local root when the file is missing in that session", async () => {
await withTempDir(async tempDir => {
const overrideArtifactsDir = path.join(tempDir, "override-artifacts");
const callerArtifactsDir = path.join(tempDir, "caller-artifacts");
await fs.mkdir(path.join(overrideArtifactsDir, "local"), { recursive: true });
await fs.mkdir(path.join(callerArtifactsDir, "local"), { recursive: true });
// PLAN.md exists only in the override-pointed session.
await Bun.write(path.join(overrideArtifactsDir, "local", "PLAN.md"), "# wrong session");
LocalProtocolHandler.setOverride({
getArtifactsDir: () => overrideArtifactsDir,
getSessionId: () => "stale-session",
});
const router = InternalUrlRouter.instance();
await expect(
router.resolve("local://PLAN.md", {
localProtocolOptions: {
getArtifactsDir: () => callerArtifactsDir,
getSessionId: () => "caller-session",
},
}),
).rejects.toThrow("Local file not found: local://PLAN.md");
});
});
});
@@ -35,7 +35,6 @@ describe("issue #1150 — release-build script must list all worker --compile en
"./packages/stats/src/sync-worker.ts",
"./packages/coding-agent/src/tools/browser/tab-worker-entry.ts",
"./packages/coding-agent/src/eval/js/worker-entry.ts",
"./packages/coding-agent/src/tiny/worker.ts",
];
it("scripts/ci-release-build-binaries.ts lists every worker as an explicit --compile entrypoint", async () => {
@@ -56,7 +55,6 @@ describe("issue #1150 — release-build script must list all worker --compile en
"../stats/src/sync-worker.ts",
"./src/tools/browser/tab-worker-entry.ts",
"./src/eval/js/worker-entry.ts",
"./src/tiny/worker.ts",
];
const source = await Bun.file(devScriptPath).text();
for (const entry of devEntrypoints) {
@@ -0,0 +1,89 @@
/**
* Regression for https://github.com/can1357/oh-my-pi/issues/1606
*
* On Windows, `onnxruntime-node`'s NAPI finalizer segfaults Bun during
* shutdown after `@huggingface/transformers` has loaded a tiny model in a
* Worker thread. The agent used to host the tiny-model worker as a Worker
* inside its own process; tearing the worker down ran the native destructor
* in the parent's address space and crashed the CLI on exit.
*
* The fix relocates the worker to a child process: `title-client.ts` spawns
* `process.execPath … --tiny-worker`, `cli.ts` dispatches that flag into
* `runTinyWorker`, and the parent `SIGKILL`s the child on dispose so the
* native finalizer never runs in either address space. These tests pin the
* three pieces of that contract so a future refactor cannot quietly land
* the original crash again.
*/
import { describe, expect, it } from "bun:test";
import { createTinyTitleSubprocess, smokeTestTinyTitleWorker, TINY_WORKER_ARG } from "../src/tiny/title-client";
describe("issue #1606 — tiny model lives in an isolated subprocess", () => {
it("ping/pongs through the spawned worker subprocess and tears it down cleanly", async () => {
// `smokeTestTinyTitleWorker` is the runtime probe wired into
// `omp --smoke-test`: it spawns the worker subprocess via
// `Bun.spawn`, sends a ping over the IPC channel, awaits the pong,
// then SIGKILLs the child. If anyone reverts the worker to an
// in-process `new Worker(...)` thread or drops the `--tiny-worker`
// CLI dispatch, the spawn either picks up the wrong entrypoint or
// the ping never round-trips, and this test fails.
await expect(smokeTestTinyTitleWorker({ timeoutMs: 15_000 })).resolves.toBeUndefined();
}, 30_000);
it("CLI dispatches the flag that `title-client.ts` passes to the spawned child", async () => {
// `tinyWorkerSpawnCmd()` and the cli switch must agree on the exact
// flag, character-for-character — the spawned `bun`/binary sees only
// `argv` and there is no fallback path that "re-routes" the worker
// on misnamed flags. Pin the spelling on both ends.
const cliSource = await Bun.file(new URL("../src/cli.ts", import.meta.url)).text();
expect(cliSource).toContain(`argv[0] === "${TINY_WORKER_ARG}"`);
expect(cliSource).toContain("runTinyWorker");
});
it("surfaces unexpected signal exits so in-flight callers don't await forever", async () => {
// If the child dies from a signal we did NOT request — SIGSEGV from a
// native crash (the original Windows shutdown bug, now relocated to
// the child), an OOM SIGKILL, or an operator `kill -9` — the
// subprocess wrapper must fault every in-flight request via the
// `errors` channel. The original fix swallowed any `exitCode === null`
// exit unconditionally, which left `TinyTitleClient.#pending`
// promises hanging forever. Pin the new contract: an external
// SIGKILL (no `intentionalExit` flip) MUST surface a worker error.
const sub = createTinyTitleSubprocess();
try {
const { promise, resolve } = Promise.withResolvers<Error>();
sub.errors.add(resolve);
sub.proc.kill("SIGKILL");
const err = await promise;
expect(err.message).toMatch(/signal/i);
} finally {
// Ensure the child is reaped even on assertion failure.
try {
sub.proc.kill("SIGKILL");
} catch {}
await sub.proc.exited;
}
}, 15_000);
it("does not surface intentional terminate() SIGKILLs as worker errors", async () => {
// Inverse of the previous test: a SIGKILL issued by the wrapper's
// own `terminate()` MUST NOT fault callers — terminate is the
// shutdown path and the worker handle is already torn down by then.
// Regression guard against an over-eager fix that surfaces every
// signal exit indiscriminately.
const sub = createTinyTitleSubprocess();
let errored = false;
sub.errors.add(() => {
errored = true;
});
// Simulate what `wrapSubprocess.terminate()` does: flip the flag,
// then SIGKILL. We test the primitive directly rather than going
// through the wrapper to avoid coupling to `WorkerHandle` internals.
sub.intentionalExit.value = true;
sub.proc.kill("SIGKILL");
await sub.proc.exited;
// Give onExit a microtask to drain — Bun's exited promise resolves
// after onExit fires, but be defensive.
await Bun.sleep(20);
expect(errored).toBe(false);
}, 10_000);
});
@@ -0,0 +1,82 @@
/**
* Regression test for issue #1592: an MCP stdio server that exits immediately
* after completing initialize + tools/list must not trigger an unbounded
* respawn loop.
*
* The reporter's agent forked 66 487 PHP child processes in ~7 minutes
* (~158 spawns/sec) before macOS force-rebooted. The crashing fixture below
* models that pathology: each spawn answers the handshake and exits cleanly,
* which fires `transport.onClose` → `reconnectServer` with no rate limiter
* in the unpatched build.
*
* The contract this test defends: per-server crash bursts are capped so that
* even a fast-crashing stdio server stays well below the OS process budget.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { MCPManager } from "../src/mcp/manager";
import type { MCPStdioServerConfig } from "../src/mcp/types";
const FIXTURE_PATH = path.join(import.meta.dir, "fixtures", "crash-after-init-mcp.ts");
const BUN_EXEC = process.execPath;
describe("MCP reconnect storm (issue #1592)", () => {
let workDir: string;
let spawnLog: string;
beforeEach(() => {
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-mcp-storm-"));
spawnLog = path.join(workDir, "spawns.log");
fs.writeFileSync(spawnLog, "");
});
afterEach(() => {
fs.rmSync(workDir, { recursive: true, force: true });
});
function countSpawns(): number {
const text = fs.readFileSync(spawnLog, "utf8");
return text.split("\n").filter(line => line.trim().length > 0).length;
}
it("stops respawning after a burst of immediate exits", async () => {
const manager = new MCPManager(workDir);
const config: MCPStdioServerConfig = {
type: "stdio",
command: BUN_EXEC,
args: [FIXTURE_PATH],
env: { OMP_TEST_SPAWN_LOG: spawnLog },
};
try {
await manager.connectServers({ crashy: config }, {});
// Give the reconnect loop generous time to fire. With the bug this
// produced thousands of processes within a second; with the fix the
// circuit breaker caps the per-server spawn budget.
await Bun.sleep(3000);
const spawns = countSpawns();
// `RECONNECT_BURST_LIMIT` (5) is the per-server reconnect cap inside
// the burst window. The initial connect from `connectServers` adds
// one more spawn. On the "initialize + tools/list succeed, then
// exit" path the inner retry-with-backoff in `#doReconnect` never
// fires, so the steady-state ceiling is
// `1 + RECONNECT_BURST_LIMIT + 1` ≈ 7 spawns. 10 leaves room for
// scheduling jitter without weakening the bound.
expect(spawns).toBeLessThanOrEqual(10);
// Sanity check: we did spawn at least once. If the fixture never ran
// the regression target is wrong and the test is meaningless.
expect(spawns).toBeGreaterThan(0);
// Once the breaker trips, the stale connection must be torn down so
// `getConnectionStatus`/`waitForConnection` cannot hand callers a
// dead transport. Tools stay registered in the manager's tool list
// so the user can recover via `/mcp reconnect`.
expect(manager.getConnectionStatus("crashy")).toBe("disconnected");
} finally {
await manager.disconnectAll();
}
}, 15_000);
});
@@ -54,7 +54,7 @@ describe("EventController idle compaction teardown", () => {
streamingMessage: undefined,
pendingTools: new Map<string, unknown>(),
flushPendingModelSwitch: async () => {},
ui: { requestRender: vi.fn() },
ui: { requestRender: vi.fn(), setEagerNativeScrollbackRebuild: vi.fn() },
chatContainer: { removeChild: vi.fn() },
statusContainer: { clear: vi.fn() },
statusLine: { invalidate: vi.fn() },
@@ -39,7 +39,7 @@ function createContext(options: {
isInitialized: true,
statusLine: { invalidate: vi.fn() },
updateEditorTopBorder: vi.fn(),
ui: { requestRender: vi.fn() },
ui: { requestRender: vi.fn(), setEagerNativeScrollbackRebuild: vi.fn() },
editor,
addMessageToChat,
updatePendingMessagesDisplay,
@@ -52,6 +52,7 @@ function createContext(options: {
.join(""),
optimisticUserMessageSignature: options.optimisticSignature,
locallySubmittedUserSignatures: new Set<string>(options.locallySubmittedSignatures ?? []),
pendingTools: new Map(),
} as unknown as InteractiveModeContext;
return { ctx, editor, setText, addMessageToChat, updatePendingMessagesDisplay };
}
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
@@ -6,11 +7,23 @@ import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-
function createContext() {
const setEagerNativeScrollbackRebuild = vi.fn();
const pendingTools = new Map<string, unknown>();
const chatContainer = { addChild: vi.fn(), removeChild: vi.fn() };
const ctx = {
isInitialized: true,
isBackgrounded: false,
statusLine: { invalidate: vi.fn() },
updateEditorTopBorder: vi.fn(),
pendingTools,
chatContainer,
hideThinkingBlock: false,
editor: { getText: vi.fn(() => "") },
flushPendingModelSwitch: vi.fn(),
session: {
agent: { state: { messages: [] } },
isCompacting: false,
isTtsrAbortPending: false,
retryAttempt: 0,
},
ui: { setEagerNativeScrollbackRebuild, requestRender: vi.fn() },
} as unknown as InteractiveModeContext;
return { ctx, pendingTools, setEagerNativeScrollbackRebuild };
@@ -25,8 +38,32 @@ const REFRESH_TRIGGER = {
partialResult: { content: [], details: {} },
} as unknown as AgentSessionEvent;
const ASSISTANT_MESSAGE = {
role: "assistant",
content: [{ type: "text", text: "" }],
api: "anthropic-messages",
provider: "anthropic",
model: "test-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 0,
} as const;
describe("EventController tool render mode", () => {
beforeEach(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true });
});
afterEach(() => {
resetSettingsForTest();
vi.restoreAllMocks();
});
@@ -42,4 +79,32 @@ describe("EventController tool render mode", () => {
await controller.handleEvent(REFRESH_TRIGGER);
expect(setEagerNativeScrollbackRebuild).toHaveBeenLastCalledWith(false);
});
it("enables eager native scrollback rebuild while assistant text is streaming", async () => {
const { ctx, setEagerNativeScrollbackRebuild } = createContext();
const controller = new EventController(ctx);
await controller.handleEvent({
type: "message_start",
message: ASSISTANT_MESSAGE,
} as unknown as AgentSessionEvent);
expect(setEagerNativeScrollbackRebuild).toHaveBeenLastCalledWith(true);
await controller.handleEvent({ type: "message_end", message: ASSISTANT_MESSAGE } as unknown as AgentSessionEvent);
expect(setEagerNativeScrollbackRebuild).toHaveBeenLastCalledWith(false);
});
it("resets eager native scrollback rebuild when a stream ends without assistant message_end", async () => {
const { ctx, setEagerNativeScrollbackRebuild } = createContext();
const controller = new EventController(ctx);
await controller.handleEvent({
type: "message_start",
message: ASSISTANT_MESSAGE,
} as unknown as AgentSessionEvent);
expect(setEagerNativeScrollbackRebuild).toHaveBeenLastCalledWith(true);
await controller.handleEvent({ type: "agent_end" } as unknown as AgentSessionEvent);
expect(setEagerNativeScrollbackRebuild).toHaveBeenLastCalledWith(false);
});
});
@@ -104,6 +104,16 @@ async function withTempDir(run: (dir: string) => Promise<void>): Promise<void> {
}
}
function createEmptyWorkspaceTree(rootPath: string) {
return {
rootPath,
rendered: "",
truncated: false,
totalLines: 0,
agentsMdFiles: [],
};
}
describe("system Handlebars prompt templates", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -181,11 +191,11 @@ describe("system Handlebars prompt templates", () => {
assignment: "Do the task.",
});
expect(subagentSystem).toContain("[CONTEXT]\nShared task background\n[/CONTEXT]");
expect(subagentSystem).toContain("[ROLE]");
expect(subagentSystem).toMatch(/CONTEXT\n=+\n\nShared task background/);
expect(subagentSystem).toMatch(/ROLE\n=+/);
expect(subagentUser).toContain("Complete the assignment below, thoroughly:");
expect(subagentUser).toContain("Do the task.");
expect(subagentUser).not.toContain("[CONTEXT]");
expect(subagentUser).not.toMatch(/CONTEXT\n=+/);
expect(subagentUser).not.toContain("Shared task background");
});
test("system-prompt renders MCP discovery hint when enabled", async () => {
@@ -212,6 +222,7 @@ describe("system Handlebars prompt templates", () => {
skills: [],
rules: [],
toolNames: ["read"],
workspaceTree: createEmptyWorkspaceTree(os.tmpdir()),
};
const enabled = await buildSystemPrompt({
@@ -247,7 +258,7 @@ describe("system Handlebars prompt templates", () => {
});
expect(systemPrompt).toHaveLength(2);
expect(systemPrompt[0]).toContain("[CONTRACT]");
expect(systemPrompt[0]).toMatch(/CONTRACT\n=+/);
expect(systemPrompt[0]).not.toContain("current working directory");
expect(systemPrompt[1]).toContain("<workstation>");
expect(systemPrompt[1]).toContain("<workspace-tree>");
@@ -300,6 +311,7 @@ describe("system Handlebars prompt templates", () => {
skills: [],
rules: [],
toolNames: ["read"],
workspaceTree: createEmptyWorkspaceTree(dir),
customPrompt: "Custom prompt body",
alwaysApplyRules: [
{ name: "no-dynamic-loading", content: duplicateRule, path: "/tmp/no-dynamic-loading.md" },
@@ -325,6 +337,7 @@ describe("system Handlebars prompt templates", () => {
skills: [],
rules: [],
toolNames: ["read"],
workspaceTree: createEmptyWorkspaceTree(os.tmpdir()),
customPrompt: ["Custom guidance", "", duplicateRule, "", "More custom guidance"].join("\n"),
alwaysApplyRules: [
{ name: "small-functions", content: duplicateRule, path: "/tmp/small-functions.md" },
@@ -361,6 +374,7 @@ describe("system Handlebars prompt templates", () => {
skills: [],
rules: [],
toolNames: ["read", "search", "find", "edit", "lsp", "bash", "eval"],
workspaceTree: createEmptyWorkspaceTree(os.tmpdir()),
tools: new Map([
["read", { label: "Read", description: "Reads files" }],
["search", { label: "Search", description: "Searches files" }],
@@ -390,6 +404,7 @@ describe("system Handlebars prompt templates", () => {
skills: [],
rules: [],
toolNames: ["read"],
workspaceTree: createEmptyWorkspaceTree(os.tmpdir()),
});
const projectPrompt = systemPrompt[1] ?? "";
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { AgentBusyError, type AgentTelemetryConfig, type Tracer } from "@oh-my-pi/pi-agent-core";
import { type AssistantMessage, Effort } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import { Settings } from "../../src/config/settings";
import type { ExtensionActions, LoadExtensionsResult } from "../../src/extensibility/extensions/types";
import type { CreateAgentSessionResult } from "../../src/sdk";
@@ -226,10 +227,10 @@ describe("runSubprocess yield reminders", () => {
expect(systemPrompt).toHaveLength(4);
expect(systemPrompt?.[0]).toBe("system");
expect(systemPrompt?.[1]).toBe("project");
expect(systemPrompt?.[2]).toContain("[CONTEXT]\nShared task background\n[/CONTEXT]");
expect(systemPrompt?.[2]).toContain("[ROLE]\ntest\n[/ROLE]");
expect(systemPrompt?.[2]).toMatch(/CONTEXT\n=+\n\nShared task background/);
expect(systemPrompt?.[2]).toMatch(/ROLE\n=+\n\ntest/);
expect(systemPrompt?.[3]).toBe("now");
expect(userPrompt).not.toContain("[CONTEXT]");
expect(userPrompt).not.toMatch(/CONTEXT\n=+/);
expect(userPrompt).not.toContain("Shared task background");
});
@@ -521,6 +522,42 @@ describe("runSubprocess yield reminders", () => {
expect(result.stderr).toMatch(/options\.authStorage.*modelRegistry\.authStorage/);
expect(createAgentSessionSpy).not.toHaveBeenCalled();
});
it("logs reminder-loop aborts at debug, not error (issue #1623)", async () => {
// Repro: user ^C or compaction aborts pending operations while the
// yield-reminder loop is awaiting session.prompt. awaitAbortable rejects
// with ToolAbortError, which previously surfaced as logger.error and
// polluted operator dashboards.
const abortController = new AbortController();
const debugSpy = vi.spyOn(logger, "debug").mockImplementation(() => {});
const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
const session = createMockSession(({ promptIndex, emit, state }) => {
if (promptIndex === 1) {
// Initial prompt: stop without yielding so the reminder loop kicks in.
const assistant = createAssistantStopMessage("no yield yet");
state.messages.push(assistant);
emit({ type: "message_end", message: assistant });
return;
}
// Reminder prompt: abort the run while it is in flight. The follow-up
// awaitAbortable(session.waitForIdle()) then throws ToolAbortError into
// the catch we are guarding.
abortController.abort();
});
mockCreateAgentSession(session);
const result = await runSubprocess({
...baseOptions,
id: "subagent-abort-during-reminder",
signal: abortController.signal,
});
expect(result.aborted).toBe(true);
expect(errorSpy).not.toHaveBeenCalledWith("Subagent prompt failed", expect.anything());
expect(debugSpy).toHaveBeenCalledWith("Subagent prompt aborted", expect.anything());
});
});
describe("runSubprocess telemetry propagation", () => {
@@ -1,5 +1,25 @@
import { describe, expect, it } from "bun:test";
import { validateFindPathInputs } from "../../src/tools/find";
import { beforeAll, describe, expect, it } from "bun:test";
import type { Component } from "@oh-my-pi/pi-tui";
import type { RenderResultOptions } from "../../src/extensibility/custom-tools/types";
import { getThemeByName, initTheme, type Theme } from "../../src/modes/theme/theme";
import { findToolRenderer, validateFindPathInputs } from "../../src/tools/find";
let uiTheme: Theme;
beforeAll(async () => {
await initTheme(false, undefined, undefined, "dark", "light");
const theme = await getThemeByName("dark");
if (!theme) throw new Error("Missing dark theme");
uiTheme = theme;
});
const renderOptions: RenderResultOptions = {
expanded: false,
isPartial: true,
};
function renderText(component: Component): string {
return Bun.stripANSI(component.render(160).join("\n"));
}
describe("validateFindPathInputs", () => {
it("accepts a normal array of glob entries", () => {
@@ -40,3 +60,34 @@ describe("validateFindPathInputs", () => {
expect(() => validateFindPathInputs(["\\{a,b}"])).toThrow(/paths is an array/);
});
});
describe("findToolRenderer", () => {
it("accepts a single string paths value before validation", async () => {
const args = { paths: "src/**/*.ts" };
const renderings = [
findToolRenderer.renderCall(args, renderOptions, uiTheme),
findToolRenderer.renderResult(
{ content: [{ type: "text", text: "src/index.ts\n" }] },
renderOptions,
uiTheme,
args,
),
findToolRenderer.renderResult(
{ content: [{ type: "text", text: "" }], details: { fileCount: 0, files: [] } },
renderOptions,
uiTheme,
args,
),
findToolRenderer.renderResult(
{ content: [{ type: "text", text: "src/index.ts" }], details: { fileCount: 1, files: ["src/index.ts"] } },
renderOptions,
uiTheme,
args,
),
];
for (const component of renderings) {
expect(renderText(component)).toContain("src/**/*.ts");
}
});
});
@@ -0,0 +1,80 @@
import { describe, expect, it } from "bun:test";
import { repairDoubleEncodedJsonString, repairTaskParams } from "../../src/task/repair-args";
import type { TaskParams } from "../../src/task/types";
describe("repairDoubleEncodedJsonString", () => {
it("decodes a uniformly double-encoded prose value", () => {
// One JSON decode already applied by the provider; the value still
// carries literal `\n`, `\"`, and `\u2014` because the model escaped twice.
const doubled = '# Role\\nYou are a judge \\"describe this\\" return \\u2014';
expect(repairDoubleEncodedJsonString(doubled)).toBe('# Role\nYou are a judge "describe this" return —');
});
it("decodes a double-encoded multi-line plain-text value", () => {
expect(repairDoubleEncodedJsonString("line one\\nline two\\nline three")).toBe("line one\nline two\nline three");
});
it("preserves a Windows path (bare backslashes are not valid escapes)", () => {
expect(repairDoubleEncodedJsonString("C:\\Users\\me")).toBe("C:\\Users\\me");
});
it("preserves a regex with a backslash class", () => {
expect(repairDoubleEncodedJsonString("match \\d+ digits")).toBe("match \\d+ digits");
});
it("preserves text containing a bare double quote", () => {
expect(repairDoubleEncodedJsonString('she said "hi" loudly')).toBe('she said "hi" loudly');
});
it("leaves a lone literal \\n mention alone (no double-encode signature)", () => {
expect(repairDoubleEncodedJsonString("split lines on \\n then count")).toBe("split lines on \\n then count");
});
it("is a no-op for plain text without escapes", () => {
const plain = "just some normal instructions";
expect(repairDoubleEncodedJsonString(plain)).toBe(plain);
});
it("leaves a partially-decoded value (real newline mixed with literal escape) untouched", () => {
// A real newline cannot appear inside a JSON string literal unescaped, so
// the round-trip parse throws and the value is preserved as-is.
const mixed = "real\nnewline with \\t tab";
expect(repairDoubleEncodedJsonString(mixed)).toBe(mixed);
});
});
describe("repairTaskParams", () => {
it("repairs context and each task's assignment/description, leaving ids intact", () => {
const params = {
agent: "task",
context: "# Goal\\nDo the thing \\u2014 carefully",
tasks: [
{
id: "FirstTask",
description: 'judge \\"sketch\\" accuracy',
assignment: "Score 0-100.\\nUse the full range.\\nNo bunching.",
},
],
} as unknown as TaskParams;
const repaired = repairTaskParams(params);
expect(repaired.context).toBe("# Goal\nDo the thing — carefully");
expect(repaired.tasks[0].id).toBe("FirstTask");
expect(repaired.tasks[0].description).toBe('judge "sketch" accuracy');
expect(repaired.tasks[0].assignment).toBe("Score 0-100.\nUse the full range.\nNo bunching.");
});
it("returns the same reference when nothing needs repair", () => {
const params = {
agent: "task",
context: "plain context",
tasks: [{ id: "A", description: "label", assignment: "do work" }],
} as unknown as TaskParams;
expect(repairTaskParams(params)).toBe(params);
});
it("tolerates partially-streamed args without throwing", () => {
const partial = { agent: "task", tasks: [{ id: "A" }, undefined] } as unknown as TaskParams;
expect(() => repairTaskParams(partial)).not.toThrow();
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/hashline",
"version": "15.7.4",
"version": "15.7.5",
"description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
"homepage": "https://omp.sh",
"author": "Can Boluk",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-mnemopi",
"version": "15.7.4",
"version": "15.7.5",
"description": "Local SQLite memory engine for Oh My Pi agents",
"homepage": "https://omp.sh",
"author": "Can Boluk",
+1 -1
View File
@@ -136,7 +136,7 @@ export declare class Shell {
* `packages/natives/native/index.js` (which derives the name from
* `package.json#version`).
*/
export declare function __piNativesV15_7_4(): void
export declare function __piNativesV15_7_5(): void
/**
* Apply conservative pre-execution rewrites to a bash command.
+1 -1
View File
@@ -23,7 +23,7 @@ export const PtySession = nativeBindings.PtySession;
export const Shell = nativeBindings.Shell;
// functions
export const __piNativesV15_7_4 = nativeBindings.__piNativesV15_7_4;
export const __piNativesV15_7_5 = nativeBindings.__piNativesV15_7_5;
export const applyBashFixups = nativeBindings.applyBashFixups;
export const astEdit = nativeBindings.astEdit;
export const astGrep = nativeBindings.astGrep;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@oh-my-pi/pi-natives",
"version": "15.7.4",
"version": "15.7.5",
"description": "Native Rust bindings for grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API",
"type": "module",
"homepage": "https://omp.sh",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/omp-stats",
"version": "15.7.4",
"version": "15.7.5",
"description": "Local observability dashboard for pi AI usage statistics",
"homepage": "https://omp.sh",
"author": "Can Boluk",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/swarm-extension",
"version": "15.7.4",
"version": "15.7.5",
"description": "Swarm orchestration extension for omp",
"homepage": "https://omp.sh",
"author": "Derek Rynd",
+6
View File
@@ -2,6 +2,12 @@
## [Unreleased]
## [15.7.5] - 2026-06-01
### Fixed
- Fixed native Windows + Windows Terminal scrollback being yanked to the top when a streaming response triggered a TUI full redraw. Under ConPTY the `kernel32` `GetConsoleScreenBufferInfo` probe answers about the pseudo-console (always at the buffer tail) and not about WT's host scrollback, so `isNativeViewportAtBottom()` falsely returned `true` while the user was scrolled up and the shrink-across-viewport branch issued a destructive `historyRebuild` (`\x1b[2J\x1b[H\x1b[3J`). The probe now short-circuits to `undefined` whenever `WT_SESSION` is set, letting the existing deferred-rebuild path keep streaming-time mutations non-destructive and reconcile native history at the next prompt-submit checkpoint. ([#1635](https://github.com/can1357/oh-my-pi/issues/1635))
## [15.7.3] - 2026-05-31
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-tui",
"version": "15.7.4",
"version": "15.7.5",
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
"homepage": "https://omp.sh",
"author": "Can Boluk",
+31 -1
View File
@@ -113,6 +113,27 @@ function isWindowsSubsystemForLinux(): boolean {
return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
}
/**
* Whether the native console viewport-position probe should be consulted.
*
* Returns `true` only on native Windows that is *not* fronted by Windows
* Terminal. The kernel32 `GetConsoleScreenBufferInfo` API answers about the
* ConPTY pseudo-console — which is always pinned to its tail — and not about
* the user-visible scrollback in modern hosts. Treat any such host as
* unreportable so the renderer falls back to the deferred-rebuild path.
*
* Pure helper for unit testing; the runtime call site reads `$env` /
* `process.platform`. See #1635.
*/
export function shouldTrustNativeViewportProbe(
env: { WT_SESSION?: string | undefined } = $env,
platform: NodeJS.Platform = process.platform,
): boolean {
if (platform !== "win32") return false;
if (env.WT_SESSION) return false;
return true;
}
/**
* Real terminal using process.stdin/stdout
*/
@@ -214,9 +235,18 @@ export class ProcessTerminal implements Terminal {
/**
* Returns true when Windows' active console viewport is at the scrollback tail.
* POSIX terminals do not expose native scrollback position through a standard API.
*
* On native Windows running under Windows Terminal (the default modern
* host), the `kernel32` probe answers about the ConPTY pseudo-console — not
* the user-visible WT viewport — so it would always read "at bottom" while
* the user is scrolled up. Return `undefined` there so the renderer falls
* back to the POSIX-style deferred-rebuild path: streaming mutations stay
* non-destructive (no `\x1b[3J`), and the rebuild fires at the next prompt
* checkpoint via {@link TUI.refreshNativeScrollbackIfDirty} where the user
* is already pinned to the bottom by the editor keystroke. See #1635.
*/
isNativeViewportAtBottom(): boolean | undefined {
if (process.platform !== "win32") return undefined;
if (!shouldTrustNativeViewportProbe()) return undefined;
try {
const kernel32 = dlopen("kernel32.dll", {
GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
+35 -1
View File
@@ -1335,8 +1335,42 @@ export class TUI extends Container {
) {
return { kind: "historyRebuild" };
}
// POSIX terminals — and Windows Terminal/ConPTY — that cannot report the
// viewport position fall through here (`canRebuildNativeScrollbackLive` is
// false). A destructive rebuild emits `\x1b[3J`, which on modern terminals
// resets the viewport to the top of scrollback and yanks a scrolled-up
// reader (issue #1635), so it is unsafe while the probe is unavailable.
//
// When the shrunk transcript now fits entirely in the viewport there is no
// new native history to preserve during the live frame: repaint the screen
// in place (no `\x1b[3J`) and defer stale-scrollback cleanup to the next
// checkpoint rebuild (e.g. prompt submit -> `refreshNativeScrollbackIfDirty`).
if (nativeViewportAtBottom === undefined && newLines.length <= height) {
this.#markNativeScrollbackDirty();
return { kind: "viewportRepaint" };
}
// The shrunk transcript still overflows the viewport. A plain viewport
// repaint would re-emit the rows between the new and old viewport tops on top
// of the copies the terminal already kept in native scrollback; `deferredShrink`
// pads to the previous row count so no committed row is re-emitted, and the
// next checkpoint rebuild cleans up.
//
// That deferral only carries real content when `newLines.length` reaches the
// padded viewport top (`previousLines.length - height`) — otherwise every row
// the padded repaint draws is past the end of `newLines` and renders blank,
// hiding the prompt until the next checkpoint. This can happen even when
// `scrollbackHighWater` is far below `previousLines.length - height`, because
// prior unknown-POSIX viewport repaints commit longer logical frames without
// moving the native scrollback boundary. For a shrink that large a blank,
// uninteractable viewport is the greater evil, so yank with `historyRebuild`.
// Real win32 unknown probes defer as scrolled above and never reach this; the
// yank only lands on non-win32 hosts whose probe is genuinely unavailable.
const paddedViewportTop = Math.max(0, this.#previousLines.length - height);
if (newLines.length <= paddedViewportTop) {
return { kind: "historyRebuild" };
}
this.#markNativeScrollbackDirty();
return { kind: "viewportRepaint" };
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
}
const suppressSuffixScroll = this.#suppressNextSuffixScroll;
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it } from "bun:test";
import { type Component, TUI } from "@oh-my-pi/pi-tui";
import { shouldTrustNativeViewportProbe } from "@oh-my-pi/pi-tui/terminal";
import { VirtualTerminal } from "./virtual-terminal";
// Regression test for https://github.com/can1357/oh-my-pi/issues/1635
//
// Native Windows + Windows Terminal (ConPTY) routes `omp` through a
// pseudo-console whose `GetConsoleScreenBufferInfo` answer always reports
// "viewport at bottom" — it cannot see the WT host scrollback. When the user
// scrolled up in WT and the renderer hit a `historyRebuild` intent (the
// shrink-across-viewport branch), the destructive `\x1b[2J\x1b[H\x1b[3J`
// sequence reset the WT viewport to the top of scrollback.
//
// Fix: `shouldTrustNativeViewportProbe` returns false under WT_SESSION so the
// probe falls back to `undefined`, and the renderer's existing
// deferred-rebuild path keeps streaming-time mutations non-destructive.
//
// The renderer assertions below override the VirtualTerminal probe to simulate
// the two relevant post-fix outcomes:
//
// - `undefined`: probe is unreportable (WT-hosted on win32, or any POSIX
// host where the probe never had an answer to begin with).
// - `false`: the host can see scrollback and reports the user scrolled
// up. Both must avoid `\x1b[3J`.
class LineList implements Component {
#lines: string[];
constructor(lines: string[]) {
this.#lines = [...lines];
}
invalidate(): void {}
render(width: number): string[] {
return this.#lines.map(l => l.slice(0, width));
}
setLines(lines: string[]): void {
this.#lines = [...lines];
}
}
async function settle(term: VirtualTerminal): Promise<void> {
await new Promise<void>(r => process.nextTick(r));
await new Promise<void>(r => setTimeout(r, 20));
await term.flush();
}
function capture(term: VirtualTerminal): string[] {
const writes: string[] = [];
const realWrite = term.write.bind(term);
(term as unknown as { write: (s: string) => void }).write = (data: string) => {
writes.push(data);
realWrite(data);
};
return writes;
}
function overrideProbe(term: VirtualTerminal, answer: boolean | undefined): void {
(term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => answer;
}
const ERASE_SCROLLBACK = /\x1b\[3J/g;
describe("issue #1635: shouldTrustNativeViewportProbe", () => {
it("returns true on bare native Windows (legacy console)", () => {
expect(shouldTrustNativeViewportProbe({}, "win32")).toBe(true);
});
it("returns false when running under Windows Terminal", () => {
expect(shouldTrustNativeViewportProbe({ WT_SESSION: "abcd-efgh" }, "win32")).toBe(false);
});
it("returns false on POSIX where the probe has no answer", () => {
expect(shouldTrustNativeViewportProbe({}, "linux")).toBe(false);
expect(shouldTrustNativeViewportProbe({}, "darwin")).toBe(false);
});
it("returns false on POSIX even if WT_SESSION leaked through (defense in depth)", () => {
expect(shouldTrustNativeViewportProbe({ WT_SESSION: "x" }, "linux")).toBe(false);
});
});
describe("issue #1635: TUI must not emit \\x1b[3J when probe is unreliable", () => {
it("content shrink with unreportable viewport must not emit \\x1b[3J", async () => {
const term = new VirtualTerminal(100, 24);
overrideProbe(term, undefined);
const tui = new TUI(term);
const component = new LineList(Array.from({ length: 80 }, (_, i) => `init-${i}`));
tui.addChild(component);
try {
tui.start();
await settle(term);
const writes = capture(term);
component.setLines(Array.from({ length: 20 }, (_, i) => `shrunk-${i}`));
tui.requestRender();
await settle(term);
expect(writes.join("").match(ERASE_SCROLLBACK)).toBeNull();
} finally {
tui.stop();
}
});
it("content shrink with scrolled-up viewport must not emit \\x1b[3J", async () => {
const term = new VirtualTerminal(100, 24);
overrideProbe(term, false);
const tui = new TUI(term);
const component = new LineList(Array.from({ length: 80 }, (_, i) => `init-${i}`));
tui.addChild(component);
try {
tui.start();
await settle(term);
const writes = capture(term);
component.setLines(Array.from({ length: 20 }, (_, i) => `shrunk-${i}`));
tui.requestRender();
await settle(term);
expect(writes.join("").match(ERASE_SCROLLBACK)).toBeNull();
} finally {
tui.stop();
}
});
it("height change with unreportable viewport must not emit \\x1b[3J", async () => {
const term = new VirtualTerminal(100, 24);
overrideProbe(term, undefined);
const tui = new TUI(term);
const component = new LineList(Array.from({ length: 40 }, (_, i) => `init-${i}`));
tui.addChild(component);
try {
tui.start();
await settle(term);
const writes = capture(term);
term.resize(100, 25);
await settle(term);
expect(writes.join("").match(ERASE_SCROLLBACK)).toBeNull();
} finally {
tui.stop();
}
});
it("width change with unreportable viewport must not emit \\x1b[3J", async () => {
const term = new VirtualTerminal(100, 24);
overrideProbe(term, undefined);
const tui = new TUI(term);
const component = new LineList(Array.from({ length: 40 }, (_, i) => `init-${i}`));
tui.addChild(component);
try {
tui.start();
await settle(term);
const writes = capture(term);
term.resize(99, 24);
await settle(term);
expect(writes.join("").match(ERASE_SCROLLBACK)).toBeNull();
} finally {
tui.stop();
}
});
});
@@ -1531,6 +1531,156 @@ describe("TUI terminal-state regressions", () => {
tui.stop();
}
});
it("defers bottom-anchored shrink when POSIX viewport state is unknown", async () => {
// Repro for #1566 follow-up (kitty/Linux): a bottom-anchored shrink across the
// viewport boundary used to fall through to `viewportRepaint`, which redrew the
// new transcript at `newLength - height` while leaving rows
// `[newLength - height .. prevLength - height - 1]` already in native
// scrollback — they reappeared at the top of the viewport, duplicating two rows
// at the boundary in the captured trace.
const term = new UnknownViewportTerminal(40, 6);
const tui = new TUI(term);
const body = rows("line-", 12);
const component = new MutableLinesComponent([...body, "spinner-row", "spacer-row", "prompt-row"]);
tui.addChild(component);
try {
tui.start();
await settle(term);
component.setLines([...body, "prompt-row"]);
tui.requestRender();
await settle(term);
const scrollback = term.getScrollBuffer();
for (let i = 0; i < body.length; i++) {
const pattern = new RegExp(`\\bline-${i}\\b`);
expect(
countMatches(scrollback, pattern),
`line-${i} must not duplicate at boundary`,
).toBeLessThanOrEqual(1);
}
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true);
await settle(term);
expect(visible(term).map(line => line.trim())).toEqual([
"line-7",
"line-8",
"line-9",
"line-10",
"line-11",
"prompt-row",
]);
const rebuilt = term.getScrollBuffer();
for (let i = 0; i < body.length; i++) {
const pattern = new RegExp(`\\bline-${i}\\b`);
expect(countMatches(rebuilt, pattern), `line-${i} appears once post-checkpoint`).toBe(1);
}
} finally {
tui.stop();
}
});
it("rebuilds history when a shrink leaves no real rows above the scrollback boundary", async () => {
// Reviewer scenario (#1599): a large completion-style collapse (e.g. a 100-row
// streamed transcript shrinking to a 20-row final cell in a 10-row viewport)
// must NOT use the padded `deferredShrink` — the viewport would fall entirely
// past the end of `newLines` and render as all blanks (no prompt visible) until
// the next checkpoint. Yank the scrollback instead so the new tail stays on
// screen.
const term = new UnknownViewportTerminal(40, 10);
const tui = new TUI(term);
const body = rows("line-", 99);
const component = new MutableLinesComponent([...body, "prompt-row"]);
tui.addChild(component);
try {
tui.start();
await settle(term);
const short = rows("short-", 19);
component.setLines([...short, "prompt-row"]);
tui.requestRender();
await settle(term);
const viewport = visible(term).map(line => line.trim());
expect(viewport).toEqual([
"short-10",
"short-11",
"short-12",
"short-13",
"short-14",
"short-15",
"short-16",
"short-17",
"short-18",
"prompt-row",
]);
const scrollback = term.getScrollBuffer();
for (let i = 0; i < short.length; i++) {
const pattern = new RegExp(`\\bshort-${i}\\b`);
expect(countMatches(scrollback, pattern), `short-${i} appears once`).toBe(1);
}
expect(scrollback.join("\n")).not.toContain("line-");
} finally {
tui.stop();
}
});
it("rebuilds history when prior POSIX repaint left the padded viewport past the new tail", async () => {
const term = new UnknownViewportTerminal(40, 10);
const tui = new TUI(term);
const initial = rows("line-", 19);
const component = new MutableLinesComponent([...initial, "prompt-row"]);
tui.addChild(component);
try {
tui.start();
await settle(term);
// Unknown-POSIX offscreen mutation: repainting the viewport commits the
// 120-row logical frame, but `#emitViewportRepaint` intentionally does not
// advance `#scrollbackHighWater` (it remains at the original 20-row frame's
// 10-row overflow). The later shrink must compare against the padded viewport
// top (`120 - height`) rather than the stale high-water mark.
const expanded = ["edited-line", ...rows("line-", 118), "prompt-row"];
component.setLines(expanded);
tui.requestRender();
await settle(term);
expect(visible(term).map(line => line.trim())).toEqual([
"line-109",
"line-110",
"line-111",
"line-112",
"line-113",
"line-114",
"line-115",
"line-116",
"line-117",
"prompt-row",
]);
const short = [...rows("short-", 14), "prompt-row"];
component.setLines(short);
tui.requestRender();
await settle(term);
expect(visible(term).map(line => line.trim())).toEqual([
"short-5",
"short-6",
"short-7",
"short-8",
"short-9",
"short-10",
"short-11",
"short-12",
"short-13",
"prompt-row",
]);
expect(term.getScrollBuffer().join("\n")).not.toContain("line-");
} finally {
tui.stop();
}
});
it("renders streaming row inserts on WSL Windows Terminal even when viewport probe is unavailable", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-utils",
"version": "15.7.4",
"version": "15.7.5",
"description": "Shared utilities for pi packages",
"homepage": "https://omp.sh",
"author": "Can Boluk",
-1
View File
@@ -27,7 +27,6 @@ const workerEntrypoints = [
"./packages/stats/src/sync-worker.ts",
"./packages/coding-agent/src/tools/browser/tab-worker-entry.ts",
"./packages/coding-agent/src/eval/js/worker-entry.ts",
"./packages/coding-agent/src/tiny/worker.ts",
];
const isDryRun = process.argv.includes("--dry-run");
const targets: BinaryTarget[] = [