Merge origin/main into omp-506

This commit is contained in:
Vu Anh Nguyen
2026-03-26 16:22:21 +07:00
136 changed files with 12487 additions and 1618 deletions
Generated
+3 -3
View File
@@ -1245,9 +1245,9 @@ dependencies = [
[[package]]
name = "html-to-markdown-rs"
version = "2.28.6"
version = "2.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6869b5e058b5ebb8c176269406b692d0695b4b19c36e532b56a2c355590978ae"
checksum = "9013679b8c3600142e5a8f742748c3c38c49d9fc50675dad62f8f1721090a85a"
dependencies = [
"ahash",
"astral-tl",
@@ -2114,7 +2114,7 @@ dependencies = [
[[package]]
name = "pi-natives"
version = "13.14.2"
version = "13.15.0"
dependencies = [
"arboard",
"ast-grep-core",
+1 -1
View File
@@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"]
resolver = "3"
[workspace.package]
version = "13.14.2"
version = "13.15.0"
edition = "2024"
license = "MIT"
authors = ["Can Boluk"]
+4
View File
@@ -3,6 +3,10 @@
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedImports": "error",
"noVoidTypeReturn": "off"
},
"style": {
"noNonNullAssertion": "off",
"useConst": "error",
+17 -17
View File
@@ -15,7 +15,7 @@
},
"packages/agent": {
"name": "@oh-my-pi/pi-agent-core",
"version": "13.14.2",
"version": "13.15.0",
"dependencies": {
"@oh-my-pi/pi-ai": "workspace:*",
"@oh-my-pi/pi-utils": "workspace:*",
@@ -27,7 +27,7 @@
},
"packages/ai": {
"name": "@oh-my-pi/pi-ai",
"version": "13.14.2",
"version": "13.15.0",
"bin": {
"pi-ai": "./src/cli.ts",
},
@@ -51,7 +51,7 @@
},
"packages/coding-agent": {
"name": "@oh-my-pi/pi-coding-agent",
"version": "13.14.2",
"version": "13.15.0",
"bin": {
"omp": "src/cli.ts",
},
@@ -80,7 +80,7 @@
},
"packages/natives": {
"name": "@oh-my-pi/pi-natives",
"version": "13.14.2",
"version": "13.15.0",
"dependencies": {
"@oh-my-pi/pi-utils": "workspace:*",
},
@@ -114,7 +114,7 @@
},
"packages/stats": {
"name": "@oh-my-pi/omp-stats",
"version": "13.14.2",
"version": "13.15.0",
"bin": {
"omp-stats": "./src/index.ts",
},
@@ -139,7 +139,7 @@
},
"packages/swarm-extension": {
"name": "@oh-my-pi/swarm-extension",
"version": "13.14.2",
"version": "13.15.0",
"bin": {
"omp-swarm": "src/cli.ts",
},
@@ -152,7 +152,7 @@
},
"packages/tui": {
"name": "@oh-my-pi/pi-tui",
"version": "13.14.2",
"version": "13.15.0",
"dependencies": {
"@oh-my-pi/pi-natives": "workspace:*",
"@oh-my-pi/pi-utils": "workspace:*",
@@ -165,7 +165,7 @@
},
"packages/utils": {
"name": "@oh-my-pi/pi-utils",
"version": "13.14.2",
"version": "13.15.0",
"dependencies": {
"beautiful-mermaid": "^1.1",
"winston": "^3.19",
@@ -467,21 +467,21 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260321.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260321.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260321.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260321.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260321.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260321.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260321.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260321.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-uScJZRWRxyi1l4EWwOtuO88Gh8sUTi0itcI4oKlyNtXkqik4Y7EHfs1sfYPDuAEJO3cvW6bqohHjGx3mcXSZzQ=="],
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260322.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260322.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260322.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260322.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260322.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260322.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260322.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260322.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-CmzQTKvesYHmz3g92G+XPDis25ocvHqa/gK8m98w+bML99KJLEWQKVlvkLrYA85JiJEK+XBIiz+6lCgUqRkWXA=="],
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260321.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3LQP363bDCF/pmXqzhSCSkKXr1PpNl2elC167YFRPKRyJdrETiIwj3YAB8A6esn9D30pas5VLzfmeK/tUOf+6g=="],
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260322.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5wSilxwLGX5fMKJgsUkCBwOfW9GMG3WF5j77CVBOdFI7miFaR3JQaPzTA+uyHDMNIIeSDo1KtV77GT48Y/d0Xg=="],
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260321.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gCoKiv415CROgl0K8hEV8Lw/zvbYriWWmD7VxvpiQiTRqQmHppVXhLtb2OrGaPcsqpoBdYeCJQHN4wnohAkNLA=="],
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260322.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-G806SrfxkYNAgZ9Xk53+OvbmIg9iD5hjaiD2QhDQL2aZjzy10D4MhcdaZEOoMfw0OI/PoJPYOiPD+9/x2kw3Lg=="],
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260321.1", "", { "os": "linux", "cpu": "arm" }, "sha512-QuAFR9eFQzuqtKTIaJ5XkNR4i5Q55b1SE7fUcIAS528aY9j+5P1cMpvJa8aOBCuRKxfMgV5UtamolZKGWWzaMw=="],
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260322.1", "", { "os": "linux", "cpu": "arm" }, "sha512-0a12pp19ELiNHMqTglfQQQNMsxvtzpjAa4qf12oMJoGyy+UnguKEmaaaCHdp75KvBXGDzlssfDAdiy+NirN19A=="],
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260321.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-15z7UWt0PG870ktcUbaa0NogAjXIYT4pSFWlsc95u8+1aITrBTMQgqRih5qUH8bHke3eeYwbpjfXaU4gNmexvw=="],
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260322.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+FyomEEt3K8TBO//n3Ijr61SDM2F7cxZCVqGt+Wk3rLcOCQ2i+8+p64gdsZCmImy3CyP0hBnxPydEbyNkZLtvg=="],
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260321.1", "", { "os": "linux", "cpu": "x64" }, "sha512-8yuzwkxQnNSpXjXK43Y5Pn6rBfNbJVIcd3Qh9n3Tzhgtr+lcoGgwgMvn8axnqaazkxIUB3PZuiGRcqr6XIq3LA=="],
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260322.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MviQe5x4WqQGv/Vhu4hcv2A0qTW/BTaZPbOLYCtvhuovNFO6D++ZmJAbHvA0h/bJEaNTgxKZdZPHMpCfSEKfjA=="],
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260321.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-fCUk/VElUjMFmE6iFAtsy5r7kLxeLggEHOTWuR0HGYIUQze6EyAdDFqMPFFxvbzpUyFQFpRfUa0I/Fa5tqKh8g=="],
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260322.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-ibnMaXDJPSgMXKC61NHiFlww/xjAEINgc1mcn2ntTfuGHwduU4P9Bi038TxXg95Wmu3v6xIPIorXXsBOdE+p3Q=="],
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260321.1", "", { "os": "win32", "cpu": "x64" }, "sha512-CWGyck7+sbNwOhcL+ObHhtKZe2/+Y6OZlEdWX2mHjpv8ef7ohUbPCdS94p+e7jbVY56w8NAce2Xx7ppn/C1Ucg=="],
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260322.1", "", { "os": "win32", "cpu": "x64" }, "sha512-O+r1RToWBbGkK7NXC7DpraLObSWyxvSqRiSfr/BlZ351Cdq1q3121zCGzVtqERGeRtVoEMRrzS5ITOd6On/pCw=="],
"@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="],
@@ -941,7 +941,7 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
+11 -5
View File
@@ -576,12 +576,18 @@ pub(crate) async fn invoke_command_in_subshell_and_get_output(
rt.block_on(run_substitution_command(subshell, params, s))
});
// Extract output.
let output_str = std::io::read_to_string(reader)?;
// Read subshell output on a blocking thread to avoid stalling the
// async runtime when the pipe stays open (e.g. a hung child process).
let output_join_handle = tokio::task::spawn_blocking(move || {
std::io::read_to_string(reader)
});
// Now observe the command's completion.
let run_result = cmd_join_handle.await?;
let cmd_result = run_result?;
// Wait for both the output reader and the command to complete.
let (output_result, cmd_result) = tokio::join!(output_join_handle, cmd_join_handle);
let output_str = output_result
.map_err(|e| std::io::Error::other(e))??;
let cmd_result = cmd_result
.map_err(|e| std::io::Error::other(e))??;
// Store the status.
*shell.last_exit_status_mut() = cmd_result.exit_code.into();
+3 -3
View File
@@ -5,15 +5,15 @@ Use it for any merge: single file, feature branch, or full release sync.
## Last Sync Point
**Commit:** `1feccfedcb1eeeca91be0b9d389e8e5a9daee505`
**Date:** 2026-03-14
**Commit:** `b21b42d032919de2f2e6920a76fa9a37c3920c0a`
**Date:** 2026-03-22
Update this section after each sync; do not reuse the previous range.
When starting a new sync, generate patches from this commit forward:
```bash
git format-patch 15e0957b045d9e0d49253b2285cb585cf3a75c55..HEAD --stdout > changes.patch
git format-patch b21b42d032919de2f2e6920a76fa9a37c3920c0a..HEAD --stdout > changes.patch
```
## 0) Define the scope
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-agent-core",
"version": "13.14.2",
"version": "13.15.0",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"homepage": "https://github.com/can1357/oh-my-pi",
"author": "Can Boluk",
+29 -35
View File
@@ -196,7 +196,6 @@ async function runLoop(
// Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) {
let hasMoreToolCalls = true;
let steeringAfterTools: AgentMessage[] | null = null;
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
@@ -225,6 +224,7 @@ async function runLoop(
// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
newMessages.push(message);
let steeringMessagesFromExecution: AgentMessage[] | undefined;
if (message.stopReason === "error" || message.stopReason === "aborted") {
// Create placeholder tool results for any tool calls in the aborted message
@@ -250,19 +250,20 @@ async function runLoop(
const toolResults: ToolResultMessage[] = [];
if (hasMoreToolCalls) {
const toolExecution = await executeToolCalls(
const executionResult = await executeToolCalls(
currentContext.tools,
message,
signal,
stream,
config.getSteeringMessages,
config.getToolContext,
config.interruptMode,
config.getToolContext,
config.transformToolCallArguments,
config.intentTracing,
);
toolResults.push(...toolExecution.toolResults);
steeringAfterTools = toolExecution.steeringMessages ?? null;
toolResults.push(...executionResult.toolResults);
steeringMessagesFromExecution = executionResult.steeringMessages;
for (const result of toolResults) {
currentContext.messages.push(result);
@@ -272,13 +273,7 @@ async function runLoop(
stream.push({ type: "turn_end", message, toolResults });
// Get steering messages after turn completes
if (steeringAfterTools && steeringAfterTools.length > 0) {
pendingMessages = steeringAfterTools;
steeringAfterTools = null;
} else {
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
pendingMessages = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
}
// Agent would stop here. Check for follow-up messages.
@@ -433,25 +428,37 @@ async function executeToolCalls(
signal: AbortSignal | undefined,
stream: EventStream<AgentEvent, AgentMessage[]>,
getSteeringMessages?: AgentLoopConfig["getSteeringMessages"],
getToolContext?: AgentLoopConfig["getToolContext"],
interruptMode: AgentLoopConfig["interruptMode"] = "immediate",
getToolContext?: AgentLoopConfig["getToolContext"],
transformToolCallArguments?: AgentLoopConfig["transformToolCallArguments"],
intentTracing?: AgentLoopConfig["intentTracing"],
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall");
const emittedToolResults: ToolResultMessage[] = [];
let steeringMessages: AgentMessage[] | undefined;
const shouldInterruptImmediately = interruptMode !== "wait";
const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
const shouldInterruptImmediately = interruptMode !== "wait";
const steeringAbortController = new AbortController();
const toolSignal = signal
? AbortSignal.any([signal, steeringAbortController.signal])
: steeringAbortController.signal;
const interruptState = { triggered: false };
let steeringMessages: AgentMessage[] | undefined;
let steeringCheck: Promise<void> | null = null;
const records = toolCalls.map(toolCall => ({
toolCall,
tool: tools?.find(t => t.name === toolCall.name),
args: toolCall.arguments as Record<string, unknown>,
started: false,
result: undefined as AgentToolResult<any> | undefined,
isError: false,
skipped: false,
toolResultMessage: undefined as ToolResultMessage | undefined,
resultEmitted: false,
}));
const checkSteering = async (): Promise<void> => {
if (!shouldInterruptImmediately || !getSteeringMessages || interruptState.triggered) {
return;
@@ -473,18 +480,6 @@ async function executeToolCalls(
await steeringCheck;
};
const records = toolCalls.map(toolCall => ({
toolCall,
tool: tools?.find(t => t.name === toolCall.name),
args: toolCall.arguments as Record<string, unknown>,
started: false,
result: undefined as AgentToolResult<any> | undefined,
isError: false,
skipped: false,
toolResultMessage: undefined as ToolResultMessage | undefined,
resultEmitted: false,
}));
const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
if (record.resultEmitted) return;
const { toolCall } = record;
@@ -578,7 +573,6 @@ async function executeToolCalls(
transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs,
tool.nonAbortable ? undefined : toolSignal,
partialResult => {
if (interruptState.triggered) return;
stream.push({
type: "tool_execution_update",
toolCallId: toolCall.id,
@@ -637,13 +631,6 @@ async function executeToolCalls(
return { toolResults: emittedToolResults, steeringMessages };
}
function createSkippedToolResult(): AgentToolResult<any> {
return {
content: [{ type: "text", text: "Skipped due to queued user message." }],
details: {},
};
}
/**
* Create a tool result for a tool call that was aborted or errored before execution.
* Maintains the tool_use/tool_result pairing required by the API.
@@ -690,3 +677,10 @@ function createAbortedToolResult(
return toolResultMessage;
}
function createSkippedToolResult(): AgentToolResult<any> {
return {
content: [{ type: "text", text: "Skipped due to queued user message." }],
details: {},
};
}
+20 -35
View File
@@ -603,33 +603,16 @@ describe("agentLoop with AgentMessage", () => {
expect(text).not.toContain("Tool execution was aborted.:");
}
});
it("should inject queued messages and skip remaining tool calls", async () => {
it("should skip remaining tool calls when steering is queued", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const executed: string[] = [];
const { promise: allowSecond, resolve: allowSecondResolve } = Promise.withResolvers<void>();
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params, signal) {
if (params.value === "second") {
await new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Tool aborted"));
return;
}
const onAbort = () => reject(new Error("Tool aborted"));
signal?.addEventListener("abort", onAbort, { once: true });
allowSecond.then(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
});
});
if (signal?.aborted) {
throw new Error("Tool aborted");
}
}
concurrency: "exclusive",
async execute(_toolCallId, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `ok:${params.value}` }],
@@ -654,11 +637,11 @@ describe("agentLoop with AgentMessage", () => {
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
interruptMode: "immediate",
getSteeringMessages: async () => {
// Return queued message after first tool executes
if (executed.length === 1 && !queuedDelivered) {
// Return steering message after tool execution has started
if (executed.length >= 1 && !queuedDelivered) {
queuedDelivered = true;
allowSecondResolve();
return [queuedUserMessage];
}
return [];
@@ -700,29 +683,31 @@ describe("agentLoop with AgentMessage", () => {
events.push(event);
}
// Only first tool should have executed
// Only the first tool should execute; the second is skipped after steering is queued.
expect(executed).toEqual(["first"]);
// Second tool should be skipped
const toolEnds = events.filter(
(e): e is Extract<AgentEvent, { type: "tool_execution_end" }> => e.type === "tool_execution_end",
);
expect(toolEnds.length).toBe(2);
expect(toolEnds[0].isError).toBeFalsy();
expect(toolEnds[0].isError).toBe(false);
expect(toolEnds[1].isError).toBe(true);
if (toolEnds[1].result.content[0]?.type === "text") {
expect(toolEnds[1].result.content[0].text).toContain("Skipped due to queued user message");
}
// Queued message should appear in events
const queuedMessageEvent = events.find(
e =>
e.type === "message_start" &&
e.message.role === "user" &&
typeof e.message.content === "string" &&
e.message.content === "interrupt",
);
expect(queuedMessageEvent).toBeDefined();
// Queued message should appear in events after the tool results and before the next model call.
const eventSequence = events.flatMap(event => {
if (event.type !== "message_start") return [];
if (event.message.role === "toolResult") return [`tool:${event.message.toolCallId}`];
if (event.message.role === "user" && typeof event.message.content === "string") {
return [event.message.content];
}
return [];
});
expect(eventSequence).toContain("interrupt");
expect(eventSequence.indexOf("tool:tool-1")).toBeLessThan(eventSequence.indexOf("interrupt"));
expect(eventSequence.indexOf("tool:tool-2")).toBeLessThan(eventSequence.indexOf("interrupt"));
// Interrupt message should be in context when second LLM call is made
expect(sawInterruptInContext).toBe(true);
+5
View File
@@ -1,12 +1,17 @@
# Changelog
## [Unreleased]
## [13.15.0] - 2026-03-23
### Added
- Added `isUsageLimitError()` to `rate-limit-utils` as a single source of truth for detecting usage/quota limit errors across all providers
### Fixed
- Fixed lazy stream forwarding to properly handle final results from source streams with `result()` methods
- Fixed lazy stream error handling to convert iterator failures into terminal error results instead of silently failing
- Fixed `parseRateLimitReason` to recognize "usage limit" in error messages and correctly classify them as `QUOTA_EXHAUSTED`
- Fixed Codex `fetchWithRetry` retrying 429 responses for `usage_limit_reached` errors for up to 5 minutes instead of returning immediately for credential switching
- Removed `usage.?limit` from `TRANSIENT_MESSAGE_PATTERN` in retry utils since usage limits are not transient and require credential rotation
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-ai",
"version": "13.14.2",
"version": "13.15.0",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"homepage": "https://github.com/can1357/oh-my-pi",
"author": "Can Boluk",
+1
View File
@@ -16,6 +16,7 @@ export * from "./providers/google";
export * from "./providers/google-gemini-cli";
export * from "./providers/google-vertex";
export * from "./providers/kimi";
export type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses";
export * from "./providers/openai-completions";
export * from "./providers/openai-responses";
export * from "./providers/synthetic";
@@ -360,6 +360,13 @@ function handleContentBlockStop(
/**
* Check if the model supports prompt caching.
* Supported: Claude 3.5 Haiku, Claude 3.7 Sonnet, Claude 4.x+ models, Haiku 4.5+
*
* For base models and system-defined inference profiles the model ID / ARN
* contains the model name, so we can decide locally.
*
* For application inference profiles (whose ARNs don't contain the model name),
* set AWS_BEDROCK_FORCE_CACHE=1 to enable cache points. Amazon Nova models
* have automatic caching and don't need explicit cache points.
*/
function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean {
if (model.cost.cacheRead || model.cost.cacheWrite) return true;
@@ -370,6 +377,9 @@ function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean
if (id.includes("claude-3-7-sonnet") || id.includes("claude-3-5-haiku")) return true;
// Claude Haiku 4.5+ (new naming)
if (id.includes("claude-haiku")) return true;
// Application inference profiles don't contain the model name in the ARN.
// Allow users to force cache points via environment variable.
if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") return true;
return false;
}
+31 -12
View File
@@ -376,6 +376,12 @@ export interface AnthropicOptions extends StreamOptions {
betas?: string[] | string;
/** Force OAuth bearer auth mode for proxy tokens that don't match Anthropic token prefixes. */
isOAuth?: boolean;
/**
* Pre-built Anthropic client instance. When provided, skips internal client
* construction entirely. Use this to inject alternative SDK clients such as
* `AnthropicVertex` that shares the same messaging API.
*/
client?: Anthropic;
}
export type AnthropicClientOptionsArgs = {
@@ -611,19 +617,31 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
let rawRequestDump: RawHttpRequestDump | undefined;
try {
const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? "";
const baseUrl = resolveAnthropicBaseUrl(model, apiKey) ?? "https://api.anthropic.com";
let client: Anthropic;
let isOAuthToken: boolean;
const { client, isOAuthToken } = createClient(model, {
model,
apiKey,
extraBetas: normalizeExtraBetas(options?.betas),
stream: true,
interleavedThinking: options?.interleavedThinking ?? true,
headers: options?.headers,
dynamicHeaders: copilotDynamicHeaders?.headers,
isOAuth: options?.isOAuth,
});
if (options?.client) {
client = options.client;
isOAuthToken = false;
} else {
const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? "";
const created = createClient(model, {
model,
apiKey,
extraBetas: normalizeExtraBetas(options?.betas),
stream: true,
interleavedThinking: options?.interleavedThinking ?? true,
headers: options?.headers,
dynamicHeaders: copilotDynamicHeaders?.headers,
isOAuth: options?.isOAuth,
});
client = created.client;
isOAuthToken = created.isOAuthToken;
}
const baseUrl =
resolveAnthropicBaseUrl(model, options?.apiKey ?? getEnvApiKey(model.provider) ?? "") ??
"https://api.anthropic.com";
let params = buildParams(model, baseUrl, context, isOAuthToken, options);
const replacementPayload = await options?.onPayload?.(params, model);
if (replacementPayload !== undefined) {
@@ -661,6 +679,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
for await (const event of anthropicStream) {
started = true;
if (event.type === "message_start") {
output.responseId = event.message.id;
// Capture initial token usage from message_start event
// This ensures we have input token counts even if the stream is aborted early
output.usage.input = event.message.usage.input_tokens || 0;
+21 -8
View File
@@ -68,6 +68,20 @@ export function requiresToolCallId(modelId: string): boolean {
return modelId.startsWith("claude-");
}
function getGeminiMajorVersion(modelId: string): number | undefined {
const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/);
if (!match) return undefined;
return Number.parseInt(match[1], 10);
}
function supportsMultimodalFunctionResponse(modelId: string): boolean {
const geminiMajorVersion = getGeminiMajorVersion(modelId);
if (geminiMajorVersion !== undefined) {
return geminiMajorVersion >= 3;
}
return true;
}
function isGemini3Model(modelId: string): boolean {
return modelId.includes("gemini-3");
}
@@ -189,10 +203,10 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
const hasText = textResult.length > 0;
const hasImages = imageContent.length > 0;
// Gemini 3 supports multimodal function responses with images nested inside functionResponse.parts
// See: https://ai.google.dev/gemini-api/docs/function-calling#multimodal
// Older models don't support this, so we put images in a separate user message.
const supportsMultimodalFunctionResponse = model.id.includes("gemini-3");
// Gemini 3+ models support multimodal function responses with images nested inside
// functionResponse.parts. Claude and other non-Gemini models behind Cloud Code Assist /
// Antigravity also accept this shape. Gemini < 3 still needs a separate user image turn.
const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id);
// Use "output" key for success, "error" key for errors as per SDK documentation
const responseValue = hasText ? textResult.toWellFormed() : hasImages ? "(see attached image)" : "";
@@ -209,8 +223,7 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
functionResponse: {
name: msg.toolName,
response: msg.isError ? { error: responseValue } : { output: responseValue },
// Nest images inside functionResponse.parts for Gemini 3
...(hasImages && supportsMultimodalFunctionResponse && { parts: imageParts }),
...(hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }),
...(includeId ? { id: msg.toolCallId } : {}),
},
};
@@ -231,8 +244,8 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
});
}
// For older models, add images in a separate user message
if (hasImages && !supportsMultimodalFunctionResponse) {
// For Gemini < 3, add images in a separate user message
if (hasImages && !modelSupportsMultimodalFunctionResponse) {
contents.push({
role: "user",
parts: [{ text: "Tool result image:" }, ...imageParts],
@@ -811,7 +811,7 @@ function handleCodexStreamEvent(args: {
return handleResponseCreated(runtime, rawEvent);
}
if (eventType === "response.completed" || eventType === "response.done") {
if (eventType === "response.completed" || eventType === "response.done" || eventType === "response.incomplete") {
handleResponseCompleted(model, output, runtime, rawEvent);
return firstTokenTime;
}
@@ -1046,6 +1046,9 @@ function handleResponseCompleted(
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
if (typeof response?.id === "string" && response.id.length > 0) {
output.responseId = response.id;
}
const state = runtime.websocketState;
if (runtime.transport === "websocket" && state) {
@@ -1764,6 +1767,7 @@ class CodexWebSocketConnection {
if (
eventType === "response.completed" ||
eventType === "response.done" ||
eventType === "response.incomplete" ||
eventType === "response.failed" ||
eventType === "error"
) {
@@ -89,7 +89,13 @@ export function detectOpenAICompat(model: Model<"openai-completions">): Resolved
requiresAssistantAfterToolResult: false,
requiresThinkingAsText: isMistral,
requiresMistralToolIds: isMistral,
thinkingFormat: isZai ? "zai" : isAlibaba || isQwen ? "qwen" : "openai",
thinkingFormat: isZai
? "zai"
: provider === "openrouter" || baseUrl.includes("openrouter.ai")
? "openrouter"
: isAlibaba || isQwen
? "qwen"
: "openai",
reasoningContentField: "reasoning_content",
requiresReasoningContentForToolCalls: isKimiModel,
requiresAssistantContentForToolCalls: isKimiModel,
+38 -11
View File
@@ -337,11 +337,17 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
errorMessage: "OpenAI completions stream stalled while waiting for the next event",
onIdle: () => requestAbortController.abort(),
})) {
if (!chunk || typeof chunk !== "object") continue;
// OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
// and each chunk in a streamed completion carries the same id.
output.responseId ||= chunk.id;
if (chunk.usage) {
output.usage = parseChunkUsage(chunk.usage, model, copilotPremiumRequests);
}
const choice = chunk.choices[0];
const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice) continue;
if (!chunk.usage) {
@@ -352,7 +358,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
}
if (choice.finish_reason) {
output.stopReason = mapStopReason(choice.finish_reason);
const finishReasonResult = mapStopReason(choice.finish_reason);
output.stopReason = finishReasonResult.stopReason;
if (finishReasonResult.errorMessage) {
output.errorMessage = finishReasonResult.errorMessage;
}
}
if (choice.delta) {
@@ -463,8 +473,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unknown error occurred");
if (output.stopReason === "aborted") {
throw new Error("Request was aborted");
}
if (output.stopReason === "error") {
throw new Error(output.errorMessage || "Provider returned an error stop reason");
}
output.duration = Date.now() - startTime;
@@ -616,6 +629,12 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
Reflect.set(params, "enable_thinking", !!options?.reasoning);
} else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {
Reflect.set(params, "chat_template_kwargs", { enable_thinking: !!options?.reasoning });
} else if (compat.thinkingFormat === "openrouter" && options?.reasoning && model.reasoning) {
// OpenRouter normalizes reasoning across providers via a nested reasoning object.
const openRouterParams = params as typeof params & { reasoning?: { effort?: string } };
openRouterParams.reasoning = {
effort: mapReasoningEffort(options.reasoning, compat.reasoningEffortMap),
};
} else if (options?.reasoning && model.reasoning && compat.supportsReasoningEffort) {
// OpenAI-style reasoning_effort
Reflect.set(params, "reasoning_effort", mapReasoningEffort(options.reasoning, compat.reasoningEffortMap));
@@ -1061,21 +1080,29 @@ function convertTools(tools: Tool[], compat: ResolvedOpenAICompat): OpenAI.Chat.
});
}
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): StopReason {
if (reason === null) return "stop";
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
stopReason: StopReason;
errorMessage?: string;
} {
if (reason === null) return { stopReason: "stop" };
switch (reason) {
case "stop":
case "end":
return "stop";
return { stopReason: "stop" };
case "length":
return "length";
return { stopReason: "length" };
case "function_call":
case "tool_calls":
return "toolUse";
return { stopReason: "toolUse" };
case "content_filter":
return "error";
return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" };
case "network_error":
return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" };
default:
throw new Error(`Unhandled stop reason: ${reason}`);
return {
stopReason: "error",
errorMessage: `Provider finish_reason: ${reason}`,
};
}
}
@@ -52,8 +52,26 @@ export function parseTextSignature(
return { id: signature };
}
export function normalizeResponsesToolCallIdForTransform(id: string): string {
export function normalizeResponsesToolCallIdForTransform(
id: string,
model?: Model<Api>,
source?: AssistantMessage,
): string {
if (!id.includes("|")) return id;
const isForeignToolCall =
source != null && model != null && (source.provider !== model.provider || source.api !== model.api);
if (isForeignToolCall) {
const [callId, itemId] = id.split("|");
const normalizeIdPart = (part: string): string => {
const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
const truncated = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
return truncated.replace(/_+$/, "");
};
const normalizedCallId = normalizeIdPart(callId);
let normalizedItemId = `fc_${Bun.hash(itemId).toString(36)}`;
if (normalizedItemId.length > 64) normalizedItemId = normalizedItemId.slice(0, 64);
return `${normalizedCallId}|${normalizedItemId}`;
}
const normalized = normalizeResponsesToolCallId(id);
return `${normalized.callId}|${normalized.itemId}`;
}
@@ -221,7 +239,9 @@ export async function processResponsesStream<TApi extends Api>(
let sawFirstToken = false;
for await (const event of openaiStream) {
if (event.type === "response.output_item.added") {
if (event.type === "response.created") {
output.responseId = event.response.id;
} else if (event.type === "response.output_item.added") {
if (!sawFirstToken) {
sawFirstToken = true;
options?.onFirstToken?.();
@@ -376,6 +396,9 @@ export async function processResponsesStream<TApi extends Api>(
}
} else if (event.type === "response.completed") {
const response = event.response;
if (response?.id) {
output.responseId = response.id;
}
if (response?.usage) {
const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0;
output.usage = {
@@ -0,0 +1,329 @@
/**
* Lazy provider module loading.
*
* Each provider module is loaded only when its stream function is first called.
* This avoids eagerly importing heavy SDK dependencies (e.g., @anthropic-ai/sdk,
* openai) at startup. The loaded module promise is cached so subsequent calls
* reuse the same import.
*
* NOTE: stream.ts currently imports providers directly, so this file is not yet
* wired into the main streaming path. It provides the infrastructure for lazy
* loading that can be integrated when stream.ts is refactored.
*/
import type {
Api,
AssistantMessage,
AssistantMessageEvent,
AssistantMessageEventStream,
Context,
Model,
OptionsForApi,
} from "../types";
import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream";
import type { BedrockOptions } from "./amazon-bedrock";
import type { AnthropicOptions } from "./anthropic";
import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses";
import type { CursorOptions } from "./cursor";
import type { GoogleOptions } from "./google";
import type { GoogleGeminiCliOptions } from "./google-gemini-cli";
import type { GoogleVertexOptions } from "./google-vertex";
import type { OpenAICodexResponsesOptions } from "./openai-codex-responses";
import type { OpenAICompletionsOptions } from "./openai-completions";
import type { OpenAIResponsesOptions } from "./openai-responses";
// ---------------------------------------------------------------------------
// Lazy provider module shape
// ---------------------------------------------------------------------------
interface LazyProviderModule<TApi extends Api> {
stream: (model: Model<TApi>, context: Context, options: OptionsForApi<TApi>) => AsyncIterable<AssistantMessageEvent>;
}
interface AnthropicProviderModule {
streamAnthropic: (
model: Model<"anthropic-messages">,
context: Context,
options: AnthropicOptions,
) => AssistantMessageEventStream;
}
interface AzureOpenAIResponsesProviderModule {
streamAzureOpenAIResponses: (
model: Model<"azure-openai-responses">,
context: Context,
options: AzureOpenAIResponsesOptions,
) => AssistantMessageEventStream;
}
interface GoogleProviderModule {
streamGoogle: (
model: Model<"google-generative-ai">,
context: Context,
options: GoogleOptions,
) => AssistantMessageEventStream;
}
interface GoogleGeminiCliProviderModule {
streamGoogleGeminiCli: (
model: Model<"google-gemini-cli">,
context: Context,
options: GoogleGeminiCliOptions,
) => AssistantMessageEventStream;
}
interface GoogleVertexProviderModule {
streamGoogleVertex: (
model: Model<"google-vertex">,
context: Context,
options: GoogleVertexOptions,
) => AssistantMessageEventStream;
}
interface OpenAICodexResponsesProviderModule {
streamOpenAICodexResponses: (
model: Model<"openai-codex-responses">,
context: Context,
options: OpenAICodexResponsesOptions,
) => AssistantMessageEventStream;
}
interface OpenAICompletionsProviderModule {
streamOpenAICompletions: (
model: Model<"openai-completions">,
context: Context,
options: OpenAICompletionsOptions,
) => AssistantMessageEventStream;
}
interface OpenAIResponsesProviderModule {
streamOpenAIResponses: (
model: Model<"openai-responses">,
context: Context,
options: OpenAIResponsesOptions,
) => AssistantMessageEventStream;
}
interface CursorProviderModule {
streamCursor: (
model: Model<"cursor-agent">,
context: Context,
options: CursorOptions,
) => AssistantMessageEventStream;
}
interface BedrockProviderModule {
streamBedrock: (
model: Model<"bedrock-converse-stream">,
context: Context,
options: BedrockOptions,
) => AssistantMessageEventStream;
}
// ---------------------------------------------------------------------------
// Module-level lazy promise caches
// ---------------------------------------------------------------------------
const importNodeOnlyProvider = (specifier: string): Promise<unknown> => import(specifier);
let anthropicProviderModulePromise: Promise<LazyProviderModule<"anthropic-messages">> | undefined;
let azureOpenAIResponsesProviderModulePromise: Promise<LazyProviderModule<"azure-openai-responses">> | undefined;
let googleProviderModulePromise: Promise<LazyProviderModule<"google-generative-ai">> | undefined;
let googleGeminiCliProviderModulePromise: Promise<LazyProviderModule<"google-gemini-cli">> | undefined;
let googleVertexProviderModulePromise: Promise<LazyProviderModule<"google-vertex">> | undefined;
let openAICodexResponsesProviderModulePromise: Promise<LazyProviderModule<"openai-codex-responses">> | undefined;
let openAICompletionsProviderModulePromise: Promise<LazyProviderModule<"openai-completions">> | undefined;
let openAIResponsesProviderModulePromise: Promise<LazyProviderModule<"openai-responses">> | undefined;
let cursorProviderModulePromise: Promise<LazyProviderModule<"cursor-agent">> | undefined;
let bedrockProviderModuleOverride: LazyProviderModule<"bedrock-converse-stream"> | undefined;
let bedrockProviderModulePromise: Promise<LazyProviderModule<"bedrock-converse-stream">> | undefined;
export function setBedrockProviderModule(module: BedrockProviderModule): void {
bedrockProviderModuleOverride = {
stream: module.streamBedrock,
};
}
// ---------------------------------------------------------------------------
// Stream forwarding / error helpers
// ---------------------------------------------------------------------------
function hasFinalResult(
source: AsyncIterable<AssistantMessageEvent>,
): source is AsyncIterable<AssistantMessageEvent> & { result(): Promise<AssistantMessage> } {
return typeof (source as { result?: unknown }).result === "function";
}
function forwardStream<TApi extends Api>(
target: EventStreamImpl,
source: AsyncIterable<AssistantMessageEvent>,
model: Model<TApi>,
): void {
(async () => {
try {
for await (const event of source) {
target.push(event);
}
if (hasFinalResult(source)) {
target.end(await source.result());
} else {
target.end();
}
} catch (error) {
const message = createLazyLoadErrorMessage(model, error);
target.push({ type: "error", reason: "error", error: message });
target.end(message);
}
})();
}
function createLazyLoadErrorMessage<TApi extends Api>(model: Model<TApi>, error: unknown): AssistantMessage {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
}
// ---------------------------------------------------------------------------
// Generic lazy stream factory
// ---------------------------------------------------------------------------
function createLazyStream<TApi extends Api>(
loadModule: () => Promise<LazyProviderModule<TApi>>,
): (model: Model<TApi>, context: Context, options: OptionsForApi<TApi>) => EventStreamImpl {
return (model, context, options) => {
const outer = new EventStreamImpl();
loadModule()
.then(module => {
const inner = module.stream(model, context, options);
forwardStream(outer, inner, model);
})
.catch(error => {
const message = createLazyLoadErrorMessage(model, error);
outer.push({ type: "error", reason: "error", error: message });
outer.end(message);
});
return outer;
};
}
// ---------------------------------------------------------------------------
// Module loaders (one per provider, cached via ||=)
// ---------------------------------------------------------------------------
function loadAnthropicProviderModule(): Promise<LazyProviderModule<"anthropic-messages">> {
anthropicProviderModulePromise ||= import("./anthropic").then(module => {
const provider = module as AnthropicProviderModule;
return { stream: provider.streamAnthropic };
});
return anthropicProviderModulePromise;
}
function loadAzureOpenAIResponsesProviderModule(): Promise<LazyProviderModule<"azure-openai-responses">> {
azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses").then(module => {
const provider = module as AzureOpenAIResponsesProviderModule;
return { stream: provider.streamAzureOpenAIResponses };
});
return azureOpenAIResponsesProviderModulePromise;
}
function loadGoogleProviderModule(): Promise<LazyProviderModule<"google-generative-ai">> {
googleProviderModulePromise ||= import("./google").then(module => {
const provider = module as GoogleProviderModule;
return { stream: provider.streamGoogle };
});
return googleProviderModulePromise;
}
function loadGoogleGeminiCliProviderModule(): Promise<LazyProviderModule<"google-gemini-cli">> {
googleGeminiCliProviderModulePromise ||= import("./google-gemini-cli").then(module => {
const provider = module as GoogleGeminiCliProviderModule;
return { stream: provider.streamGoogleGeminiCli };
});
return googleGeminiCliProviderModulePromise;
}
function loadGoogleVertexProviderModule(): Promise<LazyProviderModule<"google-vertex">> {
googleVertexProviderModulePromise ||= import("./google-vertex").then(module => {
const provider = module as GoogleVertexProviderModule;
return { stream: provider.streamGoogleVertex };
});
return googleVertexProviderModulePromise;
}
function loadOpenAICodexResponsesProviderModule(): Promise<LazyProviderModule<"openai-codex-responses">> {
openAICodexResponsesProviderModulePromise ||= import("./openai-codex-responses").then(module => {
const provider = module as OpenAICodexResponsesProviderModule;
return { stream: provider.streamOpenAICodexResponses };
});
return openAICodexResponsesProviderModulePromise;
}
function loadOpenAICompletionsProviderModule(): Promise<LazyProviderModule<"openai-completions">> {
openAICompletionsProviderModulePromise ||= import("./openai-completions").then(module => {
const provider = module as OpenAICompletionsProviderModule;
return { stream: provider.streamOpenAICompletions };
});
return openAICompletionsProviderModulePromise;
}
function loadOpenAIResponsesProviderModule(): Promise<LazyProviderModule<"openai-responses">> {
openAIResponsesProviderModulePromise ||= import("./openai-responses").then(module => {
const provider = module as OpenAIResponsesProviderModule;
return { stream: provider.streamOpenAIResponses };
});
return openAIResponsesProviderModulePromise;
}
function loadCursorProviderModule(): Promise<LazyProviderModule<"cursor-agent">> {
cursorProviderModulePromise ||= import("./cursor").then(module => {
const provider = module as CursorProviderModule;
return { stream: provider.streamCursor };
});
return cursorProviderModulePromise;
}
function loadBedrockProviderModule(): Promise<LazyProviderModule<"bedrock-converse-stream">> {
if (bedrockProviderModuleOverride) {
return Promise.resolve(bedrockProviderModuleOverride);
}
bedrockProviderModulePromise ||= importNodeOnlyProvider("./amazon-bedrock").then(module => {
const provider = module as BedrockProviderModule;
return { stream: provider.streamBedrock };
});
return bedrockProviderModulePromise;
}
// ---------------------------------------------------------------------------
// Lazy stream function exports
//
// These use the same names as the direct provider stream functions. When
// stream.ts is updated to import from this module instead of individual
// providers, the lazy loading will take effect on the main code path.
// ---------------------------------------------------------------------------
export const streamAnthropic = createLazyStream(loadAnthropicProviderModule);
export const streamAzureOpenAIResponses = createLazyStream(loadAzureOpenAIResponsesProviderModule);
export const streamGoogle = createLazyStream(loadGoogleProviderModule);
export const streamGoogleGeminiCli = createLazyStream(loadGoogleGeminiCliProviderModule);
export const streamGoogleVertex = createLazyStream(loadGoogleVertexProviderModule);
export const streamOpenAICodexResponses = createLazyStream(loadOpenAICodexResponsesProviderModule);
export const streamOpenAICompletions = createLazyStream(loadOpenAICompletionsProviderModule);
export const streamOpenAIResponses = createLazyStream(loadOpenAIResponsesProviderModule);
export const streamCursor = createLazyStream(loadCursorProviderModule);
export const streamBedrock = createLazyStream(loadBedrockProviderModule);
+3 -2
View File
@@ -338,6 +338,7 @@ export interface AssistantMessage {
api: Api;
provider: Provider;
model: string;
responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one
usage: Usage;
stopReason: StopReason;
errorMessage?: string;
@@ -444,8 +445,8 @@ export interface OpenAICompat {
requiresThinkingAsText?: boolean;
/** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */
requiresMistralToolIds?: boolean;
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }, "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */
thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template";
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "zai" uses thinking: { type: "enabled" }, "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */
thinkingFormat?: "openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template";
/** Which reasoning content field to emit on assistant messages. Default: auto-detected. */
reasoningContentField?: "reasoning_content" | "reasoning" | "reasoning_text";
/** Whether assistant tool-call messages must include reasoning content. Default: false. */
@@ -0,0 +1,95 @@
import { describe, expect, it } from "bun:test";
import { setBedrockProviderModule, streamBedrock } from "../src/providers/register-builtins";
import type { AssistantMessage, Context, Model } from "../src/types";
import type { AssistantMessageEventStream } from "../src/utils/event-stream";
function createModel(): Model<"bedrock-converse-stream"> {
return {
id: "mock-bedrock",
name: "Mock Bedrock",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://example.invalid",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
};
}
function createAssistantMessage(
stopReason: AssistantMessage["stopReason"] = "stop",
errorMessage?: string,
): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text: errorMessage ? `error: ${errorMessage}` : "ok" }],
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: "mock-bedrock",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason,
errorMessage,
timestamp: Date.now(),
};
}
const baseContext: Context = { messages: [] };
describe("register-builtins lazy streams", () => {
it("resolves the outer stream result from source.result() when no terminal event is iterated", async () => {
const finalMessage = createAssistantMessage("stop");
const partialMessage = createAssistantMessage("stop");
const source = {
async *[Symbol.asyncIterator]() {
yield { type: "start", partial: partialMessage } as const;
},
result: async () => finalMessage,
} as unknown as AssistantMessageEventStream;
setBedrockProviderModule({
streamBedrock: () => source,
});
const stream = streamBedrock(createModel(), baseContext, {});
const result = await Promise.race([stream.result(), Bun.sleep(100).then(() => "timeout" as const)]);
expect(result).not.toBe("timeout");
if (result === "timeout") {
throw new Error("Timed out waiting for forwarded stream result");
}
expect(result).toEqual(finalMessage);
});
it("turns iterator failures into terminal error results", async () => {
const partialMessage = createAssistantMessage("stop");
const source = {
async *[Symbol.asyncIterator]() {
yield { type: "start", partial: partialMessage } as const;
throw new Error("bedrock exploded");
},
} as unknown as AssistantMessageEventStream;
setBedrockProviderModule({
streamBedrock: () => source,
});
const stream = streamBedrock(createModel(), baseContext, {});
const result = await Promise.race([stream.result(), Bun.sleep(100).then(() => "timeout" as const)]);
expect(result).not.toBe("timeout");
if (result === "timeout") {
throw new Error("Timed out waiting for forwarded error result");
}
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("bedrock exploded");
});
});
+130
View File
@@ -1,8 +1,70 @@
# Changelog
## [Unreleased]
## [13.15.0] - 2026-03-23
### Breaking Changes
- Changed hashline edit schema from flat `op`/`pos`/`end`/`lines` fields to structured `loc`/`content` format with location-specific objects
- Renamed hashline edit operations: `replace_line` → `{ line: anchor }`, `replace_range` → `{ block: { pos, end } }`, `append_at` → `{ append: anchor }`, `prepend_at` → `{ prepend: anchor }`, `append_file` → `"append"`, `prepend_file` → `"prepend"`
- Changed `lines` parameter to `content` in hashline edit entries
- Renamed hashline edit operation types: `append` → `append_at`, `prepend` → `prepend_at`, `append_eof` → `append_file`, `prepend_bof` → `prepend_file`
- Changed hashline edit operation types from `replace` (with optional `end`) to explicit `replace_line` and `replace_range` operations
- Added required `append_eof` and `prepend_bof` operations for file-level edits; `append` and `prepend` now require an anchor position
- Made `pos` parameter required for `replace_line`, `append`, and `prepend` operations; `append_eof` and `prepend_bof` no longer accept anchors
### Added
- Added prompt for tradeoff metrics during autoresearch setup to collect secondary metrics alongside primary metric
- Added validation of contract path specifications to reject absolute paths and parent directory references
- Added stricter benchmark command validation in `isAutoresearchShCommand()` to reject chained commands, pipes, and redirects
- Added protection against prototype pollution in ASI data and metric cloning by filtering `__proto__`, `constructor`, and `prototype` keys
- Added `autoResumeArmed` flag to track when autoresearch should automatically resume pending runs
- Added `lastAutoResumePendingRunNumber` to prevent duplicate auto-resume prompts for the same pending run
- Added `git clean -X` invocation during failed experiment rollback to remove ignored build artifacts
- Added validation to reject `init_experiment` when a previous run is still pending and unlogged
- Added autoresearch contract system for validating benchmark commands, metrics, scope paths, off-limits paths, and constraints with fingerprint tracking to detect configuration drift
- Added `autoresearch.program.md` support for repo-local playbook overlays that guide session strategy while preserving `autoresearch.md` as source of truth
- Added pending run artifact tracking and recovery to resume incomplete experiments from `.autoresearch/runs/` directory with run numbers and benchmark logs
- Added run directory organization with numbered run artifacts, benchmark logs, and optional checks logs for experiment traceability
- Added segment fingerprinting to detect when benchmark configuration changes between runs and warn about potential incomparability
- Added support for secondary metrics tracking alongside primary metric with configurable direction (lower/higher is better)
- Added `getCurrentAutoresearchBranch()` helper to detect and validate existing autoresearch branches for session resumption
- Added `PendingRunSummary` type to track unlogged run state including parsed metrics, ASI data, and pass/fail status
- Added hidden next-turn message delivery via `deliverAs: 'nextTurn'` with optional `triggerTurn` to queue context for next LLM call without exposing in editable queue
- Added `#queueHiddenNextTurnMessage()` and `#promptQueuedHiddenNextTurnMessages()` to AgentSession for autonomous tool reactions
- Added resume context support in `command-resume.md` template for user-provided guidance when resuming sessions
- Added current segment snapshot display in autoresearch prompt showing recent runs, baseline metrics, and best results
- Added pending run indicator in autoresearch prompt to guide users to complete unlogged experiments before starting new benchmarks
- Added local playbook section in autoresearch prompt when `autoresearch.program.md` exists
- Added tab replacement in dashboard and tool output rendering to prevent display corruption from shell commands with tabs
- Added boundary duplication warning when replace_range or replace_line operations include a last inserted line that matches the next surviving line, helping detect off-by-one range errors
- Added git branch isolation for autoresearch sessions via `ensureAutoresearchBranch()` to safely revert failed experiments
- Added branch status line to autoresearch initialization and resume prompts showing created or reused branch name
- Added `Files in Scope`, `Off Limits`, and `Constraints` sections to autoresearch.md template for explicit scope definition
- Added validation of ASI metadata requirements in `log_experiment` tool, requiring hypothesis for all runs and rollback context for failed runs
- Added keybinding matcher utilities `matchesAppInterrupt()` and `matchesSelectCancel()` for consistent escape key handling across components
- Added support for customizable `app.interrupt` and `tui.select.cancel` keybindings in interactive components
- Added `defaultInactive` property to `ToolDefinition` to allow tools to be registered but excluded from the initial active set, with extension responsibility for activation/deactivation
- Added dynamic tool activation/deactivation in autoresearch mode via `setActiveTools()` API
- Added separate initialization and resume workflows for autoresearch with `command-initialize.md` and `command-resume.md` prompts
- Added intent dialog to prompt users for autoresearch optimization goals when starting fresh
- Added automatic detection of existing `autoresearch.md` to resume from previous sessions without re-prompting for intent
- Added autoresearch extension with autonomous experiment loop capabilities
- Added `init_experiment` tool to initialize and reset autoresearch sessions with configurable metrics
- Added `log_experiment` tool to record experiment results with metric parsing and confidence tracking
- Added `run_experiment` tool to execute commands and capture metrics with timeout and crash detection
- Added autoresearch dashboard controller for displaying experiment results and optimization progress
- Added support for secondary metrics tracking alongside primary metric
- Added `ExtensionWidgetContent` and `ExtensionUiComponentFactory` types for flexible widget configuration
- Added `ExtensionWidgetOptions` interface with `placement` parameter to position widgets above or below editor
- Added `WidgetPlacement` type supporting 'aboveEditor' and 'belowEditor' placement options
- Added `hookWidgetContainerAbove` and `hookWidgetContainerBelow` containers to InteractiveMode for separate widget management
- Added autoresearch mode for autonomous experiment loops with init_experiment, log_experiment, and run_experiment tools
- Added autoresearch dashboard widget displaying experiment results, metrics, and optimization progress
- Added support for metric tracking with configurable direction (lower/higher is better) and secondary metrics
- Added widget placement options to position extensions above or below the editor via `placement` parameter
- Added `ExtensionWidgetContent` and `ExtensionWidgetOptions` types for flexible widget configuration
- Added ACP (Agent Client Protocol) mode for headless agent operation via `--mode acp`
- Added support for Agent Client Protocol SDK integration with session management, MCP server configuration, and streaming communication
- Added `ensureOnDisk()` method to SessionManager to persist sessions immediately for ACP discovery
@@ -10,8 +72,76 @@
### Changed
- Changed `isAutoresearchShCommand()` to use proper command-line argument parsing instead of regex, improving accuracy for complex shell invocations
- Changed autoresearch initialization prompt to display collected tradeoff metrics in the setup summary
- Changed `command-initialize.md` template to include guidance on preflight requirements, comparability invariants, and marking measurement-critical files as off-limits
- Changed `command-initialize.md` to instruct users to write or update `autoresearch.program.md` with durable heuristics and repo-specific strategy
- Changed autoresearch resume guidance to emphasize continuing on the current protected branch rather than switching branches
- Changed autoresearch prompt to clarify that `autoresearch.md` holds durable conclusions while `autoresearch.ideas.md` is the scratch backlog
- Changed autoresearch prompt guidance to require stable measurement harness and fixed benchmark inputs unless intentionally starting a new segment
- Changed autoresearch prompt to recommend keeping equal or near-equal results when they materially simplify implementation
- Changed `init_experiment` to reset pending run state (checks, duration, ASI, artifact directory) when initializing a new segment
- Changed `log_experiment` to set `autoResumeArmed` flag after successfully logging a run to enable auto-resume on next agent turn
- Changed `run_experiment` to set `autoResumeArmed` flag and update dashboard after completing a run
- Changed auto-resume logic to only prompt when a new pending run exists or when `autoResumeArmed` is explicitly set, preventing duplicate prompts
- Changed path normalization in contract validation to use `path.posix.normalize()` for consistent path handling
- Changed autoresearch initialization to collect and validate benchmark command, metric definition, scope paths, off-limits list, and constraints before `init_experiment`
- Changed `init_experiment` to require exact benchmark command, metric definition, scope, off-limits, and constraints matching collected contract
- Changed `log_experiment` to record run number, benchmark command, scope paths, off-limits list, constraints, and segment fingerprint with each result
- Changed `run_experiment` to organize output in numbered run directories with separate benchmark and checks logs for artifact preservation
- Changed autoresearch dashboard to show pending run indicator when unlogged experiment exists
- Changed autoresearch resume workflow to detect and offer recovery of pending run artifacts before continuing experiment loop
- Changed `ExperimentResult` to include `runNumber`, `benchmarkCommand`, `scopePaths`, `offLimits`, `constraints`, and `segmentFingerprint` fields
- Changed `RunningExperiment` to track `runDirectory` and `runNumber` for artifact organization
- Changed `AutoresearchRuntime` to include `lastRunArtifactDir`, `lastRunNumber`, `lastRunSummary`, `benchmarkCommand`, `secondaryMetrics`, `scopePaths`, `offLimits`, `constraints`, and `segmentFingerprint`
- Changed autoresearch prompts to emphasize `autoresearch.md` as source of truth for benchmark, scope, and constraints
- Changed `command-initialize.md` to display collected setup (benchmark command, metric, direction, scope, off-limits, constraints) before initialization
- Changed `resume-message.md` to reference pending run artifacts and guide completion of unlogged experiments
- Changed `sendMessage()` API documentation to clarify `deliverAs: 'nextTurn'` behavior for hidden context delivery
- Changed `SendMessageHandler` type documentation to explain hidden next-turn message queuing during prompt teardown
- Changed autoresearch startup to create or reuse a dedicated `autoresearch/...` git branch before enabling the experiment loop
- Changed autoresearch to refuse startup when unrelated worktree changes would make auto-reverts unsafe
- Changed autoresearch prompts to emphasize scope and constraints as source of truth for session direction
- Changed component escape key handling to use keybinding manager for `app.interrupt` and `tui.select.cancel` with fallback to raw Escape matching
- Updated autoresearch prompt guidance to require explicit files in scope, off-limits paths, and session constraints
- Changed autoresearch command to use intent-based initialization instead of goal parameter, with user input dialog for new sessions
- Changed autoresearch startup to create or reuse a dedicated `autoresearch/...` git branch before enabling the experiment loop, and to refuse startup when unrelated worktree changes would make auto-reverts unsafe
- Changed autoresearch startup to activate experiment tools (`init_experiment`, `run_experiment`, `log_experiment`) only when autoresearch mode is enabled
- Changed autoresearch shutdown to deactivate experiment tools when mode is disabled or cleared
- Changed autoresearch session rehydration to dynamically manage experiment tool activation based on session state
- Changed autoresearch prompts and notes guidance to require explicit files in scope, off-limits paths, and session constraints
- Refactored hashline edit validation to enforce stricter anchor requirements per operation type
- Updated edit application logic to handle explicit file-level operations (`append_eof`, `prepend_bof`) separately from anchor-based operations
- Changed `setWidget` API to accept `ExtensionWidgetOptions` parameter for placement control
- Changed widget placement logic to manage widgets above and below editor separately
- Changed hashline edit application to preserve duplicated boundary lines exactly as provided instead of auto-correcting them
- Updated RPC mode to support widget placement option in `setWidget` requests
- Changed hashline edit application to preserve duplicated boundary lines exactly as provided instead of auto-correcting them
- Changed widget API to support placement options and component factories in addition to string arrays
- Updated extension UI controller to manage widgets above and below the editor separately
- Updated ask tool rendering to support markdown formatting in questions and option labels
- Refactored hook input and selector components to render titles as markdown for richer text formatting
- Changed session collection to include sessions with zero messages, enabling ACP mode to create discoverable sessions immediately
- Changed session persistence logic to use atomic file rewrite when flushing unflushed sessions to prevent duplication
- Removed hashline edit autocorrection for duplicated boundary lines; escaped-tab autocorrection remains available for leading `\\t` sequences
### Removed
- Removed `command-start.md` prompt template in favor of separate initialize and resume workflows
- Removed auto-correction of off-by-one range edits that duplicated closing braces or boundary lines
- Removed `shouldAutocorrect` function and related boundary line deduplication logic from hashline editor
- Removed auto-correction of off-by-one range edits that duplicated closing braces or boundary lines
### Fixed
- Fixed boundary duplication warnings to always display when replacement lines match the next surviving line, even when auto-correction is disabled
- Fixed secondary metrics validation to properly reject missing configured metrics and new metrics without force flag
- Fixed ASI data cloning to prevent prototype pollution attacks by filtering reserved property names
- Fixed autoresearch resume to detect and recover pending run artifacts that were left unlogged from previous sessions
- Fixed dashboard overlay to display when running experiment even with zero completed results
- Fixed tab character rendering in dashboard command display and tool output summaries
- Fixed autoresearch logging to require durable ASI metadata (hypothesis, rollback_reason, next_action_hint) for every run including rollback context for discarded, crashed, and checks-failed experiments
- Fixed autoresearch logging to require durable ASI metadata for every run, including rollback context for discarded, crashed, and checks-failed experiments
## [13.14.0] - 2026-03-20
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-coding-agent",
"version": "13.14.2",
"version": "13.15.0",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"homepage": "https://github.com/can1357/oh-my-pi",
"author": "Can Boluk",
@@ -0,0 +1,34 @@
Set up autoresearch for this intent:
{{intent}}
{{branch_status_line}}
Collected setup:
- benchmark command: `{{benchmark_command}}`
- primary metric: `{{metric_name}}`
- metric unit: `{{metric_unit}}`
- direction: `{{direction}}`
- tradeoff metrics:
{{{secondary_metrics_block}}}
- files in scope:
{{{scope_paths_block}}}
- off limits:
{{{off_limits_block}}}
- constraints:
{{{constraints_block}}}
Explain briefly what autoresearch will do in this repository, then initialize the workspace.
Your first actions:
- write `autoresearch.md`
- record the collected benchmark command, primary metric, metric unit, direction, tradeoff metrics, scope, off-limits list, and constraints in `autoresearch.md`
- add a short preflight section in `autoresearch.md` covering prerequisites, one-time setup, and the comparability invariant that must stay fixed across runs
- explicitly mark the ground-truth evaluator, fixed datasets, and other measurement-critical files as off-limits or hard constraints when they define the benchmark contract
- write or update `autoresearch.program.md` when you learn durable heuristics, failure patterns, or repo-specific strategy that future resume turns should inherit
- define the benchmark entrypoint in `autoresearch.sh`
- optionally add `autoresearch.checks.sh` if correctness or quality needs a hard gate
- run `init_experiment` with the exact collected benchmark command, metric definition, scope paths, off-limits list, and constraints
- run and log the baseline
- keep iterating until interrupted or until the configured iteration cap is reached
@@ -0,0 +1,17 @@
Resume autoresearch from the attached notes.
@{{autoresearch_md_path}}
{{branch_status_line}}
{{#if has_resume_context}}
Additional context from the user:
{{resume_context}}
{{/if}}
Use the notes as the source of truth for the current direction, scope, and constraints.
- inspect recent git history for context
- inspect `autoresearch.jsonl` if it exists
- continue the most promising unfinished direction on the current protected branch
- keep iterating until interrupted or until the configured iteration cap is reached
@@ -0,0 +1,332 @@
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import type { AutoresearchBenchmarkContract, AutoresearchContract, MetricDirection } from "./types";
export interface AutoresearchContractLoadResult {
contract: AutoresearchContract;
errors: string[];
path: string;
}
export interface AutoresearchScriptSnapshot {
benchmarkScript: string;
benchmarkScriptPath: string;
checksScript: string | null;
checksScriptPath: string;
errors: string[];
}
const HEADING_REGEX = /^##\s+(.+?)\s*$/;
const LIST_ITEM_REGEX = /^\s*[-*]\s+(.*)$/;
const KEY_VALUE_REGEX = /^\s*[-*]\s+([^:]+):\s*(.*)$/;
export function readAutoresearchContract(workDir: string): AutoresearchContractLoadResult {
const contractPath = path.join(workDir, "autoresearch.md");
let content = "";
try {
content = fs.readFileSync(contractPath, "utf8");
} catch {
return {
contract: createEmptyAutoresearchContract(),
errors: [`${contractPath} does not exist. Create it before initializing autoresearch.`],
path: contractPath,
};
}
const contract = parseAutoresearchContract(content);
const errors = validateAutoresearchContract(contract);
return { contract, errors, path: contractPath };
}
export function parseAutoresearchContract(markdown: string): AutoresearchContract {
const sections = extractSections(markdown);
return {
benchmark: parseBenchmarkSection(sections.get("benchmark") ?? ""),
scopePaths: parseListSection(sections.get("files in scope") ?? "", normalizeContractPathSpec),
offLimits: parseListSection(sections.get("off limits") ?? "", normalizeContractPathSpec),
constraints: parseListSection(sections.get("constraints") ?? ""),
};
}
export function validateAutoresearchContract(contract: AutoresearchContract): string[] {
const errors: string[] = [];
if (!contract.benchmark.command) {
errors.push("Benchmark.command is required in autoresearch.md.");
}
if (!contract.benchmark.primaryMetric) {
errors.push("Benchmark.primary metric is required in autoresearch.md.");
}
if (!contract.benchmark.direction) {
errors.push("Benchmark.direction must be `lower` or `higher` in autoresearch.md.");
}
if (contract.scopePaths.length === 0) {
errors.push("Files in Scope must contain at least one path in autoresearch.md.");
}
for (const scopePath of contract.scopePaths) {
if (isUnsafeContractPathSpec(scopePath)) {
errors.push(`Files in Scope contains an invalid path: ${scopePath}`);
}
}
for (const offLimitsPath of contract.offLimits) {
if (isUnsafeContractPathSpec(offLimitsPath)) {
errors.push(`Off Limits contains an invalid path: ${offLimitsPath}`);
}
}
return errors;
}
export function buildAutoresearchSegmentFingerprint(
contract: AutoresearchContract,
scripts: {
benchmarkScript: string;
checksScript: string | null;
},
): string {
const payload = {
benchmark: contract.benchmark,
scopePaths: contract.scopePaths,
offLimits: contract.offLimits,
constraints: contract.constraints,
scripts,
};
return crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
}
export function getAutoresearchFingerprintMismatchError(
stateFingerprint: string | null,
workDir: string,
): string | null {
if (!stateFingerprint) {
return "The current segment has no fingerprint metadata. Re-run init_experiment before continuing.";
}
const contractResult = readAutoresearchContract(workDir);
const scriptSnapshot = loadAutoresearchScriptSnapshot(workDir);
const errors = [...contractResult.errors, ...scriptSnapshot.errors];
if (errors.length > 0) {
return `${errors.join(" ")} Re-run init_experiment after fixing the workspace contract.`;
}
const currentFingerprint = buildAutoresearchSegmentFingerprint(contractResult.contract, {
benchmarkScript: scriptSnapshot.benchmarkScript,
checksScript: scriptSnapshot.checksScript,
});
if (currentFingerprint === stateFingerprint) {
return null;
}
return "autoresearch.md, autoresearch.sh, or autoresearch.checks.sh changed since the current segment was initialized. Re-run init_experiment before continuing.";
}
export function loadAutoresearchScriptSnapshot(workDir: string): AutoresearchScriptSnapshot {
const benchmarkScriptPath = path.join(workDir, "autoresearch.sh");
const checksScriptPath = path.join(workDir, "autoresearch.checks.sh");
const errors: string[] = [];
let benchmarkScript = "";
try {
benchmarkScript = fs.readFileSync(benchmarkScriptPath, "utf8");
} catch {
errors.push(`${benchmarkScriptPath} does not exist. Create it before initializing autoresearch.`);
}
let checksScript: string | null = null;
try {
checksScript = fs.readFileSync(checksScriptPath, "utf8");
} catch {
checksScript = null;
}
return {
benchmarkScript,
benchmarkScriptPath,
checksScript,
checksScriptPath,
errors,
};
}
export function normalizeAutoresearchList(values: readonly string[]): string[] {
const normalized: string[] = [];
const seen = new Set<string>();
for (const value of values) {
const trimmed = value.trim();
if (trimmed.length === 0) continue;
if (seen.has(trimmed)) continue;
seen.add(trimmed);
normalized.push(trimmed);
}
return normalized;
}
export function normalizeContractPathSpec(value: string): string {
const normalized = path.posix.normalize(value.trim().replaceAll("\\", "/"));
if (normalized === "." || normalized === "./") return ".";
return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "");
}
export function pathMatchesContractPath(pathValue: string, specValue: string): boolean {
const normalizedPath = normalizeContractPathSpec(pathValue);
const normalizedSpec = normalizeContractPathSpec(specValue);
if (normalizedSpec === ".") return true;
return normalizedPath === normalizedSpec || normalizedPath.startsWith(`${normalizedSpec}/`);
}
export function contractListsEqual(left: readonly string[], right: readonly string[]): boolean {
const normalizedLeft = normalizeAutoresearchList(left);
const normalizedRight = normalizeAutoresearchList(right);
if (normalizedLeft.length !== normalizedRight.length) return false;
return normalizedLeft.every((value, index) => value === normalizedRight[index]);
}
export function contractPathListsEqual(left: readonly string[], right: readonly string[]): boolean {
const normalizedLeft = normalizeContractPathList(left);
const normalizedRight = normalizeContractPathList(right);
if (normalizedLeft.length !== normalizedRight.length) return false;
return normalizedLeft.every((value, index) => value === normalizedRight[index]);
}
function createEmptyAutoresearchContract(): AutoresearchContract {
return {
benchmark: {
command: null,
primaryMetric: null,
metricUnit: "",
direction: null,
secondaryMetrics: [],
},
scopePaths: [],
offLimits: [],
constraints: [],
};
}
function normalizeContractPathList(values: readonly string[]): string[] {
return normalizeAutoresearchList(values.map(normalizeContractPathSpec)).sort((left, right) =>
left.localeCompare(right),
);
}
function extractSections(markdown: string): Map<string, string> {
const sections = new Map<string, string>();
const lines = markdown.split("\n");
let currentHeading: string | null = null;
let currentLines: string[] = [];
for (const line of lines) {
const headingMatch = line.match(HEADING_REGEX);
if (headingMatch) {
if (currentHeading) {
sections.set(currentHeading, currentLines.join("\n").trim());
}
currentHeading = headingMatch[1]?.trim().toLowerCase() ?? null;
currentLines = [];
continue;
}
if (currentHeading) {
currentLines.push(line);
}
}
if (currentHeading) {
sections.set(currentHeading, currentLines.join("\n").trim());
}
return sections;
}
function parseBenchmarkSection(section: string): AutoresearchBenchmarkContract {
const entries = new Map<string, string>();
const lines = section.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const rawLine = lines[index] ?? "";
const match = rawLine.match(KEY_VALUE_REGEX);
if (!match) continue;
const key = normalizeKey(match[1] ?? "");
let value = (match[2] ?? "").trim();
if (key === "secondarymetrics") {
const nestedItems: string[] = [];
for (let nestedIndex = index + 1; nestedIndex < lines.length; nestedIndex += 1) {
const nestedLine = lines[nestedIndex] ?? "";
if (nestedLine.match(KEY_VALUE_REGEX)) break;
const nestedMatch = nestedLine.match(/^\s{2,}[-*]\s+(.*)$/);
if (!nestedMatch) {
if (nestedLine.trim().length > 0) break;
continue;
}
nestedItems.push((nestedMatch[1] ?? "").trim());
index = nestedIndex;
}
if (nestedItems.length > 0) {
value = [value, ...nestedItems].filter(Boolean).join(", ");
}
}
entries.set(key, value);
}
const direction = parseDirection(entries.get("direction"));
return {
command: readNullableEntry(entries.get("command")),
primaryMetric: readNullableEntry(entries.get("primarymetric")),
metricUnit: entries.get("metricunit")?.trim() ?? "",
direction,
secondaryMetrics: parseSecondaryMetrics(entries.get("secondarymetrics")),
};
}
function parseListSection(section: string, normalizeItem?: (value: string) => string): string[] {
const items: string[] = [];
let activeItem: string | null = null;
for (const rawLine of section.split("\n")) {
const line = rawLine.trimEnd();
if (line.trim().length === 0) continue;
const match = rawLine.match(LIST_ITEM_REGEX);
if (match) {
if (activeItem) items.push(activeItem);
activeItem = (match[1] ?? "").trim();
continue;
}
if (activeItem && /^\s{2,}\S/.test(rawLine)) {
activeItem = `${activeItem} ${line.trim()}`;
continue;
}
if (activeItem) {
items.push(activeItem);
activeItem = null;
}
items.push(line.trim());
}
if (activeItem) {
items.push(activeItem);
}
const normalizedItems = normalizeAutoresearchList(items);
return normalizeItem ? normalizedItems.map(normalizeItem) : normalizedItems;
}
function normalizeKey(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function parseDirection(value: string | undefined): MetricDirection | null {
if (value === "lower" || value === "higher") return value;
return null;
}
function readNullableEntry(value: string | undefined): string | null {
const trimmed = value?.trim() ?? "";
return trimmed.length > 0 ? trimmed : null;
}
function parseSecondaryMetrics(value: string | undefined): string[] {
if (!value) return [];
return normalizeAutoresearchList(
value
.split(",")
.map(entry => entry.trim())
.filter(Boolean),
);
}
function isUnsafeContractPathSpec(value: string): boolean {
return path.posix.isAbsolute(value) || value === ".." || value.startsWith("../");
}
@@ -0,0 +1,447 @@
import { matchesKey, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import type { Theme } from "../modes/theme/theme";
import { replaceTabs } from "../tools/render-utils";
import { formatElapsed, formatNum, isBetter } from "./helpers";
import { currentResults, findBaselineMetric, findBaselineRunNumber, findBaselineSecondary } from "./state";
import type { AutoresearchRuntime, DashboardController, ExperimentResult, ExperimentState } from "./types";
export function createDashboardController(): DashboardController {
let overlayTui: { requestRender(): void } | null = null;
let spinnerTimer: NodeJS.Timeout | undefined;
let spinnerFrame = 0;
const requestRender = (): void => {
overlayTui?.requestRender();
};
const clear = (): void => {
overlayTui = null;
if (spinnerTimer) {
clearInterval(spinnerTimer);
spinnerTimer = undefined;
}
};
return {
clear(ctx): void {
clear();
if (ctx.hasUI) {
ctx.ui.setWidget("autoresearch", undefined);
}
},
requestRender,
updateWidget(ctx, runtime): void {
if (!ctx.hasUI) return;
const state = runtime.state;
if (!shouldShowDashboard(runtime, state)) {
ctx.ui.setWidget("autoresearch", undefined);
return;
}
ctx.ui.setWidget("autoresearch", (_tui, theme) => {
if (state.results.length === 0 && runtime.runningExperiment) {
return new Text(renderRunningOnly(runtime, state, theme), 0, 0);
}
if (runtime.dashboardExpanded) {
const width = process.stdout.columns ?? 120;
const lines = [
renderExpandedHeader(runtime, width, theme),
...renderDashboardLines(runtime, width, theme, 8),
];
return new Text(lines.join("\n"), 0, 0);
}
return new Text(renderCollapsedLine(runtime, state, theme), 0, 0);
});
},
async showOverlay(ctx, runtime): Promise<void> {
if (!ctx.hasUI || !shouldShowDashboard(runtime, runtime.state)) return;
await ctx.ui.custom<void>(
(tui, theme, _keybindings, done) => {
overlayTui = tui;
if (!spinnerTimer) {
spinnerTimer = setInterval(() => {
spinnerFrame += 1;
requestRender();
}, 80);
}
let scrollOffset = 0;
return {
render(width: number): string[] {
const terminalRows = process.stdout.rows ?? 40;
const header = renderExpandedHeader(runtime, width, theme);
const body = renderDashboardLines(runtime, width, theme, 0);
if (runtime.runningExperiment) {
body.push(renderOverlayRunningLine(runtime, theme, width, spinnerFrame));
}
const viewportRows = Math.max(4, terminalRows - 4);
const maxScroll = Math.max(0, body.length - viewportRows);
if (scrollOffset > maxScroll) scrollOffset = maxScroll;
const visible = body.slice(scrollOffset, scrollOffset + viewportRows);
const footer = renderOverlayFooter(width, scrollOffset, viewportRows, body.length, theme);
return [
header,
...visible,
...Array.from({ length: Math.max(0, viewportRows - visible.length) }, () => ""),
footer,
];
},
handleInput(data: string): void {
const totalRows =
renderDashboardLines(runtime, process.stdout.columns ?? 120, theme, 0).length +
(runtime.runningExperiment ? 1 : 0);
const viewportRows = Math.max(4, (process.stdout.rows ?? 40) - 4);
const maxScroll = Math.max(0, totalRows - viewportRows);
if (matchesKey(data, "escape") || matchesKey(data, "esc") || data === "q") {
done(undefined);
return;
}
if (matchesKey(data, "up") || data === "k") {
scrollOffset = Math.max(0, scrollOffset - 1);
} else if (matchesKey(data, "down") || data === "j") {
scrollOffset = Math.min(maxScroll, scrollOffset + 1);
} else if (matchesKey(data, "pageUp")) {
scrollOffset = Math.max(0, scrollOffset - viewportRows);
} else if (matchesKey(data, "pageDown")) {
scrollOffset = Math.min(maxScroll, scrollOffset + viewportRows);
} else if (data === "g") {
scrollOffset = 0;
} else if (data === "G") {
scrollOffset = maxScroll;
}
tui.requestRender();
},
invalidate(): void {},
dispose(): void {
clear();
},
};
},
{ overlay: true },
);
},
};
}
function renderRunningOnly(runtime: AutoresearchRuntime, state: ExperimentState, theme: Theme): string {
const parts = [theme.fg("accent", "autoresearch"), theme.fg("warning", " running...")];
if (state.name) {
parts.push(theme.fg("dim", ` | ${replaceTabs(state.name)}`));
}
if (runtime.runningExperiment) {
parts.push(theme.fg("dim", ` | ${replaceTabs(runtime.runningExperiment.command)}`));
}
return parts.join("");
}
function shouldShowDashboard(runtime: AutoresearchRuntime, state: ExperimentState): boolean {
return (
runtime.autoresearchMode ||
state.results.length > 0 ||
runtime.runningExperiment !== null ||
runtime.lastRunSummary !== null
);
}
function renderExpandedHeader(runtime: AutoresearchRuntime, width: number, theme: Theme): string {
const state = runtime.state;
const status = renderModeStatus(runtime, state);
const label = state.name ? ` autoresearch: ${replaceTabs(state.name)} ` : " autoresearch ";
const hint = theme.fg("dim", ` ctrl+x collapse ctrl+shift+x overlay${status ? ` ${status}` : ""} `);
const fillWidth = Math.max(0, width - visibleWidth(label) - visibleWidth(hint));
return truncateToWidth(theme.fg("accent", label) + theme.fg("borderMuted", "-".repeat(fillWidth)) + hint, width);
}
function renderCollapsedLine(runtime: AutoresearchRuntime, state: ExperimentState, theme: Theme): string {
if (runtime.lastRunSummary) {
const parts = [
theme.fg("accent", "autoresearch"),
theme.fg("warning", ` pending run #${runtime.lastRunSummary.runNumber}`),
theme.fg("dim", runtime.lastRunSummary.passed ? " pass" : " fail"),
];
if (runtime.lastRunSummary.parsedPrimary !== null) {
parts.push(
theme.fg(
"muted",
` | ${state.metricName}=${formatNum(runtime.lastRunSummary.parsedPrimary, state.metricUnit)}`,
),
);
}
parts.push(theme.fg("warning", " | log_experiment required"));
if (!runtime.autoresearchMode) {
parts.push(theme.fg("dim", " | mode off"));
}
return parts.join("");
}
if (state.results.length === 0) {
const modeStatus = runtime.autoresearchMode ? "baseline pending" : "mode off";
const parts = [theme.fg("accent", "autoresearch"), theme.fg("warning", ` ${modeStatus}`)];
if (state.name) {
parts.push(theme.fg("dim", ` | ${replaceTabs(state.name)}`));
}
if (runtime.autoresearchMode) {
parts.push(theme.fg("dim", " | run the baseline"));
}
return parts.join("");
}
const current = currentResults(state.results, state.currentSegment);
const kept = current.filter(result => result.status === "keep").length;
const crashed = current.filter(result => result.status === "crash").length;
const checksFailed = current.filter(result => result.status === "checks_failed").length;
const best = findBestResult(state);
const archivedRuns = Math.max(0, state.results.length - current.length);
const parts = [
theme.fg("accent", "autoresearch"),
theme.fg("muted", ` ${current.length} runs`),
theme.fg("success", ` ${kept} kept`),
];
if (archivedRuns > 0) parts.push(theme.fg("dim", ` +${archivedRuns} archived`));
if (crashed > 0) parts.push(theme.fg("error", ` ${crashed} crash`));
if (checksFailed > 0) parts.push(theme.fg("error", ` ${checksFailed} checks_failed`));
parts.push(theme.fg("dim", " | "));
if (best && state.bestMetric !== null && best.result.metric !== state.bestMetric) {
parts.push(theme.fg("warning", `best ${formatNum(best.result.metric, state.metricUnit)}`));
parts.push(theme.fg("dim", ` baseline ${formatNum(state.bestMetric, state.metricUnit)}`));
} else if (state.bestMetric !== null) {
parts.push(theme.fg("warning", `baseline ${formatNum(state.bestMetric, state.metricUnit)}`));
} else {
parts.push(theme.fg("warning", `no kept runs yet`));
}
if (state.confidence !== null) {
const confidenceColor = state.confidence >= 2 ? "success" : state.confidence >= 1 ? "warning" : "error";
parts.push(theme.fg("dim", " | "));
parts.push(theme.fg(confidenceColor, `conf ${state.confidence.toFixed(1)}x`));
}
if (runtime.runningExperiment) {
parts.push(theme.fg("dim", ` | running ${formatElapsed(Date.now() - runtime.runningExperiment.startedAt)}`));
} else if (!runtime.autoresearchMode) {
parts.push(theme.fg("dim", ` | ${renderModeStatus(runtime, state)}`));
}
parts.push(theme.fg("dim", " | ctrl+x expand"));
return parts.join("");
}
export function renderDashboardLines(
runtime: AutoresearchRuntime,
width: number,
theme: Theme,
maxRows: number,
): string[] {
const state = runtime.state;
if (state.results.length === 0) {
if (runtime.lastRunSummary) {
const lines = [
truncateToWidth(`Pending run: #${runtime.lastRunSummary.runNumber}`, width),
truncateToWidth(
`Result: ${runtime.lastRunSummary.passed ? "passed" : "failed"}${runtime.lastRunSummary.parsedPrimary !== null ? ` ${state.metricName} ${formatNum(runtime.lastRunSummary.parsedPrimary, state.metricUnit)}` : ""}`,
width,
),
truncateToWidth("Next action: finish log_experiment before starting another run.", width),
];
if (!runtime.autoresearchMode) {
lines.push(truncateToWidth("Mode: off", width));
}
return lines;
}
if (runtime.autoresearchMode) {
return [
truncateToWidth("Current segment: 0 runs", width),
truncateToWidth("Baseline: pending", width),
truncateToWidth("Next action: run and log the baseline experiment.", width),
];
}
return [theme.fg("dim", "No experiments logged yet.")];
}
const current = currentResults(state.results, state.currentSegment);
const kept = current.filter(result => result.status === "keep").length;
const discarded = current.filter(result => result.status === "discard").length;
const crashed = current.filter(result => result.status === "crash").length;
const checksFailed = current.filter(result => result.status === "checks_failed").length;
const baseline = findBaselineMetric(state.results, state.currentSegment);
const baselineRunNumber = findBaselineRunNumber(state.results, state.currentSegment);
const baselineSecondary = findBaselineSecondary(state.results, state.currentSegment, state.secondaryMetrics);
const best = findBestResult(state);
const lines = [
truncateToWidth(
`Current segment: ${current.length} runs ${kept} kept ${discarded} discarded ${crashed} crashed ${checksFailed} checks_failed`,
width,
),
truncateToWidth(
`Baseline: ${formatNum(baseline, state.metricUnit)}${baselineRunNumber ? ` (#${baselineRunNumber})` : ""}`,
width,
),
];
if (state.results.length > current.length) {
lines.push(
truncateToWidth(`Archived from earlier segments: ${state.results.length - current.length} runs`, width),
);
}
if (runtime.lastRunSummary) {
lines.push(
truncateToWidth(
`Pending run: #${runtime.lastRunSummary.runNumber} (${runtime.lastRunSummary.passed ? "passed" : "failed"}) — log_experiment required`,
width,
),
);
}
if (!runtime.autoresearchMode) {
lines.push(truncateToWidth(`Mode: ${renderModeStatus(runtime, state)}`, width));
}
if (best) {
const bestRunNumber = best.result.runNumber ?? best.index + 1;
let progress = `Best: ${formatNum(best.result.metric, state.metricUnit)} (#${bestRunNumber})`;
if (baseline !== null && baseline !== 0 && best.result.metric !== baseline) {
const delta = ((best.result.metric - baseline) / baseline) * 100;
const sign = delta > 0 ? "+" : "";
progress += ` ${sign}${delta.toFixed(1)}%`;
}
if (state.confidence !== null) {
progress += ` conf ${state.confidence.toFixed(1)}x`;
}
lines.push(truncateToWidth(progress, width));
if (state.secondaryMetrics.length > 0) {
const details = state.secondaryMetrics
.map(metric =>
renderSecondarySummary(
metric.name,
best.result.metrics[metric.name],
baselineSecondary[metric.name],
metric.unit,
),
)
.filter((value): value is string => Boolean(value));
if (details.length > 0) {
lines.push(truncateToWidth(`Secondary: ${details.join(" ")}`, width));
}
}
}
lines.push("");
lines.push(renderTableHeader(state, width, theme));
lines.push(theme.fg("borderMuted", "-".repeat(Math.max(0, width - 1))));
const visible = maxRows > 0 ? current.slice(-maxRows) : current;
if (visible.length < current.length) {
lines.push(theme.fg("dim", `... ${current.length - visible.length} earlier runs hidden ...`));
}
for (const result of visible) {
lines.push(renderResultRow(result, state, baselineSecondary, width, theme));
}
return lines;
}
function renderTableHeader(state: ExperimentState, width: number, theme: Theme): string {
const secondaryHeader = state.secondaryMetrics.map(metric => truncateToWidth(metric.name, 10)).join(" ");
return truncateToWidth(
`${theme.fg("muted", "#".padEnd(4))}${theme.fg("muted", "commit".padEnd(10))}${theme.fg("warning", state.metricName.padEnd(12))}${secondaryHeader ? `${theme.fg("muted", secondaryHeader)} ` : ""}${theme.fg("muted", "status".padEnd(14))}${theme.fg("muted", "description")}`,
width,
);
}
function renderResultRow(
result: ExperimentResult,
state: ExperimentState,
baselineSecondary: { [key: string]: number },
width: number,
theme: Theme,
): string {
const runNumber = result.runNumber ?? state.results.indexOf(result) + 1;
const secondary = state.secondaryMetrics
.map(metric =>
truncateToWidth(
renderSecondaryCell(result.metrics[metric.name], metric.unit, baselineSecondary[metric.name]),
10,
).padEnd(11),
)
.join("");
const statusColor = result.status === "keep" ? "success" : result.status === "discard" ? "warning" : "error";
const line =
`${theme.fg("dim", String(runNumber).padEnd(4))}` +
`${theme.fg("accent", (result.commit || "-").padEnd(10))}` +
`${theme.fg(statusColor, formatNum(result.metric, state.metricUnit).padEnd(12))}` +
`${secondary}` +
`${theme.fg(statusColor, result.status.padEnd(14))}` +
`${theme.fg("muted", replaceTabs(result.description))}`;
return truncateToWidth(line, width);
}
function renderSecondaryCell(value: number | undefined, unit: string, baseline: number | undefined): string {
if (value === undefined) return "-";
const formatted = formatNum(value, unit);
if (baseline === undefined || baseline === 0 || baseline === value) return formatted;
const delta = ((value - baseline) / baseline) * 100;
const sign = delta > 0 ? "+" : "";
return `${formatted} ${sign}${delta.toFixed(1)}%`;
}
function renderSecondarySummary(
name: string,
value: number | undefined,
baseline: number | undefined,
unit: string,
): string | null {
if (value === undefined) return null;
if (baseline === undefined || baseline === 0 || baseline === value) {
return `${name} ${formatNum(value, unit)}`;
}
const delta = ((value - baseline) / baseline) * 100;
const sign = delta > 0 ? "+" : "";
return `${name} ${formatNum(value, unit)} ${sign}${delta.toFixed(1)}%`;
}
function renderOverlayRunningLine(
runtime: AutoresearchRuntime,
theme: Theme,
width: number,
spinnerFrame: number,
): string {
const spinner = theme.spinnerFrames[spinnerFrame % theme.spinnerFrames.length] ?? "*";
return truncateToWidth(
theme.fg(
"warning",
`${spinner} running ${formatElapsed(Date.now() - (runtime.runningExperiment?.startedAt ?? Date.now()))} ${replaceTabs(
runtime.runningExperiment?.command ?? "",
)}`,
),
width,
);
}
function renderOverlayFooter(
width: number,
scrollOffset: number,
viewportRows: number,
totalRows: number,
theme: Theme,
): string {
const position =
totalRows > viewportRows
? ` ${scrollOffset + 1}-${Math.min(totalRows, scrollOffset + viewportRows)}/${totalRows}`
: "";
const hint = theme.fg("dim", ` up/down j/k pageup pagedown g G esc${position} `);
const fill = Math.max(0, width - visibleWidth(hint));
return theme.fg("borderMuted", "-".repeat(fill)) + hint;
}
function renderModeStatus(runtime: AutoresearchRuntime, state: ExperimentState): string {
if (runtime.autoresearchMode) {
return state.results.length === 0 ? "baseline pending" : "mode on";
}
const current = currentResults(state.results, state.currentSegment);
if (state.maxExperiments !== null && current.length >= state.maxExperiments) {
return "segment complete";
}
return "mode off";
}
function findBestResult(state: ExperimentState): { index: number; result: ExperimentResult } | null {
let best: { index: number; result: ExperimentResult } | null = null;
for (let index = 0; index < state.results.length; index += 1) {
const result = state.results[index];
if (result.segment !== state.currentSegment || result.status !== "keep" || result.metric <= 0) continue;
if (!best || isBetter(result.metric, best.result.metric, state.bestDirection)) {
best = { index, result };
}
}
return best;
}
@@ -0,0 +1,243 @@
import type { ExtensionAPI } from "../extensibility/extensions";
import { isAutoresearchLocalStatePath, normalizeAutoresearchPath } from "./helpers";
const AUTORESEARCH_BRANCH_PREFIX = "autoresearch/";
const BRANCH_NAME_MAX_LENGTH = 48;
export interface EnsureAutoresearchBranchFailure {
error: string;
ok: false;
}
export interface EnsureAutoresearchBranchSuccess {
branchName: string;
created: boolean;
ok: true;
}
export type EnsureAutoresearchBranchResult = EnsureAutoresearchBranchFailure | EnsureAutoresearchBranchSuccess;
export async function getCurrentAutoresearchBranch(api: ExtensionAPI, workDir: string): Promise<string | null> {
const currentBranchResult = await api.exec("git", ["branch", "--show-current"], { cwd: workDir, timeout: 5_000 });
const currentBranch = currentBranchResult.stdout.trim();
return currentBranch.startsWith(AUTORESEARCH_BRANCH_PREFIX) ? currentBranch : null;
}
export async function ensureAutoresearchBranch(
api: ExtensionAPI,
workDir: string,
goal: string | null,
): Promise<EnsureAutoresearchBranchResult> {
const repoRootResult = await api.exec("git", ["rev-parse", "--show-toplevel"], { cwd: workDir, timeout: 5_000 });
if (repoRootResult.code !== 0) {
return {
error: "Autoresearch requires a git repository so it can isolate experiments and revert failed runs safely.",
ok: false,
};
}
const repoRoot = repoRootResult.stdout.trim() || workDir;
const dirtyPathsResult = await api.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
cwd: repoRoot,
timeout: 5_000,
});
if (dirtyPathsResult.code !== 0) {
return {
error: `Unable to inspect git status before starting autoresearch: ${mergeStdoutStderr(dirtyPathsResult).trim() || `exit ${dirtyPathsResult.code}`}`,
ok: false,
};
}
const workDirPrefix = await readGitWorkDirPrefix(api, workDir);
const unsafeDirtyPaths = collectUnsafeDirtyPaths(dirtyPathsResult.stdout, workDirPrefix);
const currentBranch = await getCurrentAutoresearchBranch(api, workDir);
if (currentBranch) {
if (unsafeDirtyPaths.length > 0) {
return buildUnsafeDirtyPathsFailure(unsafeDirtyPaths);
}
return {
branchName: currentBranch,
created: false,
ok: true,
};
}
if (unsafeDirtyPaths.length > 0) {
return buildUnsafeDirtyPathsFailure(unsafeDirtyPaths);
}
const branchName = await allocateBranchName(api, workDir, goal);
const checkoutResult = await api.exec("git", ["checkout", "-b", branchName], { cwd: workDir, timeout: 10_000 });
if (checkoutResult.code !== 0) {
return {
error:
`Failed to create autoresearch branch ${branchName}: ` +
`${mergeStdoutStderr(checkoutResult).trim() || `exit ${checkoutResult.code}`}`,
ok: false,
};
}
return {
branchName,
created: true,
ok: true,
};
}
export function parseWorkDirDirtyPaths(statusOutput: string, workDirPrefix: string): string[] {
const relativePaths: string[] = [];
for (const dirtyPath of parseDirtyPaths(statusOutput)) {
const relativePath = relativizeGitPathToWorkDir(dirtyPath, workDirPrefix);
if (relativePath === null) continue;
relativePaths.push(relativePath);
}
return relativePaths;
}
export function relativizeGitPathToWorkDir(repoRelativePath: string, workDirPrefix: string): string | null {
const normalizedPath = normalizeStatusPath(repoRelativePath);
const normalizedPrefix = normalizeAutoresearchPath(workDirPrefix);
if (normalizedPrefix === "" || normalizedPrefix === ".") {
return normalizedPath;
}
if (normalizedPath === normalizedPrefix) {
return ".";
}
if (!normalizedPath.startsWith(`${normalizedPrefix}/`)) {
return null;
}
return normalizeAutoresearchPath(normalizedPath.slice(normalizedPrefix.length + 1));
}
async function readGitWorkDirPrefix(api: ExtensionAPI, workDir: string): Promise<string> {
const prefixResult = await api.exec("git", ["rev-parse", "--show-prefix"], { cwd: workDir, timeout: 5_000 });
if (prefixResult.code !== 0) {
return "";
}
return prefixResult.stdout.trim();
}
export function parseDirtyPaths(statusOutput: string): string[] {
if (statusOutput.includes("\0")) {
return parseDirtyPathsNul(statusOutput);
}
return parseDirtyPathsLines(statusOutput);
}
function parseDirtyPathsNul(statusOutput: string): string[] {
const unsafePaths = new Set<string>();
let index = 0;
while (index + 3 <= statusOutput.length) {
const statusToken = statusOutput.slice(index, index + 3);
index += 3;
const pathEnd = statusOutput.indexOf("\0", index);
if (pathEnd < 0) break;
const firstPath = statusOutput.slice(index, pathEnd);
index = pathEnd + 1;
addDirtyPath(unsafePaths, firstPath);
if (isRenameOrCopy(statusToken)) {
const secondPathEnd = statusOutput.indexOf("\0", index);
if (secondPathEnd < 0) break;
const secondPath = statusOutput.slice(index, secondPathEnd);
index = secondPathEnd + 1;
addDirtyPath(unsafePaths, secondPath);
}
}
return [...unsafePaths];
}
function parseDirtyPathsLines(statusOutput: string): string[] {
const unsafePaths = new Set<string>();
for (const line of statusOutput.split("\n")) {
const trimmedLine = line.trimEnd();
if (trimmedLine.length < 4) continue;
const rawPath = trimmedLine.slice(3).trim();
if (rawPath.length === 0) continue;
const renameParts = rawPath.split(" -> ");
for (const renamePart of renameParts) {
addDirtyPath(unsafePaths, renamePart);
}
}
return [...unsafePaths];
}
export function normalizeStatusPath(path: string): string {
let normalized = path.trim();
if (normalized.startsWith('"') && normalized.endsWith('"')) {
normalized = normalized.slice(1, -1);
}
return normalizeAutoresearchPath(normalized);
}
async function allocateBranchName(api: ExtensionAPI, workDir: string, goal: string | null): Promise<string> {
const baseName = `${AUTORESEARCH_BRANCH_PREFIX}${slugifyGoal(goal)}-${currentDateStamp()}`;
let candidate = baseName;
let suffix = 2;
while (await branchExists(api, workDir, candidate)) {
candidate = `${baseName}-${suffix}`;
suffix += 1;
}
return candidate;
}
async function branchExists(api: ExtensionAPI, workDir: string, branchName: string): Promise<boolean> {
const result = await api.exec("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
cwd: workDir,
timeout: 5_000,
});
return result.code === 0;
}
function slugifyGoal(goal: string | null): string {
const normalized = (goal ?? "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const trimmed = normalized.slice(0, BRANCH_NAME_MAX_LENGTH).replace(/-+$/g, "");
return trimmed || "session";
}
function currentDateStamp(): string {
const now = new Date();
const year = String(now.getFullYear());
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
function mergeStdoutStderr(result: { stderr: string; stdout: string }): string {
return `${result.stdout}${result.stderr}`;
}
function addDirtyPath(paths: Set<string>, rawPath: string): void {
const normalizedPath = normalizeStatusPath(rawPath);
if (normalizedPath.length === 0) return;
paths.add(normalizedPath);
}
function buildUnsafeDirtyPathsFailure(unsafeDirtyPaths: string[]): EnsureAutoresearchBranchFailure {
const preview = unsafeDirtyPaths.slice(0, 5).join(", ");
const suffix = unsafeDirtyPaths.length > 5 ? ` (+${unsafeDirtyPaths.length - 5} more)` : "";
return {
error:
"Autoresearch needs a clean git worktree before it can create or reuse an isolated branch. " +
`Commit or stash these paths first: ${preview}${suffix}`,
ok: false,
};
}
function isRenameOrCopy(statusToken: string): boolean {
const trimmed = statusToken.trim();
return trimmed.startsWith("R") || trimmed.startsWith("C");
}
function collectUnsafeDirtyPaths(statusOutput: string, workDirPrefix: string): string[] {
const unsafeDirtyPaths: string[] = [];
for (const dirtyPath of parseDirtyPaths(statusOutput)) {
const relativePath = relativizeGitPathToWorkDir(dirtyPath, workDirPrefix);
if (relativePath && isAutoresearchLocalStatePath(relativePath)) {
continue;
}
unsafeDirtyPaths.push(relativePath ?? normalizeStatusPath(dirtyPath));
}
return unsafeDirtyPaths;
}
@@ -0,0 +1,458 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { parseCommandArgs } from "../utils/command-args";
import type {
ASIData,
ASIValue,
AutoresearchConfig,
MetricDirection,
NumericMetricMap,
PendingRunSummary,
} from "./types";
export const METRIC_LINE_PREFIX = "METRIC";
export const ASI_LINE_PREFIX = "ASI";
export const EXPERIMENT_MAX_LINES = 10;
export const EXPERIMENT_MAX_BYTES = 4 * 1024;
export const AUTORESEARCH_COMMITTABLE_FILES = [
"autoresearch.md",
"autoresearch.program.md",
"autoresearch.sh",
"autoresearch.checks.sh",
"autoresearch.ideas.md",
] as const;
export const AUTORESEARCH_LOCAL_STATE_FILES = ["autoresearch.jsonl"] as const;
export const AUTORESEARCH_LOCAL_STATE_DIRECTORIES = [".autoresearch"] as const;
const DENIED_KEY_NAMES = new Set(["__proto__", "constructor", "prototype"]);
export function parseMetricLines(output: string): Map<string, number> {
const metrics = new Map<string, number>();
const regex = new RegExp(`^${METRIC_LINE_PREFIX}\\s+([\\w.µ-]+)=(\\S+)\\s*$`, "gm");
let match = regex.exec(output);
while (match !== null) {
const name = match[1];
if (!DENIED_KEY_NAMES.has(name)) {
const value = Number(match[2]);
if (Number.isFinite(value)) {
metrics.set(name, value);
}
}
match = regex.exec(output);
}
return metrics;
}
export function parseAsiLines(output: string): ASIData | null {
const asi: ASIData = {};
const regex = new RegExp(`^${ASI_LINE_PREFIX}\\s+([\\w.-]+)=(.+)\\s*$`, "gm");
let match = regex.exec(output);
while (match !== null) {
const key = match[1];
if (!DENIED_KEY_NAMES.has(key)) {
asi[key] = parseAsiValue(match[2]);
}
match = regex.exec(output);
}
return Object.keys(asi).length > 0 ? asi : null;
}
function parseAsiValue(raw: string): ASIValue {
const value = raw.trim();
if (value === "true") return true;
if (value === "false") return false;
if (value === "null") return null;
if (/^-?\d+(?:\.\d+)?$/.test(value)) {
const numberValue = Number(value);
if (Number.isFinite(numberValue)) return numberValue;
}
if (value.startsWith("{") || value.startsWith("[") || value.startsWith('"')) {
try {
const parsed = JSON.parse(value) as ASIValue;
return parsed;
} catch {
return value;
}
}
return value;
}
export function mergeAsi(base: ASIData | null, override: ASIData | undefined): ASIData | undefined {
if (!base && !override) return undefined;
return {
...(base ?? {}),
...(override ?? {}),
};
}
export function commas(value: number): string {
const sign = value < 0 ? "-" : "";
const digits = String(Math.trunc(Math.abs(value)));
const groups: string[] = [];
for (let index = digits.length; index > 0; index -= 3) {
groups.unshift(digits.slice(Math.max(0, index - 3), index));
}
return sign + groups.join(",");
}
export function fmtNum(value: number, decimals: number = 0): string {
if (decimals <= 0) return commas(Math.round(value));
const absolute = Math.abs(value);
const whole = Math.floor(absolute);
const fraction = (absolute - whole).toFixed(decimals).slice(1);
return `${value < 0 ? "-" : ""}${commas(whole)}${fraction}`;
}
export function formatNum(value: number | null, unit: string): string {
if (value === null) return "-";
if (Number.isInteger(value)) return `${fmtNum(value)}${unit}`;
return `${fmtNum(value, 2)}${unit}`;
}
export function formatElapsed(milliseconds: number): string {
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) {
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
}
return `${seconds}s`;
}
export function getAutoresearchRunDirectory(workDir: string, runNumber: number): string {
return path.join(workDir, ".autoresearch", "runs", String(runNumber).padStart(4, "0"));
}
export function getNextAutoresearchRunNumber(workDir: string, lastRunNumber: number | null): number {
const runsDirectory = path.join(workDir, ".autoresearch", "runs");
let maxRunNumber = lastRunNumber ?? 0;
try {
for (const entry of fs.readdirSync(runsDirectory, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const runNumber = Number.parseInt(entry.name, 10);
if (Number.isFinite(runNumber)) {
maxRunNumber = Math.max(maxRunNumber, runNumber);
}
}
} catch (error) {
if (!isEnoent(error)) {
throw error;
}
}
return maxRunNumber + 1;
}
export function normalizeAutoresearchPath(relativePath: string): string {
const normalized = relativePath.replaceAll("\\", "/").trim();
if (normalized === "." || normalized === "./") return ".";
return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "");
}
export function isAutoresearchCommittableFile(relativePath: string): boolean {
const normalized = normalizeAutoresearchPath(relativePath);
return AUTORESEARCH_COMMITTABLE_FILES.some(candidate => candidate === normalized);
}
export function isAutoresearchLocalStatePath(relativePath: string): boolean {
const normalized = normalizeAutoresearchPath(relativePath);
if (AUTORESEARCH_LOCAL_STATE_FILES.some(candidate => candidate === normalized)) {
return true;
}
return AUTORESEARCH_LOCAL_STATE_DIRECTORIES.some(candidate => {
const normalizedCandidate = normalizeAutoresearchPath(candidate);
return normalized === normalizedCandidate || normalized.startsWith(`${normalizedCandidate}/`);
});
}
export function killTree(pid: number, signal: NodeJS.Signals | number = "SIGTERM"): void {
try {
process.kill(-pid, signal);
} catch {
try {
process.kill(pid, signal);
} catch {
// Process already exited.
}
}
}
export function isAutoresearchShCommand(command: string): boolean {
let normalized = command.trim();
normalized = normalized.replace(/^(?:\w+=\S*\s+)+/, "");
let previous = "";
while (previous !== normalized) {
previous = normalized;
normalized = normalized.replace(/^(?:env|time|nice|nohup)(?:\s+-\S+(?:\s+\d+)?)?\s+/, "");
}
if (/[;&|<>]/.test(normalized)) {
return false;
}
const tokens = parseCommandArgs(normalized);
if (tokens.length === 0) return false;
let index = 0;
if (tokens[index] === "bash" || tokens[index] === "sh") {
index += 1;
while (index < tokens.length && tokens[index]?.startsWith("-")) {
if (tokens[index]?.includes("c")) {
return false;
}
index += 1;
}
}
const scriptToken = tokens[index];
if (!scriptToken || !/^(?:\.\/|\/[\w/.-]*\/)?autoresearch\.sh$/.test(scriptToken)) {
return false;
}
for (const token of tokens.slice(index + 1)) {
if (token === "&&" || token === "||" || token === ";" || token === "|" || token === ">" || token === "<") {
return false;
}
}
return true;
}
export function isBetter(current: number, best: number, direction: MetricDirection): boolean {
return direction === "lower" ? current < best : current > best;
}
export function inferMetricUnitFromName(name: string): string {
if (name.endsWith("µs") || name.endsWith("_µs")) return "µs";
if (name.endsWith("ms") || name.endsWith("_ms")) return "ms";
if (name.endsWith("_s") || name.endsWith("_sec") || name.endsWith("_secs")) return "s";
if (name.endsWith("_kb") || name.endsWith("kb")) return "kb";
if (name.endsWith("_mb") || name.endsWith("mb")) return "mb";
return "";
}
export async function readPendingRunSummary(
workDir: string,
loggedRunNumbers: ReadonlySet<number> = new Set<number>(),
): Promise<PendingRunSummary | null> {
const runsDir = path.join(workDir, ".autoresearch", "runs");
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(runsDir, { withFileTypes: true });
} catch (error) {
if (isEnoent(error)) return null;
throw error;
}
const runDirectories = entries
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort((left, right) => right.localeCompare(left));
for (const directoryName of runDirectories) {
const runDirectory = path.join(runsDir, directoryName);
const runJsonPath = path.join(runDirectory, "run.json");
let parsed: unknown;
try {
parsed = await Bun.file(runJsonPath).json();
} catch (error) {
if (isEnoent(error)) continue;
throw error;
}
const pendingRun = parsePendingRunSummary(parsed, runDirectory, directoryName, loggedRunNumbers);
if (pendingRun) {
return pendingRun;
}
}
return null;
}
export function readConfig(cwd: string): AutoresearchConfig {
const configPath = path.join(cwd, "autoresearch.config.json");
try {
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== "object" || parsed === null) return {};
const candidate = parsed as { maxIterations?: unknown; workingDir?: unknown };
const config: AutoresearchConfig = {};
if (typeof candidate.maxIterations === "number" && Number.isFinite(candidate.maxIterations)) {
config.maxIterations = candidate.maxIterations;
}
if (typeof candidate.workingDir === "string" && candidate.workingDir.trim().length > 0) {
config.workingDir = candidate.workingDir;
}
return config;
} catch (error) {
if (isEnoent(error)) return {};
return {};
}
}
export function readMaxExperiments(cwd: string): number | null {
const value = readConfig(cwd).maxIterations;
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
return Math.floor(value);
}
export function resolveWorkDir(cwd: string): string {
const configured = readConfig(cwd).workingDir;
if (!configured) return cwd;
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
}
export function validateWorkDir(cwd: string): string | null {
const workDir = resolveWorkDir(cwd);
try {
const stat = fs.statSync(workDir);
if (!stat.isDirectory()) {
return `workingDir ${workDir} is not a directory.`;
}
return null;
} catch (error) {
if (isEnoent(error)) {
return `workingDir ${workDir} does not exist.`;
}
return `workingDir ${workDir} is unavailable.`;
}
}
function parsePendingRunSummary(
value: unknown,
runDirectory: string,
directoryName: string,
loggedRunNumbers: ReadonlySet<number>,
): PendingRunSummary | null {
if (typeof value !== "object" || value === null) return null;
const candidate = value as {
checks?: { durationSeconds?: unknown; passed?: unknown; timedOut?: unknown };
completedAt?: unknown;
command?: unknown;
durationSeconds?: unknown;
exitCode?: unknown;
loggedAt?: unknown;
parsedAsi?: unknown;
parsedMetrics?: unknown;
parsedPrimary?: unknown;
runNumber?: unknown;
status?: unknown;
timedOut?: unknown;
};
if (candidate.loggedAt !== undefined || candidate.status !== undefined) {
return null;
}
const command = typeof candidate.command === "string" ? candidate.command : "";
const runNumber =
typeof candidate.runNumber === "number" && Number.isFinite(candidate.runNumber)
? candidate.runNumber
: parseInt(directoryName, 10);
if (!Number.isFinite(runNumber)) return null;
if (loggedRunNumbers.has(runNumber)) return null;
const hasCompletedMetadata =
typeof candidate.completedAt === "string" ||
candidate.exitCode !== undefined ||
candidate.timedOut !== undefined ||
candidate.durationSeconds !== undefined ||
candidate.checks !== undefined ||
candidate.parsedPrimary !== undefined ||
candidate.parsedMetrics !== undefined ||
candidate.parsedAsi !== undefined;
if (!hasCompletedMetadata) {
return null;
}
const checksPass =
typeof candidate.checks?.passed === "boolean"
? candidate.checks.passed
: typeof candidate.checks?.timedOut === "boolean" && candidate.checks.timedOut
? false
: null;
const exitCode =
typeof candidate.exitCode === "number" && Number.isFinite(candidate.exitCode) ? candidate.exitCode : null;
const timedOut = candidate.timedOut === true;
const durationSeconds =
typeof candidate.durationSeconds === "number" && Number.isFinite(candidate.durationSeconds)
? candidate.durationSeconds
: null;
const parsedPrimary =
typeof candidate.parsedPrimary === "number" && Number.isFinite(candidate.parsedPrimary)
? candidate.parsedPrimary
: null;
const parsedAsi = cloneAsiData(candidate.parsedAsi);
const parsedMetrics = cloneNumericMetricMap(candidate.parsedMetrics);
const checksDurationSeconds =
typeof candidate.checks?.durationSeconds === "number" && Number.isFinite(candidate.checks.durationSeconds)
? candidate.checks.durationSeconds
: null;
const checksTimedOut = candidate.checks?.timedOut === true;
return {
checksDurationSeconds,
checksPass,
checksTimedOut,
command,
durationSeconds,
parsedAsi,
parsedMetrics,
parsedPrimary,
passed: exitCode === 0 && !timedOut && checksPass !== false,
runDirectory,
runNumber,
};
}
function cloneNumericMetricMap(value: unknown): NumericMetricMap | null {
if (typeof value !== "object" || value === null) return null;
const metrics = value as { [key: string]: unknown };
const clone: NumericMetricMap = {};
for (const [key, entryValue] of Object.entries(metrics)) {
if (DENIED_KEY_NAMES.has(key)) continue;
if (typeof entryValue === "number" && Number.isFinite(entryValue)) {
clone[key] = entryValue;
}
}
return Object.keys(clone).length > 0 ? clone : null;
}
function cloneAsiData(value: unknown): ASIData | null {
if (typeof value !== "object" || value === null) return null;
const candidate = value as { [key: string]: unknown };
const clone: ASIData = {};
for (const [key, entryValue] of Object.entries(candidate)) {
if (DENIED_KEY_NAMES.has(key)) continue;
const sanitized = clonePendingAsiValue(entryValue);
if (sanitized !== undefined) {
clone[key] = sanitized;
}
}
return Object.keys(clone).length > 0 ? clone : null;
}
function clonePendingAsiValue(value: unknown): ASIValue | undefined {
if (value === null) return null;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}
if (Array.isArray(value)) {
const items = value
.map(entry => clonePendingAsiValue(entry))
.filter((entry): entry is NonNullable<typeof entry> => entry !== undefined);
return items;
}
if (typeof value === "object") {
const candidate = value as { [key: string]: unknown };
const clone: { [key: string]: ASIValue } = {};
for (const [key, entryValue] of Object.entries(candidate)) {
if (DENIED_KEY_NAMES.has(key)) continue;
const sanitized = clonePendingAsiValue(entryValue);
if (sanitized !== undefined) {
clone[key] = sanitized;
}
}
return clone;
}
return undefined;
}
@@ -0,0 +1,693 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
import { renderPromptTemplate } from "../config/prompt-templates";
import type { ExtensionContext, ExtensionFactory } from "../extensibility/extensions";
import commandInitializeTemplate from "./command-initialize.md" with { type: "text" };
import commandResumeTemplate from "./command-resume.md" with { type: "text" };
import { pathMatchesContractPath } from "./contract";
import { createDashboardController } from "./dashboard";
import { ensureAutoresearchBranch } from "./git";
import {
formatNum,
isAutoresearchCommittableFile,
isAutoresearchLocalStatePath,
isAutoresearchShCommand,
normalizeAutoresearchPath,
readMaxExperiments,
readPendingRunSummary,
resolveWorkDir,
validateWorkDir,
} from "./helpers";
import promptTemplate from "./prompt.md" with { type: "text" };
import resumeMessageTemplate from "./resume-message.md" with { type: "text" };
import {
cloneExperimentState,
createExperimentState,
createRuntimeStore,
currentResults,
findBaselineMetric,
reconstructControlState,
reconstructStateFromJsonl,
} from "./state";
import { createInitExperimentTool } from "./tools/init-experiment";
import { createLogExperimentTool } from "./tools/log-experiment";
import { createRunExperimentTool } from "./tools/run-experiment";
import type { AutoresearchRuntime, ChecksResult, ExperimentResult, PendingRunSummary } from "./types";
const EXPERIMENT_TOOL_NAMES = ["init_experiment", "run_experiment", "log_experiment"];
interface AutoresearchSetupInput {
intent: string;
benchmarkCommand: string;
metricName: string;
metricUnit: string;
direction: "lower" | "higher";
secondaryMetrics: string[];
scopePaths: string[];
offLimits: string[];
constraints: string[];
}
export const createAutoresearchExtension: ExtensionFactory = api => {
const runtimeStore = createRuntimeStore();
const dashboard = createDashboardController();
const getSessionKey = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId();
const getRuntime = (ctx: ExtensionContext): AutoresearchRuntime => runtimeStore.ensure(getSessionKey(ctx));
const rehydrate = async (ctx: ExtensionContext): Promise<void> => {
const runtime = getRuntime(ctx);
const workDir = resolveWorkDir(ctx.cwd);
const reconstructed = reconstructStateFromJsonl(workDir);
const control = reconstructControlState(ctx.sessionManager.getBranch());
const loggedRunNumbers = collectLoggedRunNumbers(reconstructed.state.results);
runtime.state = cloneExperimentState(reconstructed.state);
runtime.state.maxExperiments = readMaxExperiments(ctx.cwd);
runtime.goal = control.goal;
runtime.autoresearchMode = control.autoresearchMode;
runtime.autoResumeArmed = false;
runtime.lastAutoResumePendingRunNumber = null;
runtime.lastRunSummary = await readPendingRunSummary(workDir, loggedRunNumbers);
runtime.lastRunChecks = summaryToChecks(runtime.lastRunSummary);
runtime.lastRunDuration = runtime.lastRunSummary?.durationSeconds ?? null;
runtime.lastRunAsi = runtime.lastRunSummary?.parsedAsi ?? null;
runtime.lastRunArtifactDir = runtime.lastRunSummary?.runDirectory ?? null;
runtime.lastRunNumber = runtime.lastRunSummary?.runNumber ?? null;
runtime.runningExperiment = null;
dashboard.updateWidget(ctx, runtime);
const activeTools = api.getActiveTools();
const experimentTools = new Set(EXPERIMENT_TOOL_NAMES);
const nextActiveTools = runtime.autoresearchMode
? [...new Set([...activeTools, ...EXPERIMENT_TOOL_NAMES])]
: activeTools.filter(name => !experimentTools.has(name));
const toolsChanged =
nextActiveTools.length !== activeTools.length ||
nextActiveTools.some((name, index) => name !== activeTools[index]);
if (toolsChanged) {
await api.setActiveTools(nextActiveTools);
}
};
const setMode = (
ctx: ExtensionContext,
enabled: boolean,
goal: string | null,
mode: "on" | "off" | "clear",
): void => {
const runtime = getRuntime(ctx);
runtime.autoresearchMode = enabled;
runtime.autoResumeArmed = false;
runtime.goal = goal;
runtime.lastAutoResumePendingRunNumber = null;
api.appendEntry("autoresearch-control", goal ? { mode, goal } : { mode });
};
api.registerTool(createInitExperimentTool({ dashboard, getRuntime, pi: api }));
api.registerTool(createRunExperimentTool({ dashboard, getRuntime, pi: api }));
api.registerTool(createLogExperimentTool({ dashboard, getRuntime, pi: api }));
api.on("tool_call", (event, ctx) => {
const runtime = getRuntime(ctx);
if (!runtime.autoresearchMode) return;
if (event.toolName === "bash") {
const command = typeof event.input.command === "string" ? event.input.command : "";
const validationError = validateAutoresearchBashCommand(command);
if (validationError) {
return {
block: true,
reason: validationError,
};
}
return;
}
if (event.toolName !== "write" && event.toolName !== "edit" && event.toolName !== "ast_edit") return;
const rawPaths = getGuardedToolPaths(event.toolName, event.input);
if (rawPaths === null) {
return {
block: true,
reason:
"Autoresearch requires an explicit target path for this editing tool so it can enforce Files in Scope and Off Limits before changes are made.",
};
}
const workDir = resolveWorkDir(ctx.cwd);
for (const rawPath of rawPaths) {
const relativePath = resolveAutoresearchRelativePath(workDir, rawPath);
if (!relativePath.ok) {
return {
block: true,
reason: relativePath.reason,
};
}
const validationError = validateEditableAutoresearchPath(relativePath.relativePath, runtime);
if (validationError) {
return {
block: true,
reason: `Autoresearch blocked edits to ${relativePath.relativePath}: ${validationError}`,
};
}
}
});
api.registerCommand("autoresearch", {
description: "Start, stop, or clear builtin autoresearch mode.",
getArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
if (argumentPrefix.includes(" ")) return null;
const completions: AutocompleteItem[] = [
{ label: "off", value: "off", description: "Leave autoresearch mode" },
{ label: "clear", value: "clear", description: "Delete autoresearch.jsonl and leave autoresearch mode" },
];
const normalized = argumentPrefix.trim().toLowerCase();
const filtered = completions.filter(item => item.label.startsWith(normalized));
return filtered.length > 0 ? filtered : null;
},
async handler(args, ctx): Promise<void> {
const trimmed = args.trim();
const runtime = getRuntime(ctx);
const workDirError = validateWorkDir(ctx.cwd);
if (workDirError) {
ctx.ui.notify(workDirError, "error");
return;
}
if (trimmed === "off") {
setMode(ctx, false, runtime.goal, "off");
dashboard.updateWidget(ctx, runtime);
const experimentTools = new Set(EXPERIMENT_TOOL_NAMES);
await api.setActiveTools(api.getActiveTools().filter(name => !experimentTools.has(name)));
ctx.ui.notify("Autoresearch mode disabled", "info");
return;
}
if (trimmed === "clear") {
const workDir = resolveWorkDir(ctx.cwd);
const jsonlPath = path.join(workDir, "autoresearch.jsonl");
const localStatePath = path.join(workDir, ".autoresearch");
if (fs.existsSync(jsonlPath)) {
fs.rmSync(jsonlPath);
}
if (fs.existsSync(localStatePath)) {
fs.rmSync(localStatePath, { force: true, recursive: true });
}
runtime.state = createExperimentState();
runtime.state.maxExperiments = readMaxExperiments(ctx.cwd);
runtime.goal = null;
runtime.lastRunChecks = null;
runtime.lastRunDuration = null;
runtime.lastRunAsi = null;
runtime.lastRunArtifactDir = null;
runtime.lastRunNumber = null;
runtime.lastRunSummary = null;
setMode(ctx, false, null, "clear");
dashboard.updateWidget(ctx, runtime);
const experimentTools = new Set(EXPERIMENT_TOOL_NAMES);
await api.setActiveTools(api.getActiveTools().filter(name => !experimentTools.has(name)));
ctx.ui.notify("Autoresearch local state cleared", "info");
return;
}
const workDir = resolveWorkDir(ctx.cwd);
const autoresearchMdPath = path.join(workDir, "autoresearch.md");
const hasAutoresearchMd = fs.existsSync(autoresearchMdPath);
const controlState = reconstructControlState(ctx.sessionManager.getBranch());
const shouldResumeExistingNotes =
hasAutoresearchMd &&
(hasLocalAutoresearchState(workDir) || (controlState.lastMode !== "clear" && trimmed.length === 0));
if (shouldResumeExistingNotes) {
const resumeContext = trimmed;
const resumeGoal = runtime.goal ?? runtime.state.name ?? null;
const branchResult = await ensureAutoresearchBranch(api, workDir, resumeGoal);
if (!branchResult.ok) {
ctx.ui.notify(branchResult.error, "error");
return;
}
setMode(ctx, true, resumeGoal, "on");
dashboard.updateWidget(ctx, runtime);
await api.setActiveTools([...new Set([...api.getActiveTools(), ...EXPERIMENT_TOOL_NAMES])]);
api.sendUserMessage(
renderPromptTemplate(commandResumeTemplate, {
autoresearch_md_path: autoresearchMdPath,
branch_status_line: branchResult.created
? `Created and checked out dedicated git branch \`${branchResult.branchName}\` before resuming.`
: `Using dedicated git branch \`${branchResult.branchName}\`.`,
has_resume_context: resumeContext.length > 0,
resume_context: resumeContext,
}),
);
return;
}
const setup = await promptForAutoresearchSetup(
ctx,
trimmed || runtime.goal || "what should autoresearch improve?",
);
if (!setup) return;
const branchResult = await ensureAutoresearchBranch(api, workDir, setup.intent);
if (!branchResult.ok) {
ctx.ui.notify(branchResult.error, "error");
return;
}
setMode(ctx, true, setup.intent, "on");
runtime.state.name = setup.intent;
runtime.state.metricName = setup.metricName;
runtime.state.metricUnit = setup.metricUnit;
runtime.state.bestDirection = setup.direction;
runtime.state.secondaryMetrics = setup.secondaryMetrics.map(name => ({ name, unit: "" }));
runtime.state.benchmarkCommand = setup.benchmarkCommand;
runtime.state.scopePaths = [...setup.scopePaths];
runtime.state.offLimits = [...setup.offLimits];
runtime.state.constraints = [...setup.constraints];
dashboard.updateWidget(ctx, runtime);
await api.setActiveTools([...new Set([...api.getActiveTools(), ...EXPERIMENT_TOOL_NAMES])]);
api.sendUserMessage(
renderPromptTemplate(commandInitializeTemplate, {
branch_status_line: branchResult.created
? `Created and checked out dedicated git branch \`${branchResult.branchName}\`.`
: `Using dedicated git branch \`${branchResult.branchName}\`.`,
intent: setup.intent,
benchmark_command: setup.benchmarkCommand,
metric_name: setup.metricName,
metric_unit: setup.metricUnit,
direction: setup.direction,
has_secondary_metrics: setup.secondaryMetrics.length > 0,
secondary_metrics: setup.secondaryMetrics,
secondary_metrics_block: formatBulletBlock(
setup.secondaryMetrics,
value => ` - \`${value}\``,
" - `(none)`",
),
scope_paths: setup.scopePaths,
scope_paths_block: formatBulletBlock(setup.scopePaths, value => ` - \`${value}\``),
has_off_limits: setup.offLimits.length > 0,
off_limits: setup.offLimits,
off_limits_block: formatBulletBlock(setup.offLimits, value => ` - \`${value}\``, " - `(none)`"),
has_constraints: setup.constraints.length > 0,
constraints: setup.constraints,
constraints_block: formatBulletBlock(setup.constraints, value => ` - ${value}`, " - `(none)`"),
}),
);
},
});
api.registerShortcut("ctrl+x", {
description: "Toggle autoresearch dashboard",
handler(ctx): void {
const runtime = getRuntime(ctx);
if (runtime.state.results.length === 0 && !runtime.runningExperiment) {
ctx.ui.notify("No autoresearch results yet", "info");
return;
}
runtime.dashboardExpanded = !runtime.dashboardExpanded;
dashboard.updateWidget(ctx, runtime);
},
});
api.registerShortcut("ctrl+shift+x", {
description: "Show autoresearch dashboard overlay",
handler(ctx): Promise<void> {
return dashboard.showOverlay(ctx, getRuntime(ctx));
},
});
api.on("session_start", (_event, ctx) => rehydrate(ctx));
api.on("session_switch", (_event, ctx) => rehydrate(ctx));
api.on("session_branch", (_event, ctx) => rehydrate(ctx));
api.on("session_tree", (_event, ctx) => rehydrate(ctx));
api.on("session_shutdown", (_event, ctx) => {
dashboard.clear(ctx);
runtimeStore.clear(getSessionKey(ctx));
});
api.on("agent_end", async (_event, ctx) => {
const runtime = getRuntime(ctx);
runtime.runningExperiment = null;
dashboard.updateWidget(ctx, runtime);
dashboard.requestRender();
if (!runtime.autoresearchMode) return;
if (ctx.hasPendingMessages()) {
runtime.autoResumeArmed = false;
return;
}
const workDir = resolveWorkDir(ctx.cwd);
const pendingRun =
runtime.lastRunSummary ??
(await readPendingRunSummary(workDir, collectLoggedRunNumbers(runtime.state.results)));
runtime.lastRunSummary = pendingRun;
runtime.lastRunChecks = summaryToChecks(pendingRun);
runtime.lastRunDuration = pendingRun?.durationSeconds ?? runtime.lastRunDuration;
runtime.lastRunAsi = pendingRun?.parsedAsi ?? runtime.lastRunAsi;
const shouldResumePendingRun =
pendingRun !== null && runtime.lastAutoResumePendingRunNumber !== pendingRun.runNumber;
if (!shouldResumePendingRun && !runtime.autoResumeArmed) {
return;
}
runtime.autoResumeArmed = false;
runtime.lastAutoResumePendingRunNumber = pendingRun?.runNumber ?? null;
const autoresearchMdPath = path.join(workDir, "autoresearch.md");
const ideasPath = path.join(workDir, "autoresearch.ideas.md");
api.sendMessage(
{
customType: "autoresearch-resume",
content: renderPromptTemplate(resumeMessageTemplate, {
autoresearch_md_path: autoresearchMdPath,
has_ideas: fs.existsSync(ideasPath),
has_pending_run: Boolean(pendingRun),
}),
display: false,
attribution: "agent",
},
{ deliverAs: "nextTurn", triggerTurn: true },
);
});
api.on("before_agent_start", async (event, ctx) => {
const runtime = getRuntime(ctx);
if (!runtime.autoresearchMode) return;
const workDir = resolveWorkDir(ctx.cwd);
const autoresearchMdPath = path.join(workDir, "autoresearch.md");
const checksPath = path.join(workDir, "autoresearch.checks.sh");
const ideasPath = path.join(workDir, "autoresearch.ideas.md");
const programPath = path.join(workDir, "autoresearch.program.md");
const pendingRun =
runtime.lastRunSummary ??
(await readPendingRunSummary(workDir, collectLoggedRunNumbers(runtime.state.results)));
runtime.lastRunSummary = pendingRun;
runtime.lastRunChecks = summaryToChecks(pendingRun);
runtime.lastRunDuration = pendingRun?.durationSeconds ?? runtime.lastRunDuration;
runtime.lastRunAsi = pendingRun?.parsedAsi ?? runtime.lastRunAsi;
const currentSegmentResults = currentResults(runtime.state.results, runtime.state.currentSegment);
const baselineMetric = findBaselineMetric(runtime.state.results, runtime.state.currentSegment);
const bestResult = findBestResult(runtime);
const goal = runtime.goal ?? runtime.state.name ?? "";
const recentResults = currentSegmentResults.slice(-3).map(result => {
const asiSummary = summarizeExperimentAsi(result);
return {
asi_summary: asiSummary,
description: result.description,
has_asi_summary: Boolean(asiSummary),
metric_display: formatNum(result.metric, runtime.state.metricUnit),
run_number: result.runNumber ?? runtime.state.results.indexOf(result) + 1,
status: result.status,
};
});
return {
systemPrompt: renderPromptTemplate(promptTemplate, {
base_system_prompt: event.systemPrompt,
has_goal: goal.trim().length > 0,
goal,
working_dir: workDir,
default_metric_name: runtime.state.metricName,
metric_name: runtime.state.metricName,
has_autoresearch_md: fs.existsSync(autoresearchMdPath),
autoresearch_md_path: autoresearchMdPath,
has_checks: fs.existsSync(checksPath),
checks_path: checksPath,
has_ideas: fs.existsSync(ideasPath),
ideas_path: ideasPath,
has_program: fs.existsSync(programPath),
program_path: programPath,
current_segment: runtime.state.currentSegment + 1,
current_segment_run_count: currentSegmentResults.length,
has_baseline_metric: baselineMetric !== null,
baseline_metric_display: formatNum(baselineMetric, runtime.state.metricUnit),
has_best_result: Boolean(bestResult),
best_metric_display: bestResult
? formatNum(bestResult.metric, runtime.state.metricUnit)
: formatNum(baselineMetric, runtime.state.metricUnit),
best_run_number: bestResult
? (bestResult.runNumber ?? runtime.state.results.indexOf(bestResult) + 1)
: null,
has_recent_results: recentResults.length > 0,
recent_results: recentResults,
has_pending_run: Boolean(pendingRun),
pending_run_number: pendingRun?.runNumber,
pending_run_command: pendingRun?.command,
pending_run_directory: pendingRun?.runDirectory,
pending_run_passed: pendingRun?.passed ?? false,
has_pending_run_metric: pendingRun?.parsedPrimary !== null && pendingRun?.parsedPrimary !== undefined,
pending_run_metric_display:
pendingRun?.parsedPrimary !== null && pendingRun?.parsedPrimary !== undefined
? formatNum(pendingRun.parsedPrimary, runtime.state.metricUnit)
: null,
}),
};
});
};
async function promptForAutoresearchSetup(
ctx: ExtensionContext,
defaultIntent: string,
): Promise<AutoresearchSetupInput | undefined> {
const intentInput = await ctx.ui.input("Autoresearch Intent", defaultIntent);
if (intentInput === undefined) return undefined;
const intent = intentInput.trim();
if (intent.length === 0) {
ctx.ui.notify("Autoresearch intent is required", "info");
return undefined;
}
const benchmarkCommandInput = await ctx.ui.input("Benchmark Command", "bash autoresearch.sh");
if (benchmarkCommandInput === undefined) return undefined;
const benchmarkCommand = benchmarkCommandInput.trim();
if (benchmarkCommand.length === 0) {
ctx.ui.notify("Benchmark command is required", "info");
return undefined;
}
if (!isAutoresearchShCommand(benchmarkCommand)) {
ctx.ui.notify("Benchmark command must invoke `autoresearch.sh` directly", "info");
return undefined;
}
const metricNameInput = await ctx.ui.input("Primary Metric Name", "runtime_ms");
if (metricNameInput === undefined) return undefined;
const metricName = metricNameInput.trim();
if (metricName.length === 0) {
ctx.ui.notify("Primary metric name is required", "info");
return undefined;
}
const metricUnitInput = await ctx.ui.input("Metric Unit", "ms");
if (metricUnitInput === undefined) return undefined;
const metricUnit = metricUnitInput.trim();
const directionInput = await ctx.ui.input("Metric Direction", "lower");
if (directionInput === undefined) return undefined;
const normalizedDirection = directionInput.trim().toLowerCase();
if (normalizedDirection !== "lower" && normalizedDirection !== "higher") {
ctx.ui.notify("Metric direction must be `lower` or `higher`", "info");
return undefined;
}
const secondaryMetricsInput = await ctx.ui.input("Tradeoff Metrics", "");
if (secondaryMetricsInput === undefined) return undefined;
const scopePathsInput = await ctx.ui.input("Files in Scope", "packages/coding-agent/src/autoresearch");
if (scopePathsInput === undefined) return undefined;
const scopePaths = splitSetupList(scopePathsInput);
if (scopePaths.length === 0) {
ctx.ui.notify("Files in Scope must include at least one path", "info");
return undefined;
}
const offLimitsInput = await ctx.ui.input("Off Limits", "");
if (offLimitsInput === undefined) return undefined;
const constraintsInput = await ctx.ui.input("Constraints", "");
if (constraintsInput === undefined) return undefined;
return {
intent,
benchmarkCommand,
metricName,
metricUnit,
direction: normalizedDirection,
secondaryMetrics: splitSetupList(secondaryMetricsInput),
scopePaths,
offLimits: splitSetupList(offLimitsInput),
constraints: splitSetupList(constraintsInput),
};
}
function splitSetupList(value: string): string[] {
return value
.split(/\r?\n|,/)
.map(entry => entry.trim())
.filter((entry, index, values) => entry.length > 0 && values.indexOf(entry) === index);
}
function formatBulletBlock(values: string[], renderValue: (value: string) => string, emptyValue = ""): string {
if (values.length === 0) {
return emptyValue;
}
return values.map(renderValue).join("\n");
}
function hasLocalAutoresearchState(workDir: string): boolean {
return fs.existsSync(path.join(workDir, "autoresearch.jsonl")) || fs.existsSync(path.join(workDir, ".autoresearch"));
}
function summarizeExperimentAsi(result: ExperimentResult): string | null {
const hypothesis = typeof result.asi?.hypothesis === "string" ? result.asi.hypothesis.trim() : "";
const rollbackReason = typeof result.asi?.rollback_reason === "string" ? result.asi.rollback_reason.trim() : "";
const nextActionHint = typeof result.asi?.next_action_hint === "string" ? result.asi.next_action_hint.trim() : "";
const summary = [hypothesis, rollbackReason, nextActionHint].filter(part => part.length > 0).join(" | ");
return summary.length > 0 ? summary.slice(0, 220) : null;
}
function getGuardedToolPaths(toolName: string, input: Record<string, unknown>): string[] | null {
if (toolName === "write") {
return typeof input.path === "string" ? [input.path] : null;
}
if (toolName === "ast_edit") {
return typeof input.path === "string" ? [input.path] : null;
}
if (toolName !== "edit") {
return [];
}
const paths: string[] = [];
if (typeof input.path === "string") {
paths.push(input.path);
}
if (typeof input.rename === "string") {
paths.push(input.rename);
}
if (typeof input.move === "string") {
paths.push(input.move);
}
return paths;
}
function resolveAutoresearchRelativePath(
workDir: string,
rawPath: string,
): { ok: false; reason: string } | { ok: true; relativePath: string } {
if (looksLikeInternalUrl(rawPath)) {
return {
ok: false,
reason: `Autoresearch cannot validate internal URL paths during scoped editing: ${rawPath}`,
};
}
const resolvedPath = path.isAbsolute(rawPath) ? path.resolve(rawPath) : path.resolve(workDir, rawPath);
const canonicalWorkDir = canonicalizeExistingPath(workDir);
const canonicalTargetPath = canonicalizeTargetPath(resolvedPath);
const relativePath = path.relative(canonicalWorkDir, canonicalTargetPath);
if (relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
return {
ok: false,
reason: `Autoresearch blocked edits outside the working tree: ${rawPath}`,
};
}
return {
ok: true,
relativePath: relativePath.length === 0 ? "." : normalizeAutoresearchPath(relativePath),
};
}
function validateEditableAutoresearchPath(relativePath: string, runtime: AutoresearchRuntime): string | null {
if (isAutoresearchLocalStatePath(relativePath)) {
return "autoresearch local state files are managed by the experiment tools and cannot be edited directly";
}
if (runtime.state.offLimits.some(spec => pathMatchesContractPath(relativePath, spec))) {
return "this path is listed under Off Limits in autoresearch.md";
}
if (isAutoresearchCommittableFile(relativePath)) {
return null;
}
if (runtime.state.scopePaths.length === 0) {
return "Files in Scope is not initialized yet; only autoresearch control files may be edited before init_experiment runs";
}
if (!runtime.state.scopePaths.some(spec => pathMatchesContractPath(relativePath, spec))) {
return "this path is outside Files in Scope in autoresearch.md";
}
return null;
}
function findBestResult(runtime: AutoresearchRuntime): ExperimentResult | null {
let best: ExperimentResult | null = null;
for (const result of runtime.state.results) {
if (result.segment !== runtime.state.currentSegment || result.status !== "keep") continue;
if (!best) {
best = result;
continue;
}
if (runtime.state.bestDirection === "lower" ? result.metric < best.metric : result.metric > best.metric) {
best = result;
}
}
return best;
}
function collectLoggedRunNumbers(results: ExperimentResult[]): Set<number> {
const runNumbers = new Set<number>();
for (const result of results) {
if (result.runNumber !== null) {
runNumbers.add(result.runNumber);
}
}
return runNumbers;
}
function summaryToChecks(summary: PendingRunSummary | null): ChecksResult | null {
if (!summary || summary.checksPass === null) {
return null;
}
return {
pass: summary.checksPass,
output: "",
duration: summary.checksDurationSeconds ?? 0,
};
}
function looksLikeInternalUrl(value: string): boolean {
return /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
}
function canonicalizeExistingPath(targetPath: string): string {
try {
return fs.realpathSync.native(targetPath);
} catch {
return path.resolve(targetPath);
}
}
function canonicalizeTargetPath(targetPath: string): string {
const pendingSegments: string[] = [];
let currentPath = path.resolve(targetPath);
while (!fs.existsSync(currentPath)) {
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
return currentPath;
}
pendingSegments.unshift(path.basename(currentPath));
currentPath = parentPath;
}
return path.resolve(canonicalizeExistingPath(currentPath), ...pendingSegments);
}
function validateAutoresearchBashCommand(command: string): string | null {
const trimmed = command.trim();
if (trimmed.length === 0) {
return null;
}
const mutationPatterns = [
/(^|[;&|()]\s*)(?:bash|sh)\b/,
/(^|[;&|()]\s*)(?:python|python3|node|perl|ruby|php)\b/,
/(^|[;&|()]\s*)(?:mv|cp|rm|mkdir|touch|chmod|chown|ln|install|patch)\b/,
/(^|[;&|()]\s*)sed\s+-i\b/,
/(^|[;&|()]\s*)git\s+(?:add|apply|checkout|clean|commit|merge|rebase|reset|restore|revert|stash|switch|worktree)\b/,
/(^|[^<])>>?/,
/\|\s*tee\b/,
/<<<?/,
];
if (mutationPatterns.some(pattern => pattern.test(trimmed))) {
return (
"Autoresearch only allows read-only shell inspection. " +
"Use write/edit/ast_edit for file changes and run_experiment for benchmark execution."
);
}
return null;
}
@@ -0,0 +1,227 @@
{{{base_system_prompt}}}
## Autoresearch Mode
Autoresearch mode is active.
{{#if has_goal}}
Primary goal:
{{goal}}
{{else}}
Primary goal is documented in `autoresearch.md` for this session.
{{/if}}
Working directory:
`{{working_dir}}`
You are running an autonomous experiment loop. Keep iterating until the user interrupts you or the configured maximum iteration count is reached.
{{#if has_program}}
### Local Playbook
`autoresearch.program.md` exists at `{{program_path}}`.
Use it as a repo-local strategy overlay for this session. `autoresearch.md` remains the source of truth for benchmark, scope, and constraints.
{{/if}}
{{#if has_recent_results}}
### Current Segment Snapshot
- segment: `{{current_segment}}`
- runs in current segment: `{{current_segment_run_count}}`
{{#if has_baseline_metric}}
- baseline `{{metric_name}}`: `{{baseline_metric_display}}`
{{/if}}
{{#if has_best_result}}
- best kept `{{metric_name}}`: `{{best_metric_display}}`{{#if best_run_number}} from run `#{{best_run_number}}`{{/if}}
{{/if}}
Recent runs:
{{#each recent_results}}
- run `#{{run_number}}`: `{{status}}` `{{metric_display}}` — {{description}}
{{#if has_asi_summary}}
ASI: {{asi_summary}}
{{/if}}
{{/each}}
{{/if}}
{{#if has_pending_run}}
### Pending Run
An unlogged run artifact exists at `{{pending_run_directory}}`.
- run: `#{{pending_run_number}}`
- command: `{{pending_run_command}}`
{{#if has_pending_run_metric}}
- parsed `{{metric_name}}`: `{{pending_run_metric_display}}`
{{/if}}
- result status: {{#if pending_run_passed}}passed{{else}}failed{{/if}}
- finish the `log_experiment` step before starting another benchmark
{{/if}}
### Available tools
- `init_experiment` — initialize or reset the experiment session for the current optimization target.
- `run_experiment` — run a benchmark or experiment command with timing, output capture, structured metric parsing, and optional backpressure checks.
- `log_experiment` — record the result, update the dashboard, persist JSONL history, auto-commit kept experiments, and auto-revert discarded or failed experiments.
### Operating protocol
1. Understand the target before touching code.
- Read the relevant source files.
- Identify the true bottleneck or quality constraint.
- Check existing scripts, benchmark harnesses, and config files.
- Verify prerequisites, one-time setup, and benchmark inputs before the first run of a segment.
2. Keep your notes in `autoresearch.md`.
- Record the goal, the benchmark command, the primary metric, important secondary metrics, the files in scope, hard constraints, preflight requirements, and the benchmark comparability invariant.
- Update the notes whenever the strategy changes.
- Keep durable conclusions in `autoresearch.md`.
- Use `autoresearch.ideas.md` for deferred experiment ideas that are promising but not active yet.
3. Use `autoresearch.sh` as the canonical benchmark entrypoint.
- If it does not exist yet, create it.
- Make it print structured metric lines in the form `METRIC name=value`.
- Use the same workload every run unless you intentionally re-initialize with a new segment.
- Keep the measurement harness, evaluator, and fixed benchmark inputs stable unless you intentionally start a new segment and document the change.
4. Initialize the loop with `init_experiment` before the first logged run of a segment.
5. Run a baseline first.
- Establish the baseline metric before attempting optimizations.
- Track secondary metrics only when they matter to correctness, quality, or obvious regressions.
6. Iterate.
- Make one coherent experiment at a time.
- Run `run_experiment`.
- Interpret the result honestly.
- Call `log_experiment` after every run.
7. Keep the primary metric as the decision maker.
- `keep` when the primary metric improves.
- `discard` when it regresses or stays flat.
- `crash` when the run fails.
- `checks_failed` when the benchmark passes but backpressure checks fail.
8. Record ASI on every `log_experiment` call.
- At minimum include `hypothesis`.
- On `discard`, `crash`, or `checks_failed`, also include `rollback_reason` and `next_action_hint`.
- Use ASI to capture what you learned, not just what you changed.
9. Prefer simpler wins.
- Remove dead ends.
- Keep equal or near-equal results when they materially simplify the implementation.
- Do not keep ugly complexity for tiny gains unless the payoff is clearly worth it.
- Do not thrash between unrelated ideas without writing down the conclusion.
10. When confidence is low, confirm.
- The dashboard confidence score compares the best observed improvement against the observed noise floor.
- Below `1.0x` usually means the improvement is within noise.
- Re-run promising changes when needed before keeping them.
### Benchmark harness guidance
Your benchmark script SHOULD:
- live at `autoresearch.sh`
- run from `{{working_dir}}`
- fail with a non-zero exit status on invalid runs
- print the primary metric as `METRIC {{default_metric_name}}=<number>` or another explicit metric name chosen during initialization
- print secondary metrics as additional `METRIC name=value` lines
- avoid extra randomness when possible
- use repeated samples and median-style summaries for fast benchmarks
- preserve the comparability invariant for the current segment
- keep the ground-truth evaluator and fixed benchmark inputs unchanged unless the segment is explicitly re-initialized
### Notes file template
Keep `autoresearch.md` concise and current.
Suggested structure:
```md
# Autoresearch
## Goal
{{#if has_goal}}
- {{goal}}
{{else}}
- document the active target here before the first benchmark
{{/if}}
## Benchmark
- command:
- primary metric:
- metric unit:
- direction:
- secondary metrics: memory_mb, rss_mb
## Files in Scope
- path:
## Off Limits
- path:
## Constraints
- rule:
## Baseline
- metric:
- notes:
## Current best
- metric:
- why it won:
## What's Been Tried
- experiment:
- lesson:
```
### Guardrails
- Do not game the benchmark.
- Do not overfit to synthetic inputs if the real workload is broader.
- Preserve correctness.
- Only modify files that are explicitly in scope for the current session.
- Do not use the general shell tool for file mutations during autoresearch. Use `write`, `edit`, or `ast_edit` for scoped code changes and `run_experiment` for benchmark execution.
- If you create `autoresearch.checks.sh`, treat it as a hard gate for `keep`.
- If the user sends another message while a run is in progress, finish the current run and logging cycle first, then address the new input in the next iteration.
{{#if has_autoresearch_md}}
### Resume mode
`autoresearch.md` already exists at `{{autoresearch_md_path}}`.
Resume from the existing notes:
- read `autoresearch.md`
- inspect recent git history
- inspect `autoresearch.jsonl`
- continue from the most promising unfinished direction on the current protected branch
{{else}}
### Initial setup
`autoresearch.md` does not exist yet.
Create the experiment workspace before the first benchmark:
- write `autoresearch.md`
- write `autoresearch.sh`
- optionally write `autoresearch.checks.sh`
- run `init_experiment`
- run and log the baseline
{{/if}}
{{#if has_checks}}
### Backpressure checks
`autoresearch.checks.sh` exists at `{{checks_path}}` and runs automatically after passing benchmark runs.
Treat failing checks as a failed experiment:
- do not `keep` a run when checks fail
- log it as `checks_failed`
- diagnose the regression before continuing
{{/if}}
{{#if has_ideas}}
### Ideas backlog
`autoresearch.ideas.md` exists at `{{ideas_path}}`.
Use it to keep promising but deferred experiments. `autoresearch.md` should hold durable conclusions; `autoresearch.ideas.md` is the scratch backlog. Prune stale ideas when they are disproven or superseded.
{{/if}}
@@ -0,0 +1,16 @@
Continue the autoresearch loop now.
@{{autoresearch_md_path}}
- Read `autoresearch.md` and `autoresearch.jsonl`.
- Treat `autoresearch.md` as the source of truth for the current direction, scope, and constraints.
- Inspect recent git history for context.
{{#if has_pending_run}}
- Inspect the latest unlogged `run.json` under `.autoresearch/runs/` and finish the pending `log_experiment` step before starting a new benchmark.
{{/if}}
- Continue from the most promising unfinished direction.
{{#if has_ideas}}
- Review `autoresearch.ideas.md` for deferred next steps and prune stale items.
{{/if}}
- Keep iterating until interrupted or until the configured iteration cap is reached.
- Preserve correctness and do not game the benchmark.
@@ -0,0 +1,386 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { SessionEntry } from "../session/session-manager";
import { normalizeAutoresearchList, normalizeContractPathSpec } from "./contract";
import { inferMetricUnitFromName, isBetter } from "./helpers";
import type {
AutoresearchControlEntryData,
AutoresearchJsonConfigEntry,
AutoresearchJsonRunEntry,
AutoresearchRuntime,
ExperimentResult,
ExperimentState,
MetricDef,
MetricDirection,
NumericMetricMap,
ReconstructedControlState,
ReconstructedExperimentData,
RuntimeStore,
} from "./types";
export function createExperimentState(): ExperimentState {
return {
results: [],
bestMetric: null,
bestDirection: "lower",
metricName: "metric",
metricUnit: "",
secondaryMetrics: [],
name: null,
currentSegment: 0,
maxExperiments: null,
confidence: null,
benchmarkCommand: null,
scopePaths: [],
offLimits: [],
constraints: [],
segmentFingerprint: null,
};
}
export function createSessionRuntime(): AutoresearchRuntime {
return {
autoresearchMode: false,
autoResumeArmed: false,
dashboardExpanded: false,
lastAutoResumePendingRunNumber: null,
lastRunChecks: null,
lastRunDuration: null,
lastRunAsi: null,
lastRunArtifactDir: null,
lastRunNumber: null,
lastRunSummary: null,
runningExperiment: null,
state: createExperimentState(),
goal: null,
};
}
export function cloneExperimentState(state: ExperimentState): ExperimentState {
return {
...state,
results: state.results.map(result => ({
...result,
metrics: { ...result.metrics },
asi: result.asi ? structuredClone(result.asi) : undefined,
})),
secondaryMetrics: state.secondaryMetrics.map(metric => ({ ...metric })),
scopePaths: [...state.scopePaths],
offLimits: [...state.offLimits],
constraints: [...state.constraints],
};
}
export function currentResults(results: ExperimentResult[], segment: number): ExperimentResult[] {
return results.filter(result => result.segment === segment);
}
export function findBaselineResult(results: ExperimentResult[], segment: number): ExperimentResult | null {
return currentResults(results, segment).find(result => result.status === "keep") ?? null;
}
export function findBaselineMetric(results: ExperimentResult[], segment: number): number | null {
const baseline = findBaselineResult(results, segment);
return baseline ? baseline.metric : null;
}
export function findBestKeptMetric(
results: ExperimentResult[],
segment: number,
direction: MetricDirection,
): number | null {
let best: number | null = null;
for (const result of currentResults(results, segment)) {
if (result.status !== "keep") continue;
if (best === null || isBetter(result.metric, best, direction)) {
best = result.metric;
}
}
return best;
}
export function findBaselineRunNumber(results: ExperimentResult[], segment: number): number | null {
const baseline = findBaselineResult(results, segment);
if (!baseline) return null;
if (baseline.runNumber !== null) return baseline.runNumber;
const index = results.indexOf(baseline);
return index >= 0 ? index + 1 : null;
}
export function findBaselineSecondary(
results: ExperimentResult[],
segment: number,
knownMetrics: MetricDef[],
): NumericMetricMap {
const baseline = findBaselineResult(results, segment);
const values: NumericMetricMap = baseline ? { ...baseline.metrics } : {};
for (const metric of knownMetrics) {
if (values[metric.name] !== undefined) continue;
for (const result of currentResults(results, segment)) {
const value = result.metrics[metric.name];
if (value !== undefined) {
values[metric.name] = value;
break;
}
}
}
return values;
}
export function sortedMedian(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((left, right) => left - right);
const midpoint = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return (sorted[midpoint - 1] + sorted[midpoint]) / 2;
}
return sorted[midpoint];
}
export function computeConfidence(
results: ExperimentResult[],
segment: number,
direction: MetricDirection,
): number | null {
const current = currentResults(results, segment).filter(result => result.metric > 0);
if (current.length < 3) return null;
const values = current.map(result => result.metric);
const median = sortedMedian(values);
const mad = sortedMedian(values.map(value => Math.abs(value - median)));
if (mad === 0) return null;
const baseline = findBaselineMetric(results, segment);
if (baseline === null) return null;
let bestKept: number | null = null;
for (const result of current) {
if (result.status !== "keep" || result.metric <= 0) continue;
if (bestKept === null || isBetter(result.metric, bestKept, direction)) {
bestKept = result.metric;
}
}
if (bestKept === null || bestKept === baseline) return null;
return Math.abs(bestKept - baseline) / mad;
}
export function reconstructStateFromJsonl(workDir: string): ReconstructedExperimentData {
const state = createExperimentState();
const jsonlPath = path.join(workDir, "autoresearch.jsonl");
if (!fs.existsSync(jsonlPath)) {
return { hasLog: false, state };
}
const content = fs.readFileSync(jsonlPath, "utf8");
const lines = content
.split("\n")
.map(line => line.trim())
.filter(line => line.length > 0);
let segment = 0;
let sawConfig = false;
for (const line of lines) {
let parsed: unknown;
try {
parsed = JSON.parse(line) as unknown;
} catch {
continue;
}
const configEntry = parseConfigEntry(parsed);
if (configEntry) {
if (sawConfig || state.results.length > 0) {
segment += 1;
}
sawConfig = true;
state.currentSegment = segment;
if (configEntry.name) state.name = configEntry.name;
if (configEntry.metricName) state.metricName = configEntry.metricName;
if (configEntry.metricUnit !== undefined) state.metricUnit = configEntry.metricUnit;
if (configEntry.bestDirection) state.bestDirection = configEntry.bestDirection;
if (configEntry.benchmarkCommand !== undefined) state.benchmarkCommand = configEntry.benchmarkCommand;
state.scopePaths = cloneStringArray(configEntry.scopePaths);
state.offLimits = cloneStringArray(configEntry.offLimits);
state.constraints = cloneStringArray(configEntry.constraints);
state.segmentFingerprint =
typeof configEntry.segmentFingerprint === "string" ? configEntry.segmentFingerprint : null;
state.secondaryMetrics = hydrateMetricDefs(configEntry.secondaryMetrics);
continue;
}
if (!isRunEntry(parsed)) continue;
const result: ExperimentResult = {
runNumber: typeof parsed.run === "number" && Number.isFinite(parsed.run) ? parsed.run : null,
commit: typeof parsed.commit === "string" ? parsed.commit : "",
metric: typeof parsed.metric === "number" && Number.isFinite(parsed.metric) ? parsed.metric : 0,
metrics: cloneNumericMetrics(parsed.metrics),
status: isExperimentStatus(parsed.status) ? parsed.status : "keep",
description: typeof parsed.description === "string" ? parsed.description : "",
timestamp: typeof parsed.timestamp === "number" && Number.isFinite(parsed.timestamp) ? parsed.timestamp : 0,
segment,
confidence:
typeof parsed.confidence === "number" && Number.isFinite(parsed.confidence) ? parsed.confidence : null,
asi: cloneAsi(parsed.asi),
};
state.results.push(result);
if (segment !== state.currentSegment) continue;
registerSecondaryMetrics(state.secondaryMetrics, result.metrics);
}
state.bestMetric = findBaselineMetric(state.results, state.currentSegment);
state.confidence = computeConfidence(state.results, state.currentSegment, state.bestDirection);
return { hasLog: true, state };
}
export function reconstructControlState(entries: SessionEntry[]): ReconstructedControlState {
let autoresearchMode = false;
let goal: string | null = null;
let lastMode: ReconstructedControlState["lastMode"] = null;
for (const entry of entries) {
if (entry.type !== "custom" || entry.customType !== "autoresearch-control") continue;
const data = parseControlEntry(entry.data);
if (!data) continue;
lastMode = data.mode;
autoresearchMode = data.mode === "on";
goal = data.goal ?? goal;
if (data.mode === "clear") {
goal = null;
}
}
return { autoresearchMode, goal, lastMode };
}
export function createRuntimeStore(): RuntimeStore {
const runtimes = new Map<string, AutoresearchRuntime>();
return {
clear(sessionKey: string): void {
runtimes.delete(sessionKey);
},
ensure(sessionKey: string): AutoresearchRuntime {
const existing = runtimes.get(sessionKey);
if (existing) return existing;
const runtime = createSessionRuntime();
runtimes.set(sessionKey, runtime);
return runtime;
},
};
}
function registerSecondaryMetrics(metrics: MetricDef[], values: NumericMetricMap): void {
for (const name of Object.keys(values)) {
if (metrics.some(metric => metric.name === name)) continue;
metrics.push({
name,
unit: inferMetricUnitFromName(name),
});
}
}
function isConfigEntry(value: unknown): value is AutoresearchJsonConfigEntry {
if (typeof value !== "object" || value === null) return false;
const candidate = value as { type?: unknown };
return candidate.type === "config";
}
function parseConfigEntry(value: unknown): AutoresearchJsonConfigEntry | null {
if (!isConfigEntry(value)) return null;
const candidate = value as AutoresearchJsonConfigEntry;
const config: AutoresearchJsonConfigEntry = { type: "config" };
if (typeof candidate.name === "string" && candidate.name.trim().length > 0) {
config.name = candidate.name;
}
if (typeof candidate.metricName === "string" && candidate.metricName.trim().length > 0) {
config.metricName = candidate.metricName;
}
if (typeof candidate.metricUnit === "string") {
config.metricUnit = candidate.metricUnit;
}
if (candidate.bestDirection === "lower" || candidate.bestDirection === "higher") {
config.bestDirection = candidate.bestDirection;
}
if (typeof candidate.benchmarkCommand === "string" && candidate.benchmarkCommand.trim().length > 0) {
config.benchmarkCommand = candidate.benchmarkCommand;
}
if (Array.isArray(candidate.secondaryMetrics)) {
config.secondaryMetrics = normalizeAutoresearchList(
candidate.secondaryMetrics.filter((item): item is string => typeof item === "string"),
);
}
if (Array.isArray(candidate.scopePaths)) {
config.scopePaths = normalizeAutoresearchList(
candidate.scopePaths.filter((item): item is string => typeof item === "string").map(normalizeContractPathSpec),
);
}
if (Array.isArray(candidate.offLimits)) {
config.offLimits = normalizeAutoresearchList(
candidate.offLimits.filter((item): item is string => typeof item === "string").map(normalizeContractPathSpec),
);
}
if (Array.isArray(candidate.constraints)) {
config.constraints = normalizeAutoresearchList(
candidate.constraints.filter((item): item is string => typeof item === "string"),
);
}
if (typeof candidate.segmentFingerprint === "string" && candidate.segmentFingerprint.trim().length > 0) {
config.segmentFingerprint = candidate.segmentFingerprint;
}
return config;
}
function isRunEntry(value: unknown): value is AutoresearchJsonRunEntry {
if (typeof value !== "object" || value === null) return false;
const candidate = value as { type?: unknown };
return candidate.type === undefined || candidate.type === "run";
}
function isExperimentStatus(value: unknown): value is ExperimentResult["status"] {
return value === "keep" || value === "discard" || value === "crash" || value === "checks_failed";
}
function cloneNumericMetrics(value: unknown): NumericMetricMap {
if (typeof value !== "object" || value === null) return {};
const metrics = value as { [key: string]: unknown };
const clone: NumericMetricMap = {};
for (const [key, entryValue] of Object.entries(metrics)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
if (typeof entryValue === "number" && Number.isFinite(entryValue)) {
clone[key] = entryValue;
}
}
return clone;
}
function cloneStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string");
}
function hydrateMetricDefs(metricNames: string[] | undefined): MetricDef[] {
if (!metricNames) return [];
return metricNames.map(name => ({
name,
unit: inferMetricUnitFromName(name),
}));
}
function cloneAsi(value: unknown): ExperimentResult["asi"] {
if (typeof value !== "object" || value === null) return undefined;
const clone: { [key: string]: unknown } = {};
for (const [key, entryValue] of Object.entries(value)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
clone[key] = structuredClone(entryValue);
}
return clone as ExperimentResult["asi"];
}
function parseControlEntry(value: unknown): AutoresearchControlEntryData | null {
if (typeof value !== "object" || value === null) return null;
const candidate = value as { goal?: unknown; mode?: unknown };
if (candidate.mode !== "on" && candidate.mode !== "off" && candidate.mode !== "clear") return null;
const data: AutoresearchControlEntryData = { mode: candidate.mode };
if (typeof candidate.goal === "string" && candidate.goal.trim().length > 0) {
data.goal = candidate.goal;
}
return data;
}
@@ -0,0 +1,310 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { StringEnum } from "@oh-my-pi/pi-ai";
import { Text } from "@oh-my-pi/pi-tui";
import { Type } from "@sinclair/typebox";
import type { ToolDefinition } from "../../extensibility/extensions";
import type { Theme } from "../../modes/theme/theme";
import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
import {
buildAutoresearchSegmentFingerprint,
contractListsEqual,
contractPathListsEqual,
loadAutoresearchScriptSnapshot,
readAutoresearchContract,
} from "../contract";
import {
inferMetricUnitFromName,
isAutoresearchShCommand,
readMaxExperiments,
readPendingRunSummary,
resolveWorkDir,
validateWorkDir,
} from "../helpers";
import { cloneExperimentState } from "../state";
import type { AutoresearchToolFactoryOptions, ExperimentState } from "../types";
const initExperimentSchema = Type.Object({
name: Type.String({
description: "Human-readable experiment name.",
}),
metric_name: Type.String({
description: "Primary metric name shown in the dashboard.",
}),
metric_unit: Type.Optional(
Type.String({
description: "Unit for the primary metric, for example µs, ms, s, kb, or empty.",
}),
),
direction: Type.Optional(
StringEnum(["lower", "higher"], {
description: "Whether lower or higher values are better. Defaults to lower.",
}),
),
benchmark_command: Type.String({
description: "Benchmark command recorded in autoresearch.md.",
}),
scope_paths: Type.Array(Type.String(), {
description: "Files in Scope from autoresearch.md. Must be non-empty.",
minItems: 1,
}),
off_limits: Type.Optional(
Type.Array(Type.String(), {
description: "Off Limits paths from autoresearch.md.",
}),
),
constraints: Type.Optional(
Type.Array(Type.String(), {
description: "Constraints from autoresearch.md.",
}),
),
});
interface InitExperimentDetails {
state: ExperimentState;
}
export function createInitExperimentTool(
options: AutoresearchToolFactoryOptions,
): ToolDefinition<typeof initExperimentSchema, InitExperimentDetails> {
return {
name: "init_experiment",
label: "Init Experiment",
description:
"Initialize or reset the autoresearch session for the current optimization target before the first logged run of a segment.",
parameters: initExperimentSchema,
defaultInactive: true,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const workDirError = validateWorkDir(ctx.cwd);
if (workDirError) {
return {
content: [{ type: "text", text: `Error: ${workDirError}` }],
};
}
const runtime = options.getRuntime(ctx);
const state = runtime.state;
const isReinitializing = state.results.length > 0;
const workDir = resolveWorkDir(ctx.cwd);
const pendingRun = await readPendingRunSummary(workDir, collectLoggedRunNumbers(state.results));
if (pendingRun) {
return {
content: [
{
type: "text",
text:
`Error: run #${pendingRun.runNumber} has not been logged yet. ` +
"Call log_experiment before re-initializing the current segment.",
},
],
};
}
const contractResult = readAutoresearchContract(workDir);
const scriptSnapshot = loadAutoresearchScriptSnapshot(workDir);
const errors = [...contractResult.errors, ...scriptSnapshot.errors];
if (errors.length > 0) {
return {
content: [{ type: "text", text: `Error: ${errors.join(" ")}` }],
};
}
const benchmarkContract = contractResult.contract.benchmark;
const expectedDirection = benchmarkContract.direction ?? "lower";
const expectedMetricUnit = benchmarkContract.metricUnit;
if (benchmarkContract.command && !isAutoresearchShCommand(benchmarkContract.command)) {
return {
content: [
{
type: "text",
text:
"Error: Benchmark.command in autoresearch.md must invoke `autoresearch.sh` directly. " +
"Move the real workload into `autoresearch.sh` and re-run init_experiment.",
},
],
};
}
if (benchmarkContract.command !== params.benchmark_command.trim()) {
return {
content: [
{
type: "text",
text:
"Error: benchmark_command does not match autoresearch.md. " +
`Expected: ${benchmarkContract.command ?? "(missing)"}\nReceived: ${params.benchmark_command}`,
},
],
};
}
if (benchmarkContract.primaryMetric !== params.metric_name.trim()) {
return {
content: [
{
type: "text",
text:
"Error: metric_name does not match autoresearch.md. " +
`Expected: ${benchmarkContract.primaryMetric ?? "(missing)"}\nReceived: ${params.metric_name}`,
},
],
};
}
if ((params.metric_unit ?? "") !== expectedMetricUnit) {
return {
content: [
{
type: "text",
text:
"Error: metric_unit does not match autoresearch.md. " +
`Expected: ${expectedMetricUnit || "(empty)"}\nReceived: ${params.metric_unit ?? "(empty)"}`,
},
],
};
}
if ((params.direction ?? "lower") !== expectedDirection) {
return {
content: [
{
type: "text",
text:
"Error: direction does not match autoresearch.md. " +
`Expected: ${expectedDirection}\nReceived: ${params.direction ?? "lower"}`,
},
],
};
}
if (!contractPathListsEqual(params.scope_paths, contractResult.contract.scopePaths)) {
return {
content: [
{
type: "text",
text:
"Error: scope_paths do not match autoresearch.md. " +
`Expected: ${contractResult.contract.scopePaths.join(", ")}`,
},
],
};
}
if (!contractPathListsEqual(params.off_limits ?? [], contractResult.contract.offLimits)) {
return {
content: [
{
type: "text",
text:
"Error: off_limits do not match autoresearch.md. " +
`Expected: ${contractResult.contract.offLimits.join(", ") || "(empty)"}`,
},
],
};
}
if (!contractListsEqual(params.constraints ?? [], contractResult.contract.constraints)) {
return {
content: [
{
type: "text",
text:
"Error: constraints do not match autoresearch.md. " +
`Expected: ${contractResult.contract.constraints.join(", ") || "(empty)"}`,
},
],
};
}
const segmentFingerprint = buildAutoresearchSegmentFingerprint(contractResult.contract, {
benchmarkScript: scriptSnapshot.benchmarkScript,
checksScript: scriptSnapshot.checksScript,
});
state.name = params.name;
state.metricName = params.metric_name;
state.metricUnit = params.metric_unit ?? "";
state.bestDirection = params.direction ?? "lower";
state.maxExperiments = readMaxExperiments(ctx.cwd);
state.bestMetric = null;
state.confidence = null;
state.secondaryMetrics = benchmarkContract.secondaryMetrics.map(name => ({
name,
unit: inferMetricUnitFromName(name),
}));
state.benchmarkCommand = params.benchmark_command.trim();
state.scopePaths = [...contractResult.contract.scopePaths];
state.offLimits = [...contractResult.contract.offLimits];
state.constraints = [...contractResult.contract.constraints];
state.segmentFingerprint = segmentFingerprint;
if (isReinitializing) {
state.currentSegment += 1;
}
const jsonlPath = path.join(workDir, "autoresearch.jsonl");
const configLine = JSON.stringify({
type: "config",
name: state.name,
metricName: state.metricName,
metricUnit: state.metricUnit,
bestDirection: state.bestDirection,
benchmarkCommand: state.benchmarkCommand,
secondaryMetrics: state.secondaryMetrics.map(metric => metric.name),
scopePaths: state.scopePaths,
offLimits: state.offLimits,
constraints: state.constraints,
segmentFingerprint,
});
if (isReinitializing) {
fs.appendFileSync(jsonlPath, `${configLine}\n`);
} else {
fs.writeFileSync(jsonlPath, `${configLine}\n`);
}
runtime.autoresearchMode = true;
runtime.autoResumeArmed = true;
runtime.lastAutoResumePendingRunNumber = null;
runtime.lastRunChecks = null;
runtime.lastRunDuration = null;
runtime.lastRunAsi = null;
runtime.lastRunArtifactDir = null;
runtime.lastRunNumber = null;
runtime.lastRunSummary = null;
options.dashboard.updateWidget(ctx, runtime);
options.dashboard.requestRender();
const lines = [
`Experiment initialized: ${state.name}`,
`Metric: ${state.metricName} (${state.metricUnit || "unitless"}, ${state.bestDirection} is better)`,
`Benchmark command: ${state.benchmarkCommand}`,
`Working directory: ${workDir}`,
`Files in Scope: ${state.scopePaths.join(", ")}`,
isReinitializing
? "Previous results remain in history. This starts a new segment and requires a fresh baseline."
: "Now run the baseline experiment and log it.",
];
if (state.maxExperiments !== null) {
lines.push(`Max iterations: ${state.maxExperiments}`);
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { state: cloneExperimentState(state) },
};
},
renderCall(args, _options, theme): Text {
return new Text(renderInitCall(args.name, theme), 0, 0);
},
renderResult(result): Text {
const text = replaceTabs(result.content.find(part => part.type === "text")?.text ?? "");
return new Text(text, 0, 0);
},
};
}
function renderInitCall(name: string, theme: Theme): string {
return `${theme.fg("toolTitle", theme.bold("init_experiment"))} ${theme.fg("accent", truncateToWidth(replaceTabs(name), 100))}`;
}
function collectLoggedRunNumbers(results: ExperimentState["results"]): Set<number> {
const runNumbers = new Set<number>();
for (const result of results) {
if (result.runNumber !== null) {
runNumbers.add(result.runNumber);
}
}
return runNumbers;
}
@@ -0,0 +1,833 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { StringEnum } from "@oh-my-pi/pi-ai";
import { Text } from "@oh-my-pi/pi-tui";
import { logger } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import type { ToolDefinition } from "../../extensibility/extensions";
import type { Theme } from "../../modes/theme/theme";
import { replaceTabs, truncateToWidth } from "../../tools/render-utils";
import { getAutoresearchFingerprintMismatchError, pathMatchesContractPath } from "../contract";
import { getCurrentAutoresearchBranch, parseWorkDirDirtyPaths } from "../git";
import {
AUTORESEARCH_COMMITTABLE_FILES,
formatNum,
inferMetricUnitFromName,
isAutoresearchCommittableFile,
isAutoresearchLocalStatePath,
isBetter,
mergeAsi,
readPendingRunSummary,
resolveWorkDir,
validateWorkDir,
} from "../helpers";
import {
cloneExperimentState,
computeConfidence,
currentResults,
findBaselineMetric,
findBaselineSecondary,
findBestKeptMetric,
} from "../state";
import type {
ASIData,
AutoresearchToolFactoryOptions,
ExperimentResult,
ExperimentState,
LogDetails,
NumericMetricMap,
} from "../types";
const EXPERIMENT_TOOL_NAMES = ["init_experiment", "run_experiment", "log_experiment"];
const logExperimentSchema = Type.Object({
commit: Type.String({
description: "Current git commit hash or placeholder.",
}),
metric: Type.Number({
description: "Primary metric value for this run.",
}),
status: StringEnum(["keep", "discard", "crash", "checks_failed"], {
description: "Outcome for this run.",
}),
description: Type.String({
description: "Short description of the experiment.",
}),
metrics: Type.Optional(
Type.Record(Type.String(), Type.Number(), {
description: "Secondary metrics for this run.",
}),
),
force: Type.Optional(
Type.Boolean({
description: "Allow introducing new secondary metrics.",
}),
),
asi: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
description: "Actionable side information captured for this run.",
}),
),
});
interface PreservedFile {
content: Buffer;
path: string;
}
interface KeepCommitResult {
error?: string;
note?: string;
}
export function createLogExperimentTool(
options: AutoresearchToolFactoryOptions,
): ToolDefinition<typeof logExperimentSchema, LogDetails> {
return {
name: "log_experiment",
label: "Log Experiment",
description:
"Log the experiment result, update dashboard state, persist JSONL history, and apply git keep or revert behavior.",
parameters: logExperimentSchema,
defaultInactive: true,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const workDirError = validateWorkDir(ctx.cwd);
if (workDirError) {
return {
content: [{ type: "text", text: `Error: ${workDirError}` }],
};
}
const runtime = options.getRuntime(ctx);
const state = runtime.state;
const workDir = resolveWorkDir(ctx.cwd);
const fingerprintError = getAutoresearchFingerprintMismatchError(state.segmentFingerprint, workDir);
if (fingerprintError) {
return {
content: [{ type: "text", text: `Error: ${fingerprintError}` }],
};
}
const pendingRun =
runtime.lastRunSummary ?? (await readPendingRunSummary(workDir, collectLoggedRunNumbers(state.results)));
if (!pendingRun) {
return {
content: [{ type: "text", text: "Error: no unlogged run is available. Run run_experiment first." }],
};
}
runtime.lastRunSummary = pendingRun;
runtime.lastRunAsi = pendingRun.parsedAsi;
runtime.lastRunChecks =
pendingRun.checksPass === null
? null
: {
pass: pendingRun.checksPass,
output: "",
duration: pendingRun.checksDurationSeconds ?? 0,
};
runtime.lastRunDuration = pendingRun.durationSeconds;
if (pendingRun.parsedPrimary !== null && params.metric !== pendingRun.parsedPrimary) {
return {
content: [
{
type: "text",
text:
"Error: metric does not match the parsed primary metric from the pending run.\n" +
`Expected: ${pendingRun.parsedPrimary}\nReceived: ${params.metric}`,
},
],
};
}
if (params.status === "keep" && !pendingRun.passed) {
return {
content: [
{
type: "text",
text: "Error: cannot keep this run because the pending benchmark did not pass. Log it as crash or checks_failed instead.",
},
],
};
}
if (params.status === "keep" && runtime.lastRunChecks && !runtime.lastRunChecks.pass) {
return {
content: [
{
type: "text",
text: "Error: cannot keep this run because autoresearch.checks.sh failed. Log it as checks_failed instead.",
},
],
};
}
const observedStatusError = validateObservedStatus(params.status, pendingRun);
if (observedStatusError) {
return {
content: [{ type: "text", text: `Error: ${observedStatusError}` }],
};
}
const secondaryMetrics = buildSecondaryMetrics(params.metrics, pendingRun.parsedMetrics, state.metricName);
const validationError = validateSecondaryMetrics(state, secondaryMetrics, params.force ?? false);
if (validationError) {
return {
content: [{ type: "text", text: `Error: ${validationError}` }],
};
}
const mergedAsi = mergeAsi(runtime.lastRunAsi, sanitizeAsi(params.asi));
const asiValidationError = validateAsiRequirements(mergedAsi, params.status);
if (asiValidationError) {
return {
content: [{ type: "text", text: `Error: ${asiValidationError}` }],
};
}
let keepScopeValidation: { committablePaths: string[] } | undefined;
if (params.status === "keep") {
const scopeValidation = await validateKeepPaths(options, workDir, state);
if (typeof scopeValidation === "string") {
return {
content: [{ type: "text", text: `Error: ${scopeValidation}` }],
};
}
const currentBestMetric = findBestKeptMetric(state.results, state.currentSegment, state.bestDirection);
if (
currentBestMetric !== null &&
params.metric !== currentBestMetric &&
!isBetter(params.metric, currentBestMetric, state.bestDirection)
) {
return {
content: [
{
type: "text",
text:
"Error: cannot keep this run because the primary metric regressed.\n" +
`Current best: ${currentBestMetric}\nReceived: ${params.metric}`,
},
],
};
}
keepScopeValidation = scopeValidation;
}
const experiment: ExperimentResult = {
runNumber: runtime.lastRunNumber ?? pendingRun.runNumber,
commit: params.commit.slice(0, 7),
metric: params.metric,
metrics: secondaryMetrics,
status: params.status,
description: params.description,
timestamp: Date.now(),
segment: state.currentSegment,
confidence: null,
asi: mergedAsi,
};
const activeBranch = await getCurrentAutoresearchBranch(options.pi, workDir);
if (!activeBranch) {
return {
content: [
{
type: "text",
text:
"Error: autoresearch keep/discard actions require an active `autoresearch/...` branch. " +
"Run `/autoresearch` again to restore the protected branch before logging this run.",
},
],
};
}
let gitNote: string | null = null;
if (params.status === "keep") {
const commitResult = await commitKeptExperiment(options, workDir, state, experiment, keepScopeValidation);
if (commitResult.error) {
return {
content: [{ type: "text", text: `Error: ${commitResult.error}` }],
};
}
gitNote = commitResult.note ?? null;
} else {
const revertResult = await revertFailedExperiment(options, workDir);
if (revertResult.error) {
return {
content: [{ type: "text", text: `Error: ${revertResult.error}` }],
};
}
gitNote = revertResult.note ?? null;
}
const previousState = cloneExperimentState(state);
state.results.push(experiment);
registerSecondaryMetrics(state, secondaryMetrics);
state.bestMetric = findBaselineMetric(state.results, state.currentSegment);
state.confidence = computeConfidence(state.results, state.currentSegment, state.bestDirection);
experiment.confidence = state.confidence;
const wallClockSeconds = runtime.lastRunDuration;
try {
persistRun(workDir, experiment);
} catch (error) {
runtime.state = previousState;
options.dashboard.updateWidget(ctx, runtime);
options.dashboard.requestRender();
throw error;
}
try {
await updateRunMetadata(runtime.lastRunArtifactDir ?? pendingRun.runDirectory, {
commit: experiment.commit,
confidence: experiment.confidence,
description: experiment.description,
gitNote,
loggedAt: new Date(experiment.timestamp).toISOString(),
loggedAsi: experiment.asi,
loggedMetric: experiment.metric,
loggedMetrics: experiment.metrics,
runNumber: runtime.lastRunNumber ?? pendingRun.runNumber,
status: experiment.status,
wallClockSeconds,
});
} catch (error) {
logger.warn("Failed to update autoresearch run metadata after persisting JSONL history", {
error: error instanceof Error ? error.message : String(error),
runDirectory: runtime.lastRunArtifactDir ?? pendingRun.runDirectory,
runNumber: runtime.lastRunNumber ?? pendingRun.runNumber,
});
}
runtime.runningExperiment = null;
runtime.lastRunChecks = null;
runtime.lastRunDuration = null;
runtime.lastRunAsi = null;
runtime.lastRunArtifactDir = null;
runtime.lastRunNumber = null;
runtime.lastRunSummary = null;
runtime.autoResumeArmed = true;
runtime.lastAutoResumePendingRunNumber = null;
const currentSegmentRuns = currentResults(state.results, state.currentSegment).length;
const text = buildLogText(state, experiment, currentSegmentRuns, wallClockSeconds, gitNote);
if (state.maxExperiments !== null && currentSegmentRuns >= state.maxExperiments) {
runtime.autoresearchMode = false;
options.pi.appendEntry(
"autoresearch-control",
runtime.goal ? { mode: "off", goal: runtime.goal } : { mode: "off" },
);
await options.pi.setActiveTools(
options.pi.getActiveTools().filter(name => !EXPERIMENT_TOOL_NAMES.includes(name)),
);
}
options.dashboard.updateWidget(ctx, runtime);
options.dashboard.requestRender();
return {
content: [{ type: "text", text }],
details: {
experiment: {
...experiment,
metrics: { ...experiment.metrics },
asi: experiment.asi ? structuredClone(experiment.asi) : undefined,
},
state: cloneExperimentState(state),
wallClockSeconds,
},
};
},
renderCall(args, _options, theme): Text {
const color = args.status === "keep" ? "success" : args.status === "discard" ? "warning" : "error";
const description = truncateToWidth(replaceTabs(args.description), 100);
return new Text(
`${theme.fg("toolTitle", theme.bold("log_experiment"))} ${theme.fg(color, args.status)} ${theme.fg("muted", description)}`,
0,
0,
);
},
renderResult(result, _options, theme): Text {
const details = result.details;
if (!details) {
return new Text(replaceTabs(result.content.find(part => part.type === "text")?.text ?? ""), 0, 0);
}
const summary = renderSummary(details, theme);
return new Text(summary, 0, 0);
},
};
}
function cloneMetrics(value: NumericMetricMap | undefined): NumericMetricMap {
return value ? { ...value } : {};
}
function buildSecondaryMetrics(
overrides: NumericMetricMap | undefined,
parsedMetrics: NumericMetricMap | null,
primaryMetricName: string,
): NumericMetricMap {
const merged: NumericMetricMap = {};
for (const [name, value] of Object.entries(parsedMetrics ?? {})) {
if (name === "__proto__" || name === "constructor" || name === "prototype") continue;
if (name === primaryMetricName) continue;
merged[name] = value;
}
for (const [name, value] of Object.entries(cloneMetrics(overrides))) {
if (name === "__proto__" || name === "constructor" || name === "prototype") continue;
merged[name] = value;
}
return merged;
}
function sanitizeAsi(value: { [key: string]: unknown } | undefined): ASIData | undefined {
if (!value) return undefined;
const result: ASIData = {};
for (const [key, entryValue] of Object.entries(value)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
const sanitized = sanitizeAsiValue(entryValue);
if (sanitized !== undefined) {
result[key] = sanitized;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
function sanitizeAsiValue(value: unknown): ASIData[string] | undefined {
if (value === null) return null;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
if (Array.isArray(value)) {
const items = value
.map(item => sanitizeAsiValue(item))
.filter((item): item is NonNullable<typeof item> => item !== undefined);
return items;
}
if (typeof value === "object") {
const objectValue = value as { [key: string]: unknown };
const result: ASIData = {};
for (const [key, entryValue] of Object.entries(objectValue)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
const sanitized = sanitizeAsiValue(entryValue);
if (sanitized !== undefined) {
result[key] = sanitized;
}
}
return result;
}
return undefined;
}
export function validateAsiRequirements(asi: ASIData | undefined, status: ExperimentResult["status"]): string | null {
if (!asi) {
return "asi is required. Include at minimum a non-empty hypothesis.";
}
if (typeof asi.hypothesis !== "string" || asi.hypothesis.trim().length === 0) {
return "asi.hypothesis is required and must be a non-empty string.";
}
if (status === "keep") return null;
if (typeof asi.rollback_reason !== "string" || asi.rollback_reason.trim().length === 0) {
return "asi.rollback_reason is required for discard, crash, and checks_failed results.";
}
if (typeof asi.next_action_hint !== "string" || asi.next_action_hint.trim().length === 0) {
return "asi.next_action_hint is required for discard, crash, and checks_failed results.";
}
return null;
}
function validateSecondaryMetrics(state: ExperimentState, metrics: NumericMetricMap, force: boolean): string | null {
if (state.secondaryMetrics.length === 0) return null;
const knownNames = new Set(state.secondaryMetrics.map(metric => metric.name));
const providedNames = new Set(Object.keys(metrics));
const missing = [...knownNames].filter(name => !providedNames.has(name));
if (missing.length > 0) {
return `missing secondary metrics: ${missing.join(", ")}`;
}
const newMetrics = [...providedNames].filter(name => !knownNames.has(name));
if (newMetrics.length > 0 && !force) {
return `new secondary metrics require force=true: ${newMetrics.join(", ")}`;
}
return null;
}
function registerSecondaryMetrics(state: ExperimentState, metrics: NumericMetricMap): void {
for (const name of Object.keys(metrics)) {
if (state.secondaryMetrics.some(metric => metric.name === name)) continue;
state.secondaryMetrics.push({
name,
unit: inferMetricUnitFromName(name),
});
}
}
function persistRun(workDir: string, experiment: ExperimentResult): void {
const entry = {
run: experiment.runNumber,
...experiment,
};
const jsonlPath = path.join(workDir, "autoresearch.jsonl");
fs.appendFileSync(jsonlPath, `${JSON.stringify(entry)}\n`);
}
function collectLoggedRunNumbers(results: ExperimentResult[]): Set<number> {
const runNumbers = new Set<number>();
for (const result of results) {
if (result.runNumber !== null) {
runNumbers.add(result.runNumber);
}
}
return runNumbers;
}
function validateObservedStatus(
status: ExperimentResult["status"],
pendingRun: { checksPass: boolean | null; passed: boolean },
): string | null {
if (pendingRun.checksPass === false) {
return status === "checks_failed"
? null
: "benchmark checks failed for the pending run. Log it as checks_failed.";
}
if (!pendingRun.passed) {
return status === "crash" ? null : "the pending benchmark failed. Log it as crash.";
}
return status === "keep" || status === "discard" ? null : "the pending benchmark passed. Log it as keep or discard.";
}
async function commitKeptExperiment(
options: AutoresearchToolFactoryOptions,
workDir: string,
state: ExperimentState,
experiment: ExperimentResult,
scopeValidation: { committablePaths: string[] } | undefined,
): Promise<KeepCommitResult> {
if (!scopeValidation || scopeValidation.committablePaths.length === 0) {
return { note: "nothing to commit" };
}
const addResult = await options.pi.exec("git", ["add", "--all", "--", ...scopeValidation.committablePaths], {
cwd: workDir,
timeout: 10_000,
});
if (addResult.code !== 0) {
return {
error: `git add failed: ${mergeStdoutStderr(addResult).trim() || `exit ${addResult.code}`}`,
};
}
const diffResult = await options.pi.exec(
"git",
["diff", "--cached", "--quiet", "--", ...scopeValidation.committablePaths],
{
cwd: workDir,
timeout: 10_000,
},
);
if (diffResult.code === 0) {
return { note: "nothing to commit" };
}
const payload: { [key: string]: string | number } = {
status: experiment.status,
[state.metricName]: experiment.metric,
};
for (const [name, value] of Object.entries(experiment.metrics)) {
payload[name] = value;
}
const commitMessage = `${experiment.description}\n\nResult: ${JSON.stringify(payload)}`;
const commitResult = await options.pi.exec(
"git",
["commit", "-m", commitMessage, "--", ...scopeValidation.committablePaths],
{
cwd: workDir,
timeout: 10_000,
},
);
if (commitResult.code !== 0) {
return {
error: `git commit failed: ${mergeStdoutStderr(commitResult).trim() || `exit ${commitResult.code}`}`,
};
}
const revParseResult = await options.pi.exec("git", ["rev-parse", "--short=7", "HEAD"], {
cwd: workDir,
timeout: 5_000,
});
const newCommit = revParseResult.stdout.trim();
if (newCommit.length >= 7) {
experiment.commit = newCommit;
}
const summaryLine =
mergeStdoutStderr(commitResult)
.split("\n")
.find(line => line.trim().length > 0) ?? "committed";
return { note: summaryLine.trim() };
}
async function revertFailedExperiment(
options: AutoresearchToolFactoryOptions,
workDir: string,
): Promise<KeepCommitResult> {
const preservedFiles = preserveAutoresearchFiles(workDir);
const restoreResult = await options.pi.exec(
"git",
["restore", "--source=HEAD", "--staged", "--worktree", "--", "."],
{ cwd: workDir, timeout: 10_000 },
);
const cleanResult = await options.pi.exec("git", ["clean", "-fd", "--", "."], { cwd: workDir, timeout: 10_000 });
const cleanIgnoredResult = await options.pi.exec("git", ["clean", "-fdX", "--", "."], {
cwd: workDir,
timeout: 10_000,
});
restoreAutoresearchFiles(preservedFiles);
if (restoreResult.code !== 0) {
return {
error: `git restore failed: ${mergeStdoutStderr(restoreResult).trim() || `exit ${restoreResult.code}`}`,
};
}
if (cleanResult.code !== 0) {
return {
error: `git clean failed: ${mergeStdoutStderr(cleanResult).trim() || `exit ${cleanResult.code}`}`,
};
}
if (cleanIgnoredResult.code !== 0) {
return {
error: `git clean -X failed: ${mergeStdoutStderr(cleanIgnoredResult).trim() || `exit ${cleanIgnoredResult.code}`}`,
};
}
const dirtyCheckResult = await options.pi.exec(
"git",
["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."],
{ cwd: workDir, timeout: 10_000 },
);
if (dirtyCheckResult.code !== 0) {
return {
error: `git status failed after cleanup: ${mergeStdoutStderr(dirtyCheckResult).trim() || `exit ${dirtyCheckResult.code}`}`,
};
}
const workDirPrefix = await readGitWorkDirPrefix(options, workDir);
const remainingDirtyPaths = parseWorkDirDirtyPaths(dirtyCheckResult.stdout, workDirPrefix).filter(
relativePath => !isAutoresearchLocalStatePath(relativePath),
);
if (remainingDirtyPaths.length > 0) {
return {
error:
"Autoresearch cleanup left the worktree dirty. Resolve these paths before continuing: " +
remainingDirtyPaths.join(", "),
};
}
return { note: "reverted changes" };
}
function preserveAutoresearchFiles(workDir: string): PreservedFile[] {
const files: PreservedFile[] = [];
for (const relativePath of [...AUTORESEARCH_COMMITTABLE_FILES, "autoresearch.jsonl"]) {
const absolutePath = path.join(workDir, relativePath);
if (!fs.existsSync(absolutePath)) continue;
files.push({
content: fs.readFileSync(absolutePath),
path: absolutePath,
});
}
const localStateDir = path.join(workDir, ".autoresearch");
if (fs.existsSync(localStateDir)) {
collectDirectoryFiles(localStateDir, files);
}
return files;
}
function restoreAutoresearchFiles(files: PreservedFile[]): void {
for (const file of files) {
fs.mkdirSync(path.dirname(file.path), { recursive: true });
fs.writeFileSync(file.path, file.content);
}
}
function mergeStdoutStderr(result: { stderr: string; stdout: string }): string {
return `${result.stdout}${result.stderr}`;
}
async function validateKeepPaths(
options: AutoresearchToolFactoryOptions,
workDir: string,
state: ExperimentState,
): Promise<{ committablePaths: string[] } | string> {
if (state.scopePaths.length === 0) {
return "Files in Scope is empty for the current segment. Re-run init_experiment after fixing autoresearch.md.";
}
const statusResult = await options.pi.exec(
"git",
["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."],
{
cwd: workDir,
timeout: 10_000,
},
);
if (statusResult.code !== 0) {
return `git status failed: ${mergeStdoutStderr(statusResult).trim() || `exit ${statusResult.code}`}`;
}
const workDirPrefix = await readGitWorkDirPrefix(options, workDir);
const committablePaths: string[] = [];
for (const normalizedPath of parseWorkDirDirtyPaths(statusResult.stdout, workDirPrefix)) {
if (isAutoresearchLocalStatePath(normalizedPath)) {
continue;
}
if (isAutoresearchCommittableFile(normalizedPath)) {
committablePaths.push(normalizedPath);
continue;
}
if (state.offLimits.some(spec => pathMatchesContractPath(normalizedPath, spec))) {
return `cannot keep this run because ${normalizedPath} is listed under Off Limits in autoresearch.md`;
}
if (!state.scopePaths.some(spec => pathMatchesContractPath(normalizedPath, spec))) {
return `cannot keep this run because ${normalizedPath} is outside Files in Scope`;
}
committablePaths.push(normalizedPath);
}
return { committablePaths };
}
function collectDirectoryFiles(directory: string, files: PreservedFile[]): void {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
collectDirectoryFiles(absolutePath, files);
continue;
}
files.push({
content: fs.readFileSync(absolutePath),
path: absolutePath,
});
}
}
async function updateRunMetadata(
runDirectory: string | null,
metadata: {
commit: string;
confidence: number | null;
description: string;
gitNote: string | null;
loggedAt: string;
loggedAsi: ASIData | undefined;
loggedMetric: number;
loggedMetrics: NumericMetricMap;
runNumber: number | null;
status: ExperimentResult["status"];
wallClockSeconds: number | null;
},
): Promise<void> {
if (!runDirectory) return;
const runJsonPath = path.join(runDirectory, "run.json");
let existing: Record<string, unknown> = {};
try {
existing = (await Bun.file(runJsonPath).json()) as Record<string, unknown>;
} catch {
existing = {};
}
await Bun.write(
runJsonPath,
JSON.stringify(
{
...existing,
loggedRunNumber: metadata.runNumber,
loggedAt: metadata.loggedAt,
loggedAsi: metadata.loggedAsi,
loggedMetric: metadata.loggedMetric,
loggedMetrics: metadata.loggedMetrics,
status: metadata.status,
description: metadata.description,
commit: metadata.commit,
gitNote: metadata.gitNote,
confidence: metadata.confidence,
wallClockSeconds: metadata.wallClockSeconds,
},
null,
2,
),
);
}
function buildLogText(
state: ExperimentState,
experiment: ExperimentResult,
currentSegmentRuns: number,
wallClockSeconds: number | null,
gitNote: string | null,
): string {
const displayRunNumber = experiment.runNumber ?? state.results.length;
const lines = [`Logged run #${displayRunNumber}: ${experiment.status} - ${experiment.description}`];
if (wallClockSeconds !== null) {
lines.push(`Wall clock: ${wallClockSeconds.toFixed(1)}s`);
}
if (state.bestMetric !== null) {
lines.push(`Baseline ${state.metricName}: ${formatNum(state.bestMetric, state.metricUnit)}`);
}
if (currentSegmentRuns > 1 && state.bestMetric !== null && experiment.metric !== state.bestMetric) {
const delta = ((experiment.metric - state.bestMetric) / state.bestMetric) * 100;
const sign = delta > 0 ? "+" : "";
lines.push(`This run: ${formatNum(experiment.metric, state.metricUnit)} (${sign}${delta.toFixed(1)}%)`);
} else {
lines.push(`This run: ${formatNum(experiment.metric, state.metricUnit)}`);
}
if (Object.keys(experiment.metrics).length > 0) {
const baselineSecondary = findBaselineSecondary(state.results, state.currentSegment, state.secondaryMetrics);
const parts = Object.entries(experiment.metrics).map(([name, value]) => {
const unit = state.secondaryMetrics.find(metric => metric.name === name)?.unit ?? "";
const baseline = baselineSecondary[name];
if (baseline === undefined || baseline === 0 || currentSegmentRuns === 1) {
return `${name}: ${formatNum(value, unit)}`;
}
const delta = ((value - baseline) / baseline) * 100;
const sign = delta > 0 ? "+" : "";
return `${name}: ${formatNum(value, unit)} (${sign}${delta.toFixed(1)}%)`;
});
lines.push(`Secondary metrics: ${parts.join(" ")}`);
}
if (experiment.asi) {
const asiSummary = Object.entries(experiment.asi)
.map(([key, value]) => `${key}: ${truncateAsiValue(value)}`)
.join(" | ");
lines.push(`ASI: ${asiSummary}`);
}
if (state.confidence !== null) {
const status = state.confidence >= 2 ? "likely real" : state.confidence >= 1 ? "marginal" : "within noise";
lines.push(`Confidence: ${state.confidence.toFixed(1)}x noise floor (${status})`);
}
if (gitNote) {
lines.push(`Git: ${gitNote}`);
}
if (state.maxExperiments !== null) {
lines.push(`Progress: ${currentSegmentRuns}/${state.maxExperiments} runs in current segment`);
if (currentSegmentRuns >= state.maxExperiments) {
lines.push(`Maximum experiments reached (${state.maxExperiments}). Autoresearch mode is now off.`);
}
}
return lines.join("\n");
}
async function readGitWorkDirPrefix(options: AutoresearchToolFactoryOptions, workDir: string): Promise<string> {
const prefixResult = await options.pi.exec("git", ["rev-parse", "--show-prefix"], { cwd: workDir, timeout: 5_000 });
if (prefixResult.code !== 0) return "";
return prefixResult.stdout.trim();
}
function truncateAsiValue(value: ASIData[string]): string {
const text = typeof value === "string" ? value : JSON.stringify(value);
return text.length > 120 ? `${text.slice(0, 117)}...` : text;
}
function renderSummary(details: LogDetails, theme: Theme): string {
const { experiment, state } = details;
const color = experiment.status === "keep" ? "success" : experiment.status === "discard" ? "warning" : "error";
let summary = `${theme.fg(color, experiment.status.toUpperCase())} ${theme.fg("muted", truncateToWidth(replaceTabs(experiment.description), 100))}`;
summary += ` ${theme.fg("accent", `${state.metricName}=${formatNum(experiment.metric, state.metricUnit)}`)}`;
if (state.bestMetric !== null) {
summary += ` ${theme.fg("dim", `baseline ${formatNum(state.bestMetric, state.metricUnit)}`)}`;
}
if (state.confidence !== null) {
summary += ` ${theme.fg("dim", `conf ${state.confidence.toFixed(1)}x`)}`;
}
return summary;
}
@@ -0,0 +1,640 @@
import * as childProcess from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { Text } from "@oh-my-pi/pi-tui";
import { formatBytes } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import type { ToolDefinition } from "../../extensibility/extensions";
import type { Theme } from "../../modes/theme/theme";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "../../session/streaming-output";
import { replaceTabs, shortenPath, truncateToWidth } from "../../tools/render-utils";
import { getAutoresearchFingerprintMismatchError } from "../contract";
import {
EXPERIMENT_MAX_BYTES,
EXPERIMENT_MAX_LINES,
formatElapsed,
formatNum,
getAutoresearchRunDirectory,
getNextAutoresearchRunNumber,
isAutoresearchShCommand,
killTree,
parseAsiLines,
parseMetricLines,
readPendingRunSummary,
resolveWorkDir,
validateWorkDir,
} from "../helpers";
import type { AutoresearchToolFactoryOptions, RunDetails, RunExperimentProgressDetails } from "../types";
const runExperimentSchema = Type.Object({
command: Type.String({
description: "Shell command to run for this experiment.",
}),
timeout_seconds: Type.Optional(
Type.Number({
description: "Timeout in seconds. Defaults to 600.",
}),
),
checks_timeout_seconds: Type.Optional(
Type.Number({
description: "Timeout in seconds for autoresearch.checks.sh. Defaults to 300.",
}),
),
});
interface ProcessExecutionResult {
exitCode: number | null;
killed: boolean;
logPath: string;
output: string;
}
interface ChecksExecutionResult {
code: number | null;
killed: boolean;
logPath: string;
output: string;
}
interface ProgressSnapshot {
elapsed: string;
runDirectory: string;
fullOutputPath: string;
tailOutput: string;
truncation?: RunExperimentProgressDetails["truncation"];
}
export function createRunExperimentTool(
options: AutoresearchToolFactoryOptions,
): ToolDefinition<typeof runExperimentSchema, RunDetails | RunExperimentProgressDetails> {
return {
name: "run_experiment",
label: "Run Experiment",
description:
"Run an experiment command with timing, output capture, structured metric parsing, durable run artifacts, and optional autoresearch.checks.sh validation.",
parameters: runExperimentSchema,
defaultInactive: true,
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const workDirError = validateWorkDir(ctx.cwd);
if (workDirError) {
return {
content: [{ type: "text", text: `Error: ${workDirError}` }],
};
}
const runtime = options.getRuntime(ctx);
const state = runtime.state;
const workDir = resolveWorkDir(ctx.cwd);
const checksPath = path.join(workDir, "autoresearch.checks.sh");
const autoresearchScriptPath = path.join(workDir, "autoresearch.sh");
const fingerprintError = getAutoresearchFingerprintMismatchError(state.segmentFingerprint, workDir);
if (fingerprintError) {
return {
content: [{ type: "text", text: `Error: ${fingerprintError}` }],
};
}
if (state.benchmarkCommand && params.command.trim() !== state.benchmarkCommand) {
return {
content: [
{
type: "text",
text:
"Error: command does not match the benchmark command recorded for this segment.\n" +
`Expected: ${state.benchmarkCommand}\nReceived: ${params.command}`,
},
],
};
}
if (fs.existsSync(autoresearchScriptPath) && !isAutoresearchShCommand(params.command)) {
return {
content: [
{
type: "text",
text:
`Error: autoresearch.sh exists. Run it directly instead of using a different command.\n` +
`Expected something like: bash autoresearch.sh\n` +
`Received: ${params.command}`,
},
],
};
}
if (state.maxExperiments !== null) {
const segmentRuns = state.results.filter(result => result.segment === state.currentSegment).length;
if (segmentRuns >= state.maxExperiments) {
return {
content: [
{
type: "text",
text: `Maximum experiments reached (${state.maxExperiments}). Re-initialize to start a new segment.`,
},
],
};
}
}
const pendingRun =
runtime.lastRunSummary ?? (await readPendingRunSummary(workDir, collectLoggedRunNumbers(state.results)));
if (pendingRun) {
return {
content: [
{
type: "text",
text:
`Error: run #${pendingRun.runNumber} has not been logged yet. ` +
"Call log_experiment before starting another benchmark run.",
},
],
};
}
const runNumber = getNextAutoresearchRunNumber(workDir, runtime.lastRunNumber);
const runDirectory = getAutoresearchRunDirectory(workDir, runNumber);
const benchmarkLogPath = path.join(runDirectory, "benchmark.log");
const checksLogPath = path.join(runDirectory, "checks.log");
const runJsonPath = path.join(runDirectory, "run.json");
await fs.promises.mkdir(runDirectory, { recursive: true });
runtime.lastRunChecks = null;
runtime.lastRunDuration = null;
runtime.lastRunAsi = null;
runtime.lastRunArtifactDir = runDirectory;
runtime.lastRunNumber = runNumber;
runtime.lastRunSummary = null;
await Bun.write(
runJsonPath,
JSON.stringify(
{
runNumber,
runDirectory,
benchmarkLogPath,
checksLogPath,
command: params.command,
startedAt: new Date().toISOString(),
},
null,
2,
),
);
runtime.runningExperiment = {
startedAt: Date.now(),
command: params.command,
runDirectory,
runNumber,
};
options.dashboard.updateWidget(ctx, runtime);
options.dashboard.requestRender();
const timeoutMs = Math.max(0, Math.floor((params.timeout_seconds ?? 600) * 1000));
const startedAt = Date.now();
let execution: ProcessExecutionResult;
try {
execution = await executeProcess({
command: ["bash", "-lc", params.command],
cwd: workDir,
logPath: benchmarkLogPath,
timeoutMs,
signal,
onProgress: details => {
onUpdate?.({
content: [{ type: "text", text: details.tailOutput }],
details: {
phase: "running",
elapsed: details.elapsed,
truncation: details.truncation,
fullOutputPath: details.fullOutputPath,
runDirectory: details.runDirectory,
},
});
},
});
} finally {
runtime.runningExperiment = null;
options.dashboard.updateWidget(ctx, runtime);
options.dashboard.requestRender();
}
const durationSeconds = (Date.now() - startedAt) / 1000;
runtime.lastRunDuration = durationSeconds;
const benchmarkPassed = execution.exitCode === 0 && !execution.killed;
let checksPass: boolean | null = null;
let checksTimedOut = false;
let checksOutput = "";
let checksDuration = 0;
let checksLogPathValue: string | undefined;
if (benchmarkPassed && fs.existsSync(checksPath)) {
const checksStartedAt = Date.now();
const checksResult = await runChecks({
cwd: workDir,
pathToChecks: checksPath,
logPath: checksLogPath,
timeoutMs: Math.max(0, Math.floor((params.checks_timeout_seconds ?? 300) * 1000)),
signal,
});
checksDuration = (Date.now() - checksStartedAt) / 1000;
checksTimedOut = checksResult.killed;
checksPass = checksResult.code === 0 && !checksResult.killed;
checksOutput = checksResult.output;
checksLogPathValue = checksResult.logPath;
}
runtime.lastRunChecks =
checksPass === null
? null
: {
pass: checksPass,
output: checksOutput,
duration: checksDuration,
};
const llmTruncation = truncateTail(execution.output, {
maxBytes: EXPERIMENT_MAX_BYTES,
maxLines: EXPERIMENT_MAX_LINES,
});
const displayTruncation = truncateTail(execution.output, {
maxBytes: DEFAULT_MAX_BYTES,
maxLines: DEFAULT_MAX_LINES,
});
const parsedMetricsMap = parseMetricLines(execution.output);
const parsedMetrics = parsedMetricsMap.size > 0 ? Object.fromEntries(parsedMetricsMap.entries()) : null;
const parsedPrimary = parsedMetricsMap.get(state.metricName) ?? null;
const parsedAsi = parseAsiLines(execution.output);
runtime.lastRunAsi = parsedAsi;
const resultDetails: RunDetails = {
runNumber,
runDirectory,
benchmarkLogPath,
checksLogPath: checksLogPathValue,
command: params.command,
exitCode: execution.exitCode,
durationSeconds,
passed: benchmarkPassed && (checksPass === null || checksPass),
crashed: execution.exitCode !== 0 || execution.killed || checksPass === false,
timedOut: execution.killed,
tailOutput: displayTruncation.content,
checksPass,
checksTimedOut,
checksOutput: checksOutput.split("\n").slice(-80).join("\n"),
checksDuration,
parsedMetrics,
parsedPrimary,
parsedAsi,
metricName: state.metricName,
metricUnit: state.metricUnit,
truncation: llmTruncation.truncated ? llmTruncation : undefined,
fullOutputPath: execution.logPath,
};
runtime.lastRunSummary = {
checksDurationSeconds: checksDuration,
checksPass,
checksTimedOut,
command: params.command,
durationSeconds,
parsedAsi,
parsedMetrics,
parsedPrimary,
passed: resultDetails.passed,
runDirectory,
runNumber,
};
runtime.autoResumeArmed = true;
runtime.lastAutoResumePendingRunNumber = null;
options.dashboard.updateWidget(ctx, runtime);
options.dashboard.requestRender();
await Bun.write(
runJsonPath,
JSON.stringify(
{
runNumber,
runDirectory,
benchmarkLogPath,
checksLogPath: checksLogPathValue,
command: params.command,
completedAt: new Date().toISOString(),
durationSeconds,
exitCode: execution.exitCode,
timedOut: execution.killed,
checks: {
durationSeconds: checksDuration,
passed: checksPass,
timedOut: checksTimedOut,
},
parsedMetrics,
parsedPrimary,
parsedAsi,
truncation: resultDetails.truncation,
fullOutputPath: resultDetails.fullOutputPath,
},
null,
2,
),
);
return {
content: [{ type: "text", text: buildRunText(resultDetails, llmTruncation.content, state.bestMetric) }],
details: resultDetails,
};
},
renderCall(args, _options, theme): Text {
const commandPreview = truncateToWidth(replaceTabs(args.command), 100);
return new Text(
`${theme.fg("toolTitle", theme.bold("run_experiment"))} ${theme.fg("muted", commandPreview)}`,
0,
0,
);
},
renderResult(result, options, theme): Text {
if (isProgressDetails(result.details)) {
const header = theme.fg("warning", `Running ${result.details.elapsed}...`);
const preview = replaceTabs(result.content.find(part => part.type === "text")?.text ?? "");
return new Text(preview ? `${header}\n${theme.fg("dim", preview)}` : header, 0, 0);
}
const details = result.details;
if (!details || !isRunDetails(details)) {
return new Text(replaceTabs(result.content.find(part => part.type === "text")?.text ?? ""), 0, 0);
}
const statusText = renderStatus(details, theme);
if (!options.expanded && details.tailOutput.trim().length === 0) {
return new Text(statusText, 0, 0);
}
const preview = replaceTabs(
options.expanded ? details.tailOutput : details.tailOutput.split("\n").slice(-5).join("\n"),
);
const suffix =
options.expanded && details.truncation && details.fullOutputPath
? `\n${theme.fg("warning", `Full output: ${shortenPath(details.fullOutputPath)}`)}`
: "";
return new Text(preview ? `${statusText}\n${theme.fg("dim", preview)}${suffix}` : statusText, 0, 0);
},
};
}
async function executeProcess(options: {
command: string[];
cwd: string;
logPath: string;
timeoutMs: number;
signal?: AbortSignal;
onProgress?(details: ProgressSnapshot): void;
}): Promise<ProcessExecutionResult> {
const { promise, resolve, reject } = Promise.withResolvers<ProcessExecutionResult>();
const child = childProcess.spawn(options.command[0] ?? "bash", options.command.slice(1), {
cwd: options.cwd,
detached: true,
stdio: ["ignore", "pipe", "pipe"],
});
const tailChunks: Buffer[] = [];
let chunksBytes = 0;
let killedByTimeout = false;
let resolved = false;
let writeStream: fs.WriteStream | undefined = fs.createWriteStream(options.logPath);
let forceKillTimeout: NodeJS.Timeout | undefined;
const closeWriteStream = (): Promise<void> => {
if (!writeStream) return Promise.resolve();
const stream = writeStream;
writeStream = undefined;
return new Promise<void>((resolveClose, rejectClose) => {
stream.end((error?: Error | null) => {
if (error) {
rejectClose(error);
return;
}
resolveClose();
});
});
};
const cleanup = (): void => {
if (progressTimer) clearInterval(progressTimer);
if (timeoutHandle) clearTimeout(timeoutHandle);
if (forceKillTimeout) clearTimeout(forceKillTimeout);
options.signal?.removeEventListener("abort", abortHandler);
};
const finish = (callback: () => void): void => {
if (resolved) return;
resolved = true;
cleanup();
callback();
};
const appendChunk = (data: Buffer): void => {
writeStream?.write(data);
tailChunks.push(data);
chunksBytes += data.length;
while (chunksBytes > DEFAULT_MAX_BYTES * 2 && tailChunks.length > 1) {
const removed = tailChunks.shift();
if (removed) chunksBytes -= removed.length;
}
};
const snapshot = (): ProgressSnapshot => {
const tail = truncateTail(Buffer.concat(tailChunks).toString("utf8"), {
maxBytes: DEFAULT_MAX_BYTES,
maxLines: DEFAULT_MAX_LINES,
});
return {
elapsed: formatElapsed(Date.now() - startedAt),
runDirectory: path.dirname(options.logPath),
fullOutputPath: options.logPath,
tailOutput: tail.content,
truncation: tail.truncated ? tail : undefined,
};
};
const killTreeWithEscalation = (): void => {
if (!child.pid) return;
killTree(child.pid);
forceKillTimeout = setTimeout(() => {
if (child.pid) killTree(child.pid, "SIGKILL");
}, 1_000);
forceKillTimeout.unref?.();
};
const startedAt = Date.now();
const progressTimer = options.onProgress
? setInterval(() => {
options.onProgress?.(snapshot());
}, 1000)
: undefined;
const timeoutHandle =
options.timeoutMs > 0
? setTimeout(() => {
killedByTimeout = true;
killTreeWithEscalation();
}, options.timeoutMs)
: undefined;
const abortHandler = (): void => {
killTreeWithEscalation();
};
if (options.signal?.aborted) {
abortHandler();
} else {
options.signal?.addEventListener("abort", abortHandler, { once: true });
}
child.stdout?.on("data", data => {
appendChunk(data);
});
child.stderr?.on("data", data => {
appendChunk(data);
});
child.on("error", error => {
void closeWriteStream().finally(() => {
finish(() => reject(error));
});
});
child.on("close", async code => {
try {
await closeWriteStream();
if (options.signal?.aborted) {
finish(() => reject(new Error("aborted")));
return;
}
const output = await fs.promises.readFile(options.logPath, "utf8");
finish(() =>
resolve({
exitCode: code,
killed: killedByTimeout,
logPath: options.logPath,
output,
}),
);
} catch (error) {
finish(() => reject(error));
}
});
return promise;
}
async function runChecks(options: {
cwd: string;
pathToChecks: string;
logPath: string;
timeoutMs: number;
signal?: AbortSignal;
}): Promise<ChecksExecutionResult> {
const result = await executeProcess({
command: ["bash", options.pathToChecks],
cwd: options.cwd,
logPath: options.logPath,
timeoutMs: options.timeoutMs,
signal: options.signal,
});
return {
code: result.exitCode,
killed: result.killed,
logPath: result.logPath,
output: result.output.trim(),
};
}
function buildRunText(details: RunDetails, outputPreview: string, bestMetric: number | null): string {
const lines: string[] = [];
lines.push(`Run directory: ${details.runDirectory}`);
if (details.timedOut) {
lines.push(`TIMEOUT after ${details.durationSeconds.toFixed(1)}s`);
} else if (details.exitCode !== 0) {
lines.push(`FAILED with exit code ${details.exitCode} in ${details.durationSeconds.toFixed(1)}s`);
} else {
lines.push(`PASSED in ${details.durationSeconds.toFixed(1)}s`);
}
if (details.checksTimedOut) {
lines.push(`Checks timed out after ${details.checksDuration.toFixed(1)}s`);
} else if (details.checksPass === false) {
lines.push(`Checks failed in ${details.checksDuration.toFixed(1)}s`);
} else if (details.checksPass === true) {
lines.push(`Checks passed in ${details.checksDuration.toFixed(1)}s`);
}
if (bestMetric !== null) {
lines.push(`Current baseline ${details.metricName}: ${formatNum(bestMetric, details.metricUnit)}`);
}
if (details.parsedPrimary !== null) {
lines.push(`Parsed ${details.metricName}: ${details.parsedPrimary}`);
lines.push(`Next log_experiment metric: ${details.parsedPrimary}`);
}
if (details.parsedMetrics) {
const secondaryEntries = Object.entries(details.parsedMetrics)
.filter(([name]) => name !== details.metricName)
.map(([name, value]) => [name, value] as const);
const secondary = secondaryEntries.map(([name, value]) => `${name}=${value}`);
if (secondary.length > 0) {
lines.push(`Parsed metrics: ${secondary.join(", ")}`);
lines.push(`Next log_experiment metrics: ${JSON.stringify(Object.fromEntries(secondaryEntries))}`);
}
}
if (details.parsedAsi) {
lines.push(`Parsed ASI keys: ${Object.keys(details.parsedAsi).join(", ")}`);
}
lines.push("");
lines.push(outputPreview);
if (details.truncation && details.fullOutputPath) {
lines.push("");
lines.push(
`Output truncated (${formatBytes(EXPERIMENT_MAX_BYTES)} limit). Full output: ${details.fullOutputPath}`,
);
}
if (details.checksLogPath) {
lines.push(`Checks log: ${details.checksLogPath}`);
}
if (details.checksPass === false && details.checksOutput.length > 0) {
lines.push("");
lines.push("Checks output:");
lines.push(details.checksOutput);
}
return lines.join("\n").trimEnd();
}
function renderStatus(details: RunDetails, theme: Theme): string {
if (details.timedOut) {
return theme.fg("error", `TIMEOUT ${details.durationSeconds.toFixed(1)}s`);
}
if (details.checksTimedOut) {
return theme.fg("warning", `Checks timeout ${details.checksDuration.toFixed(1)}s`);
}
if (details.checksPass === false) {
return theme.fg("error", `Checks failed ${details.checksDuration.toFixed(1)}s`);
}
if (details.exitCode !== 0) {
return theme.fg("error", `FAIL exit=${details.exitCode} ${details.durationSeconds.toFixed(1)}s`);
}
const metric =
details.parsedPrimary !== null
? ` ${details.metricName}=${formatNum(details.parsedPrimary, details.metricUnit)}`
: "";
return theme.fg("success", `PASS ${details.durationSeconds.toFixed(1)}s${metric}`);
}
function isRunDetails(value: unknown): value is RunDetails {
if (typeof value !== "object" || value === null) return false;
return "command" in value && "durationSeconds" in value;
}
function isProgressDetails(value: unknown): value is RunExperimentProgressDetails {
if (typeof value !== "object" || value === null) return false;
return "phase" in value && value.phase === "running";
}
function collectLoggedRunNumbers(results: Array<{ runNumber: number | null }>): Set<number> {
const runNumbers = new Set<number>();
for (const result of results) {
if (result.runNumber !== null) {
runNumbers.add(result.runNumber);
}
}
return runNumbers;
}
@@ -0,0 +1,218 @@
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import type { ExtensionAPI, ExtensionContext } from "../extensibility/extensions";
import type { SessionEntry } from "../session/session-manager";
import type { TruncationResult } from "../session/streaming-output";
export type MetricDirection = "lower" | "higher";
export type ExperimentStatus = "keep" | "discard" | "crash" | "checks_failed";
export type ASIValue = string | number | boolean | null | ASIValue[] | { [key: string]: ASIValue };
export interface ASIData {
[key: string]: ASIValue;
}
export interface NumericMetricMap {
[key: string]: number;
}
export interface MetricDef {
name: string;
unit: string;
}
export interface AutoresearchBenchmarkContract {
command: string | null;
primaryMetric: string | null;
metricUnit: string;
direction: MetricDirection | null;
secondaryMetrics: string[];
}
export interface AutoresearchContract {
benchmark: AutoresearchBenchmarkContract;
scopePaths: string[];
offLimits: string[];
constraints: string[];
}
export interface ExperimentResult {
runNumber: number | null;
commit: string;
metric: number;
metrics: NumericMetricMap;
status: ExperimentStatus;
description: string;
timestamp: number;
segment: number;
confidence: number | null;
asi?: ASIData;
}
export interface ExperimentState {
results: ExperimentResult[];
bestMetric: number | null;
bestDirection: MetricDirection;
metricName: string;
metricUnit: string;
secondaryMetrics: MetricDef[];
name: string | null;
currentSegment: number;
maxExperiments: number | null;
confidence: number | null;
benchmarkCommand: string | null;
scopePaths: string[];
offLimits: string[];
constraints: string[];
segmentFingerprint: string | null;
}
export interface RunExperimentProgressDetails {
phase: "running";
elapsed: string;
truncation?: TruncationResult;
fullOutputPath?: string;
runDirectory?: string;
}
export interface RunDetails {
runNumber: number;
runDirectory: string;
benchmarkLogPath: string;
checksLogPath?: string;
command: string;
exitCode: number | null;
durationSeconds: number;
passed: boolean;
crashed: boolean;
timedOut: boolean;
tailOutput: string;
checksPass: boolean | null;
checksTimedOut: boolean;
checksOutput: string;
checksDuration: number;
parsedMetrics: NumericMetricMap | null;
parsedPrimary: number | null;
parsedAsi: ASIData | null;
metricName: string;
metricUnit: string;
truncation?: TruncationResult;
fullOutputPath?: string;
}
export interface LogDetails {
experiment: ExperimentResult;
state: ExperimentState;
wallClockSeconds: number | null;
}
export interface ChecksResult {
pass: boolean;
output: string;
duration: number;
}
export interface PendingRunSummary {
checksDurationSeconds: number | null;
checksPass: boolean | null;
checksTimedOut: boolean;
command: string;
durationSeconds: number | null;
parsedAsi: ASIData | null;
parsedMetrics: NumericMetricMap | null;
parsedPrimary: number | null;
passed: boolean;
runDirectory: string;
runNumber: number;
}
export interface RunningExperiment {
startedAt: number;
command: string;
runDirectory: string;
runNumber: number;
}
export interface AutoresearchRuntime {
autoresearchMode: boolean;
autoResumeArmed: boolean;
dashboardExpanded: boolean;
lastAutoResumePendingRunNumber: number | null;
lastRunChecks: ChecksResult | null;
lastRunDuration: number | null;
lastRunAsi: ASIData | null;
lastRunArtifactDir: string | null;
lastRunNumber: number | null;
lastRunSummary: PendingRunSummary | null;
runningExperiment: RunningExperiment | null;
state: ExperimentState;
goal: string | null;
}
export interface AutoresearchConfig {
maxIterations?: number;
workingDir?: string;
}
export interface AutoresearchJsonConfigEntry {
type: "config";
name?: string;
metricName?: string;
metricUnit?: string;
bestDirection?: MetricDirection;
benchmarkCommand?: string;
secondaryMetrics?: string[];
scopePaths?: string[];
offLimits?: string[];
constraints?: string[];
segmentFingerprint?: string;
}
export interface AutoresearchJsonRunEntry {
run?: number;
commit?: string;
metric?: number;
metrics?: NumericMetricMap;
status?: ExperimentStatus;
description?: string;
timestamp?: number;
confidence?: number | null;
asi?: ASIData;
}
export interface ReconstructedExperimentData {
hasLog: boolean;
state: ExperimentState;
}
export interface AutoresearchControlEntryData {
mode: "on" | "off" | "clear";
goal?: string;
}
export interface ReconstructedControlState {
autoresearchMode: boolean;
goal: string | null;
lastMode: AutoresearchControlEntryData["mode"] | null;
}
export interface RuntimeStore {
clear(sessionKey: string): void;
ensure(sessionKey: string): AutoresearchRuntime;
}
export interface DashboardController {
clear(ctx: ExtensionContext): void;
requestRender(): void;
showOverlay(ctx: ExtensionContext, runtime: AutoresearchRuntime): Promise<void>;
updateWidget(ctx: ExtensionContext, runtime: AutoresearchRuntime): void;
}
export interface AutoresearchToolFactoryOptions {
dashboard: DashboardController;
getRuntime(ctx: ExtensionContext): AutoresearchRuntime;
pi: ExtensionAPI;
}
export type AutoresearchToolResult<TDetails> = AgentToolResult<TDetails>;
export type SessionEntries = SessionEntry[];
+6
View File
@@ -28,6 +28,8 @@ export interface Args {
mode?: Mode;
noSession?: boolean;
sessionDir?: string;
providerSessionId?: string;
fork?: string;
models?: string[];
tools?: string[];
noTools?: boolean;
@@ -79,6 +81,8 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
} else {
result.resume = true;
}
} else if (arg === "--fork" && i + 1 < args.length) {
result.fork = args[++i];
} else if (arg === "--provider" && i + 1 < args.length) {
result.provider = args[++i];
} else if (arg === "--model" && i + 1 < args.length) {
@@ -95,6 +99,8 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
result.systemPrompt = args[++i];
} else if (arg === "--append-system-prompt" && i + 1 < args.length) {
result.appendSystemPrompt = args[++i];
} else if (arg === "--provider-session-id" && i + 1 < args.length) {
result.providerSessionId = args[++i];
} else if (arg === "--no-session") {
result.noSession = true;
} else if (arg === "--session-dir" && i + 1 < args.length) {
@@ -0,0 +1,58 @@
import type { ImageContent } from "@oh-my-pi/pi-ai";
import type { Args } from "./args";
export interface InitialMessageInput {
parsed: Args;
fileText?: string;
fileImages?: ImageContent[];
stdinContent?: string;
}
export interface InitialMessageResult {
initialMessage?: string;
initialImages?: ImageContent[];
}
/**
* Combine stdin content, @file text, and the first CLI message into a single
* initial prompt for non-interactive mode.
*/
export function buildInitialMessage({
parsed,
fileText,
fileImages,
stdinContent,
}: InitialMessageInput): InitialMessageResult {
const hasInitialContext = stdinContent !== undefined || fileText !== undefined || (fileImages?.length ?? 0) > 0;
if (!hasInitialContext) {
return {
initialImages: undefined,
};
}
let body = "";
if (fileText !== undefined) {
body += fileText;
}
if (parsed.messages.length > 0) {
body += parsed.messages[0];
parsed.messages.shift();
}
const initialMessage =
stdinContent !== undefined
? body.length > 0
? `${stdinContent}\n${body}`
: stdinContent
: body.length > 0
? body
: fileImages && fileImages.length > 0
? ""
: undefined;
return {
initialMessage,
initialImages: fileImages && fileImages.length > 0 ? fileImages : undefined,
};
}
+416 -211
View File
@@ -1,120 +1,438 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import * as path from "node:path";
import {
DEFAULT_EDITOR_KEYBINDINGS,
type EditorAction,
type EditorKeybindingsConfig,
EditorKeybindingsManager,
type Keybinding,
type KeybindingDefinitions,
type KeybindingsConfig,
type KeyId,
matchesKey,
setEditorKeybindings,
setKeybindings,
TUI_KEYBINDINGS,
KeybindingsManager as TuiKeybindingsManager,
} from "@oh-my-pi/pi-tui";
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
/**
* Application-level actions (coding agent specific).
* Application-level keybindings (coding agent specific).
* Values are always `true` — used for declaration merging.
*/
export type AppAction =
| "interrupt"
| "clear"
| "exit"
| "suspend"
| "cycleThinkingLevel"
| "cycleModelForward"
| "cycleModelBackward"
| "selectModel"
| "togglePlanMode"
| "expandTools"
| "toggleThinking"
| "externalEditor"
| "historySearch"
| "followUp"
| "dequeue"
| "pasteImage"
| "copyLine"
| "copyPrompt"
| "newSession"
| "tree"
| "fork"
| "resume"
| "toggleSTT";
interface AppKeybindings {
"app.interrupt": true;
"app.clear": true;
"app.exit": true;
"app.suspend": true;
"app.thinking.cycle": true;
"app.thinking.toggle": true;
"app.model.cycleForward": true;
"app.model.cycleBackward": true;
"app.model.select": true;
"app.tools.expand": true;
"app.editor.external": true;
"app.message.followUp": true;
"app.message.dequeue": true;
"app.clipboard.pasteImage": true;
"app.clipboard.copyLine": true;
"app.clipboard.copyPrompt": true;
"app.session.new": true;
"app.session.tree": true;
"app.session.fork": true;
"app.session.resume": true;
"app.session.togglePath": true;
"app.session.toggleSort": true;
"app.session.rename": true;
"app.session.delete": true;
"app.session.deleteNoninvasive": true;
"app.tree.foldOrUp": true;
"app.tree.unfoldOrDown": true;
"app.plan.toggle": true;
"app.history.search": true;
"app.stt.toggle": true;
}
export type AppKeybinding = keyof AppKeybindings;
declare module "@oh-my-pi/pi-tui" {
interface Keybindings extends AppKeybindings {}
}
/**
* All configurable actions.
* All keybindings definitions: TUI + app-specific.
*/
export type KeyAction = AppAction | EditorAction;
export const KEYBINDINGS = {
...TUI_KEYBINDINGS,
"app.interrupt": {
defaultKeys: "escape",
description: "Interrupt current operation",
},
"app.clear": {
defaultKeys: "ctrl+c",
description: "Clear screen or cancel",
},
"app.exit": {
defaultKeys: "ctrl+d",
description: "Exit application",
},
"app.suspend": {
defaultKeys: "ctrl+z",
description: "Suspend application",
},
"app.thinking.cycle": {
defaultKeys: "shift+tab",
description: "Cycle thinking level",
},
"app.thinking.toggle": {
defaultKeys: "ctrl+t",
description: "Toggle thinking mode",
},
"app.model.cycleForward": {
defaultKeys: "ctrl+p",
description: "Cycle to next model",
},
"app.model.cycleBackward": {
defaultKeys: "shift+ctrl+p",
description: "Cycle to previous model",
},
"app.model.select": {
defaultKeys: "ctrl+l",
description: "Select model",
},
"app.tools.expand": {
defaultKeys: "ctrl+o",
description: "Expand tools",
},
"app.editor.external": {
defaultKeys: "ctrl+g",
description: "Open external editor",
},
"app.message.followUp": {
defaultKeys: "ctrl+enter",
description: "Send follow-up message",
},
"app.message.dequeue": {
defaultKeys: "alt+up",
description: "Dequeue message",
},
"app.clipboard.pasteImage": {
defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
description: "Paste image from clipboard",
},
"app.clipboard.copyLine": {
defaultKeys: "alt+shift+l",
description: "Copy current line",
},
"app.clipboard.copyPrompt": {
defaultKeys: "alt+shift+c",
description: "Copy prompt",
},
"app.session.new": {
defaultKeys: [],
description: "Create new session",
},
"app.session.tree": {
defaultKeys: [],
description: "Show session tree",
},
"app.session.fork": {
defaultKeys: [],
description: "Fork session",
},
"app.session.resume": {
defaultKeys: [],
description: "Resume session",
},
"app.session.togglePath": {
defaultKeys: "ctrl+p",
description: "Toggle session path display",
},
"app.session.toggleSort": {
defaultKeys: "ctrl+s",
description: "Toggle session sort order",
},
"app.session.rename": {
defaultKeys: "ctrl+r",
description: "Rename session",
},
"app.session.delete": {
defaultKeys: "ctrl+d",
description: "Delete session",
},
"app.session.deleteNoninvasive": {
defaultKeys: "ctrl+backspace",
description: "Delete session (non-invasive)",
},
"app.tree.foldOrUp": {
defaultKeys: ["ctrl+left", "alt+left"],
description: "Fold or move up",
},
"app.tree.unfoldOrDown": {
defaultKeys: ["ctrl+right", "alt+right"],
description: "Unfold or move down",
},
"app.plan.toggle": {
defaultKeys: "alt+shift+p",
description: "Toggle plan mode",
},
"app.history.search": {
defaultKeys: "ctrl+r",
description: "Search history",
},
"app.stt.toggle": {
defaultKeys: "alt+h",
description: "Toggle speech-to-text",
},
} as const satisfies KeybindingDefinitions;
/**
* Full keybindings configuration (app + editor actions).
* Migration map from old keybinding names to new namespaced IDs.
*/
export type KeybindingsConfig = {
[K in KeyAction]?: KeyId | KeyId[];
};
const KEYBINDING_NAME_MIGRATIONS = {
// App-specific (old names)
interrupt: "app.interrupt",
clear: "app.clear",
exit: "app.exit",
suspend: "app.suspend",
cycleThinkingLevel: "app.thinking.cycle",
cycleModelForward: "app.model.cycleForward",
cycleModelBackward: "app.model.cycleBackward",
selectModel: "app.model.select",
togglePlanMode: "app.plan.toggle",
historySearch: "app.history.search",
expandTools: "app.tools.expand",
toggleThinking: "app.thinking.toggle",
externalEditor: "app.editor.external",
followUp: "app.message.followUp",
dequeue: "app.message.dequeue",
pasteImage: "app.clipboard.pasteImage",
copyLine: "app.clipboard.copyLine",
copyPrompt: "app.clipboard.copyPrompt",
newSession: "app.session.new",
tree: "app.session.tree",
fork: "app.session.fork",
resume: "app.session.resume",
toggleSTT: "app.stt.toggle",
// TUI editor (old names for backward compatibility)
cursorUp: "tui.editor.cursorUp",
cursorDown: "tui.editor.cursorDown",
cursorLeft: "tui.editor.cursorLeft",
cursorRight: "tui.editor.cursorRight",
cursorWordLeft: "tui.editor.cursorWordLeft",
cursorWordRight: "tui.editor.cursorWordRight",
cursorLineStart: "tui.editor.cursorLineStart",
cursorLineEnd: "tui.editor.cursorLineEnd",
jumpForward: "tui.editor.jumpForward",
jumpBackward: "tui.editor.jumpBackward",
pageUp: "tui.editor.pageUp",
pageDown: "tui.editor.pageDown",
deleteCharBackward: "tui.editor.deleteCharBackward",
deleteCharForward: "tui.editor.deleteCharForward",
deleteWordBackward: "tui.editor.deleteWordBackward",
deleteWordForward: "tui.editor.deleteWordForward",
deleteToLineStart: "tui.editor.deleteToLineStart",
deleteToLineEnd: "tui.editor.deleteToLineEnd",
yank: "tui.editor.yank",
yankPop: "tui.editor.yankPop",
undo: "tui.editor.undo",
// TUI input (old names for backward compatibility)
newLine: "tui.input.newLine",
submit: "tui.input.submit",
tab: "tui.input.tab",
copy: "tui.input.copy",
// TUI select (old names for backward compatibility)
selectUp: "tui.select.up",
selectDown: "tui.select.down",
selectPageUp: "tui.select.pageUp",
selectPageDown: "tui.select.pageDown",
selectConfirm: "tui.select.confirm",
selectCancel: "tui.select.cancel",
// Upstream additional migrations
toggleSessionNamedFilter: "app.session.togglePath",
} as const satisfies Record<string, Keybinding>;
/**
* Default application keybindings.
* Check if a key is a legacy keybinding name.
*/
export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
interrupt: "escape",
clear: "ctrl+c",
exit: "ctrl+d",
suspend: "ctrl+z",
cycleThinkingLevel: "shift+tab",
cycleModelForward: "ctrl+p",
cycleModelBackward: "shift+ctrl+p",
selectModel: "ctrl+l",
togglePlanMode: "alt+shift+p",
historySearch: "ctrl+r",
expandTools: "ctrl+o",
toggleThinking: "ctrl+t",
externalEditor: "ctrl+g",
followUp: "ctrl+enter",
dequeue: "alt+up",
pasteImage: "ctrl+v",
copyLine: "alt+shift+l",
copyPrompt: "alt+shift+c",
newSession: [],
tree: [],
fork: [],
resume: [],
toggleSTT: "alt+h",
};
function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAME_MIGRATIONS {
return key in KEYBINDING_NAME_MIGRATIONS;
}
/**
* All default keybindings (app + editor).
* Normalize input to KeybindingsConfig, validating types.
*/
export const DEFAULT_KEYBINDINGS: Required<KeybindingsConfig> = {
...DEFAULT_EDITOR_KEYBINDINGS,
...DEFAULT_APP_KEYBINDINGS,
};
function toKeybindingsConfig(value: unknown): KeybindingsConfig {
if (typeof value !== "object" || value === null) {
return {};
}
// App actions list for type checking
const APP_ACTIONS: AppAction[] = [
"interrupt",
"clear",
"exit",
"suspend",
"cycleThinkingLevel",
"cycleModelForward",
"cycleModelBackward",
"selectModel",
"togglePlanMode",
"historySearch",
"expandTools",
"toggleThinking",
"externalEditor",
"followUp",
"dequeue",
"pasteImage",
"copyLine",
"copyPrompt",
"newSession",
"tree",
"fork",
"resume",
"toggleSTT",
];
const config: KeybindingsConfig = {};
for (const [key, val] of Object.entries(value)) {
// Allow undefined, string (KeyId), or array of strings
if (val === undefined) {
config[key] = undefined;
} else if (typeof val === "string") {
config[key] = val as KeyId;
} else if (Array.isArray(val) && val.every(v => typeof v === "string")) {
config[key] = val as string[] as KeyId[];
}
// Silently skip invalid entries
}
return config;
}
function isAppAction(action: string): action is AppAction {
return APP_ACTIONS.includes(action as AppAction);
/**
* Migrate old keybinding names to new namespaced IDs.
* Returns both the migrated config and a flag indicating if migration occurred.
*/
function migrateKeybindingNames(rawConfig: unknown): {
config: KeybindingsConfig;
migrated: boolean;
} {
const config = toKeybindingsConfig(rawConfig);
const migrated: KeybindingsConfig = {};
let didMigrate = false;
for (const [key, value] of Object.entries(config)) {
if (isLegacyKeybindingName(key)) {
const newKey = KEYBINDING_NAME_MIGRATIONS[key];
migrated[newKey] = value;
didMigrate = true;
} else {
// Already a new-style key
migrated[key] = value;
}
}
return { config: migrated, migrated: didMigrate };
}
/**
* Order keybindings config to match KEYBINDINGS key order.
*/
function orderKeybindingsConfig(config: KeybindingsConfig): KeybindingsConfig {
const ordered: KeybindingsConfig = {};
for (const key of Object.keys(KEYBINDINGS)) {
const value = config[key];
if (value !== undefined) {
ordered[key] = value;
}
}
// Add any remaining keys that aren't in KEYBINDINGS
for (const key of Object.keys(config)) {
if (!(key in ordered)) {
ordered[key] = config[key];
}
}
return ordered;
}
/**
* Load raw config from a file synchronously.
* Returns parsed JSON or null if file doesn't exist or is invalid.
*/
function loadRawConfig(filePath: string): unknown {
try {
if (!existsSync(filePath)) {
return null;
}
const content = readFileSync(filePath, "utf-8");
return JSON.parse(content);
} catch (error) {
if (isEnoent(error)) {
return null;
}
logger.warn("Failed to parse keybindings config", { path: filePath, error: String(error) });
return null;
}
}
/**
* Migrate keybindings config file from old format to new.
* Reads from agentDir/keybindings.json, migrates old names, and writes back.
*/
function loadKeybindingsConfig(filePath: string, writeBack: boolean): KeybindingsConfig {
const rawConfig = loadRawConfig(filePath);
if (rawConfig === null) {
return {};
}
const { config: migratedConfig, migrated } = migrateKeybindingNames(rawConfig);
if (writeBack && migrated) {
const ordered = orderKeybindingsConfig(migratedConfig);
try {
writeFileSync(filePath, `${JSON.stringify(ordered, null, 2)}\n`, "utf-8");
logger.debug("Migrated keybindings config", { path: filePath });
} catch (error) {
logger.warn("Failed to write migrated keybindings config", { path: filePath, error: String(error) });
}
}
return migratedConfig;
}
function migrateKeybindingsConfigFile(agentDir: string): void {
const configPath = path.join(agentDir, "keybindings.json");
loadKeybindingsConfig(configPath, true);
}
/**
* Manages all keybindings (app + TUI).
* Extends the TUI KeybindingsManager with app-specific functionality.
*/
export class KeybindingsManager extends TuiKeybindingsManager {
#configPath: string | undefined;
constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
super(KEYBINDINGS, userBindings);
this.#configPath = configPath;
}
/**
* Create from config file at agentDir/keybindings.json.
*/
static create(agentDir: string = getAgentDir()): KeybindingsManager {
const configPath = path.join(agentDir, "keybindings.json");
const userBindings = KeybindingsManager.#loadFromFile(configPath);
const manager = new KeybindingsManager(userBindings, configPath);
// Set globally so getKeybindings() returns this manager
setKeybindings(manager);
return manager;
}
/**
* Create an in-memory keybindings manager without file persistence.
*/
static inMemory(userBindings: KeybindingsConfig = {}): KeybindingsManager {
return new KeybindingsManager(userBindings);
}
/**
* Reload keybindings from the config file.
*/
reload(): void {
if (!this.#configPath) return;
this.setUserBindings(KeybindingsManager.#loadFromFile(this.#configPath));
}
/**
* Get the effective resolved bindings (defaults + user overrides).
*/
getEffectiveConfig(): KeybindingsConfig {
return this.getResolvedBindings();
}
/**
* Get display string for a keybinding (e.g., "ctrl+c/escape").
*/
getDisplayString(keybinding: Keybinding): string {
const keys = this.getKeys(keybinding);
return formatKeyHints(keys.length === 0 ? [] : keys);
}
/**
* Load user bindings from a file, migrating old names if needed.
*/
static #loadFromFile(filePath: string): KeybindingsConfig {
return loadKeybindingsConfig(filePath, true);
}
}
/**
@@ -145,8 +463,6 @@ const KEY_LABELS: Record<string, string> = {
right: "Right",
};
const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId;
function formatKeyPart(part: string): string {
const lower = part.toLowerCase();
const modifier = MODIFIER_LABELS[lower];
@@ -166,116 +482,5 @@ export function formatKeyHints(keys: KeyId | KeyId[]): string {
return list.map(formatKeyHint).join("/");
}
/**
* Manages all keybindings (app + editor).
*/
export class KeybindingsManager {
#appActionToKeys: Map<AppAction, KeyId[]>;
private constructor(private readonly config: KeybindingsConfig) {
this.#appActionToKeys = new Map();
this.#buildMaps();
}
/**
* Create from config file and set up editor keybindings.
*/
static async create(agentDir: string = getAgentDir()): Promise<KeybindingsManager> {
const configPath = path.join(agentDir, "keybindings.json");
const config = await KeybindingsManager.#loadFromFile(configPath);
const manager = new KeybindingsManager(config);
// Set up editor keybindings globally
const editorConfig: EditorKeybindingsConfig = {};
for (const [action, keys] of Object.entries(config)) {
if (!isAppAction(action)) {
editorConfig[action as EditorAction] = keys;
}
}
setEditorKeybindings(new EditorKeybindingsManager(editorConfig));
return manager;
}
/**
* Create in-memory.
*/
static inMemory(config: KeybindingsConfig = {}): KeybindingsManager {
return new KeybindingsManager(config);
}
static async #loadFromFile(path: string): Promise<KeybindingsConfig> {
try {
return await Bun.file(path).json();
} catch (error) {
if (isEnoent(error)) return {};
logger.warn("Failed to parse keybindings config", { path, error: String(error) });
return {};
}
}
#buildMaps(): void {
this.#appActionToKeys.clear();
// Set defaults for app actions
for (const [action, keys] of Object.entries(DEFAULT_APP_KEYBINDINGS)) {
const keyArray = Array.isArray(keys) ? keys : [keys];
this.#appActionToKeys.set(
action as AppAction,
keyArray.map(key => normalizeKeyId(key as KeyId)),
);
}
// Override with user config (app actions only)
for (const [action, keys] of Object.entries(this.config)) {
if (keys === undefined || !isAppAction(action)) continue;
const keyArray = Array.isArray(keys) ? keys : [keys];
this.#appActionToKeys.set(
action,
keyArray.map(key => normalizeKeyId(key as KeyId)),
);
}
}
/**
* Check if input matches an app action.
*/
matches(data: string, action: AppAction): boolean {
const keys = this.#appActionToKeys.get(action);
if (!keys) return false;
for (const key of keys) {
if (matchesKey(data, key)) return true;
}
return false;
}
/**
* Get keys bound to an app action.
*/
getKeys(action: AppAction): KeyId[] {
return this.#appActionToKeys.get(action) ?? [];
}
/**
* Get display string for an action.
*/
getDisplayString(action: AppAction): string {
return formatKeyHints(this.getKeys(action));
}
/**
* Get the full effective config.
*/
getEffectiveConfig(): Required<KeybindingsConfig> {
const result = { ...DEFAULT_KEYBINDINGS };
for (const [action, keys] of Object.entries(this.config)) {
if (keys !== undefined) {
(result as KeybindingsConfig)[action as KeyAction] = keys;
}
}
return result;
}
}
// Re-export for convenience
export type { EditorAction, KeyId };
export type { Keybinding, KeybindingsConfig, KeyId };
export { migrateKeybindingsConfigFile };
@@ -90,6 +90,7 @@ const OpenAICompatSchema = Type.Object({
thinkingFormat: Type.Optional(
Type.Union([
Type.Literal("openai"),
Type.Literal("openrouter"),
Type.Literal("zai"),
Type.Literal("qwen"),
Type.Literal("qwen-chat-template"),
@@ -141,6 +141,55 @@ function isAlias(id: string): boolean {
return !datePattern.test(id);
}
/**
* Find an exact model reference match.
* Supports either a bare model id or a canonical provider/modelId reference.
* When matching by bare id, ambiguous matches across providers are rejected.
*/
export function findExactModelReferenceMatch(
modelReference: string,
availableModels: Model<Api>[],
): Model<Api> | undefined {
const trimmedReference = modelReference.trim();
if (!trimmedReference) {
return undefined;
}
const normalizedReference = trimmedReference.toLowerCase();
const canonicalMatches = availableModels.filter(
model => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference,
);
if (canonicalMatches.length === 1) {
return canonicalMatches[0];
}
if (canonicalMatches.length > 1) {
return undefined;
}
const slashIndex = trimmedReference.indexOf("/");
if (slashIndex !== -1) {
const provider = trimmedReference.substring(0, slashIndex).trim();
const modelId = trimmedReference.substring(slashIndex + 1).trim();
if (provider && modelId) {
const providerMatches = availableModels.filter(
model =>
model.provider.toLowerCase() === provider.toLowerCase() &&
model.id.toLowerCase() === modelId.toLowerCase(),
);
if (providerMatches.length === 1) {
return providerMatches[0];
}
if (providerMatches.length > 1) {
return undefined;
}
}
}
const idMatches = availableModels.filter(model => model.id.toLowerCase() === normalizedReference);
return idMatches.length === 1 ? idMatches[0] : undefined;
}
/**
* Try to match a pattern to a model from the available models list.
* Returns the matched model or undefined if no match found.
@@ -150,17 +199,17 @@ function tryMatchModel(
availableModels: Model<Api>[],
context: ModelPreferenceContext,
): Model<Api> | undefined {
// Check for provider/modelId format (provider is everything before the first /)
// Try exact reference match first (handles provider/modelId and bare id with ambiguity rejection)
const exactRefMatch = findExactModelReferenceMatch(modelPattern, availableModels);
if (exactRefMatch) {
return exactRefMatch;
}
// Check for provider/modelId format — fuzzy match within provider
const slashIndex = modelPattern.indexOf("/");
if (slashIndex !== -1) {
const provider = modelPattern.substring(0, slashIndex);
const modelId = modelPattern.substring(slashIndex + 1);
const providerMatch = availableModels.find(
m => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(),
);
if (providerMatch) {
return providerMatch;
}
const providerModels = availableModels.filter(m => m.provider.toLowerCase() === provider.toLowerCase());
if (providerModels.length > 0) {
@@ -187,10 +236,9 @@ function tryMatchModel(
return scored[0]?.model;
}
}
// No exact provider/model match - fall through to other matching
}
// Check for exact ID match (case-insensitive)
// Exact ID match (case-insensitive) — with ambiguity across providers handled by preference
const exactMatches = availableModels.filter(m => m.id.toLowerCase() === modelPattern.toLowerCase());
if (exactMatches.length > 0) {
return pickPreferredModel(exactMatches, context);
@@ -139,6 +139,43 @@ type SettingDef =
// under `as const` while still letting SettingValue infer the correct element type.
const EMPTY_STRING_ARRAY: string[] = [];
const EMPTY_STRING_RECORD: Record<string, string> = {};
export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
{
pattern: "^\\s*(cat|head|tail|less|more)\\s+",
tool: "read",
message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.",
},
{
pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+",
tool: "grep",
message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.",
},
{
pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)",
tool: "find",
message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.",
},
{
pattern: "^\\s*sed\\s+(-i|--in-place)",
tool: "edit",
message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.",
},
{
pattern: "^\\s*perl\\s+.*-[pn]?i",
tool: "edit",
message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.",
},
{
pattern: "^\\s*awk\\s+.*-i\\s+inplace",
tool: "edit",
message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.",
},
{
pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S",
tool: "write",
message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
},
];
export const SETTINGS_SCHEMA = {
// ────────────────────────────────────────────────────────────────────────
@@ -943,16 +980,7 @@ export const SETTINGS_SCHEMA = {
default: false,
ui: { tab: "editing", label: "Bash Interceptor", description: "Block shell commands that have dedicated tools" },
},
"bashInterceptor.simpleLs": {
type: "boolean",
default: true,
ui: {
tab: "editing",
label: "Intercept `ls`",
description: "Intercept bare ls commands (when interceptor is enabled)",
},
},
"bashInterceptor.patterns": { type: "array", default: DEFAULT_BASH_INTERCEPTOR_RULES },
// Python
"python.toolMode": {
+1 -4
View File
@@ -341,10 +341,7 @@ export class Settings {
* Get bash interceptor rules (typed accessor for complex array config).
*/
getBashInterceptorRules(): BashInterceptorRule[] {
const patterns = (this.#merged.bashInterceptor as { patterns?: unknown[] })?.patterns;
if (!Array.isArray(patterns)) return [];
return patterns.filter((p): p is BashInterceptorRule => typeof p === "object" && p !== null && "pattern" in p);
return this.get("bashInterceptor.patterns");
}
/**
@@ -2,6 +2,10 @@
:root {
--line-height: 18px; /* 12px font * 1.5 */
--sidebar-width: 400px;
--sidebar-min-width: 240px;
--sidebar-max-width: 840px;
--sidebar-resizer-width: 6px;
}
body {
@@ -12,6 +16,11 @@
background: var(--body-bg);
}
body.sidebar-resizing {
cursor: col-resize;
user-select: none;
}
#app {
display: flex;
min-height: 100vh;
@@ -19,7 +28,9 @@
/* Sidebar */
#sidebar {
width: 400px;
width: var(--sidebar-width);
min-width: var(--sidebar-width);
max-width: var(--sidebar-width);
background: var(--container-bg);
flex-shrink: 0;
display: flex;
@@ -203,8 +214,28 @@
flex-shrink: 0;
}
#sidebar-resizer {
width: var(--sidebar-resizer-width);
flex-shrink: 0;
position: sticky;
top: 0;
height: 100vh;
cursor: col-resize;
touch-action: none;
background: transparent;
border-right: 1px solid transparent;
}
#sidebar-resizer:hover,
body.sidebar-resizing #sidebar-resizer {
background: var(--selectedBg);
border-right-color: var(--dim);
}
/* Main content */
#content {
flex: 1;
min-width: 0;
flex: 1;
overflow-y: auto;
padding: var(--line-height) calc(var(--line-height) * 2);
@@ -841,17 +872,19 @@
@media (max-width: 900px) {
#sidebar {
position: fixed;
left: -400px;
width: 400px;
transform: translateX(-100%);
width: min(var(--sidebar-width), 100vw);
min-width: 0;
max-width: 100vw;
top: 0;
bottom: 0;
height: 100vh;
z-index: 99;
transition: left 0.3s;
transition: transform 0.3s;
}
#sidebar.open {
left: 0;
transform: translateX(0);
}
#sidebar-overlay.open {
@@ -866,6 +899,10 @@
display: block;
}
#sidebar-resizer {
display: none;
}
#content {
padding: var(--line-height) 16px;
}
@@ -875,15 +912,8 @@
}
}
@media (max-width: 500px) {
#sidebar {
width: 100vw;
left: -100vw;
}
}
@media print {
#sidebar, #sidebar-toggle { display: none !important; }
#sidebar, #sidebar-toggle, #sidebar-resizer { display: none !important; }
body { background: white; color: black; }
#content { max-width: none; }
}
File diff suppressed because one or more lines are too long
@@ -28,6 +28,7 @@
<div class="tree-container" id="tree-container"></div>
<div class="tree-status" id="tree-status"></div>
</aside>
<div id="sidebar-resizer" role="separator" aria-orientation="vertical" aria-label="Resize session tree sidebar"></div>
<main id="content">
<div id="header-container"></div>
<div id="messages"></div>
@@ -1279,6 +1279,113 @@
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
const hamburger = document.getElementById('hamburger');
const sidebarResizer = document.getElementById('sidebar-resizer');
const SIDEBAR_WIDTH_STORAGE_KEY = 'pi-share:v1:sidebar-width';
const MIN_CONTENT_WIDTH = 320;
function isMobileLayout() {
return window.matchMedia('(max-width: 900px)').matches;
}
function getSidebarBounds() {
const rootStyles = getComputedStyle(document.documentElement);
const minWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-min-width')) || 240;
const maxWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-max-width')) || 720;
const viewportMaxWidth = window.innerWidth - MIN_CONTENT_WIDTH;
return {
minWidth,
maxWidth: Math.max(minWidth, Math.min(maxWidth, viewportMaxWidth))
};
}
function clampSidebarWidth(width) {
const { minWidth, maxWidth } = getSidebarBounds();
return Math.max(minWidth, Math.min(maxWidth, width));
}
function applySidebarWidth(width) {
document.documentElement.style.setProperty('--sidebar-width', `${Math.round(clampSidebarWidth(width))}px`);
}
function loadSidebarWidth() {
try {
const raw = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
if (raw === null) return null;
const width = Number(raw);
return Number.isFinite(width) ? width : null;
} catch {
return null;
}
}
function saveSidebarWidth(width) {
try {
localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(clampSidebarWidth(width))));
} catch {
// Ignore storage failures (e.g. private browsing restrictions)
}
}
function setupSidebarResize() {
const savedWidth = loadSidebarWidth();
if (savedWidth !== null) {
applySidebarWidth(savedWidth);
}
if (!sidebarResizer) return;
let cleanupDrag = null;
const stopDrag = (pointerId) => {
if (cleanupDrag) {
cleanupDrag(pointerId);
cleanupDrag = null;
}
};
sidebarResizer.addEventListener('pointerdown', (e) => {
if (isMobileLayout()) return;
e.preventDefault();
const startX = e.clientX;
const startWidth = sidebar.getBoundingClientRect().width;
document.body.classList.add('sidebar-resizing');
sidebarResizer.setPointerCapture?.(e.pointerId);
const onPointerMove = (event) => {
applySidebarWidth(startWidth + (event.clientX - startX));
};
cleanupDrag = (pointerIdToRelease) => {
document.body.classList.remove('sidebar-resizing');
sidebarResizer.releasePointerCapture?.(pointerIdToRelease);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
saveSidebarWidth(sidebar.getBoundingClientRect().width);
};
const onPointerUp = (event) => stopDrag(event.pointerId);
const onPointerCancel = (event) => stopDrag(event.pointerId);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerCancel);
});
sidebarResizer.addEventListener('dblclick', () => {
if (isMobileLayout()) return;
applySidebarWidth(400);
saveSidebarWidth(400);
});
window.addEventListener('resize', () => {
if (isMobileLayout()) return;
applySidebarWidth(sidebar.getBoundingClientRect().width);
});
}
setupSidebarResize();
hamburger.addEventListener('click', () => {
sidebar.classList.add('open');
@@ -56,7 +56,7 @@ import type { TodoItem } from "../../tools/todo-write";
import type { EventBus } from "../../utils/event-bus";
import type { SlashCommandInfo } from "../slash-commands";
export type { AppAction, KeybindingsManager } from "../../config/keybindings";
export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
export type { ExecOptions, ExecResult } from "../../exec/exec";
export type { AgentToolResult, AgentToolUpdateCallback };
@@ -87,6 +87,16 @@ export interface ExtensionUIDialogOptions {
/** Raw terminal input listener for extensions. */
export type TerminalInputHandler = (data: string) => { consume?: boolean; data?: string } | undefined;
export type WidgetPlacement = "aboveEditor" | "belowEditor";
export interface ExtensionWidgetOptions {
placement?: WidgetPlacement;
}
export type ExtensionUiComponent = Component & { dispose?(): void };
export type ExtensionUiComponentFactory = (tui: TUI, theme: Theme) => ExtensionUiComponent;
export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | undefined;
/**
* UI context for extensions to request interactive UI.
* Each mode (interactive, RPC, print) provides its own implementation.
@@ -113,15 +123,14 @@ export interface ExtensionUIContext {
/** Set the working/loading message shown during streaming. Call with no argument to restore default. */
setWorkingMessage(message?: string): void;
/** Set a widget to display above the editor. Accepts string array or component factory. */
setWidget(key: string, content: string[] | undefined): void;
setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
/** Set a widget to display above or below the editor. Accepts string array or component factory. */
setWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void;
/** Set a custom footer component, or undefined to restore the built-in footer. */
setFooter(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
setFooter(factory: ExtensionUiComponentFactory | undefined): void;
/** Set a custom header component, or undefined to restore the built-in header. */
setHeader(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
setHeader(factory: ExtensionUiComponentFactory | undefined): void;
/** Set the terminal window/tab title. */
setTitle(title: string): void;
@@ -133,7 +142,7 @@ export interface ExtensionUIContext {
theme: Theme,
keybindings: KeybindingsManager,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
) => ExtensionUiComponent | Promise<ExtensionUiComponent>,
options?: { overlay?: boolean },
): Promise<T>;
@@ -303,6 +312,9 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
parameters: TParams;
/** If true, tool is excluded unless explicitly listed in --tools or agent's tools field */
hidden?: boolean;
/** If true, tool is registered but not auto-included in the initial active set.
* The registering extension is responsible for activating/deactivating it via setActiveTools(). */
defaultInactive?: boolean;
/** If true, tool may stage deferred changes that require explicit resolve/discard. */
deferrable?: boolean;
/** MCP server name for discovery/search metadata when this tool fronts an MCP server. */
@@ -1047,7 +1059,13 @@ export interface ExtensionAPI {
// Actions
// =========================================================================
/** Send a custom message to the session. */
/**
* Send a custom message to the session.
*
* `deliverAs: "nextTurn"` keeps the message hidden from the editable pending-message UI.
* If `triggerTurn` is also true while the current turn is still unwinding, the session schedules
* an internal continuation that consumes the message on the next turn.
*/
sendMessage<T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
@@ -1223,6 +1241,11 @@ type HandlerFn = (...args: unknown[]) => Promise<unknown>;
export type SendMessageHandler = <T = unknown>(
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
/**
* `deliverAs: "nextTurn"` queues hidden custom context for the next turn.
* When paired with `triggerTurn: true` during prompt teardown, the session schedules
* an internal continuation without surfacing the message in the editable pending queue.
*/
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
) => void;
+39 -42
View File
@@ -15,6 +15,7 @@ import { $env, getProjectDir, logger, postmortem, setProjectDir, VERSION } from
import chalk from "chalk";
import type { Args } from "./cli/args";
import { processFileArguments } from "./cli/file-processor";
import { buildInitialMessage } from "./cli/initial-message";
import { listModels } from "./cli/list-models";
import { selectSession } from "./cli/session-picker";
import { findConfigFile } from "./config";
@@ -137,7 +138,7 @@ async function runInteractiveMode(
}
}
if (initialMessage) {
if (initialMessage !== undefined) {
try {
await session.prompt(initialMessage, { images: initialImages });
} catch (error: unknown) {
@@ -161,33 +162,6 @@ async function runInteractiveMode(
}
}
async function prepareInitialMessage(
parsed: Args,
autoResizeImages: boolean,
): Promise<{
initialMessage?: string;
initialImages?: ImageContent[];
}> {
if (parsed.fileArgs.length === 0) {
return {};
}
const { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages });
let initialMessage: string;
if (parsed.messages.length > 0) {
initialMessage = text + parsed.messages[0];
parsed.messages.shift();
} else {
initialMessage = text;
}
return {
initialMessage,
initialImages: images.length > 0 ? images : undefined,
};
}
function normalizePathForComparison(value: string): string {
const resolved = path.resolve(value);
let realPath = resolved;
@@ -237,6 +211,21 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
}
async function createSessionManager(parsed: Args, cwd: string): Promise<SessionManager | undefined> {
if (parsed.fork) {
if (parsed.noSession) {
throw new Error("--fork requires session persistence");
}
const forkSource = parsed.fork;
if (forkSource.includes("/") || forkSource.includes("\\") || forkSource.endsWith(".jsonl")) {
return await SessionManager.forkFrom(forkSource, cwd, parsed.sessionDir);
}
const match = await resolveResumableSession(forkSource, cwd, parsed.sessionDir);
if (!match) {
throw new Error(`Session "${forkSource}" not found.`);
}
return await SessionManager.forkFrom(match.session.path, cwd, parsed.sessionDir);
}
if (parsed.noSession) {
return SessionManager.inMemory();
}
@@ -374,6 +363,9 @@ async function buildSessionOptions(
if (sessionManager) {
options.sessionManager = sessionManager;
}
if (parsed.providerSessionId) {
options.providerSessionId = parsed.providerSessionId;
}
// Model from CLI
// - supports --provider <name> --model <pattern>
@@ -565,22 +557,27 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
if (parsedArgs.noPty) {
Bun.env.PI_NO_PTY = "1";
}
const {
pipedInput,
initialMessage: initMsg,
initialImages,
} = await logger.timeAsync("prepareInitialMessage", async () => {
const { pipedInput, fileText, fileImages } = await logger.timeAsync("prepareInitialMessage", async () => {
const pipedInput = await readPipedInput();
let { initialMessage, initialImages } = await prepareInitialMessage(
parsedArgs,
settings.get("images.autoResize"),
);
if (pipedInput) {
initialMessage = initialMessage ? `${initialMessage}\n${pipedInput}` : pipedInput;
if (parsedArgs.fileArgs.length === 0) {
return { pipedInput };
}
return { pipedInput, initialMessage, initialImages };
const { text, images } = await processFileArguments(parsedArgs.fileArgs, {
autoResizeImages: settings.get("images.autoResize"),
});
return {
pipedInput,
fileText: text,
fileImages: images,
};
});
const { initialMessage, initialImages } = buildInitialMessage({
parsed: parsedArgs,
fileText,
fileImages,
stdinContent: pipedInput,
});
const initialMessage = initMsg;
const autoPrint = pipedInput !== undefined && !parsedArgs.print && parsedArgs.mode === undefined;
const isInteractive = !parsedArgs.print && !autoPrint && parsedArgs.mode === undefined;
const mode = parsedArgs.mode || "text";
@@ -626,7 +623,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
let sessionManager = await logger.timeAsync("createSessionManager", () => createSessionManager(parsedArgs, cwd));
// Handle --resume (no value): show session picker
if (parsedArgs.resume === true) {
if (parsedArgs.resume === true && !parsedArgs.fork) {
const sessions = await logger.timeAsync("SessionManager.list", () =>
SessionManager.list(cwd, parsedArgs.sessionDir),
);
@@ -50,6 +50,7 @@ import { discoverAgents } from "../../task/discovery";
import type { AgentDefinition, AgentSource } from "../../task/types";
import { shortenPath } from "../../tools/render-utils";
import { theme } from "../theme/theme";
import { matchesAppInterrupt } from "../utils/keybinding-matchers";
import { DynamicBorder } from "./dynamic-border";
type SourceTabId = "all" | AgentSource;
@@ -993,7 +994,7 @@ export class AgentDashboard extends Container {
}
if (this.#createSpec) {
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
if (matchesAppInterrupt(data)) {
this.#clearCreateFlow();
this.#buildLayout();
return;
@@ -1017,7 +1018,7 @@ export class AgentDashboard extends Container {
}
if (this.#createInput || this.#createGenerating) {
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
if (matchesAppInterrupt(data)) {
if (!this.#createGenerating) {
this.#clearCreateFlow();
this.#buildLayout();
@@ -1037,7 +1038,7 @@ export class AgentDashboard extends Container {
}
if (this.#editInput) {
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
if (matchesAppInterrupt(data)) {
this.#cancelModelEdit();
return;
}
@@ -1048,7 +1049,7 @@ export class AgentDashboard extends Container {
return;
}
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
if (matchesAppInterrupt(data)) {
if (this.#searchQuery.length > 0) {
this.#searchQuery = "";
this.#applyFilters();
@@ -1,41 +1,41 @@
import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
import type { AppAction } from "../../config/keybindings";
import type { AppKeybinding } from "../../config/keybindings";
type ConfigurableEditorAction = Extract<
AppAction,
| "interrupt"
| "clear"
| "exit"
| "suspend"
| "cycleThinkingLevel"
| "cycleModelForward"
| "cycleModelBackward"
| "selectModel"
| "expandTools"
| "toggleThinking"
| "externalEditor"
| "historySearch"
| "dequeue"
| "pasteImage"
| "copyPrompt"
AppKeybinding,
| "app.interrupt"
| "app.clear"
| "app.exit"
| "app.suspend"
| "app.thinking.cycle"
| "app.model.cycleForward"
| "app.model.cycleBackward"
| "app.model.select"
| "app.tools.expand"
| "app.thinking.toggle"
| "app.editor.external"
| "app.history.search"
| "app.message.dequeue"
| "app.clipboard.pasteImage"
| "app.clipboard.copyPrompt"
>;
const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
interrupt: ["escape"],
clear: ["ctrl+c"],
exit: ["ctrl+d"],
suspend: ["ctrl+z"],
cycleThinkingLevel: ["shift+tab"],
cycleModelForward: ["ctrl+p"],
cycleModelBackward: ["shift+ctrl+p"],
selectModel: ["ctrl+l"],
expandTools: ["ctrl+o"],
toggleThinking: ["ctrl+t"],
externalEditor: ["ctrl+g"],
historySearch: ["ctrl+r"],
dequeue: ["alt+up"],
pasteImage: ["ctrl+v"],
copyPrompt: ["alt+shift+c"],
"app.interrupt": ["escape"],
"app.clear": ["ctrl+c"],
"app.exit": ["ctrl+d"],
"app.suspend": ["ctrl+z"],
"app.thinking.cycle": ["shift+tab"],
"app.model.cycleForward": ["ctrl+p"],
"app.model.cycleBackward": ["shift+ctrl+p"],
"app.model.select": ["ctrl+l"],
"app.tools.expand": ["ctrl+o"],
"app.thinking.toggle": ["ctrl+t"],
"app.editor.external": ["ctrl+g"],
"app.history.search": ["ctrl+r"],
"app.message.dequeue": ["alt+up"],
"app.clipboard.pasteImage": ["ctrl+v"],
"app.clipboard.copyPrompt": ["alt+shift+c"],
};
/**
@@ -115,13 +115,13 @@ export class CustomEditor extends Editor {
}
// Intercept configured image paste (async - fires and handles result)
if (this.#matchesAction(data, "pasteImage") && this.onPasteImage) {
if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) {
void this.onPasteImage();
return;
}
// Intercept configured external editor shortcut
if (this.#matchesAction(data, "externalEditor") && this.onExternalEditor) {
if (this.#matchesAction(data, "app.editor.external") && this.onExternalEditor) {
this.onExternalEditor();
return;
}
@@ -133,56 +133,56 @@ export class CustomEditor extends Editor {
}
// Intercept configured suspend shortcut
if (this.#matchesAction(data, "suspend") && this.onSuspend) {
if (this.#matchesAction(data, "app.suspend") && this.onSuspend) {
this.onSuspend();
return;
}
// Intercept configured thinking block visibility toggle
if (this.#matchesAction(data, "toggleThinking") && this.onToggleThinking) {
if (this.#matchesAction(data, "app.thinking.toggle") && this.onToggleThinking) {
this.onToggleThinking();
return;
}
// Intercept configured model selector shortcut
if (this.#matchesAction(data, "selectModel") && this.onSelectModel) {
if (this.#matchesAction(data, "app.model.select") && this.onSelectModel) {
this.onSelectModel();
return;
}
// Intercept configured history search shortcut
if (this.#matchesAction(data, "historySearch") && this.onHistorySearch) {
if (this.#matchesAction(data, "app.history.search") && this.onHistorySearch) {
this.onHistorySearch();
return;
}
// Intercept configured tool output expansion shortcut
if (this.#matchesAction(data, "expandTools") && this.onExpandTools) {
if (this.#matchesAction(data, "app.tools.expand") && this.onExpandTools) {
this.onExpandTools();
return;
}
// Intercept configured backward model cycling (check before forward cycling)
if (this.#matchesAction(data, "cycleModelBackward") && this.onCycleModelBackward) {
if (this.#matchesAction(data, "app.model.cycleBackward") && this.onCycleModelBackward) {
this.onCycleModelBackward();
return;
}
// Intercept configured forward model cycling
if (this.#matchesAction(data, "cycleModelForward") && this.onCycleModelForward) {
if (this.#matchesAction(data, "app.model.cycleForward") && this.onCycleModelForward) {
this.onCycleModelForward();
return;
}
// Intercept configured thinking level cycling
if (this.#matchesAction(data, "cycleThinkingLevel") && this.onCycleThinkingLevel) {
if (this.#matchesAction(data, "app.thinking.cycle") && this.onCycleThinkingLevel) {
this.onCycleThinkingLevel();
return;
}
// Intercept configured interrupt shortcut.
// Default behavior keeps autocomplete dismissal, but parent can prioritize global interrupt handling.
if (this.#matchesAction(data, "interrupt") && this.onEscape) {
if (this.#matchesAction(data, "app.interrupt") && this.onEscape) {
if (!this.isShowingAutocomplete() || this.shouldBypassAutocompleteOnEscape?.()) {
this.onEscape();
return;
@@ -190,13 +190,13 @@ export class CustomEditor extends Editor {
}
// Intercept configured clear shortcut
if (this.#matchesAction(data, "clear") && this.onClear) {
if (this.#matchesAction(data, "app.clear") && this.onClear) {
this.onClear();
return;
}
// Intercept configured exit shortcut (only when editor is empty)
if (this.#matchesAction(data, "exit")) {
if (this.#matchesAction(data, "app.exit")) {
if (this.getText().length === 0 && this.onExit) {
this.onExit();
}
@@ -205,13 +205,13 @@ export class CustomEditor extends Editor {
}
// Intercept configured dequeue shortcut (restore queued message to editor)
if (this.#matchesAction(data, "dequeue") && this.onDequeue) {
if (this.#matchesAction(data, "app.message.dequeue") && this.onDequeue) {
this.onDequeue();
return;
}
// Intercept configured copy-prompt shortcut
if (this.#matchesAction(data, "copyPrompt") && this.onCopyPrompt) {
if (this.#matchesAction(data, "app.clipboard.copyPrompt") && this.onCopyPrompt) {
this.onCopyPrompt();
return;
}
@@ -24,6 +24,7 @@ import {
import { Settings } from "../../../config/settings";
import { DynamicBorder } from "../../../modes/components/dynamic-border";
import { theme } from "../../../modes/theme/theme";
import { matchesAppInterrupt } from "../../../modes/utils/keybinding-matchers";
import { ExtensionList } from "./extension-list";
import { InspectorPanel } from "./inspector-panel";
import { applyFilter, createInitialState, filterByProvider, refreshState, toggleProvider } from "./state-manager";
@@ -251,7 +252,7 @@ export class ExtensionDashboard extends Container {
}
// Escape - clear search first, then close
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
if (matchesAppInterrupt(data)) {
if (this.#state.searchQuery.length > 0) {
this.#state.searchQuery = "";
this.#state.searchFiltered = this.#state.tabFiltered;
@@ -11,6 +11,7 @@ import {
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/theme/theme";
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
import type { HistoryEntry, HistoryStorage } from "../../session/history-storage";
import { DynamicBorder } from "./dynamic-border";
@@ -137,7 +138,7 @@ export class HistorySearchComponent extends Container {
return;
}
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
if (matchesAppInterrupt(keyData)) {
this.#onCancel();
return;
}
@@ -8,6 +8,7 @@
*/
import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { getEditorTheme, theme } from "../../modes/theme/theme";
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
import { DynamicBorder } from "./dynamic-border";
@@ -117,7 +118,7 @@ export class HookEditorComponent extends Container {
}
// Escape to cancel
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
if (matchesAppInterrupt(keyData)) {
this.#onCancelCallback();
return;
}
@@ -1,8 +1,9 @@
/**
* Simple text input component for hooks.
*/
import { Container, Input, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/theme/theme";
import { Container, Input, Markdown, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
import { CountdownTimer } from "./countdown-timer";
import { DynamicBorder } from "./dynamic-border";
@@ -16,7 +17,7 @@ export class HookInputComponent extends Container {
#input: Input;
#onSubmitCallback: (value: string) => void;
#onCancelCallback: () => void;
#titleText: Text;
#titleComponent: Markdown;
#baseTitle: string;
#countdown: CountdownTimer | undefined;
@@ -36,15 +37,15 @@ export class HookInputComponent extends Container {
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.#titleText = new Text(theme.fg("accent", title), 1, 0);
this.addChild(this.#titleText);
this.#titleComponent = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) });
this.addChild(this.#titleComponent);
this.addChild(new Spacer(1));
if (opts?.timeout && opts.timeout > 0 && opts.tui) {
this.#countdown = new CountdownTimer(
opts.timeout,
opts.tui,
s => this.#titleText.setText(theme.fg("accent", `${this.#baseTitle} (${s}s)`)),
s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`),
() => {
opts.onTimeout?.();
this.#onCancelCallback();
@@ -65,7 +66,7 @@ export class HookInputComponent extends Container {
this.#countdown?.reset();
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
this.#onSubmitCallback(this.#input.getValue());
} else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
} else if (matchesAppInterrupt(keyData)) {
this.#onCancelCallback();
} else {
this.#input.handleInput(keyData);
@@ -4,8 +4,10 @@
*/
import {
Container,
Markdown,
matchesKey,
padding,
renderInlineMarkdown,
replaceTabs,
Spacer,
Text,
@@ -13,7 +15,8 @@ import {
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/theme/theme";
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
import { CountdownTimer } from "./countdown-timer";
import { DynamicBorder } from "./dynamic-border";
@@ -59,7 +62,7 @@ export class HookSelectorComponent extends Container {
#outlinedList: OutlinedList | undefined;
#onSelectCallback: (option: string) => void;
#onCancelCallback: () => void;
#titleText: Text;
#titleComponent: Markdown;
#baseTitle: string;
#countdown: CountdownTimer | undefined;
#onLeftCallback: (() => void) | undefined;
@@ -85,15 +88,15 @@ export class HookSelectorComponent extends Container {
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.#titleText = new Text(theme.fg("accent", title), 1, 0);
this.addChild(this.#titleText);
this.#titleComponent = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) });
this.addChild(this.#titleComponent);
this.addChild(new Spacer(1));
if (opts?.timeout && opts.timeout > 0 && opts.tui) {
this.#countdown = new CountdownTimer(
opts.timeout,
opts.tui,
s => this.#titleText.setText(theme.fg("accent", `${this.#baseTitle} (${s}s)`)),
s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`),
() => {
opts?.onTimeout?.();
// Auto-select current option on timeout (typically the first/recommended option)
@@ -131,12 +134,14 @@ export class HookSelectorComponent extends Container {
);
const endIndex = Math.min(startIndex + this.#maxVisible, this.#options.length);
const mdTheme = getMarkdownTheme();
for (let i = startIndex; i < endIndex; i++) {
const isSelected = i === this.#selectedIndex;
const text = isSelected
? theme.fg("accent", `${theme.nav.cursor} `) + theme.fg("accent", this.#options[i])
: ` ${theme.fg("text", this.#options[i])}`;
lines.push(text);
const label = isSelected
? renderInlineMarkdown(this.#options[i], mdTheme, t => theme.fg("accent", t))
: renderInlineMarkdown(this.#options[i], mdTheme, t => theme.fg("text", t));
const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
lines.push(prefix + label);
}
if (startIndex > 0 || endIndex < this.#options.length) {
@@ -169,7 +174,7 @@ export class HookSelectorComponent extends Container {
this.#onLeftCallback?.();
} else if (matchesKey(keyData, "right")) {
this.#onRightCallback?.();
} else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
} else if (matchesSelectCancel(keyData)) {
this.#onCancelCallback();
}
}
@@ -1,8 +1,8 @@
/**
* Utilities for formatting keybinding hints in the UI.
*/
import { type EditorAction, getEditorKeybindings, type KeyId } from "@oh-my-pi/pi-tui";
import type { AppAction, KeybindingsManager } from "../../config/keybindings";
import { getKeybindings, type Keybinding, type KeyId } from "@oh-my-pi/pi-tui";
import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
import { theme } from "../../modes/theme/theme";
/**
@@ -17,14 +17,14 @@ function formatKeys(keys: KeyId[]): string {
/**
* Get display string for an editor action.
*/
export function editorKey(action: EditorAction): string {
return formatKeys(getEditorKeybindings().getKeys(action));
export function editorKey(action: Keybinding): string {
return formatKeys(getKeybindings().getKeys(action));
}
/**
* Get display string for an app action.
*/
export function appKey(keybindings: KeybindingsManager, action: AppAction): string {
export function appKey(keybindings: KeybindingsManager, action: AppKeybinding): string {
return formatKeys(keybindings.getKeys(action));
}
@@ -32,11 +32,11 @@ export function appKey(keybindings: KeybindingsManager, action: AppAction): stri
* Format a keybinding hint with consistent styling: dim key, muted description.
* Looks up the key from editor keybindings automatically.
*
* @param action - Editor action name (e.g., "selectConfirm", "expandTools")
* @param action - Keybinding action name (e.g., "tui.select.confirm", "app.tools.expand")
* @param description - Description text (e.g., "to expand", "cancel")
* @returns Formatted string with dim key and muted description
*/
export function keyHint(action: EditorAction, description: string): string {
export function keyHint(action: Keybinding, description: string): string {
return theme.fg("dim", editorKey(action)) + theme.fg("muted", ` ${description}`);
}
@@ -45,11 +45,11 @@ export function keyHint(action: EditorAction, description: string): string {
* Requires the KeybindingsManager instance.
*
* @param keybindings - KeybindingsManager instance
* @param action - App action name (e.g., "interrupt", "externalEditor")
* @param action - App keybinding name (e.g., "app.interrupt", "app.editor.external")
* @param description - Description text
* @returns Formatted string with dim key and muted description
*/
export function appKeyHint(keybindings: KeybindingsManager, action: AppAction, description: string): string {
export function appKeyHint(keybindings: KeybindingsManager, action: AppKeybinding, description: string): string {
return theme.fg("dim", appKey(keybindings, action)) + theme.fg("muted", ` ${description}`);
}
@@ -1,5 +1,5 @@
import { getOAuthProviders } from "@oh-my-pi/pi-ai";
import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { Container, getKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/theme/theme";
import { openPath } from "../../utils/open";
import { DynamicBorder } from "./dynamic-border";
@@ -151,9 +151,9 @@ export class LoginDialogComponent extends Container {
}
handleInput(data: string): void {
const kb = getEditorKeybindings();
const kb = getKeybindings();
if (kb.matches(data, "selectCancel")) {
if (kb.matches(data, "tui.select.cancel")) {
this.#cancel();
return;
}
@@ -19,6 +19,7 @@ import { analyzeAuthError, discoverOAuthEndpoints } from "../../mcp/oauth-discov
import type { MCPHttpServerConfig, MCPServerConfig, MCPSseServerConfig, MCPStdioServerConfig } from "../../mcp/types";
import { shortenPath } from "../../tools/render-utils";
import { theme } from "../theme/theme";
import { matchesAppInterrupt } from "../utils/keybinding-matchers";
import { DynamicBorder } from "./dynamic-border";
type TransportType = "stdio" | "http" | "sse";
@@ -452,7 +453,7 @@ export class MCPAddWizard extends Container {
}
// Handle Escape (always handled by wizard)
if (matchesKey(keyData, "escape")) {
if (matchesAppInterrupt(keyData)) {
if (this.#currentStep === "name") {
// Cancel wizard
this.#onCancelCallback();
@@ -1,6 +1,17 @@
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
import { Container, Input, matchesKey, Spacer, type Tab, TabBar, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
import {
Container,
getKeybindings,
Input,
matchesKey,
Spacer,
type Tab,
TabBar,
Text,
type TUI,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { MODEL_ROLE_IDS, MODEL_ROLES, type ModelRegistry, type ModelRole } from "../../config/model-registry";
import { resolveModelRoleValue } from "../../config/model-resolver";
import type { Settings } from "../../config/settings";
@@ -647,7 +658,7 @@ export class ModelSelectorComponent extends Container {
}
// Escape or Ctrl+C - close selector
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
if (getKeybindings().matches(keyData, "tui.select.cancel")) {
this.#onCancelCallback();
return;
}
@@ -698,7 +709,7 @@ export class ModelSelectorComponent extends Container {
return;
}
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
if (getKeybindings().matches(keyData, "tui.select.cancel")) {
if (this.#menuStep === "thinking" && this.#menuSelectedRole !== null) {
this.#menuStep = "role";
const roleIndex = MENU_ROLE_ACTIONS.findIndex(action => action.role === this.#menuSelectedRole);
@@ -1,6 +1,7 @@
import { getOAuthProviders, type OAuthProviderInfo } from "@oh-my-pi/pi-ai";
import { Container, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/theme/theme";
import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
import type { AuthStorage } from "../../session/auth-storage";
import { DynamicBorder } from "./dynamic-border";
/**
@@ -202,7 +203,7 @@ export class OAuthSelectorComponent extends Container {
}
}
// Escape or Ctrl+C
else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
else if (matchesSelectCancel(keyData)) {
this.stopValidation();
this.#onCancelCallback();
}
@@ -11,6 +11,7 @@ import {
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/theme/theme";
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
import type { SessionInfo } from "../../session/session-manager";
import { fuzzyFilter } from "../../utils/fuzzy";
import { DynamicBorder } from "./dynamic-border";
@@ -219,7 +220,7 @@ class SessionList implements Component {
return;
}
// Escape - cancel
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
if (matchesAppInterrupt(keyData)) {
if (this.onCancel) {
this.onCancel();
}
@@ -21,6 +21,7 @@ import type {
} from "../../config/settings-schema";
import { SETTING_TABS, TAB_METADATA } from "../../config/settings-schema";
import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme";
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
import { getTabBarTheme } from "../shared";
import { DynamicBorder } from "./dynamic-border";
import { PluginSettingsComponent } from "./plugin-settings";
@@ -521,7 +522,7 @@ export class SettingsSelectorComponent extends Container {
}
// Escape at top level cancels
if ((matchesKey(data, "escape") || matchesKey(data, "esc")) && !this.#currentSubmenu) {
if (matchesAppInterrupt(data) && !this.#currentSubmenu) {
this.callbacks.onCancel();
return;
}
@@ -11,6 +11,7 @@
import { Container, matchesKey, padding } from "@oh-my-pi/pi-tui";
import type { StatusLineSegmentId } from "../../config/settings-schema";
import { theme } from "../../modes/theme/theme";
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
import { ALL_SEGMENT_IDS } from "./status-line/segments";
// Segment display names and short descriptions
@@ -239,7 +240,7 @@ export class StatusLineSegmentEditorComponent extends Container {
const left = this.#getSegmentsForColumn("left").map(s => s.id);
const right = this.#getSegmentsForColumn("right").map(s => s.id);
this.callbacks.onSave(left, right);
} else if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
} else if (matchesAppInterrupt(data)) {
this.callbacks.onCancel();
}
}
@@ -12,6 +12,7 @@ import {
} from "@oh-my-pi/pi-tui";
import type { TreeFilterMode } from "../../config/settings-schema";
import { theme } from "../../modes/theme/theme";
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
import type { SessionTreeNode } from "../../session/session-manager";
import { shortenPath } from "../../tools/render-utils";
import { DynamicBorder } from "./dynamic-border";
@@ -702,7 +703,7 @@ class TreeList implements Component {
if (selected && this.onSelect) {
this.onSelect(selected.node.entry.id);
}
} else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
} else if (matchesAppInterrupt(keyData)) {
if (this.#searchQuery) {
this.#searchQuery = "";
this.#applyFilter();
@@ -807,7 +808,7 @@ class LabelInput implements Component {
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
const value = this.#input.getValue().trim();
this.onSubmit?.(this.entryId, value || undefined);
} else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
} else if (matchesAppInterrupt(keyData)) {
this.onCancel?.();
} else {
this.#input.handleInput(keyData);
@@ -1,5 +1,6 @@
import { type Component, Container, matchesKey, Spacer, Text, truncateToWidth } from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/theme/theme";
import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
import { DynamicBorder } from "./dynamic-border";
interface UserMessageItem {
@@ -91,14 +92,8 @@ class UserMessageList implements Component {
this.onSelect(selected.id);
}
}
// Escape - cancel
else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
if (this.onCancel) {
this.onCancel();
}
}
// Ctrl+C - cancel
else if (matchesKey(keyData, "ctrl+c")) {
// Escape / cancel
else if (matchesSelectCancel(keyData)) {
if (this.onCancel) {
this.onCancel();
}
@@ -1,6 +1,11 @@
import { Container, Markdown, Spacer } from "@oh-my-pi/pi-tui";
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
// OSC 133 shell integration: marks prompt zones for terminal multiplexers
const OSC133_ZONE_START = "\x1b]133;A\x07";
const OSC133_ZONE_END = "\x1b]133;B\x07";
const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
/**
* Component that renders a user message
*/
@@ -19,4 +24,15 @@ export class UserMessageComponent extends Container {
}),
);
}
override render(width: number): string[] {
const lines = super.render(width);
if (lines.length === 0) {
return lines;
}
lines[0] = OSC133_ZONE_START + lines[0];
lines[lines.length - 1] = lines[lines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
return lines;
}
}
@@ -1,5 +1,5 @@
import type { Component, OverlayHandle, TUI } from "@oh-my-pi/pi-tui";
import { Spacer, Text } from "@oh-my-pi/pi-tui";
import { Container, Spacer, Text } from "@oh-my-pi/pi-tui";
import { logger } from "@oh-my-pi/pi-utils";
import { KeybindingsManager } from "../../config/keybindings";
import type {
@@ -9,6 +9,9 @@ import type {
ExtensionError,
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionUiComponent,
ExtensionWidgetContent,
ExtensionWidgetOptions,
TerminalInputHandler,
} from "../../extensibility/extensions";
import { HookEditorComponent } from "../../modes/components/hook-editor";
@@ -18,8 +21,12 @@ import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, them
import type { InteractiveModeContext } from "../../modes/types";
import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
const MAX_WIDGET_LINES = 10;
export class ExtensionUiController {
#extensionTerminalInputUnsubscribers = new Set<() => void>();
#hookWidgetsAbove = new Map<string, ExtensionUiComponent>();
#hookWidgetsBelow = new Map<string, ExtensionUiComponent>();
constructor(private ctx: InteractiveModeContext) {}
/**
@@ -35,7 +42,7 @@ export class ExtensionUiController {
onTerminalInput: handler => this.addExtensionTerminalInputListener(handler),
setStatus: (key, text) => this.setHookStatus(key, text),
setWorkingMessage: message => this.ctx.setWorkingMessage(message),
setWidget: (key, content) => this.setHookWidget(key, content),
setWidget: (key, content, options) => this.setHookWidget(key, content, options),
setTitle: title => setTerminalTitle(title),
custom: (factory, options) => this.showHookCustom(factory, options),
setEditorText: text => this.ctx.editor.setText(text),
@@ -151,6 +158,7 @@ export class ExtensionUiController {
// Create new session
this.clearExtensionTerminalInputListeners();
this.clearHookWidgets();
const success = await this.ctx.session.newSession({ parentSession: options?.parentSession });
if (!success) {
return { cancelled: true };
@@ -228,6 +236,7 @@ export class ExtensionUiController {
await this.ctx.executeCompaction(instructionsOrOptions, false);
},
switchSession: async sessionPath => {
this.clearHookWidgets();
const result = await this.ctx.session.switchSession(sessionPath);
if (!result) {
return { cancelled: true };
@@ -253,11 +262,73 @@ export class ExtensionUiController {
});
}
setHookWidget(key: string, content: unknown): void {
this.ctx.statusLine.setHookStatus(key, content === undefined || content === null ? undefined : String(content));
setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void {
const placement = options?.placement ?? "aboveEditor";
this.#removeHookWidget(this.#hookWidgetsAbove, key);
this.#removeHookWidget(this.#hookWidgetsBelow, key);
if (content === undefined) {
this.#rebuildHookWidgets();
return;
}
const target = placement === "belowEditor" ? this.#hookWidgetsBelow : this.#hookWidgetsAbove;
target.set(key, this.#createHookWidget(content));
this.#rebuildHookWidgets();
}
#removeHookWidget(widgets: Map<string, ExtensionUiComponent>, key: string): void {
const existing = widgets.get(key);
existing?.dispose?.();
widgets.delete(key);
}
#createHookWidget(content: ExtensionWidgetContent): ExtensionUiComponent {
if (Array.isArray(content)) {
const container = new Container();
for (const line of content.slice(0, MAX_WIDGET_LINES)) {
container.addChild(new Text(line, 1, 0));
}
if (content.length > MAX_WIDGET_LINES) {
container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
}
return container;
}
if (content === undefined) {
throw new Error("Widget content missing");
}
return content(this.ctx.ui, theme);
}
#rebuildHookWidgets(): void {
this.#renderHookWidgetContainer(this.ctx.hookWidgetContainerAbove, this.#hookWidgetsAbove, true, true);
this.#renderHookWidgetContainer(this.ctx.hookWidgetContainerBelow, this.#hookWidgetsBelow, false, false);
this.ctx.ui.requestRender();
}
#renderHookWidgetContainer(
container: Container,
widgets: Map<string, ExtensionUiComponent>,
spacerWhenEmpty: boolean,
leadingSpacer: boolean,
): void {
container.clear();
if (widgets.size === 0) {
if (spacerWhenEmpty) {
container.addChild(new Spacer(1));
}
return;
}
if (leadingSpacer) {
container.addChild(new Spacer(1));
}
for (const widget of widgets.values()) {
container.addChild(widget);
}
}
initializeHookRunner(uiContext: ExtensionUIContext, _hasUI: boolean): void {
const extensionRunner = this.ctx.session.extensionRunner;
if (!extensionRunner) {
@@ -354,6 +425,7 @@ export class ExtensionUiController {
// Create new session
this.clearExtensionTerminalInputListeners();
this.clearHookWidgets();
const success = await this.ctx.session.newSession({ parentSession: options?.parentSession });
if (!success) {
return { cancelled: true };
@@ -433,6 +505,7 @@ export class ExtensionUiController {
if (this.ctx.isBackgrounded) {
return { cancelled: true };
}
this.clearHookWidgets();
const result = await this.ctx.session.switchSession(sessionPath);
if (!result) {
return { cancelled: true };
@@ -856,6 +929,18 @@ export class ExtensionUiController {
};
}
clearHookWidgets(): void {
for (const widget of this.#hookWidgetsAbove.values()) {
widget.dispose?.();
}
for (const widget of this.#hookWidgetsBelow.values()) {
widget.dispose?.();
}
this.#hookWidgetsAbove.clear();
this.#hookWidgetsBelow.clear();
this.#rebuildHookWidgets();
}
clearExtensionTerminalInputListeners(): void {
for (const unsubscribe of this.#extensionTerminalInputUnsubscribers) {
unsubscribe();
@@ -26,7 +26,7 @@ export class InputController {
constructor(private ctx: InteractiveModeContext) {}
setupKeyHandlers(): void {
this.ctx.editor.setActionKeys("interrupt", this.ctx.keybindings.getKeys("interrupt"));
this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
Boolean(
this.ctx.loadingAnimation ||
@@ -83,68 +83,74 @@ export class InputController {
}
};
this.ctx.editor.setActionKeys("clear", this.ctx.keybindings.getKeys("clear"));
this.ctx.editor.setActionKeys("app.clear", this.ctx.keybindings.getKeys("app.clear"));
this.ctx.editor.onClear = () => this.handleCtrlC();
this.ctx.editor.setActionKeys("exit", this.ctx.keybindings.getKeys("exit"));
this.ctx.editor.setActionKeys("app.exit", this.ctx.keybindings.getKeys("app.exit"));
this.ctx.editor.onExit = () => this.handleCtrlD();
this.ctx.editor.setActionKeys("suspend", this.ctx.keybindings.getKeys("suspend"));
this.ctx.editor.setActionKeys("app.suspend", this.ctx.keybindings.getKeys("app.suspend"));
this.ctx.editor.onSuspend = () => this.handleCtrlZ();
this.ctx.editor.setActionKeys("cycleThinkingLevel", this.ctx.keybindings.getKeys("cycleThinkingLevel"));
this.ctx.editor.setActionKeys("app.thinking.cycle", this.ctx.keybindings.getKeys("app.thinking.cycle"));
this.ctx.editor.onCycleThinkingLevel = () => this.cycleThinkingLevel();
this.ctx.editor.setActionKeys("cycleModelForward", this.ctx.keybindings.getKeys("cycleModelForward"));
this.ctx.editor.setActionKeys("app.model.cycleForward", this.ctx.keybindings.getKeys("app.model.cycleForward"));
this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel();
this.ctx.editor.setActionKeys("cycleModelBackward", this.ctx.keybindings.getKeys("cycleModelBackward"));
this.ctx.editor.setActionKeys("app.model.cycleBackward", this.ctx.keybindings.getKeys("app.model.cycleBackward"));
this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel({ temporary: true });
this.ctx.editor.onQuickSelectModel = () => this.ctx.showModelSelector({ temporaryOnly: true });
// Global debug handler on TUI (works regardless of focus)
this.ctx.ui.onDebug = () => this.ctx.showDebugSelector();
this.ctx.editor.setActionKeys("selectModel", this.ctx.keybindings.getKeys("selectModel"));
this.ctx.editor.setActionKeys("app.model.select", this.ctx.keybindings.getKeys("app.model.select"));
this.ctx.editor.onSelectModel = () => this.ctx.showModelSelector();
this.ctx.editor.setActionKeys("historySearch", this.ctx.keybindings.getKeys("historySearch"));
this.ctx.editor.setActionKeys("app.history.search", this.ctx.keybindings.getKeys("app.history.search"));
this.ctx.editor.onHistorySearch = () => this.ctx.showHistorySearch();
this.ctx.editor.setActionKeys("toggleThinking", this.ctx.keybindings.getKeys("toggleThinking"));
this.ctx.editor.setActionKeys("app.thinking.toggle", this.ctx.keybindings.getKeys("app.thinking.toggle"));
this.ctx.editor.onToggleThinking = () => this.ctx.toggleThinkingBlockVisibility();
this.ctx.editor.setActionKeys("externalEditor", this.ctx.keybindings.getKeys("externalEditor"));
this.ctx.editor.setActionKeys("app.editor.external", this.ctx.keybindings.getKeys("app.editor.external"));
this.ctx.editor.onExternalEditor = () => void this.openExternalEditor();
this.ctx.editor.onShowHotkeys = () => this.ctx.handleHotkeysCommand();
this.ctx.editor.setActionKeys("pasteImage", this.ctx.keybindings.getKeys("pasteImage"));
this.ctx.editor.setActionKeys(
"app.clipboard.pasteImage",
this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
);
this.ctx.editor.onPasteImage = () => this.handleImagePaste();
this.ctx.editor.setActionKeys("copyPrompt", this.ctx.keybindings.getKeys("copyPrompt"));
this.ctx.editor.setActionKeys(
"app.clipboard.copyPrompt",
this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"),
);
this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt();
this.ctx.editor.setActionKeys("expandTools", this.ctx.keybindings.getKeys("expandTools"));
this.ctx.editor.setActionKeys("app.tools.expand", this.ctx.keybindings.getKeys("app.tools.expand"));
this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion();
this.ctx.editor.setActionKeys("dequeue", this.ctx.keybindings.getKeys("dequeue"));
this.ctx.editor.setActionKeys("app.message.dequeue", this.ctx.keybindings.getKeys("app.message.dequeue"));
this.ctx.editor.onDequeue = () => this.handleDequeue();
this.ctx.editor.clearCustomKeyHandlers();
// Wire up extension shortcuts
this.registerExtensionShortcuts();
const planModeKeys = this.ctx.keybindings.getKeys("togglePlanMode");
const planModeKeys = this.ctx.keybindings.getKeys("app.plan.toggle");
for (const key of planModeKeys) {
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand());
}
for (const key of this.ctx.keybindings.getKeys("newSession")) {
for (const key of this.ctx.keybindings.getKeys("app.session.new")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.handleClearCommand());
}
for (const key of this.ctx.keybindings.getKeys("tree")) {
for (const key of this.ctx.keybindings.getKeys("app.session.tree")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showTreeSelector());
}
for (const key of this.ctx.keybindings.getKeys("fork")) {
for (const key of this.ctx.keybindings.getKeys("app.session.fork")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showUserMessageSelector());
}
for (const key of this.ctx.keybindings.getKeys("resume")) {
for (const key of this.ctx.keybindings.getKeys("app.session.resume")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionSelector());
}
for (const key of this.ctx.keybindings.getKeys("followUp")) {
for (const key of this.ctx.keybindings.getKeys("app.message.followUp")) {
this.ctx.editor.setCustomKeyHandler(key, () => void this.handleFollowUp());
}
for (const key of this.ctx.keybindings.getKeys("toggleSTT")) {
for (const key of this.ctx.keybindings.getKeys("app.stt.toggle")) {
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
}
for (const key of this.ctx.keybindings.getKeys("copyLine")) {
for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
}
@@ -12,7 +12,12 @@ import chalk from "chalk";
import { KeybindingsManager } from "../config/keybindings";
import { renderPromptTemplate } from "../config/prompt-templates";
import { type Settings, settings } from "../config/settings";
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../extensibility/extensions";
import type {
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionWidgetContent,
ExtensionWidgetOptions,
} from "../extensibility/extensions";
import type { CompactOptions } from "../extensibility/extensions/types";
import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands";
import { resolveLocalUrlToPath } from "../internal-urls";
@@ -93,6 +98,8 @@ export class InteractiveMode implements InteractiveModeContext {
btwContainer: Container;
editor: CustomEditor;
editorContainer: Container;
hookWidgetContainerAbove: Container;
hookWidgetContainerBelow: Container;
statusLine: StatusLineComponent;
isInitialized = false;
@@ -216,6 +223,9 @@ export class InteractiveMode implements InteractiveModeContext {
} catch (error) {
logger.warn("History storage unavailable", { error: String(error) });
}
this.hookWidgetContainerAbove = new Container();
this.hookWidgetContainerAbove.addChild(new Spacer(1));
this.hookWidgetContainerBelow = new Container();
this.editorContainer = new Container();
this.editorContainer.addChild(this.editor);
this.statusLine = new StatusLineComponent(session);
@@ -263,7 +273,7 @@ export class InteractiveMode implements InteractiveModeContext {
async init(): Promise<void> {
if (this.isInitialized) return;
this.keybindings = await logger.timeAsync("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
this.keybindings = logger.time("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
// Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP)
this.#cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush());
@@ -329,8 +339,9 @@ export class InteractiveMode implements InteractiveModeContext {
this.ui.addChild(this.todoContainer);
this.ui.addChild(this.btwContainer);
this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border)
this.ui.addChild(new Spacer(1));
this.ui.addChild(this.hookWidgetContainerAbove);
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.hookWidgetContainerBelow);
this.ui.setFocus(this.editor);
this.#inputController.setupKeyHandlers();
@@ -837,6 +848,7 @@ export class InteractiveMode implements InteractiveModeContext {
this.#sttController = undefined;
}
this.#extensionUiController.clearExtensionTerminalInputListeners();
this.#extensionUiController.clearHookWidgets();
this.statusLine.dispose();
if (this.#resizeHandler) {
process.stdout.removeListener("resize", this.#resizeHandler);
@@ -1359,8 +1371,8 @@ export class InteractiveMode implements InteractiveModeContext {
return this.#extensionUiController.emitCustomToolSessionEvent(reason, previousSessionFile);
}
setHookWidget(key: string, content: unknown): void {
this.#extensionUiController.setHookWidget(key, content);
setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void {
this.#extensionUiController.setHookWidget(key, content, options);
}
setHookStatus(key: string, text: string | undefined): void {
@@ -146,7 +146,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
});
// Send initial message with attachments
if (initialMessage) {
if (initialMessage !== undefined) {
await session.prompt(initialMessage, { images: initialImages });
}
@@ -2,7 +2,7 @@ import {
type AutocompleteItem,
type AutocompleteProvider,
CombinedAutocompleteProvider,
getEditorKeybindings,
getKeybindings,
type SlashCommand,
} from "@oh-my-pi/pi-tui";
import { formatKeyHints, type KeybindingsManager } from "../config/keybindings";
@@ -174,26 +174,26 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
export function createPromptActionAutocompleteProvider(
options: PromptActionAutocompleteOptions,
): PromptActionAutocompleteProvider {
const editorKeybindings = getEditorKeybindings();
const editorKeybindings = getKeybindings();
const actions: PromptActionDefinition[] = [
{
id: "copy-line",
label: "Copy current line",
description: formatKeyHints(options.keybindings.getKeys("copyLine")),
description: formatKeyHints(options.keybindings.getKeys("app.clipboard.copyLine")),
keywords: ["copy", "line", "clipboard", "current"],
execute: options.copyCurrentLine,
},
{
id: "copy-prompt",
label: "Copy whole prompt",
description: formatKeyHints(options.keybindings.getKeys("copyPrompt")),
description: formatKeyHints(options.keybindings.getKeys("app.clipboard.copyPrompt")),
keywords: ["copy", "prompt", "clipboard", "message"],
execute: options.copyPrompt,
},
{
id: "undo",
label: "Undo",
description: formatKeyHints(editorKeybindings.getKeys("undo")),
description: formatKeyHints(editorKeybindings.getKeys("tui.editor.undo")),
keywords: ["undo", "revert", "edit", "history"],
execute: options.undo,
},
@@ -214,14 +214,14 @@ export function createPromptActionAutocompleteProvider(
{
id: "cursor-line-start",
label: "Move cursor to beginning of line",
description: formatKeyHints(editorKeybindings.getKeys("cursorLineStart")),
description: formatKeyHints(editorKeybindings.getKeys("tui.editor.cursorLineStart")),
keywords: ["move", "cursor", "line", "start", "beginning", "home"],
execute: options.moveCursorToLineStart,
},
{
id: "cursor-line-end",
label: "Move cursor to end of line",
description: formatKeyHints(editorKeybindings.getKeys("cursorLineEnd")),
description: formatKeyHints(editorKeybindings.getKeys("tui.editor.cursorLineEnd")),
keywords: ["move", "cursor", "line", "end"],
execute: options.moveCursorToLineEnd,
},
@@ -11,7 +11,11 @@
* - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
*/
import { readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../extensibility/extensions";
import type {
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionWidgetOptions,
} from "../../extensibility/extensions";
import { type Theme, theme } from "../../modes/theme/theme";
import type { AgentSession } from "../../session/agent-session";
import type {
@@ -263,7 +267,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
// Not supported in RPC mode
}
setWidget(key: string, content: unknown): void {
setWidget(key: string, content: unknown, options?: ExtensionWidgetOptions): void {
// Only support string arrays in RPC mode - factory functions are ignored
if (content === undefined || Array.isArray(content)) {
this.output({
@@ -272,6 +276,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
method: "setWidget",
widgetKey: key,
widgetLines: content as string[] | undefined,
widgetPlacement: options?.placement,
} as RpcExtensionUIRequest);
}
// Component factories are not supported in RPC mode - would need TUI access
@@ -223,6 +223,7 @@ export type RpcExtensionUIRequest =
method: "setWidget";
widgetKey: string;
widgetLines: string[] | undefined;
widgetPlacement?: "aboveEditor" | "belowEditor";
}
| { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
| { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };
+54 -45
View File
@@ -1679,6 +1679,7 @@ export function getCurrentThemeName(): string | undefined {
var currentSymbolPresetOverride: SymbolPreset | undefined;
var currentColorBlindMode: boolean = false;
var themeWatcher: fs.FSWatcher | undefined;
var themeReloadTimer: NodeJS.Timeout | undefined;
var sigwinchHandler: (() => void) | undefined;
var autoDetectedTheme: boolean = false;
var autoDarkTheme: string = "dark";
@@ -1888,11 +1889,7 @@ export function isValidSymbolPreset(preset: string): preset is SymbolPreset {
}
async function startThemeWatcher(): Promise<void> {
// Stop existing watcher if any
if (themeWatcher) {
themeWatcher.close();
themeWatcher = undefined;
}
stopThemeWatcher();
// Only watch if it's a custom theme (not built-in)
if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") {
@@ -1900,54 +1897,62 @@ async function startThemeWatcher(): Promise<void> {
}
const customThemesDir = getCustomThemesDir();
const themeFile = path.join(customThemesDir, `${currentThemeName}.json`);
const watchedThemeName = currentThemeName;
const watchedFileName = `${watchedThemeName}.json`;
const themeFile = path.join(customThemesDir, watchedFileName);
// Only watch if the file exists
if (!fs.existsSync(themeFile)) {
return;
}
try {
themeWatcher = fs.watch(themeFile, eventType => {
if (eventType === "change") {
// Debounce rapid changes
setTimeout(() => {
loadTheme(currentThemeName!, getCurrentThemeOptions())
.then(loadedTheme => {
theme = loadedTheme;
if (onThemeChangeCallback) {
onThemeChangeCallback();
}
})
.catch(err => {
logger.debug("Theme reload error during file change", { error: String(err) });
});
}, 100);
} else if (eventType === "rename") {
// File was deleted or renamed - fall back to default theme
setTimeout(() => {
if (!fs.existsSync(themeFile)) {
currentThemeName = "dark";
loadTheme("dark", getCurrentThemeOptions())
.then(loadedTheme => {
theme = loadedTheme;
if (onThemeChangeCallback) {
onThemeChangeCallback();
}
})
.catch(err => {
logger.debug("Theme reload error during rename fallback", { error: String(err) });
});
if (themeWatcher) {
themeWatcher.close();
themeWatcher = undefined;
}
}
}, 100);
const scheduleReload = () => {
if (themeReloadTimer) {
clearTimeout(themeReloadTimer);
}
themeReloadTimer = setTimeout(() => {
themeReloadTimer = undefined;
// Ignore stale timers after switching themes or stopping the watcher
if (currentThemeName !== watchedThemeName) {
return;
}
// Keep the last successfully loaded theme active if the file is temporarily missing
if (!fs.existsSync(themeFile)) {
return;
}
loadTheme(watchedThemeName, getCurrentThemeOptions())
.then(loadedTheme => {
theme = loadedTheme;
if (onThemeChangeCallback) {
onThemeChangeCallback();
}
})
.catch(() => {
// Ignore errors (file might be in invalid state while being edited)
});
}, 100);
};
try {
themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => {
if (currentThemeName !== watchedThemeName) {
return;
}
if (!filename) {
scheduleReload();
return;
}
const changedFile = String(filename);
if (changedFile !== watchedFileName) {
return;
}
scheduleReload();
});
} catch (err) {
logger.debug("Failed to start theme watcher", { error: String(err) });
} catch {
// Ignore errors starting watcher
}
}
@@ -2023,6 +2028,10 @@ function stopSigwinchListener(): void {
}
export function stopThemeWatcher(): void {
if (themeReloadTimer) {
clearTimeout(themeReloadTimer);
themeReloadTimer = undefined;
}
if (themeWatcher) {
themeWatcher.close();
themeWatcher = undefined;
+9 -2
View File
@@ -3,7 +3,12 @@ import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-m
import type { Component, Container, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
import type { KeybindingsManager } from "../config/keybindings";
import type { Settings } from "../config/settings";
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../extensibility/extensions";
import type {
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionWidgetContent,
ExtensionWidgetOptions,
} from "../extensibility/extensions";
import type { CompactOptions } from "../extensibility/extensions/types";
import type { MCPManager } from "../mcp";
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
@@ -59,6 +64,8 @@ export interface InteractiveModeContext {
btwContainer: Container;
editor: CustomEditor;
editorContainer: Container;
hookWidgetContainerAbove: Container;
hookWidgetContainerBelow: Container;
statusLine: StatusLineComponent;
// Session access
@@ -226,7 +233,7 @@ export interface InteractiveModeContext {
reason: "start" | "switch" | "branch" | "tree" | "shutdown",
previousSessionFile?: string,
): Promise<void>;
setHookWidget(key: string, content: unknown): void;
setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void;
setHookStatus(key: string, text: string | undefined): void;
showHookSelector(
title: string,
@@ -1,10 +1,10 @@
import type { AppAction, KeybindingsManager } from "../../config/keybindings";
import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
export interface HotkeysMarkdownBindings {
keybindings: Pick<KeybindingsManager, "getDisplayString">;
}
function appKey(bindings: HotkeysMarkdownBindings, action: AppAction): string {
function appKey(bindings: HotkeysMarkdownBindings, action: AppKeybinding): string {
return bindings.keybindings.getDisplayString(action) || "Disabled";
}
@@ -26,29 +26,29 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
"| `Ctrl+W` / `Option+Backspace` | Delete word backwards |",
"| `Ctrl+U` | Delete to start of line |",
"| `Ctrl+K` | Delete to end of line |",
`| \`${appKey(bindings, "copyLine")}\` | Copy current line |`,
`| \`${appKey(bindings, "copyPrompt")}\` | Copy whole prompt |`,
`| \`${appKey(bindings, "app.clipboard.copyLine")}\` | Copy current line |`,
`| \`${appKey(bindings, "app.clipboard.copyPrompt")}\` | Copy whole prompt |`,
"",
"**Other**",
"| Key | Action |",
"|-----|--------|",
"| `Tab` | Path completion / accept autocomplete |",
`| \`${appKey(bindings, "interrupt")}\` | Cancel autocomplete / interrupt active work |`,
`| \`${appKey(bindings, "clear")}\` | Clear editor (first) / exit (second) |`,
`| \`${appKey(bindings, "exit")}\` | Exit (when editor is empty) |`,
`| \`${appKey(bindings, "suspend")}\` | Suspend to background |`,
`| \`${appKey(bindings, "cycleThinkingLevel")}\` | Cycle thinking level |`,
`| \`${appKey(bindings, "cycleModelForward")}\` | Cycle role models (slow/default/smol) |`,
`| \`${appKey(bindings, "cycleModelBackward")}\` | Cycle role models (temporary) |`,
`| \`${appKey(bindings, "app.interrupt")}\` | Cancel autocomplete / interrupt active work |`,
`| \`${appKey(bindings, "app.clear")}\` | Clear editor (first) / exit (second) |`,
`| \`${appKey(bindings, "app.exit")}\` | Exit (when editor is empty) |`,
`| \`${appKey(bindings, "app.suspend")}\` | Suspend to background |`,
`| \`${appKey(bindings, "app.thinking.cycle")}\` | Cycle thinking level |`,
`| \`${appKey(bindings, "app.model.cycleForward")}\` | Cycle role models (slow/default/smol) |`,
`| \`${appKey(bindings, "app.model.cycleBackward")}\` | Cycle role models (temporary) |`,
"| `Alt+P` | Select model (temporary) |",
`| \`${appKey(bindings, "selectModel")}\` | Select model (set roles) |`,
`| \`${appKey(bindings, "togglePlanMode")}\` | Toggle plan mode |`,
`| \`${appKey(bindings, "historySearch")}\` | Search prompt history |`,
`| \`${appKey(bindings, "expandTools")}\` | Toggle tool output expansion |`,
`| \`${appKey(bindings, "toggleThinking")}\` | Toggle thinking block visibility |`,
`| \`${appKey(bindings, "externalEditor")}\` | Edit message in external editor |`,
`| \`${appKey(bindings, "pasteImage")}\` | Paste image from clipboard |`,
`| \`${appKey(bindings, "toggleSTT")}\` | Toggle speech-to-text recording |`,
`| \`${appKey(bindings, "app.model.select")}\` | Select model (set roles) |`,
`| \`${appKey(bindings, "app.plan.toggle")}\` | Toggle plan mode |`,
`| \`${appKey(bindings, "app.history.search")}\` | Search prompt history |`,
`| \`${appKey(bindings, "app.tools.expand")}\` | Toggle tool output expansion |`,
`| \`${appKey(bindings, "app.thinking.toggle")}\` | Toggle thinking block visibility |`,
`| \`${appKey(bindings, "app.editor.external")}\` | Edit message in external editor |`,
`| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image from clipboard |`,
`| \`${appKey(bindings, "app.stt.toggle")}\` | Toggle speech-to-text recording |`,
"| `#` | Open prompt actions |",
"| `/` | Slash commands |",
"| `!` | Run bash command |",
@@ -0,0 +1,21 @@
import { getKeybindings, matchesKey } from "@oh-my-pi/pi-tui";
/**
* Match the coding-agent interrupt key.
*
* Interactive mode installs a keybinding manager that exposes `app.interrupt`
* globally, but some isolated component tests still run with only TUI
* keybindings registered. In that case, fall back to raw Escape matching.
*/
export function matchesAppInterrupt(data: string): boolean {
const keybindings = getKeybindings();
const interruptKeys = keybindings.getKeys("app.interrupt");
if (interruptKeys.length > 0) {
return keybindings.matches(data, "app.interrupt");
}
return matchesKey(data, "escape") || matchesKey(data, "esc");
}
export function matchesSelectCancel(data: string): boolean {
return getKeybindings().matches(data, "tui.select.cancel");
}
@@ -474,7 +474,7 @@ export class UiHelpers {
const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
}
const dequeueKey = this.ctx.keybindings.getDisplayString("dequeue") || "Alt+Up";
const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up";
const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`);
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
}
+139 -127
View File
@@ -16,9 +16,12 @@ import type { HashMismatch } from "./types";
export type Anchor = { line: number; hash: string };
export type HashlineEdit =
| { op: "replace"; pos: Anchor; end?: Anchor; lines: string[] }
| { op: "append"; pos?: Anchor; lines: string[] }
| { op: "prepend"; pos?: Anchor; lines: string[] };
| { op: "replace_line"; pos: Anchor; lines: string[] }
| { op: "replace_range"; pos: Anchor; end: Anchor; lines: string[] }
| { op: "append_at"; pos: Anchor; lines: string[] }
| { op: "prepend_at"; pos: Anchor; lines: string[] }
| { op: "append_file"; lines: string[] }
| { op: "prepend_file"; lines: string[] };
const NIBBLE_STR = "ZPMQVRWSNKTXJBYH";
@@ -455,18 +458,6 @@ function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warn
// Edit Application
// ═══════════════════════════════════════════════════════════════════════════
const MIN_AUTOCORRECT_LENGTH = 2;
function shouldAutocorrect(line: string, otherLine: string): boolean {
if (!line || line !== otherLine) return false;
line = line.trim();
if (line.length < MIN_AUTOCORRECT_LENGTH) {
// if brace, we allow
return line.endsWith("}") || line.endsWith(")");
}
return true;
}
/**
* Apply an array of hashline edits to file content.
*
@@ -513,28 +504,29 @@ export function applyHashlineEdits(
}
for (const edit of edits) {
switch (edit.op) {
case "replace": {
if (edit.end) {
const startValid = validateRef(edit.pos);
const endValid = validateRef(edit.end);
if (!startValid || !endValid) continue;
if (edit.pos.line > edit.end.line) {
throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
}
} else {
if (!validateRef(edit.pos)) continue;
case "replace_line": {
if (!validateRef(edit.pos)) continue;
break;
}
case "replace_range": {
const startValid = validateRef(edit.pos);
const endValid = validateRef(edit.end);
if (!startValid || !endValid) continue;
if (edit.pos.line > edit.end.line) {
throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
}
break;
}
case "append": {
if (edit.pos && !validateRef(edit.pos)) continue;
case "append_at":
case "prepend_at": {
if (!validateRef(edit.pos)) continue;
if (edit.lines.length === 0) {
edit.lines = [""]; // insert an empty line
}
break;
}
case "prepend": {
if (edit.pos && !validateRef(edit.pos)) continue;
case "append_file":
case "prepend_file": {
if (edit.lines.length === 0) {
edit.lines = [""]; // insert an empty line
}
@@ -547,6 +539,38 @@ export function applyHashlineEdits(
}
maybeAutocorrectEscapedTabIndentation(edits, warnings);
maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
// Warn when a replace_range/replace_line's last inserted line duplicates the next surviving line.
// This catches the common boundary-overreach pattern where the agent includes a closing delimiter
// in the replacement but sets `end` to the line before the delimiter, causing duplication.
for (const edit of edits) {
let endLine: number;
switch (edit.op) {
case "replace_line":
endLine = edit.pos.line;
break;
case "replace_range":
endLine = edit.end.line;
break;
default:
continue;
}
if (edit.lines.length === 0) continue;
const nextSurvivingIdx = endLine; // 0-indexed: endLine (1-indexed) is the next line after `end`
if (nextSurvivingIdx >= originalFileLines.length) continue;
const nextSurvivingLine = originalFileLines[nextSurvivingIdx];
const lastInsertedLine = edit.lines[edit.lines.length - 1];
const trimmedNext = nextSurvivingLine.trim();
const trimmedLast = lastInsertedLine.trim();
// Only warn for non-trivial lines to avoid false positives on blank lines or bare punctuation
if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
const tag = formatLineTag(endLine + 1, nextSurvivingLine);
warnings.push(
`Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
`If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
);
}
}
// Deduplicate identical edits targeting the same line(s)
const seenEditKeys = new Map<string, number>();
const dedupIndices = new Set<number>();
@@ -554,25 +578,22 @@ export function applyHashlineEdits(
const edit = edits[i];
let lineKey: string;
switch (edit.op) {
case "replace":
if (!edit.end) {
lineKey = `s:${edit.pos.line}`;
} else {
lineKey = `r:${edit.pos.line}:${edit.end.line}`;
}
case "replace_line":
lineKey = `s:${edit.pos.line}`;
break;
case "append":
if (edit.pos) {
lineKey = `i:${edit.pos.line}`;
break;
}
case "replace_range":
lineKey = `r:${edit.pos.line}:${edit.end.line}`;
break;
case "append_at":
lineKey = `i:${edit.pos.line}`;
break;
case "prepend_at":
lineKey = `ib:${edit.pos.line}`;
break;
case "append_file":
lineKey = "ieof";
break;
case "prepend":
if (edit.pos) {
lineKey = `ib:${edit.pos.line}`;
break;
}
case "prepend_file":
lineKey = "ibef";
break;
}
@@ -594,20 +615,28 @@ export function applyHashlineEdits(
let sortLine: number;
let precedence: number;
switch (edit.op) {
case "replace":
if (!edit.end) {
sortLine = edit.pos.line;
} else {
sortLine = edit.end.line;
}
case "replace_line":
sortLine = edit.pos.line;
precedence = 0;
break;
case "append":
sortLine = edit.pos ? edit.pos.line : fileLines.length + 1;
case "replace_range":
sortLine = edit.end.line;
precedence = 0;
break;
case "append_at":
sortLine = edit.pos.line;
precedence = 1;
break;
case "prepend":
sortLine = edit.pos ? edit.pos.line : 0;
case "prepend_at":
sortLine = edit.pos.line;
precedence = 2;
break;
case "append_file":
sortLine = fileLines.length + 1;
precedence = 1;
break;
case "prepend_file":
sortLine = 0;
precedence = 2;
break;
}
@@ -619,101 +648,84 @@ export function applyHashlineEdits(
// Apply edits bottom-up
for (const { edit, idx } of annotated) {
switch (edit.op) {
case "replace": {
if (!edit.end) {
const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
const newLines = edit.lines;
if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
noopEdits.push({
editIndex: idx,
loc: `${edit.pos.line}#${edit.pos.hash}`,
current: origLines.join("\n"),
});
break;
}
fileLines.splice(edit.pos.line - 1, 1, ...newLines);
trackFirstChanged(edit.pos.line);
} else {
const count = edit.end.line - edit.pos.line + 1;
const newLines = [...edit.lines];
const trailingReplacementLine = newLines[newLines.length - 1]?.trimEnd();
const nextSurvivingLine = fileLines[edit.end.line]?.trimEnd();
if (
shouldAutocorrect(trailingReplacementLine, nextSurvivingLine) &&
// Safety: only correct when end-line content differs from the duplicate.
// If end already points to the boundary, matching next line is coincidence.
fileLines[edit.end.line - 1]?.trimEnd() !== trailingReplacementLine
) {
newLines.pop();
warnings.push(
`Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed trailing replacement line "${trailingReplacementLine}" that duplicated next surviving line`,
);
}
const leadingReplacementLine = newLines[0]?.trimEnd();
const prevSurvivingLine = fileLines[edit.pos.line - 2]?.trimEnd();
if (
shouldAutocorrect(leadingReplacementLine, prevSurvivingLine) &&
// Safety: only correct when pos-line content differs from the duplicate.
// If pos already points to the boundary, matching prev line is coincidence.
fileLines[edit.pos.line - 1]?.trimEnd() !== leadingReplacementLine
) {
newLines.shift();
warnings.push(
`Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed leading replacement line "${leadingReplacementLine}" that duplicated preceding surviving line`,
);
}
fileLines.splice(edit.pos.line - 1, count, ...newLines);
trackFirstChanged(edit.pos.line);
case "replace_line": {
const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
const newLines = edit.lines;
if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
noopEdits.push({
editIndex: idx,
loc: `${edit.pos.line}#${edit.pos.hash}`,
current: origLines.join("\n"),
});
break;
}
fileLines.splice(edit.pos.line - 1, 1, ...newLines);
trackFirstChanged(edit.pos.line);
break;
}
case "append": {
case "replace_range": {
const count = edit.end.line - edit.pos.line + 1;
fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
trackFirstChanged(edit.pos.line);
break;
}
case "append_at": {
const inserted = edit.lines;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
loc: edit.pos ? `${edit.pos.line}#${edit.pos.hash}` : "EOF",
current: edit.pos ? originalFileLines[edit.pos.line - 1] : "",
loc: `${edit.pos.line}#${edit.pos.hash}`,
current: originalFileLines[edit.pos.line - 1],
});
break;
}
if (edit.pos) {
fileLines.splice(edit.pos.line, 0, ...inserted);
trackFirstChanged(edit.pos.line + 1);
} else {
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...inserted);
trackFirstChanged(1);
} else {
fileLines.splice(fileLines.length, 0, ...inserted);
trackFirstChanged(fileLines.length - inserted.length + 1);
}
}
fileLines.splice(edit.pos.line, 0, ...inserted);
trackFirstChanged(edit.pos.line + 1);
break;
}
case "prepend": {
case "prepend_at": {
const inserted = edit.lines;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
loc: edit.pos ? `${edit.pos.line}#${edit.pos.hash}` : "BOF",
current: edit.pos ? originalFileLines[edit.pos.line - 1] : "",
loc: `${edit.pos.line}#${edit.pos.hash}`,
current: originalFileLines[edit.pos.line - 1],
});
break;
}
if (edit.pos) {
fileLines.splice(edit.pos.line - 1, 0, ...inserted);
trackFirstChanged(edit.pos.line);
} else {
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...inserted);
} else {
fileLines.splice(0, 0, ...inserted);
}
fileLines.splice(edit.pos.line - 1, 0, ...inserted);
trackFirstChanged(edit.pos.line);
break;
}
case "append_file": {
const inserted = edit.lines;
if (inserted.length === 0) {
noopEdits.push({ editIndex: idx, loc: "EOF", current: "" });
break;
}
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...inserted);
trackFirstChanged(1);
} else {
fileLines.splice(fileLines.length, 0, ...inserted);
trackFirstChanged(fileLines.length - inserted.length + 1);
}
break;
}
case "prepend_file": {
const inserted = edit.lines;
if (inserted.length === 0) {
noopEdits.push({ editIndex: idx, loc: "BOF", current: "" });
break;
}
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...inserted);
} else {
fileLines.splice(0, 0, ...inserted);
}
trackFirstChanged(1);
break;
}
}
}
+76 -58
View File
@@ -174,16 +174,35 @@ export function hashlineParseText(edit: string[] | string | null): string[] {
return stripNewLinePrefixes(edit);
}
const linesSchema = Type.Union([
Type.Array(Type.String(), { description: "content (preferred format)" }),
Type.String(),
Type.Null(),
]);
const locSchema = Type.Union(
[
Type.Literal("append"),
Type.Literal("prepend"),
Type.Object({ append: Type.String({ description: "anchor" }) }),
Type.Object({ prepend: Type.String({ description: "anchor" }) }),
Type.Object({
line: Type.String({ description: "anchor" }),
}),
Type.Object({
block: Type.Object({
pos: Type.String({ description: "anchor" }),
end: Type.String({ description: "limit position" }),
}),
}),
],
{ description: "insert location" },
);
const hashlineEditSchema = Type.Object(
{
op: StringEnum(["replace", "append", "prepend"]),
pos: Type.Optional(Type.String({ description: "anchor" })),
end: Type.Optional(Type.String({ description: "limit position" })),
lines: Type.Union([
Type.Array(Type.String(), { description: "content (preferred format)" }),
Type.String(),
Type.Null(),
]),
loc: locSchema,
content: linesSchema,
},
{ additionalProperties: false },
);
@@ -206,45 +225,48 @@ export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
// ═══════════════════════════════════════════════════════════════════════════
/**
* Map flat tool-schema edits (tag/end) into typed HashlineEdit objects.
* Map loc/content tool-schema edits into typed HashlineEdit objects.
*
* Resilient: as long as at least one anchor exists, we execute.
* - replace + tag only → single-line replace
* - replace + tag + end → range replace
* - append + tag or end → append after that anchor
* - prepend + tag or end → prepend before that anchor
* - no anchors → file-level append/prepend (only for those ops)
*
* Unknown ops default to "replace".
* Each edit entry has a `loc` (where to edit) and `content` (what to insert/replace).
* loc can be:
* - "append" / "prepend" — file-level insert
* - { append: anchor } / { prepend: anchor } — insert relative to anchor
* - { replace_line: anchor } — replace one line
* - { replace_block: { pos, end } } — replace inclusive range
*/
function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
const result: HashlineEdit[] = [];
for (const edit of edits) {
const lines = hashlineParseText(edit.lines);
const tag = edit.pos ? tryParseTag(edit.pos) : undefined;
const end = edit.end ? tryParseTag(edit.end) : undefined;
const lines = hashlineParseText(edit.content);
const loc = edit.loc;
// Normalize op — default unknown values to "replace"
const op = edit.op === "append" || edit.op === "prepend" ? edit.op : "replace";
switch (op) {
case "replace": {
if (tag && end) {
result.push({ op: "replace", pos: tag, end, lines });
} else if (tag || end) {
result.push({ op: "replace", pos: tag || end!, lines });
} else {
throw new Error("Replace requires at least one anchor (tag or end).");
}
break;
}
case "append": {
result.push({ op: "append", pos: tag ?? end, lines });
break;
}
case "prepend": {
result.push({ op: "prepend", pos: end ?? tag, lines });
break;
if (loc === "append") {
result.push({ op: "append_file", lines });
} else if (loc === "prepend") {
result.push({ op: "prepend_file", lines });
} else if (typeof loc === "object") {
if ("append" in loc) {
const anchor = tryParseTag(loc.append);
if (!anchor) throw new Error("append requires a valid anchor.");
result.push({ op: "append_at", pos: anchor, lines });
} else if ("prepend" in loc) {
const anchor = tryParseTag(loc.prepend);
if (!anchor) throw new Error("prepend requires a valid anchor.");
result.push({ op: "prepend_at", pos: anchor, lines });
} else if ("line" in loc) {
const anchor = tryParseTag(loc.line);
if (!anchor) throw new Error("line requires a valid anchor.");
result.push({ op: "replace_line", pos: anchor, lines });
} else if ("block" in loc) {
const posAnchor = tryParseTag(loc.block.pos);
const endAnchor = tryParseTag(loc.block.end);
if (!posAnchor || !endAnchor) throw new Error("block requires valid pos and end anchors.");
result.push({ op: "replace_range", pos: posAnchor, end: endAnchor, lines });
} else {
throw new Error("Unknown loc shape. Expected append, prepend, line, or block.");
}
} else {
throw new Error(`Invalid loc value: ${JSON.stringify(loc)}`);
}
}
return result;
@@ -552,12 +574,10 @@ export class EditTool implements AgentTool<TInput> {
const lines: string[] = [];
for (const edit of edits) {
// For file creation, only anchorless appends/prepends are valid
if ((edit.op === "append" || edit.op === "prepend") && !edit.pos && !edit.end) {
if (edit.op === "prepend") {
lines.unshift(...hashlineParseText(edit.lines));
} else {
lines.push(...hashlineParseText(edit.lines));
}
if (edit.loc === "append") {
lines.push(...hashlineParseText(edit.content));
} else if (edit.loc === "prepend") {
lines.unshift(...hashlineParseText(edit.content));
} else {
throw new Error(`File not found: ${path}`);
}
@@ -582,7 +602,7 @@ export class EditTool implements AgentTool<TInput> {
const originalNormalized = normalizeToLF(text);
let normalizedText = originalNormalized;
// Apply anchor-based edits first (replace, append, prepend)
// Apply anchor-based edits first (replace, append_at, prepend_at)
const anchorResult = applyHashlineEdits(normalizedText, anchorEdits);
normalizedText = anchorResult.lines;
@@ -612,20 +632,18 @@ export class EditTool implements AgentTool<TInput> {
for (const edit of anchorEdits) {
refs.length = 0;
switch (edit.op) {
case "replace":
if (edit.end) {
refs.push(edit.end, edit.pos);
} else {
refs.push(edit.pos);
}
case "replace_line":
refs.push(edit.pos);
break;
case "append":
if (edit.pos) refs.push(edit.pos);
case "replace_range":
refs.push(edit.end, edit.pos);
break;
case "prepend":
if (edit.pos) refs.push(edit.pos);
case "append_at":
case "prepend_at":
refs.push(edit.pos);
break;
default:
case "append_file":
case "prepend_file":
break;
}
+19 -11
View File
@@ -157,20 +157,28 @@ function formatStreamingHashlineEdits(edits: Partial<HashlineToolEdit>[], uiThem
return { srcLabel: "• (incomplete edit)", dst: "" };
}
const contentLines = Array.isArray(edit.lines) ? (edit.lines as string[]).join("\n") : "";
const contentLines = Array.isArray(edit.content) ? (edit.content as string[]).join("\n") : "";
const loc = edit.loc;
const op = typeof edit.op === "string" ? edit.op : "?";
const pos = typeof edit.pos === "string" ? edit.pos : undefined;
const end = typeof edit.end === "string" ? edit.end : undefined;
if (pos && end && pos !== end) {
return { srcLabel: `• ${op} ${pos}…${end}`, dst: contentLines };
if (loc === "append" || loc === "prepend") {
return { srcLabel: `• ${loc} (file-level)`, dst: contentLines };
}
const anchor = pos ?? end;
if (anchor) {
return { srcLabel: `\u2022 ${op} ${anchor}`, dst: contentLines };
if (typeof loc === "object" && loc) {
if ("block" in loc && typeof loc.block === "object" && loc.block) {
const rb = loc.block as { pos?: string; end?: string };
return { srcLabel: `• block ${rb.pos ?? "?"}…${rb.end ?? "?"}`, dst: contentLines };
}
if ("line" in loc) {
return { srcLabel: `• line ${(loc as { line: string }).line}`, dst: contentLines };
}
if ("append" in loc) {
return { srcLabel: `• append ${(loc as { append: string }).append}`, dst: contentLines };
}
if ("prepend" in loc) {
return { srcLabel: `• prepend ${(loc as { prepend: string }).prepend}`, dst: contentLines };
}
}
return { srcLabel: `\u2022 ${op} (file-level)`, dst: contentLines };
return { srcLabel: "• (unknown edit)", dst: contentLines };
}
}
function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
@@ -1,25 +1,25 @@
Applies precise, surgical file edits by referencing `LINE#ID` tags from `read` output. Each tag uniquely identifies a line, so edits remain stable even when lines shift.
Applies precise file edits using `LINE#ID` anchors from `read` output.
Read the file first to get fresh tags. Submit one `edit` call per file with all operations batched — tags shift after each edit, so multiple calls require re-reading between them.
Read the file first. Copy anchors exactly from the latest `read` output. In one `edit` call, batch all edits for one file. After any successful edit, re-read before editing that file again.
This matters: your output is checked against the real file state. Invalid anchors, duplicated boundary lines, or semantically equivalent rewrites will fail.
<operations>
**`path`** — the path to the file to edit.
**`move`** — if set, move the file to the given path.
**`delete`** — if true, delete the file.
**Top level**
- `path` — file path
- `move` — optional rename target
- `delete` — optional whole-file delete
- `edits` — array of `{ loc, content }` entries
**`edits[n].pos`** — the anchor line. Meaning depends on `op`:
- if `replace`: first line to rewrite
- if `prepend`: line to insert new lines **before**; omit for beginning of file
- if `append`: line to insert new lines **after**; omit for end of file
**`edits[n].end`** — range replace only. The last line of the range (inclusive). Omit for single-line replace.
**`edits[n].lines`** — the replacement content:
- for `replace`: the exact lines that will replace `[pos, end??pos]` inclusively (or the single `pos` line when `end` is omitted)
- for `prepend`/`append`: the new lines to insert
- `[""]` — blank line
- `null` or `[]` — delete if replace
- If `lines` contains content that already exists after `end`, those lines **will be duplicated** in the output.
- Keep `lines` to exactly what belongs inside the consumed range.
- Ops are applied bottom-up. Tags **MUST** be referenced from the most recent `read` output.
**Edit entry**: `{ loc, content }`
- `loc` — where to apply the edit (see below)
- `content` — replacement/inserted lines (array of strings preferred, `null` to delete)
**`loc` values**
- `"append"` / `"prepend"` — insert at end/start of file
- `{ append: "N#ID" }` / `{ prepend: "N#ID" }` — insert after/before anchored line
- `{ line: "N#ID" }` — replace exactly one anchored line
- `{ block: { pos: "N#ID", end: "N#ID" } }` — replace inclusive `pos..end`
</operations>
<examples>
@@ -45,58 +45,14 @@ All examples below reference the same file, `util.ts`:
{{hlinefull 18 "}"}}
```
<example name="single-line replace">
Change the timeout from `5000` to `30_000`:
<example name="replace a block body">
Replace only the catch body. Do not target the shared boundary line `} catch (err) {`.
```
{
path: "util.ts",
edits: [{
op: "replace",
pos: {{hlineref 2 "const timeout = 5000;"}},
lines: ["const timeout = 30_000;"]
}]
}
```
</example>
<example name="delete lines">
Single line — `lines: null` deletes entirely:
```
{
path: "util.ts",
edits: [{
op: "replace",
pos: {{hlineref 1 "// @ts-ignore"}},
lines: null
}]
}
```
Range — remove the legacy block (lines 10–11):
```
{
path: "util.ts",
edits: [{
op: "replace",
pos: {{hlineref 10 "\t// TODO: remove after migration"}},
end: {{hlineref 11 "\tlegacy();"}},
lines: null
}]
}
```
</example>
<example name="rewrite a block body — shape (a)">
Replace the catch body with smarter error handling. Shape (a): `pos` is the first body line, `end` is the last body line. The catch header (line 14) and its closer (line 17) are outside the range and stay untouched.
When changing body content, replace the **entire** body span — not just one line inside it. Patching one line leaves the rest of the body stale.
```
{
path: "util.ts",
edits: [{
op: "replace",
pos: {{hlineref 15 "\t\tconsole.error(err);"}},
end: {{hlineref 16 "\t\treturn null;"}},
lines: [
loc: { block: { pos: {{hlineref 15 "\t\tconsole.error(err);"}}, end: {{hlineref 16 "\t\treturn null;"}} } },
content: [
"\t\tif (isEnoent(err)) return null;",
"\t\tthrow err;"
]
@@ -105,62 +61,38 @@ When changing body content, replace the **entire** body span — not just one li
```
</example>
<example name="replace whole block — shape (b)">
Simplify `beta()` to a one-liner. Shape (b): `pos`=header, `end`=closer, re-emit all in `lines`.
Bad — `end` stops at the inner `\t}` on line 17, so the outer `}` on line 18 survives. Result: two consecutive `}` lines.
<example name="replace one line">
```
{
path: "util.ts",
edits: [{
op: "replace",
pos: {{hlineref 9 "function beta() {"}},
end: {{hlineref 17 "\t}"}},
lines: [
"function beta() {",
"\treturn parse(data);",
"}"
]
}]
}
```
Good — `end` includes the function's own `}` on line 18, so the old closer is consumed:
```
{
path: "util.ts",
edits: [{
op: "replace",
pos: {{hlineref 9 "function beta() {"}},
end: {{hlineref 18 "}"}},
lines: [
"function beta() {",
"\treturn parse(data);",
"}"
]
loc: { line: {{hlineref 2 "const timeout = 5000;"}} },
content: ["const timeout = 30_000;"]
}]
}
```
</example>
<example name="avoid shared boundary lines">
Do not anchor `replace` on a mixed boundary line such as `} catch (err) {`, `} else {`, `}),`, or `},{`. Those lines belong to two adjacent structures at once.
Bad — if you need to change code on both sides of that line, replacing just the boundary span will usually leave one side's syntax behind.
Good — choose one of two safe shapes instead:
- move inward and replace only body-owned lines
- expand outward and replace one whole owned block, consuming its real closer/separator too
</example>
<example name="insert between sibling declarations">
Add a `gamma()` function between `alpha()` and `beta()`. Use `prepend` on the next declaration — not `append` on the previous block's closing brace — so the anchor is a stable declaration boundary.
<example name="delete a range">
```
{
path: "util.ts",
edits: [{
op: "prepend",
pos: {{hlineref 9 "function beta() {"}},
lines: [
loc: { block: { pos: {{hlineref 10 "\t// TODO: remove after migration"}}, end: {{hlineref 11 "\tlegacy();"}} } },
content: null
}]
}
```
</example>
<example name="insert before sibling">
When adding a sibling declaration, prefer `prepend` on the next declaration.
```
{
path: "util.ts",
edits: [{
loc: { prepend: {{hlineref 9 "function beta() {"}} },
content: [
"function gamma() {",
"\tvalidate();",
"}",
@@ -169,22 +101,17 @@ Add a `gamma()` function between `alpha()` and `beta()`. Use `prepend` on the ne
}]
}
```
Use a trailing `""` to preserve the blank line between sibling declarations.
</example>
</examples>
<critical>
- You **MUST NOT** use this tool to reformat, reindent, or adjust whitespace — run the project's formatter instead.
- Every tag **MUST** be copied exactly from your most recent `read` output as `N#ID`. Stale or mistyped tags cause mismatches.
- Edit payload: `{ path, edits[] }`. Each entry: `op`, `lines`, optional `pos`/`end`. No extra keys.
- For `append`/`prepend`, `lines` **MUST** contain only the newly introduced content. Do not re-emit surrounding content, or terminators that already exist.
- When changing existing code near a block tail or closing delimiter, default to `replace` over the owned span instead of inserting around the boundary.
- When adding a sibling declaration, default to `prepend` on the next sibling declaration instead of `append` on the previous block's closing brace.
- **Block boundaries travel together.** For a block `{ header / body / closer }`, there are exactly two valid replace shapes: (a) replace only the body — `pos`=first body line, `end`=last body line, leave the header and closer untouched; or (b) replace the whole block — `pos`=header, `end`=closer, re-emit all three in `lines`. Never split them: do not set `end` to the closer while omitting it from `lines` (deletes it), and do not emit the closer in `lines` without including it in `end` (duplicates it). This applies to every block terminator: `}`, `continue`, `break`, `return`, `throw`.
- **Never target shared boundary lines.** Do not use `replace` spans that start, end, or pivot on a line that closes one construct and opens/separates another, such as `},{`, `}),`, `} else {`, or `} catch (err) {`. Those lines are not owned by a single block. Move the range inward to body-only lines, or widen it to consume one whole owned construct including its true trailing delimiter.
- **`lines` must not extend past `end`.** `lines` replaces exactly `pos..end`. Content after `end` survives. If you include lines in `lines` that exist after `end`, they will appear twice. Either extend `end` to cover all lines you are re-emitting, or remove the extra lines from `lines`.
- `lines` entries **MUST** be literal file content with indentation copied exactly from the `read` output. If the file uses tabs, use a real tab character.
- After any successful `edit` call on a file, the next change to that same file **MUST** start with a fresh `read`. Do not chain a second `edit` call off stale mental state, even if the intended range is nearby.
- If you need a second change in the same local region, default to one wider `replace` over the whole owned block instead of a sequence of micro-edits on adjacent lines. Repeated small patches in a moving region are unstable.
- If a local region is already malformed or a prior patch partially landed, stop nibbling at it. Re-read the file and replace the full owned block from a stable boundary; for a small file, prefer rewriting the file over stacking more tiny repairs.
- Make the minimum exact edit. Do not rewrite nearby code unless the consumed range requires it.
- Use anchors exactly as `N#ID` from the latest `read` output.
- `block` requires both `pos` and `end`. Other anchored ops require one anchor.
- Replace exactly the owned span. If `content` re-emits content beyond `end`, it will duplicate.
- **Boundary duplication trap**: when replacing a block, `end` must be the **last line of the block** (e.g. the closing `}`), not the last *content* line before it. Otherwise the closing delimiter survives and your replacement adds a second copy.
- Do not target shared boundary lines such as `} else {`, `} catch (…) {`, `}),`, or `},{`.
- For a block, either replace only the body or replace the whole block. Do not split block boundaries.
- `content` must be literal file content with matching indentation. If the file uses tabs, use real tabs.
- Do not use this tool to reformat or clean up unrelated code.
</critical>
+34 -17
View File
@@ -13,6 +13,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
import { $env, getAgentDbPath, getAgentDir, getProjectDir, logger, postmortem } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { AsyncJobManager } from "./async";
import { createAutoresearchExtension } from "./autoresearch";
import { loadCapability } from "./capability";
import { type Rule, ruleCapability } from "./capability/rule";
import { ModelRegistry } from "./config/model-registry";
@@ -143,6 +144,9 @@ export interface CreateAgentSessionOptions {
/** System prompt. String replaces default, function receives default and returns final. */
systemPrompt?: string | ((defaultPrompt: string) => string);
/** Optional provider-facing session identifier for prompt caches and sticky auth selection.
* Keeps persisted session files isolated while reusing provider-side caches. */
providerSessionId?: string;
/** Custom tools to register (in addition to built-in tools). Accepts both CustomTool and ToolDefinition. */
customTools?: (CustomTool | ToolDefinition)[];
@@ -666,7 +670,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
logger.time("sessionManager", () =>
SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd, agentDir)),
);
const sessionId = sessionManager.getSessionId();
const providerSessionId = options.providerSessionId ?? sessionManager.getSessionId();
const modelApiKeyAvailability = new Map<string, boolean>();
const getModelAvailabilityKey = (candidate: Model): string =>
`${candidate.provider}\u0000${candidate.baseUrl ?? ""}`;
@@ -677,15 +681,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return cached;
}
const hasKey = !!(await modelRegistry.getApiKey(candidate, sessionId));
const hasKey = !!(await modelRegistry.getApiKey(candidate, providerSessionId));
modelApiKeyAvailability.set(availabilityKey, hasKey);
return hasKey;
};
// Check if session has existing data to restore
const existingSession = logger.time("loadSession", () => sessionManager.buildSessionContext());
const hasExistingSession = existingSession.messages.length > 0;
const hasThinkingEntry = sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
const existingBranch = sessionManager.getBranch();
const hasExistingSession = existingBranch.length > 0;
const hasThinkingEntry = existingBranch.some(entry => entry.type === "thinking_level_change");
const hasServiceTierEntry = existingBranch.some(entry => entry.type === "service_tier_change");
const hasExplicitModel = options.model !== undefined || options.modelPattern !== undefined;
const modelMatchPreferences = {
@@ -1010,6 +1016,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}
const inlineExtensions: ExtensionFactory[] = options.extensions ? [...options.extensions] : [];
inlineExtensions.push(createAutoresearchExtension);
if (customTools.length > 0) {
inlineExtensions.push(createCustomToolsExtension(customTools));
}
@@ -1283,9 +1290,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
const includeExitPlanMode = requestedToolNames.includes("exit_plan_mode");
const mcpDiscoveryEnabled = settings.get("mcp.discoveryMode") ?? false;
const defaultInactiveToolNames = new Set(
registeredTools.filter(tool => tool.definition.defaultInactive).map(tool => tool.definition.name),
);
const requestedActiveToolNames = includeExitPlanMode
? normalizedRequested
: normalizedRequested.filter(name => name !== "exit_plan_mode");
const initialRequestedActiveToolNames = options.toolNames
? requestedActiveToolNames
: requestedActiveToolNames.filter(name => !defaultInactiveToolNames.has(name));
const explicitlyRequestedMCPToolNames = options.toolNames
? requestedActiveToolNames.filter(name => name.startsWith("mcp_"))
: [];
@@ -1300,7 +1313,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
: [];
let initialSelectedMCPToolNames: string[] = [];
let defaultSelectedMCPToolNames: string[] = [];
let initialToolNames = [...requestedActiveToolNames];
let initialToolNames = [...initialRequestedActiveToolNames];
if (mcpDiscoveryEnabled) {
const restoredSelectedMCPToolNames = existingSession.selectedMCPToolNames.filter(name => toolRegistry.has(name));
defaultSelectedMCPToolNames = [
@@ -1311,7 +1324,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
: [...new Set([...restoredSelectedMCPToolNames, ...defaultSelectedMCPToolNames])];
initialToolNames = [
...new Set([
...requestedActiveToolNames.filter(name => !name.startsWith("mcp_")),
...initialRequestedActiveToolNames.filter(name => !name.startsWith("mcp_")),
...initialSelectedMCPToolNames,
]),
];
@@ -1320,7 +1333,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Custom tools and extension-registered tools are always included regardless of toolNames filter
const alwaysInclude: string[] = [
...(options.customTools?.map(t => (isCustomTool(t) ? t.name : t.name)) ?? []),
...registeredTools.map(t => t.definition.name),
...registeredTools.filter(t => !t.definition.defaultInactive).map(t => t.definition.name),
];
for (const name of alwaysInclude) {
if (mcpDiscoveryEnabled && name.startsWith("mcp_")) {
@@ -1417,6 +1430,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
openaiWebsocketSetting === "on" ? true : openaiWebsocketSetting === "off" ? false : undefined;
const serviceTierSetting = settings.get("serviceTier");
const initialServiceTier = hasServiceTierEntry
? existingSession.serviceTier
: serviceTierSetting === "none"
? undefined
: serviceTierSetting;
agent = new Agent({
initialState: {
systemPrompt,
@@ -1426,7 +1445,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
},
convertToLlm: convertToLlmFinal,
onPayload,
sessionId: sessionManager.getSessionId(),
sessionId: providerSessionId,
transformContext,
steeringMode: settings.get("steeringMode") ?? "one-at-a-time",
followUpMode: settings.get("followUpMode") ?? "one-at-a-time",
@@ -1438,14 +1457,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
minP: settings.get("minP") >= 0 ? settings.get("minP") : undefined,
presencePenalty: settings.get("presencePenalty") >= 0 ? settings.get("presencePenalty") : undefined,
repetitionPenalty: settings.get("repetitionPenalty") >= 0 ? settings.get("repetitionPenalty") : undefined,
serviceTier: serviceTierSetting === "none" ? undefined : serviceTierSetting,
serviceTier: initialServiceTier,
kimiApiFormat: settings.get("providers.kimiApiFormat") ?? "anthropic",
preferWebsockets: preferOpenAICodexWebsockets,
getToolContext: tc => toolContextStore.getContext(tc),
getApiKey: async provider => {
// Use the provider argument from the in-flight request;
// agent.state.model may already be switched mid-turn.
const key = await modelRegistry.getApiKeyForProvider(provider, sessionId);
// Use the provider-facing session id for sticky credential selection so cache keys
// and provider auth affinity stay aligned across fresh benchmark sessions.
const key = await modelRegistry.getApiKeyForProvider(provider, providerSessionId);
if (!key) {
throw new Error(`No API key found for provider "${provider}"`);
}
@@ -1476,9 +1495,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Restore messages if session has existing data
if (hasExistingSession) {
agent.replaceMessages(existingSession.messages);
if (!hasThinkingEntry) {
sessionManager.appendThinkingLevelChange(thinkingLevel);
}
} else {
// Save initial model and thinking level for new sessions so they can be restored on resume
if (model) {
@@ -1509,6 +1525,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
mcpDiscoveryEnabled,
initialSelectedMCPToolNames,
defaultSelectedMCPToolNames,
persistInitialMCPToolSelection: !hasExistingSession,
defaultSelectedMCPServerNames: [...discoveryDefaultServers],
ttsrManager,
obfuscator,
@@ -1519,8 +1536,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (model?.api === "openai-codex-responses") {
try {
await logger.timeAsync("prewarmCodexWebsocket", prewarmOpenAICodexResponses, model, {
apiKey: await modelRegistry.getApiKey(model, sessionId),
sessionId,
apiKey: await modelRegistry.getApiKey(model, providerSessionId),
sessionId: providerSessionId,
preferWebsockets: preferOpenAICodexWebsockets,
providerSessionState: session.providerSessionState,
});
@@ -225,6 +225,8 @@ export interface AgentSessionConfig {
mcpDiscoveryEnabled?: boolean;
/** MCP tool names to activate for the current session when discovery mode is enabled. */
initialSelectedMCPToolNames?: string[];
/** Whether constructor-provided MCP defaults should be persisted immediately. */
persistInitialMCPToolSelection?: boolean;
/** MCP server names whose tools should seed discovery-mode sessions whenever those servers are connected. */
defaultSelectedMCPServerNames?: string[];
/** MCP tool names that should seed brand-new sessions created from this AgentSession. */
@@ -364,6 +366,7 @@ export class AgentSession {
#followUpMessages: string[] = [];
/** Messages queued to be included with the next user prompt as context ("asides"). */
#pendingNextTurnMessages: CustomMessage[] = [];
#scheduledHiddenNextTurnGeneration: number | undefined = undefined;
#planModeState: PlanModeState | undefined;
#planReferenceSent = false;
#planReferencePath = "local://PLAN.md";
@@ -484,8 +487,11 @@ export class AgentSession {
this.#pruneSelectedMCPToolNames();
const persistedSelectedMCPToolNames = this.sessionManager.buildSessionContext().selectedMCPToolNames;
const currentSelectedMCPToolNames = this.getSelectedMCPToolNames();
const persistInitialMCPToolSelection =
config.persistInitialMCPToolSelection ?? this.sessionManager.getBranch().length === 0;
if (
this.#mcpDiscoveryEnabled &&
persistInitialMCPToolSelection &&
!this.#selectedMCPToolNamesMatch(persistedSelectedMCPToolNames, currentSelectedMCPToolNames)
) {
this.sessionManager.appendMCPToolSelection(currentSelectedMCPToolNames);
@@ -782,7 +788,6 @@ export class AgentSession {
attempt: this.#retryAttempt,
});
this.#retryAttempt = 0;
this.#resolveRetry();
}
}
@@ -858,6 +863,7 @@ export class AgentSession {
const didRetry = await this.#handleRetryableError(msg);
if (didRetry) return; // Retry was initiated, don't proceed to compaction
}
this.#resolveRetry();
if (msg.stopReason === "aborted" && this.#checkpointState) {
this.#checkpointState = undefined;
@@ -2567,6 +2573,74 @@ export class AgentSession {
});
}
#queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void {
this.#pendingNextTurnMessages.push(message);
if (!triggerTurn) return;
const generation = this.#promptGeneration;
if (this.#scheduledHiddenNextTurnGeneration === generation) {
return;
}
this.#scheduledHiddenNextTurnGeneration = generation;
this.#schedulePostPromptTask(
async () => {
if (this.#scheduledHiddenNextTurnGeneration === generation) {
this.#scheduledHiddenNextTurnGeneration = undefined;
}
if (this.#pendingNextTurnMessages.length === 0) {
return;
}
try {
await this.#promptQueuedHiddenNextTurnMessages();
} catch {
// Leave the hidden next-turn messages queued for the next explicit prompt.
}
},
{
generation,
onSkip: () => {
if (this.#scheduledHiddenNextTurnGeneration === generation) {
this.#scheduledHiddenNextTurnGeneration = undefined;
}
},
},
);
}
async #promptQueuedHiddenNextTurnMessages(): Promise<void> {
if (this.#pendingNextTurnMessages.length === 0) {
return;
}
const queuedMessages = [...this.#pendingNextTurnMessages];
this.#pendingNextTurnMessages = [];
const message = queuedMessages[queuedMessages.length - 1];
if (!message) {
return;
}
const prependMessages = queuedMessages.slice(0, -1);
const textContent = this.#getCustomMessageTextContent(message);
try {
await this.#promptWithMessage(message, textContent, {
prependMessages,
skipPostPromptRecoveryWait: true,
});
} catch (error) {
this.#pendingNextTurnMessages = [...queuedMessages, ...this.#pendingNextTurnMessages];
throw error;
}
}
#getCustomMessageTextContent(message: Pick<CustomMessage, "content">): string {
if (typeof message.content === "string") {
return message.content;
}
return message.content
.filter((content): content is TextContent => content.type === "text")
.map(content => content.text)
.join("");
}
/**
* Throw an error if the text is an extension command.
*/
@@ -2607,7 +2681,7 @@ export class AgentSession {
};
if (this.isStreaming) {
if (options?.deliverAs === "nextTurn") {
this.#pendingNextTurnMessages.push(appMessage);
this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false);
return;
}
@@ -2619,6 +2693,22 @@ export class AgentSession {
return;
}
if (options?.deliverAs === "nextTurn") {
if (options?.triggerTurn) {
await this.agent.prompt(appMessage);
return;
}
this.agent.appendMessage(appMessage);
this.sessionManager.appendCustomMessageEntry(
message.customType,
message.content,
message.display,
message.details,
message.attribution ?? "agent",
);
return;
}
if (options?.triggerTurn) {
await this.agent.prompt(appMessage);
return;
@@ -2686,9 +2776,9 @@ export class AgentSession {
return { steering, followUp };
}
/** Number of pending messages (includes both steering and follow-up) */
/** Number of pending messages (includes steering, follow-up, and next-turn messages) */
get queuedMessageCount(): number {
return this.#steeringMessages.length + this.#followUpMessages.length;
return this.#steeringMessages.length + this.#followUpMessages.length + this.#pendingNextTurnMessages.length;
}
/** Get pending messages (read-only) */
@@ -2830,6 +2920,7 @@ export class AgentSession {
async abort(): Promise<void> {
this.abortRetry();
this.#promptGeneration++;
this.#scheduledHiddenNextTurnGeneration = undefined;
this.#resolveTtsrResume();
this.#cancelPostPromptTasks();
this.agent.abort();
@@ -2879,6 +2970,7 @@ export class AgentSession {
this.#steeringMessages = [];
this.#followUpMessages = [];
this.#pendingNextTurnMessages = [];
this.#scheduledHiddenNextTurnGeneration = undefined;
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
@@ -3612,6 +3704,7 @@ export class AgentSession {
this.#steeringMessages = [];
this.#followUpMessages = [];
this.#pendingNextTurnMessages = [];
this.#scheduledHiddenNextTurnGeneration = undefined;
this.#todoReminderCount = 0;
// Inject the handoff document as a custom message
@@ -4495,7 +4588,9 @@ export class AgentSession {
}
#isTransientErrorMessage(errorMessage: string): boolean {
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|retry delay|stream stall/i.test(
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
// service unavailable, network/connection errors, fetch failed, terminated, retry delay exceeded
return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall/i.test(
errorMessage,
);
}
@@ -4959,6 +5054,7 @@ export class AgentSession {
this.#steeringMessages = [];
this.#followUpMessages = [];
this.#pendingNextTurnMessages = [];
this.#scheduledHiddenNextTurnGeneration = undefined;
// Flush pending writes before switching
await this.sessionManager.flush();
@@ -5002,21 +5098,18 @@ export class AgentSession {
const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
const hasServiceTierEntry = this.sessionManager.getBranch().some(entry => entry.type === "service_tier_change");
const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
if (hasThinkingEntry) {
this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel | undefined);
} else {
const effectiveDefaultThinkingLevel = resolveThinkingLevelForModel(this.model, defaultThinkingLevel);
this.#thinkingLevel = effectiveDefaultThinkingLevel;
this.agent.setThinkingLevel(toReasoningEffort(effectiveDefaultThinkingLevel));
this.sessionManager.appendThinkingLevelChange(effectiveDefaultThinkingLevel);
}
if (hasServiceTierEntry) {
this.agent.serviceTier = sessionContext.serviceTier;
} else {
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
}
const configuredServiceTier = this.settings.get("serviceTier");
const nextThinkingLevel = resolveThinkingLevelForModel(
this.model,
hasThinkingEntry ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel,
);
this.#thinkingLevel = nextThinkingLevel;
this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
this.agent.serviceTier = hasServiceTierEntry
? sessionContext.serviceTier
: configuredServiceTier === "none"
? undefined
: configuredServiceTier;
this.#reconnectToAgent();
return true;
@@ -5058,6 +5151,7 @@ export class AgentSession {
// Clear pending messages (bound to old session state)
this.#pendingNextTurnMessages = [];
this.#scheduledHiddenNextTurnGeneration = undefined;
// Flush pending writes before branching
await this.sessionManager.flush();
@@ -1379,6 +1379,7 @@ export class SessionManager {
#sessionName: string | undefined;
#sessionFile: string | undefined;
#flushed: boolean = false;
#needsFullRewriteOnNextPersist: boolean = false;
#fileEntries: FileEntry[] = [];
#byId: Map<string, SessionEntry> = new Map();
#labelsById: Map<string, string> = new Map();
@@ -1441,9 +1442,7 @@ export class SessionManager {
this.#sessionId = header?.id ?? Snowflake.next();
this.#sessionName = header?.title;
if (migrateToCurrentVersion(this.#fileEntries)) {
await this.#rewriteFile();
}
this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
@@ -1630,6 +1629,7 @@ export class SessionManager {
this.#labelsById.clear();
this.#leafId = null;
this.#flushed = false;
this.#needsFullRewriteOnNextPersist = false;
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
if (this.persist) {
@@ -1772,6 +1772,7 @@ export class SessionManager {
this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
);
await this.#writeEntriesAtomically(entries);
this.#needsFullRewriteOnNextPersist = false;
this.#flushed = true;
});
}
@@ -1786,7 +1787,7 @@ export class SessionManager {
*/
async ensureOnDisk(): Promise<void> {
if (!this.persist || !this.#sessionFile) return;
if (this.#flushed) return;
if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return;
await this.#rewriteFile();
}
@@ -1919,12 +1920,12 @@ export class SessionManager {
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
if (!hasAssistant) {
// Mark as not flushed so when assistant arrives, all entries get written
// Mark as not flushed so when assistant arrives, all entries get written.
this.#flushed = false;
return;
}
if (!this.#flushed) {
if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
// Full flush: rewrite the entire file atomically to avoid
// duplicating entries if the file already exists (e.g. from ensureOnDisk).
void this.#rewriteFile();
+85 -37
View File
@@ -16,13 +16,12 @@
*/
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { TERMINAL, Text } from "@oh-my-pi/pi-tui";
import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui";
import { untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "../config/prompt-templates";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { type Theme, theme } from "../modes/theme/theme";
import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
import askDescription from "../prompts/tools/ask.md" with { type: "text" };
import { renderStatusLine } from "../tui";
import type { ToolSession } from ".";
@@ -581,11 +580,17 @@ interface AskRenderArgs {
}
/** Render custom input as a single block with continuation lines (not one entry per line) */
function renderCustomInput(uiTheme: Theme, prefix: string, customInput: string, isLastEntry: boolean): string {
function renderCustomInput(
uiTheme: Theme,
prefix: string,
customInput: string,
isLastEntry: boolean,
includeLeadingNewline = true,
): string {
const lines = customInput.split("\n");
const branch = isLastEntry ? uiTheme.tree.last : uiTheme.tree.branch;
const firstLine = lines[0] ?? "";
let text = `\n${prefix}${uiTheme.fg("dim", branch)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", firstLine)}`;
let text = `${includeLeadingNewline ? "\n" : ""}${prefix}${uiTheme.fg("dim", branch)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", firstLine)}`;
const continuationIndent = isLastEntry ? " " : `${uiTheme.fg("dim", uiTheme.tree.vertical)} `;
for (let i = 1; i < lines.length; i++) {
text += `\n${prefix}${continuationIndent} ${uiTheme.fg("toolOutput", lines[i])}`;
@@ -596,10 +601,13 @@ function renderCustomInput(uiTheme: Theme, prefix: string, customInput: string,
export const askToolRenderer = {
renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const label = formatTitle("Ask", uiTheme);
const mdTheme = getMarkdownTheme();
const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
// Multi-part questions
if (args.questions && args.questions.length > 0) {
let text = `${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`;
const container = new Container();
container.addChild(new Text(`${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`, 0, 0));
for (let i = 0; i < args.questions.length; i++) {
const q = args.questions[i];
@@ -607,25 +615,29 @@ export const askToolRenderer = {
const qBranch = isLastQ ? uiTheme.tree.last : uiTheme.tree.branch;
const continuation = isLastQ ? " " : uiTheme.tree.vertical;
// Question line with metadata
const meta: string[] = [];
if (q.multi) meta.push("multi");
if (q.options?.length) meta.push(`options:${q.options.length}`);
const metaStr = meta.length > 0 ? uiTheme.fg("dim", ` · ${meta.join(" · ")}`) : "";
text += `\n ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)} ${uiTheme.fg("accent", q.question)}${metaStr}`;
container.addChild(
new Text(` ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)}${metaStr}`, 0, 0),
);
container.addChild(new Markdown(q.question, 3, 0, mdTheme, accentStyle));
// Options under question
if (q.options?.length) {
let optText = "";
for (let j = 0; j < q.options.length; j++) {
const opt = q.options[j];
const isLastOpt = j === q.options.length - 1;
const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch;
text += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
}
container.addChild(new Text(optText, 0, 0));
}
}
return new Text(text, 0, 0);
return container;
}
// Single question
@@ -633,22 +645,26 @@ export const askToolRenderer = {
return new Text(formatErrorMessage("No question provided", uiTheme), 0, 0);
}
let text = `${label} ${uiTheme.fg("accent", args.question)}`;
const container = new Container();
const meta: string[] = [];
if (args.multi) meta.push("multi");
if (args.options?.length) meta.push(`options:${args.options.length}`);
text += formatMeta(meta, uiTheme);
container.addChild(new Text(`${label}${formatMeta(meta, uiTheme)}`, 0, 0));
container.addChild(new Markdown(args.question, 1, 0, mdTheme, accentStyle));
if (args.options?.length) {
let optText = "";
for (let i = 0; i < args.options.length; i++) {
const opt = args.options[i];
const isLast = i === args.options.length - 1;
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`;
const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
optText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
}
container.addChild(new Text(optText, 0, 0));
}
return new Text(text, 0, 0);
return container;
},
renderResult(
@@ -657,6 +673,9 @@ export const askToolRenderer = {
uiTheme: Theme,
): Component {
const { details } = result;
const mdTheme = getMarkdownTheme();
const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
if (!details) {
const txt = result.content[0];
const fallback = txt?.type === "text" && txt.text ? txt.text : "";
@@ -677,7 +696,8 @@ export const askToolRenderer = {
},
uiTheme,
);
let text = header;
const container = new Container();
container.addChild(new Text(header, 0, 0));
for (let i = 0; i < details.results.length; i++) {
const r = details.results[i];
@@ -689,23 +709,38 @@ export const askToolRenderer = {
? uiTheme.styledSymbol("status.success", "success")
: uiTheme.styledSymbol("status.warning", "warning");
text += `\n ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)} ${uiTheme.fg("accent", r.question)}`;
container.addChild(
new Text(` ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)}`, 0, 0),
);
container.addChild(new Markdown(r.question, 3, 0, mdTheme, accentStyle));
if (r.selectedOptions.length > 0) {
for (let j = 0; j < r.selectedOptions.length; j++) {
const isLast = j === r.selectedOptions.length - 1 && r.customInput === undefined;
const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
text += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", r.selectedOptions[j])}`;
}
const answerLines: string[] = [];
for (let j = 0; j < r.selectedOptions.length; j++) {
const isLast = j === r.selectedOptions.length - 1 && r.customInput === undefined;
const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t =>
uiTheme.fg("toolOutput", t),
);
answerLines.push(
`${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`,
);
}
if (answerLines.length > 0) {
container.addChild(new Text(answerLines.join("\n"), 0, 0));
}
if (r.customInput !== undefined) {
text += renderCustomInput(uiTheme, continuation, r.customInput, true);
container.addChild(new Text(renderCustomInput(uiTheme, continuation, r.customInput, true, false), 0, 0));
} else if (r.selectedOptions.length === 0) {
text += `\n${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
container.addChild(
new Text(
`${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`,
0,
0,
),
);
}
}
return new Text(text, 0, 0);
return container;
}
// Single question result
@@ -717,26 +752,39 @@ export const askToolRenderer = {
const hasSelection =
details.customInput !== undefined || (details.selectedOptions && details.selectedOptions.length > 0);
const header = renderStatusLine(
{ icon: hasSelection ? "success" : "warning", title: "Ask", description: details.question },
uiTheme,
);
let text = header;
const header = renderStatusLine({ icon: hasSelection ? "success" : "warning", title: "Ask" }, uiTheme);
const container = new Container();
container.addChild(new Text(header, 0, 0));
container.addChild(new Markdown(details.question, 1, 0, mdTheme, accentStyle));
const answerLines: string[] = [];
if (details.selectedOptions && details.selectedOptions.length > 0) {
for (let i = 0; i < details.selectedOptions.length; i++) {
const isLast = i === details.selectedOptions.length - 1 && details.customInput === undefined;
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", details.selectedOptions[i])}`;
const selectedLabel = renderInlineMarkdown(details.selectedOptions[i], mdTheme, t =>
uiTheme.fg("toolOutput", t),
);
answerLines.push(
` ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`,
);
}
}
if (answerLines.length > 0) {
container.addChild(new Text(answerLines.join("\n"), 0, 0));
}
if (details.customInput !== undefined) {
text += renderCustomInput(uiTheme, " ", details.customInput, true);
container.addChild(new Text(renderCustomInput(uiTheme, " ", details.customInput, true, false), 0, 0));
} else if (!details.selectedOptions || details.selectedOptions.length === 0) {
text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
container.addChild(
new Text(
` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`,
0,
0,
),
);
}
return new Text(text, 0, 0);
return container;
},
};
@@ -5,45 +5,7 @@
* this interceptor provides helpful error messages directing them to use
* the specialized tools instead.
*/
import type { BashInterceptorRule } from "../config/settings-schema";
export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
{
pattern: "^\\s*(cat|head|tail|less|more)\\s+",
tool: "read",
message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.",
},
{
pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+",
tool: "grep",
message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.",
},
{
pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)",
tool: "find",
message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.",
},
{
pattern: "^\\s*sed\\s+(-i|--in-place)",
tool: "edit",
message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.",
},
{
pattern: "^\\s*perl\\s+.*-[pn]?i",
tool: "edit",
message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.",
},
{
pattern: "^\\s*awk\\s+.*-i\\s+inplace",
tool: "edit",
message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.",
},
{
pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S",
tool: "write",
message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
},
];
import { type BashInterceptorRule, DEFAULT_BASH_INTERCEPTOR_RULES } from "../config/settings-schema";
export interface InterceptionResult {
/** If true, the bash command should be blocked */
@@ -0,0 +1,88 @@
import type { ChildProcess } from "node:child_process";
const EXIT_STDIO_GRACE_MS = 100;
/**
* Wait for a child process to terminate without hanging on inherited stdio handles.
*
* Daemonized descendants can inherit the child's stdout/stderr pipe handles. In that
* case the child emits `exit`, but `close` can hang forever even though the original
* process is already gone. We wait briefly for stdio to end, then forcibly stop
* tracking the inherited handles.
*/
export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
const { promise, resolve, reject } = Promise.withResolvers<number | null>();
let settled = false;
let exited = false;
let exitCode: number | null = null;
let postExitTimer: NodeJS.Timeout | undefined;
let stdoutEnded = child.stdout === null;
let stderrEnded = child.stderr === null;
const cleanup = () => {
if (postExitTimer) {
clearTimeout(postExitTimer);
postExitTimer = undefined;
}
child.removeListener("error", onError);
child.removeListener("exit", onExit);
child.removeListener("close", onClose);
child.stdout?.removeListener("end", onStdoutEnd);
child.stderr?.removeListener("end", onStderrEnd);
};
const finalize = (code: number | null) => {
if (settled) return;
settled = true;
cleanup();
child.stdout?.destroy();
child.stderr?.destroy();
resolve(code);
};
const maybeFinalizeAfterExit = () => {
if (!exited || settled) return;
if (stdoutEnded && stderrEnded) {
finalize(exitCode);
}
};
const onStdoutEnd = () => {
stdoutEnded = true;
maybeFinalizeAfterExit();
};
const onStderrEnd = () => {
stderrEnded = true;
maybeFinalizeAfterExit();
};
const onError = (err: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(err);
};
const onExit = (code: number | null) => {
exited = true;
exitCode = code;
maybeFinalizeAfterExit();
if (!settled) {
postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS);
}
};
const onClose = (code: number | null) => {
finalize(code);
};
child.stdout?.once("end", onStdoutEnd);
child.stderr?.once("end", onStderrEnd);
child.once("error", onError);
child.once("exit", onExit);
child.once("close", onClose);
return promise;
}
@@ -2,12 +2,12 @@
* Tests for AgentSession concurrent prompt guard.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Agent, AgentBusyError, type AgentTool } from "@oh-my-pi/pi-agent-core";
import { type AssistantMessage, getBundledModel, type ToolCall } from "@oh-my-pi/pi-ai";
import { type AssistantMessage, getBundledModel, type Message, type ToolCall } from "@oh-my-pi/pi-ai";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import type { Rule } from "@oh-my-pi/pi-coding-agent/capability/rule";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
@@ -15,6 +15,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { TtsrManager } from "@oh-my-pi/pi-coding-agent/export/ttsr";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { Snowflake } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
@@ -62,6 +63,7 @@ describe("AgentSession concurrent prompt guard", () => {
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true });
}
vi.restoreAllMocks();
});
async function createSession() {
@@ -111,6 +113,16 @@ describe("AgentSession concurrent prompt guard", () => {
return session;
}
async function waitFor(predicate: () => boolean, timeoutMs = 500): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await Bun.sleep(10);
}
throw new Error("Timed out waiting for condition");
}
it("should throw when prompt() called while streaming", async () => {
await createSession();
@@ -163,6 +175,82 @@ describe("AgentSession concurrent prompt guard", () => {
await firstPrompt.catch(() => {});
});
it("delivers hidden nextTurn stop reactions through the next LLM call without exposing them in the visible queue", async () => {
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
let firstStream: MockAssistantStream | undefined;
const callMessages: Message[][] = [];
const agent = new Agent({
getApiKey: () => "test-key",
initialState: {
model,
systemPrompt: "Test",
tools: [],
},
convertToLlm,
streamFn: (_model, context) => {
callMessages.push([...context.messages]);
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({ type: "start", partial: createAssistantMessage("") });
if (callMessages.length > 1) {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Resumed") });
return;
}
});
firstStream = stream;
return stream;
},
});
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated();
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
authStorages.push(authStorage);
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
session = new AgentSession({
agent,
sessionManager,
settings,
modelRegistry,
});
const firstPrompt = session.prompt("First message");
await waitFor(() => session.isStreaming && firstStream !== undefined && callMessages.length === 1);
await session.sendCustomMessage(
{
customType: "autoresearch-resume",
content: "Hidden stop reaction",
display: false,
attribution: "agent",
},
{ deliverAs: "nextTurn", triggerTurn: true },
);
expect(session.queuedMessageCount).toBe(0);
expect(session.getQueuedMessages()).toEqual({ steering: [], followUp: [] });
firstStream?.push({ type: "done", reason: "stop", message: createAssistantMessage("Done") });
await firstPrompt;
await session.waitForIdle();
expect(callMessages).toHaveLength(2);
expect(
callMessages[1]?.some(message => {
if (typeof message.content === "string") {
return message.content.includes("Hidden stop reaction");
}
return message.content.some(
content => content.type === "text" && content.text.includes("Hidden stop reaction"),
);
}),
).toBe(true);
});
it("should allow prompt() after previous completes", async () => {
// Create session with a stream that completes immediately
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
@@ -2,9 +2,8 @@ import { afterEach, 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 type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type { Model } from "@oh-my-pi/pi-ai";
import { Agent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { Effort, type Model } from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import { Settings } from "../src/config/settings";
import type { CustomTool } from "../src/extensibility/custom-tools/types";
@@ -398,7 +397,7 @@ describe("AgentSession MCP discovery", () => {
expect(sessionManager.buildSessionContext().selectedMCPToolNames).toEqual([]);
});
it("persists corrected empty MCP selections when restored tools are unavailable", async () => {
it("restores unavailable MCP selections in memory without rewriting the persisted session selection", async () => {
const readTool = createBasicTool("read", "Read");
const sessionManager = SessionManager.inMemory();
sessionManager.appendMCPToolSelection(["mcp_docs_search"]);
@@ -422,7 +421,7 @@ describe("AgentSession MCP discovery", () => {
sessions.push(session);
expect(session.getSelectedMCPToolNames()).toEqual([]);
expect(sessionManager.buildSessionContext().selectedMCPToolNames).toEqual([]);
expect(sessionManager.buildSessionContext().selectedMCPToolNames).toEqual(["mcp_docs_search"]);
});
it("restores MCP discovery selections when branching to a context without them", async () => {
@@ -562,6 +561,11 @@ describe("AgentSession MCP discovery", () => {
content: "start",
timestamp: Date.now(),
});
sessionManager.appendMessage({
role: "user",
content: "follow up",
timestamp: Date.now(),
});
const toolRegistry = new Map([
[readTool.name, readTool],
[docsSearchTool.name, docsSearchTool],
@@ -595,7 +599,7 @@ describe("AgentSession MCP discovery", () => {
expect(session.systemPrompt).toBe("tools:read,mcp_docs_search");
});
it("does not leak MCP defaults across session switches without persisted selections", async () => {
it("restores session defaults in memory across session switches without rewriting sessions missing persisted metadata", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-agent-session-mcp-switch-"));
tempDirs.push(tempDir);
const readTool = createBasicTool("read", "Read");
@@ -606,18 +610,31 @@ describe("AgentSession MCP discovery", () => {
]);
const olderSessionManager = SessionManager.create(tempDir, tempDir);
olderSessionManager.appendMessage({
role: "user",
content: "older session",
timestamp: Date.now(),
});
const olderSessionFile = olderSessionManager.getSessionFile();
expect(olderSessionFile).toBeString();
await olderSessionManager.flush();
await olderSessionManager.rewriteEntries();
const olderSessionBeforeSwitch = fs.readFileSync(olderSessionFile!, "utf8");
const olderSessionMtimeBeforeSwitch = fs.statSync(olderSessionFile!).mtimeMs;
const sessionManager = SessionManager.create(tempDir, tempDir);
const originalSessionFile = sessionManager.getSessionFile();
expect(originalSessionFile).toBeString();
await sessionManager.flush();
const reasoningModel: Model<"openai-responses"> = {
...createModel(),
reasoning: true,
thinking: { mode: "effort", minLevel: Effort.Medium, maxLevel: Effort.Medium },
};
const agent = new Agent({
initialState: {
model: createModel(),
model: reasoningModel,
systemPrompt: "initial",
tools: [readTool, docsSearchTool],
messages: sessionManager.buildSessionContext().messages,
@@ -626,7 +643,11 @@ describe("AgentSession MCP discovery", () => {
const session = new AgentSession({
agent,
sessionManager,
settings: Settings.isolated({ "mcp.discoveryMode": true }),
settings: Settings.isolated({
"mcp.discoveryMode": true,
defaultThinkingLevel: "high",
serviceTier: "priority",
}),
modelRegistry: {} as never,
toolRegistry,
mcpDiscoveryEnabled: true,
@@ -637,17 +658,37 @@ describe("AgentSession MCP discovery", () => {
sessions.push(session);
expect(session.getSelectedMCPToolNames()).toEqual(["mcp_docs_search"]);
sessionManager.appendThinkingLevelChange(ThinkingLevel.High);
sessionManager.appendServiceTierChange("flex");
sessionManager.appendMCPToolSelection(["mcp_docs_search"]);
expect(sessionManager.buildSessionContext().thinkingLevel).toBe(ThinkingLevel.High);
expect(sessionManager.buildSessionContext().serviceTier).toBe("flex");
expect(sessionManager.buildSessionContext().selectedMCPToolNames).toEqual(["mcp_docs_search"]);
expect(sessionManager.buildSessionContext().hasPersistedMCPToolSelection).toBe(true);
await sessionManager.rewriteEntries();
const originalSessionBeforeSwitch = fs.readFileSync(originalSessionFile!, "utf8");
const originalSessionMtimeBeforeSwitch = fs.statSync(originalSessionFile!).mtimeMs;
await Bun.sleep(20);
await session.switchSession(olderSessionFile!);
expect(session.sessionFile).toBe(olderSessionFile);
expect(session.thinkingLevel).toBe(ThinkingLevel.Medium);
expect(session.serviceTier).toBe("priority");
expect(session.getSelectedMCPToolNames()).toEqual([]);
expect(session.getActiveToolNames()).toEqual(["read"]);
expect(session.systemPrompt).toBe("tools:read");
expect(fs.readFileSync(olderSessionFile!, "utf8")).toBe(olderSessionBeforeSwitch);
expect(fs.statSync(olderSessionFile!).mtimeMs).toBe(olderSessionMtimeBeforeSwitch);
await session.switchSession(originalSessionFile!);
expect(session.sessionFile).toBe(originalSessionFile);
expect(session.thinkingLevel).toBe(ThinkingLevel.Medium);
expect(session.serviceTier).toBe("flex");
expect(session.getSelectedMCPToolNames()).toEqual(["mcp_docs_search"]);
expect(session.getActiveToolNames()).toEqual(["read", "mcp_docs_search"]);
expect(session.systemPrompt).toBe("tools:read,mcp_docs_search");
expect(fs.readFileSync(originalSessionFile!, "utf8")).toBe(originalSessionBeforeSwitch);
expect(fs.statSync(originalSessionFile!).mtimeMs).toBe(originalSessionMtimeBeforeSwitch);
});
it("restores explicit MCP defaults after startup outage once tools recover in a new session", async () => {
+12
View File
@@ -86,6 +86,13 @@ describe("parseArgs", () => {
});
});
describe("--fork flag", () => {
test("parses --fork with session ID", () => {
const result = parseArgs(["--fork", "abc123"]);
expect(result.fork).toBe("abc123");
});
});
describe("flags with values", () => {
test("parses --provider", () => {
const result = parseArgs(["--provider", "openai"]);
@@ -112,6 +119,11 @@ describe("parseArgs", () => {
expect(result.appendSystemPrompt).toBe("Additional context");
});
test("parses --provider-session-id", () => {
const result = parseArgs(["--provider-session-id", "reb_cache_key"]);
expect(result.providerSessionId).toBe("reb_cache_key");
});
test("parses --mode", () => {
const result = parseArgs(["--mode", "json"]);
expect(result.mode).toBe("json");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -246,7 +246,7 @@ describe("validateLineRef", () => {
describe("applyHashlineEdits — replace", () => {
it("replaces single line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nBBB\nccc");
@@ -255,7 +255,9 @@ describe("applyHashlineEdits — replace", () => {
it("range replace (shrink)", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["ONE"] }];
const edits: HashlineEdit[] = [
{ op: "replace_range", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["ONE"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nONE\nddd");
@@ -264,7 +266,7 @@ describe("applyHashlineEdits — replace", () => {
it("range replace (same count)", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["XXX", "YYY"] },
{ op: "replace_range", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["XXX", "YYY"] },
];
const result = applyHashlineEdits(content, edits);
@@ -274,7 +276,7 @@ describe("applyHashlineEdits — replace", () => {
it("replaces first line", () => {
const content = "first\nsecond\nthird";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(1, "first"), lines: ["FIRST"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(1, "first"), lines: ["FIRST"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("FIRST\nsecond\nthird");
@@ -283,7 +285,7 @@ describe("applyHashlineEdits — replace", () => {
it("replaces last line", () => {
const content = "first\nsecond\nthird";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "third"), lines: ["THIRD"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(3, "third"), lines: ["THIRD"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("first\nsecond\nTHIRD");
@@ -298,7 +300,7 @@ describe("applyHashlineEdits — replace", () => {
describe("applyHashlineEdits — delete", () => {
it("deletes single line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: [] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nccc");
@@ -307,7 +309,9 @@ describe("applyHashlineEdits — delete", () => {
it("deletes range of lines", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: [] }];
const edits: HashlineEdit[] = [
{ op: "replace_range", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: [] },
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nddd");
@@ -315,7 +319,7 @@ describe("applyHashlineEdits — delete", () => {
it("deletes first line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(1, "aaa"), lines: [] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(1, "aaa"), lines: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("bbb\nccc");
@@ -323,7 +327,7 @@ describe("applyHashlineEdits — delete", () => {
it("deletes last line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "ccc"), lines: [] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(3, "ccc"), lines: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nbbb");
@@ -331,7 +335,7 @@ describe("applyHashlineEdits — delete", () => {
it("replaces line with blank line when lines is ['']", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: [""] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: [""] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\n\nccc");
@@ -346,7 +350,7 @@ describe("applyHashlineEdits — delete", () => {
describe("applyHashlineEdits — append", () => {
it("inserts after a line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "append", pos: makeTag(1, "aaa"), lines: ["NEW"] }];
const edits: HashlineEdit[] = [{ op: "append_at", pos: makeTag(1, "aaa"), lines: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nNEW\nbbb\nccc");
@@ -355,7 +359,7 @@ describe("applyHashlineEdits — append", () => {
it("inserts multiple lines", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ op: "append", pos: makeTag(1, "aaa"), lines: ["x", "y", "z"] }];
const edits: HashlineEdit[] = [{ op: "append_at", pos: makeTag(1, "aaa"), lines: ["x", "y", "z"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nx\ny\nz\nbbb");
@@ -363,7 +367,7 @@ describe("applyHashlineEdits — append", () => {
it("inserts after last line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ op: "append", pos: makeTag(2, "bbb"), lines: ["NEW"] }];
const edits: HashlineEdit[] = [{ op: "append_at", pos: makeTag(2, "bbb"), lines: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nbbb\nNEW");
@@ -371,7 +375,7 @@ describe("applyHashlineEdits — append", () => {
it("insert with empty dst inserts an empty line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ op: "append", pos: makeTag(1, "aaa"), lines: [] }];
const edits: HashlineEdit[] = [{ op: "append_at", pos: makeTag(1, "aaa"), lines: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\n\nbbb");
@@ -380,7 +384,7 @@ describe("applyHashlineEdits — append", () => {
it("inserts at EOF without anchors", () => {
const content = "aaa\nbbb";
const edits = [{ op: "append", lines: ["NEW"] }] as unknown as HashlineEdit[];
const edits: HashlineEdit[] = [{ op: "append_file", lines: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nbbb\nNEW");
@@ -389,7 +393,7 @@ describe("applyHashlineEdits — append", () => {
it("inserts at EOF into empty file without anchors", () => {
const content = "";
const edits = [{ op: "append", lines: ["NEW"] }] as unknown as HashlineEdit[];
const edits: HashlineEdit[] = [{ op: "append_file", lines: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("NEW");
@@ -398,7 +402,7 @@ describe("applyHashlineEdits — append", () => {
it("insert at EOF with empty dst inserts a trailing empty line", () => {
const content = "aaa\nbbb";
const edits = [{ op: "append", lines: [] }] as unknown as HashlineEdit[];
const edits: HashlineEdit[] = [{ op: "append_file", lines: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nbbb\n");
@@ -413,7 +417,7 @@ describe("applyHashlineEdits — append", () => {
describe("applyHashlineEdits — prepend", () => {
it("inserts before a line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(2, "bbb"), lines: ["NEW"] }];
const edits: HashlineEdit[] = [{ op: "prepend_at", pos: makeTag(2, "bbb"), lines: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nNEW\nbbb\nccc");
expect(result.firstChangedLine).toBe(2);
@@ -421,21 +425,21 @@ describe("applyHashlineEdits — prepend", () => {
it("inserts multiple lines before", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(2, "bbb"), lines: ["x", "y", "z"] }];
const edits: HashlineEdit[] = [{ op: "prepend_at", pos: makeTag(2, "bbb"), lines: ["x", "y", "z"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nx\ny\nz\nbbb");
});
it("inserts before first line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(1, "aaa"), lines: ["NEW"] }];
const edits: HashlineEdit[] = [{ op: "prepend_at", pos: makeTag(1, "aaa"), lines: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("NEW\naaa\nbbb");
});
it("prepends at BOF without anchor", () => {
const content = "aaa\nbbb";
const edits = [{ op: "prepend", lines: ["NEW"] }] as unknown as HashlineEdit[];
const edits: HashlineEdit[] = [{ op: "prepend_file", lines: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("NEW\naaa\nbbb");
expect(result.firstChangedLine).toBe(1);
@@ -443,7 +447,7 @@ describe("applyHashlineEdits — prepend", () => {
it("insert with before and empty text inserts an empty line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(1, "aaa"), lines: [] }];
const edits: HashlineEdit[] = [{ op: "prepend_at", pos: makeTag(1, "aaa"), lines: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("\naaa\nbbb");
expect(result.firstChangedLine).toBe(1);
@@ -452,8 +456,8 @@ describe("applyHashlineEdits — prepend", () => {
it("insert before and insert after at same line produce correct order", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ op: "prepend", pos: makeTag(2, "bbb"), lines: ["BEFORE"] },
{ op: "append", pos: makeTag(2, "bbb"), lines: ["AFTER"] },
{ op: "prepend_at", pos: makeTag(2, "bbb"), lines: ["BEFORE"] },
{ op: "append_at", pos: makeTag(2, "bbb"), lines: ["AFTER"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nBEFORE\nbbb\nAFTER\nccc");
@@ -462,8 +466,8 @@ describe("applyHashlineEdits — prepend", () => {
it("insert before with set at same line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ op: "prepend", pos: makeTag(2, "bbb"), lines: ["BEFORE"] },
{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] },
{ op: "prepend_at", pos: makeTag(2, "bbb"), lines: ["BEFORE"] },
{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nBEFORE\nBBB\nccc");
@@ -482,7 +486,7 @@ describe("applyHashlineEdits — heuristics", () => {
const srcHash = computeLineHash(2, "bbb");
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_line",
pos: parseTag(`2#${srcHash}export function foo(a, b) {}`), // comma in trailing content
lines: ["BBB"],
},
@@ -496,7 +500,7 @@ describe("applyHashlineEdits — heuristics", () => {
const content = ["import { foo } from 'x';", "import { bar } from 'y';", "const x = 1;"].join("\n");
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_range",
pos: makeTag(1, "import { foo } from 'x';"),
end: makeTag(2, "import { bar } from 'y';"),
lines: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"],
@@ -514,81 +518,68 @@ describe("applyHashlineEdits — heuristics", () => {
it("treats same-line ranges as single-line replacements", () => {
const content = "aaa\nbbb\nccc";
const good = makeTag(2, "bbb");
const edits: HashlineEdit[] = [{ op: "replace", pos: good, end: good, lines: ["BBB"] }];
const edits: HashlineEdit[] = [{ op: "replace_range", pos: good, end: good, lines: ["BBB"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nBBB\nccc");
});
it("auto-corrects off-by-one range end that duplicates a closing brace", () => {
it("preserves duplicated trailing closer lines exactly as provided", () => {
const content = "if (ok) {\n run();\n}\nafter();";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_range",
pos: makeTag(1, "if (ok) {"),
end: makeTag(2, " run();"),
lines: ["if (ok) {", " runSafe();", "}"],
},
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("if (ok) {\n runSafe();\n}\nafter();");
expect(result.lines).toBe("if (ok) {\n runSafe();\n}\n}\nafter();");
expect(result.warnings).toHaveLength(1);
expect(result.warnings?.[0]).toContain("Auto-corrected range replace");
expect(result.warnings?.[0]).toContain('"}"');
expect(result.warnings?.[0]).toContain("Possible boundary duplication");
expect(result.warnings?.[0]).toContain("set `end` to 3#RZ");
});
it('auto-corrects off-by-one range end that duplicates a ");" closer', () => {
const content = "doThing(\n value,\n);\nnext();";
const edits: HashlineEdit[] = [
{
op: "replace",
pos: makeTag(1, "doThing("),
end: makeTag(2, " value,"),
lines: ["doThing(", " normalize(value),", ");"],
},
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("doThing(\n normalize(value),\n);\nnext();");
expect(result.warnings).toHaveLength(1);
expect(result.warnings?.[0]).toContain('");"');
});
it("auto-corrects duplicated trailing lines when they match the next surviving line", () => {
it("preserves duplicated trailing content when replacement re-emits the next line", () => {
const content = "start\n oldCall();\nnextCall();\nafter();";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_range",
pos: makeTag(1, "start"),
end: makeTag(2, " oldCall();"),
lines: ["start", " newCall();", "nextCall();"],
},
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("start\n newCall();\nnextCall();\nafter();");
expect(result.lines).toBe("start\n newCall();\nnextCall();\nnextCall();\nafter();");
expect(result.warnings).toHaveLength(1);
expect(result.warnings?.[0]).toContain("removed trailing replacement line");
expect(result.warnings?.[0]).toContain("Possible boundary duplication");
expect(result.warnings?.[0]).toContain("set `end` to 3#HR");
});
it("auto-corrects off-by-one range start that duplicates a preceding line", () => {
it("preserves duplicated leading content when replacement re-emits the previous line", () => {
const content = "if (x) {\n oldBody();\n}\nafter();";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_range",
pos: makeTag(2, " oldBody();"),
end: makeTag(3, "}"),
lines: ["if (x) {", " newBody();", "}"],
},
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("if (x) {\n newBody();\n}\nafter();");
expect(result.warnings).toHaveLength(1);
expect(result.warnings?.[0]).toContain("removed leading replacement line");
expect(result.lines).toBe("if (x) {\nif (x) {\n newBody();\n}\nafter();");
expect(result.warnings).toBeUndefined();
});
it("auto-corrects leading escaped tab indentation by default", () => {
const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
try {
const content = "root\n\tchild\n\t\tvalue\nend";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }];
const edits: HashlineEdit[] = [
{ op: "replace_line", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("root\n\tchild\n\t\treplaced\nend");
expect(result.warnings).toHaveLength(1);
@@ -604,7 +595,9 @@ describe("applyHashlineEdits — heuristics", () => {
Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = "0";
try {
const content = "root\n\tchild\n\t\tvalue\nend";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }];
const edits: HashlineEdit[] = [
{ op: "replace_line", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("root\n\tchild\n\\t\\treplaced\nend");
expect(result.warnings).toBeUndefined();
@@ -614,14 +607,14 @@ describe("applyHashlineEdits — heuristics", () => {
}
});
it("does not auto-correct when edit already includes real tab characters", () => {
it("preserves mixed real-tab and escaped-tab content verbatim", () => {
const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
try {
const content = "root\n\tchild\n\t\tvalue\nend";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_line",
pos: makeTag(3, "\t\tvalue"),
lines: ["\t\talready-tab", "\\t\\tescaped-still-literal"],
},
@@ -634,9 +627,10 @@ describe("applyHashlineEdits — heuristics", () => {
else Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = previous;
}
});
it("warns on literal \\uDDDD without changing content", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: ["\\uDDDD"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["\\uDDDD"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\n\\uDDDD\nccc");
expect(result.warnings).toHaveLength(1);
@@ -652,8 +646,8 @@ describe("applyHashlineEdits — multiple edits", () => {
it("applies two non-overlapping replaces (bottom-up safe)", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [
{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] },
{ op: "replace", pos: makeTag(4, "ddd"), lines: ["DDD"] },
{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] },
{ op: "replace_line", pos: makeTag(4, "ddd"), lines: ["DDD"] },
];
const result = applyHashlineEdits(content, edits);
@@ -664,8 +658,8 @@ describe("applyHashlineEdits — multiple edits", () => {
it("applies replace + delete in one call", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] },
{ op: "replace", pos: makeTag(4, "ddd"), lines: [] },
{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] },
{ op: "replace_line", pos: makeTag(4, "ddd"), lines: [] },
];
const result = applyHashlineEdits(content, edits);
@@ -675,8 +669,8 @@ describe("applyHashlineEdits — multiple edits", () => {
it("applies replace + append in one call", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ op: "replace", pos: makeTag(3, "ccc"), lines: ["CCC"] },
{ op: "append", pos: makeTag(1, "aaa"), lines: ["INSERTED"] },
{ op: "replace_line", pos: makeTag(3, "ccc"), lines: ["CCC"] },
{ op: "append_at", pos: makeTag(1, "aaa"), lines: ["INSERTED"] },
];
const result = applyHashlineEdits(content, edits);
@@ -687,12 +681,12 @@ describe("applyHashlineEdits — multiple edits", () => {
const content = "one\ntwo\nthree\nfour\nfive\nsix";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_range",
pos: makeTag(2, "two"),
end: makeTag(3, "three"),
lines: ["TWO_THREE"],
},
{ op: "replace", pos: makeTag(6, "six"), lines: ["SIX"] },
{ op: "replace_line", pos: makeTag(6, "six"), lines: ["SIX"] },
];
const result = applyHashlineEdits(content, edits);
@@ -702,7 +696,9 @@ describe("applyHashlineEdits — multiple edits", () => {
it("single-line replace expanding to multiple lines is not a noop", () => {
const content = "aaa\n\nccc";
const blankHash = computeLineHash(2, "");
const edits: HashlineEdit[] = [{ op: "replace", pos: { line: 2, hash: blankHash }, lines: ["", "inserted", ""] }];
const edits: HashlineEdit[] = [
{ op: "replace_line", pos: { line: 2, hash: blankHash }, lines: ["", "inserted", ""] },
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\n\ninserted\n\nccc");
expect(result.firstChangedLine).toBe(2);
@@ -724,13 +720,13 @@ describe("applyHashlineEdits — errors", () => {
it("rejects stale hash", () => {
const content = "aaa\nbbb\nccc";
// Use a hash that doesn't match any line (avoid 00 — ccc hashes to 00)
const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("2#QQ"), lines: ["BBB"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("2#QQ"), lines: ["BBB"] }];
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
});
it("stale hash error shows >>> markers with correct hashes", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("2#QQ"), lines: ["BBB"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("2#QQ"), lines: ["BBB"] }];
try {
applyHashlineEdits(content, edits);
@@ -754,8 +750,8 @@ describe("applyHashlineEdits — errors", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
// Use hashes that don't match any line (avoid 00 — ccc hashes to 00)
const edits: HashlineEdit[] = [
{ op: "replace", pos: parseTag("2#ZZ"), lines: ["BBB"] },
{ op: "replace", pos: parseTag("4#ZZ"), lines: ["DDD"] },
{ op: "replace_line", pos: parseTag("2#ZZ"), lines: ["BBB"] },
{ op: "replace_line", pos: parseTag("4#ZZ"), lines: ["DDD"] },
];
try {
@@ -776,7 +772,7 @@ describe("applyHashlineEdits — errors", () => {
it("does not relocate stale line refs even when hash uniquely matches another line", () => {
const content = "aaa\nbbb\nccc";
const staleButUnique = parseTag(`2#${computeLineHash(1, "ccc")}`);
const edits: HashlineEdit[] = [{ op: "replace", pos: staleButUnique, lines: ["CCC"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: staleButUnique, lines: ["CCC"] }];
try {
applyHashlineEdits(content, edits);
expect.unreachable("should have thrown");
@@ -790,31 +786,33 @@ describe("applyHashlineEdits — errors", () => {
it("does not relocate when expected hash is non-unique", () => {
const content = "dup\nmid\ndup";
const staleDuplicate = parseTag(`2#${computeLineHash(1, "dup")}`);
const edits: HashlineEdit[] = [{ op: "replace", pos: staleDuplicate, lines: ["DUP"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: staleDuplicate, lines: ["DUP"] }];
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
});
it("rejects out-of-range line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("10#ZZ"), lines: ["X"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("10#ZZ"), lines: ["X"] }];
expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/);
});
it("rejects range with start > end", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(5, "eee"), end: makeTag(2, "bbb"), lines: ["X"] }];
const edits: HashlineEdit[] = [
{ op: "replace_range", pos: makeTag(5, "eee"), end: makeTag(2, "bbb"), lines: ["X"] },
];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
it("accepts append/prepend with empty text by inserting empty lines", () => {
const content = "aaa\nbbb";
const appendEdits: HashlineEdit[] = [{ op: "append", pos: makeTag(1, "aaa"), lines: [] }];
const appendEdits: HashlineEdit[] = [{ op: "append_at", pos: makeTag(1, "aaa"), lines: [] }];
expect(applyHashlineEdits(content, appendEdits).lines).toBe("aaa\n\nbbb");
const prependEdits: HashlineEdit[] = [{ op: "prepend", pos: makeTag(1, "aaa"), lines: [] }];
const prependEdits: HashlineEdit[] = [{ op: "prepend_at", pos: makeTag(1, "aaa"), lines: [] }];
expect(applyHashlineEdits(content, prependEdits).lines).toBe("\naaa\nbbb");
});
});
@@ -995,7 +993,7 @@ describe("hashlineParseContent", () => {
const fileContent = "# Title\n- old item\n- old item 2\nfooter";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_line",
pos: makeTag(2, "- old item"),
lines: hashlineParseText("- [x] new item"),
},
@@ -1008,7 +1006,7 @@ describe("hashlineParseContent", () => {
// All replacement lines start with '- ', triggering the 50% heuristic when '-' matched.
const fileContent = "- [x] done\n- [ ] pending\n- [ ] also pending";
const newContent = hashlineParseText("- [x] done");
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "- [ ] pending"), lines: newContent }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "- [ ] pending"), lines: newContent }];
const result = applyHashlineEdits(fileContent, edits);
expect(result.lines).toBe("- [x] done\n- [x] done\n- [ ] also pending");
});
@@ -1032,7 +1030,7 @@ describe("hashlineParseContent", () => {
const fileContent = [" # cuDNN section", " # Note: Using version 1.23.0", ' $Version = "1.23.0"'].join("\n");
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_line",
pos: makeTag(2, " # Note: Using version 1.23.0"),
lines: hashlineParseText([" # Note: Using version 1.24.x"]),
},
@@ -1047,7 +1045,7 @@ describe("hashlineParseContent", () => {
const fileContent = "const x = 1;\n// TODO: old\n# TODO: remove this\nconst y = 2;";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_line",
pos: makeTag(3, "# TODO: remove this"),
lines: hashlineParseText(["# TODO: remove this -- done"]),
},
@@ -0,0 +1,40 @@
import { describe, expect, it } from "bun:test";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import type { Args } from "../src/cli/args";
import { buildInitialMessage } from "../src/cli/initial-message";
function createArgs(messages: string[]): Args {
return {
messages,
fileArgs: [],
unknownFlags: new Map(),
};
}
describe("buildInitialMessage", () => {
it("combines stdin, file text, and the first CLI message", () => {
const parsed = createArgs(["first", "second"]);
const images: ImageContent[] = [{ type: "image", data: "abc123", mimeType: "image/png" }];
const result = buildInitialMessage({
parsed,
stdinContent: "stdin",
fileText: "file-",
fileImages: images,
});
expect(result.initialMessage).toBe("stdin\nfile-first");
expect(result.initialImages).toEqual(images);
expect(parsed.messages).toEqual(["second"]);
});
it("leaves plain CLI messages untouched when there is no initial file or stdin input", () => {
const parsed = createArgs(["first", "second"]);
const result = buildInitialMessage({ parsed });
expect(result.initialMessage).toBeUndefined();
expect(result.initialImages).toBeUndefined();
expect(parsed.messages).toEqual(["first", "second"]);
});
});
@@ -4,25 +4,25 @@ import { KeybindingsManager } from "../src/config/keybindings";
describe("KeybindingsManager.getDisplayString", () => {
it("formats a single binding as a human-readable key hint", () => {
const keybindings = KeybindingsManager.inMemory({
dequeue: "alt+up",
"app.message.dequeue": "alt+up",
});
expect(keybindings.getDisplayString("dequeue")).toBe("Alt+Up");
expect(keybindings.getDisplayString("app.message.dequeue")).toBe("Alt+Up");
});
it("formats multiple bindings with the existing separator", () => {
const keybindings = KeybindingsManager.inMemory({
copyPrompt: ["alt+shift+c", "ctrl+shift+c"],
"app.clipboard.copyPrompt": ["alt+shift+c", "ctrl+shift+c"],
});
expect(keybindings.getDisplayString("copyPrompt")).toBe("Alt+Shift+C/Ctrl+Shift+C");
expect(keybindings.getDisplayString("app.clipboard.copyPrompt")).toBe("Alt+Shift+C/Ctrl+Shift+C");
});
it("returns an empty string when the action has no binding", () => {
const keybindings = KeybindingsManager.inMemory({
copyPrompt: [],
"app.clipboard.copyPrompt": [],
});
expect(keybindings.getDisplayString("copyPrompt")).toBe("");
expect(keybindings.getDisplayString("app.clipboard.copyPrompt")).toBe("");
});
});
@@ -0,0 +1,104 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import { getBundledModel } from "@oh-my-pi/pi-ai";
import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings";
import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { ModelSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/model-selector";
import { SessionSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/session-selector";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { setKeybindings, type TUI } from "@oh-my-pi/pi-tui";
beforeAll(() => {
initTheme();
});
afterEach(() => {
setKeybindings(KeybindingsManager.inMemory());
vi.restoreAllMocks();
});
function createSession(id: string, title: string): SessionInfo {
return {
path: `/tmp/${id}.jsonl`,
id,
cwd: "/tmp",
title,
created: new Date("2024-01-01T00:00:00Z"),
modified: new Date("2024-01-02T00:00:00Z"),
messageCount: 1,
firstMessage: `${title} first message`,
allMessagesText: `${title} first message`,
};
}
describe("component escape bindings", () => {
it("uses app.interrupt for session selector cancel without changing Ctrl+C exit", () => {
const keybindings = KeybindingsManager.inMemory({
"app.interrupt": "alt+x",
});
setKeybindings(keybindings);
const onCancel = vi.fn();
const onExit = vi.fn();
const selector = new SessionSelectorComponent(
[createSession("session-a", "Alpha"), createSession("session-b", "Beta")],
() => {},
onCancel,
onExit,
);
selector.handleInput("\x1b");
expect(onCancel).not.toHaveBeenCalled();
selector.handleInput("\x1bx");
expect(onCancel).toHaveBeenCalledTimes(1);
selector.handleInput("\x03");
expect(onExit).toHaveBeenCalledTimes(1);
});
it("uses tui.select.cancel for model selector cancellation", async () => {
const keybindings = KeybindingsManager.inMemory({
"tui.select.cancel": "ctrl+g",
});
setKeybindings(keybindings);
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) {
throw new Error("Expected bundled model anthropic/claude-sonnet-4-5");
}
const settings = Settings.isolated({
modelRoles: {
default: `${model.provider}/${model.id}`,
},
});
const modelRegistry = {
getAll: () => [model],
getDiscoverableProviders: () => [],
} as unknown as ModelRegistry;
const ui = {
requestRender: vi.fn(),
} as unknown as TUI;
const onCancel = vi.fn();
const selector = new ModelSelectorComponent(
ui,
model,
settings,
modelRegistry,
[{ model, thinkingLevel: "off" }],
() => {},
onCancel,
);
await Bun.sleep(0);
selector.handleInput("\x1b");
expect(onCancel).not.toHaveBeenCalled();
selector.handleInput("\x07");
expect(onCancel).toHaveBeenCalledTimes(1);
});
});

Some files were not shown because too many files have changed in this diff Show More