merge: sync upstream pi-mono main into sproutclaw
Some checks failed
CI / build-check-test (push) Has been cancelled

Merge upstream/main (195 commits) while keeping sproutclaw-specific
README, webui workflow docs, and restored upstream .pi prompt templates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-05-22 20:05:31 +08:00
642 changed files with 18785 additions and 26436 deletions

View File

@@ -1,12 +1,12 @@
#!/usr/bin/env node
import { APP_NAME } from "../config.js";
import { APP_NAME } from "../config.ts";
process.title = APP_NAME;
process.emitWarning = (() => {}) as typeof process.emitWarning;
import { restoreSandboxEnv } from "./restore-sandbox-env.js";
import { restoreSandboxEnv } from "./restore-sandbox-env.ts";
restoreSandboxEnv();
await import("./register-bedrock.js");
await import("../cli.js");
await import("./register-bedrock.ts");
await import("../cli.ts");

View File

@@ -5,18 +5,16 @@
*
* Test with: npx tsx src/cli-new.ts [args...]
*/
import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
import { APP_NAME } from "./config.js";
import { main } from "./main.js";
import { APP_NAME } from "./config.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { main } from "./main.ts";
process.title = APP_NAME;
process.env.PI_CODING_AGENT = "true";
process.emitWarning = (() => {}) as typeof process.emitWarning;
// bodyTimeout/headersTimeout default to 300s in undici; long local-LLM stalls
// (e.g. vLLM buffering a large tool call) exceed that and abort the SSE stream
// with UND_ERR_BODY_TIMEOUT. Disable both — provider SDKs enforce their own
// AbortController-based deadlines via retry.provider.timeoutMs.
setGlobalDispatcher(new EnvHttpProxyAgent({ bodyTimeout: 0, headersTimeout: 0 }));
// Configure undici's global dispatcher before provider SDKs issue requests.
// Runtime settings are applied once SettingsManager has loaded global/project settings.
configureHttpDispatcher();
main(process.argv.slice(2));

View File

@@ -4,8 +4,8 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import chalk from "chalk";
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, ENV_SESSION_DIR } from "../config.js";
import type { ExtensionFlag } from "../core/extensions/types.js";
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, ENV_SESSION_DIR } from "../config.ts";
import type { ExtensionFlag } from "../core/extensions/types.ts";
export type Mode = "text" | "json" | "rpc";

View File

@@ -3,10 +3,10 @@
*/
import { ProcessTerminal, TUI } from "@earendil-works/pi-tui";
import type { ResolvedPaths } from "../core/package-manager.js";
import type { SettingsManager } from "../core/settings-manager.js";
import { ConfigSelectorComponent } from "../modes/interactive/components/config-selector.js";
import { initTheme, stopThemeWatcher } from "../modes/interactive/theme/theme.js";
import type { ResolvedPaths } from "../core/package-manager.ts";
import type { SettingsManager } from "../core/settings-manager.ts";
import { ConfigSelectorComponent } from "../modes/interactive/components/config-selector.ts";
import { initTheme, stopThemeWatcher } from "../modes/interactive/theme/theme.ts";
export interface ConfigSelectorOptions {
resolvedPaths: ResolvedPaths;
@@ -43,6 +43,7 @@ export async function selectConfig(options: ConfigSelectorOptions): Promise<void
process.exit(0);
},
() => ui.requestRender(),
ui.terminal.rows,
);
ui.addChild(selector);

View File

@@ -6,9 +6,9 @@ import { access, readFile, stat } from "node:fs/promises";
import type { ImageContent } from "@earendil-works/pi-ai";
import chalk from "chalk";
import { resolve } from "path";
import { resolveReadPath } from "../core/tools/path-utils.js";
import { formatDimensionNote, resizeImage } from "../utils/image-resize.js";
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.js";
import { resolveReadPath } from "../core/tools/path-utils.ts";
import { formatDimensionNote, resizeImage } from "../utils/image-resize.ts";
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.ts";
export interface ProcessedFiles {
text: string;

View File

@@ -1,5 +1,5 @@
import type { ImageContent } from "@earendil-works/pi-ai";
import type { Args } from "./args.js";
import type { Args } from "./args.ts";
export interface InitialMessageInput {
parsed: Args;

View File

@@ -5,8 +5,8 @@
import type { Api, Model } from "@earendil-works/pi-ai";
import { fuzzyFilter } from "@earendil-works/pi-tui";
import chalk from "chalk";
import { formatNoModelsAvailableMessage } from "../core/auth-guidance.js";
import type { ModelRegistry } from "../core/model-registry.js";
import { formatNoModelsAvailableMessage } from "../core/auth-guidance.ts";
import type { ModelRegistry } from "../core/model-registry.ts";
/**
* Format a number as human-readable (e.g., 200000 -> "200K", 1000000 -> "1M")

View File

@@ -3,9 +3,9 @@
*/
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
import { KeybindingsManager } from "../core/keybindings.js";
import type { SessionInfo, SessionListProgress } from "../core/session-manager.js";
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js";
import { KeybindingsManager } from "../core/keybindings.ts";
import type { SessionInfo, SessionListProgress } from "../core/session-manager.ts";
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.ts";
type SessionsLoader = (onProgress?: SessionListProgress) => Promise<SessionInfo[]>;

View File

@@ -1,9 +1,9 @@
import { spawnSync } from "child_process";
import { accessSync, constants, existsSync, readFileSync, realpathSync } from "fs";
import { homedir } from "os";
import { basename, dirname, join, resolve, sep, win32 } from "path";
import { fileURLToPath } from "url";
import { shouldUseWindowsShell } from "./utils/child-process.js";
import { spawnProcessSync } from "./utils/child-process.ts";
import { normalizePath } from "./utils/paths.ts";
// =============================================================================
// Package Detection
@@ -111,21 +111,21 @@ function getSelfUpdateCommandForMethod(
return undefined;
case "pnpm":
return makeSelfUpdateCommand(
makeSelfUpdateCommandStep("pnpm", ["install", "-g", updatePackageName]),
makeSelfUpdateCommandStep("pnpm", ["install", "-g", "--ignore-scripts", updatePackageName]),
updatePackageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("pnpm", ["remove", "-g", installedPackageName]),
);
case "yarn":
return makeSelfUpdateCommand(
makeSelfUpdateCommandStep("yarn", ["global", "add", updatePackageName]),
makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", updatePackageName]),
updatePackageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("yarn", ["global", "remove", installedPackageName]),
);
case "bun":
return makeSelfUpdateCommand(
makeSelfUpdateCommandStep("bun", ["install", "-g", updatePackageName]),
makeSelfUpdateCommandStep("bun", ["install", "-g", "--ignore-scripts", updatePackageName]),
updatePackageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName]),
@@ -134,7 +134,13 @@ function getSelfUpdateCommandForMethod(
const [command = "npm", ...npmArgs] = npmCommand ?? [];
const inferred = npmCommand?.length ? undefined : getInferredNpmInstall();
const prefixArgs = [...npmArgs, ...(inferred ? ["--prefix", inferred.prefix] : [])];
const installStep = makeSelfUpdateCommandStep(command, [...prefixArgs, "install", "-g", updatePackageName]);
const installStep = makeSelfUpdateCommandStep(command, [
...prefixArgs,
"install",
"-g",
"--ignore-scripts",
updatePackageName,
]);
const uninstallStep =
updatePackageName === installedPackageName
? undefined
@@ -151,10 +157,9 @@ function readCommandOutput(
args: string[],
options: { requireSuccess?: boolean } = {},
): string | undefined {
const result = spawnSync(command, args, {
const result = spawnProcessSync(command, args, {
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
shell: shouldUseWindowsShell(command),
});
if (result.status === 0) return result.stdout.trim() || undefined;
if (options.requireSuccess) {
@@ -207,16 +212,18 @@ function getGlobalPackageRoots(method: InstallMethod, _packageName: string, npmC
}
}
function normalizeExistingPathForComparison(path: string): string | undefined {
function normalizeExistingPathForComparison(path: string, resolveSymlinks: boolean): string | undefined {
const resolvedPath = resolve(path);
if (!existsSync(resolvedPath)) {
return undefined;
}
let normalizedPath: string;
try {
normalizedPath = realpathSync(resolvedPath);
} catch {
return undefined;
let normalizedPath = resolvedPath;
if (resolveSymlinks) {
try {
normalizedPath = realpathSync(resolvedPath);
} catch {
return undefined;
}
}
if (process.platform === "win32") {
normalizedPath = normalizedPath.toLowerCase();
@@ -224,6 +231,29 @@ function normalizeExistingPathForComparison(path: string): string | undefined {
return normalizedPath;
}
function getPathComparisonCandidates(path: string): string[] {
return Array.from(
new Set(
[normalizeExistingPathForComparison(path, false), normalizeExistingPathForComparison(path, true)].filter(
(candidate): candidate is string => !!candidate,
),
),
);
}
function getEntrypointPackageDir(): string | undefined {
const entrypoint = process.argv[1];
if (!entrypoint) return undefined;
let dir = dirname(entrypoint);
while (dir !== dirname(dir)) {
if (existsSync(join(dir, "package.json"))) {
return dir;
}
dir = dirname(dir);
}
return undefined;
}
function isSelfUpdatePathWritable(): boolean {
const packageDir = getPackageDir();
try {
@@ -236,17 +266,14 @@ function isSelfUpdatePathWritable(): boolean {
}
function isManagedByGlobalPackageManager(method: InstallMethod, packageName: string, npmCommand?: string[]): boolean {
const packageDir = normalizeExistingPathForComparison(getPackageDir());
return (
!!packageDir &&
getGlobalPackageRoots(method, packageName, npmCommand).some((root) => {
const normalizedRoot = normalizeExistingPathForComparison(root);
return (
!!normalizedRoot &&
packageDir.startsWith(normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`)
);
})
);
const packageDirs = [getPackageDir(), getEntrypointPackageDir()].filter((dir): dir is string => !!dir);
const packageDirCandidates = packageDirs.flatMap((dir) => getPathComparisonCandidates(dir));
return getGlobalPackageRoots(method, packageName, npmCommand).some((root) => {
return getPathComparisonCandidates(root).some((normalizedRoot) => {
const rootPrefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
return packageDirCandidates.some((packageDir) => packageDir.startsWith(rootPrefix));
});
});
}
export function getSelfUpdateCommand(
@@ -304,9 +331,7 @@ export function getPackageDir(): string {
// Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly)
const envDir = process.env.PI_PACKAGE_DIR;
if (envDir) {
if (envDir === "~") return homedir();
if (envDir.startsWith("~/")) return homedir() + envDir.slice(1);
return envDir;
return normalizePath(envDir);
}
if (isBunBinary) {
@@ -428,9 +453,7 @@ export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`;
export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`;
export function expandTildePath(path: string): string {
if (path === "~") return homedir();
if (path.startsWith("~/")) return homedir() + path.slice(1);
return path;
return normalizePath(path);
}
const DEFAULT_SHARE_VIEWER_URL = "https://pi.dev/session/";

View File

@@ -1,12 +1,13 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import type { AgentSession } from "./agent-session.js";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.js";
import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.js";
import { emitSessionShutdownEvent } from "./extensions/runner.js";
import type { CreateAgentSessionResult } from "./sdk.js";
import { assertSessionCwdExists } from "./session-cwd.js";
import { SessionManager } from "./session-manager.js";
import { resolvePath } from "../utils/paths.ts";
import type { AgentSession } from "./agent-session.ts";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts";
import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts";
import { emitSessionShutdownEvent } from "./extensions/runner.ts";
import type { CreateAgentSessionResult } from "./sdk.ts";
import { assertSessionCwdExists } from "./session-cwd.ts";
import { SessionManager } from "./session-manager.ts";
/**
* Result returned by runtime creation.
@@ -67,14 +68,25 @@ function extractUserMessageText(content: string | Array<{ type: string; text?: s
export class AgentSessionRuntime {
private rebindSession?: (session: AgentSession) => Promise<void>;
private beforeSessionInvalidate?: () => void;
private _session: AgentSession;
private _services: AgentSessionServices;
private readonly createRuntime: CreateAgentSessionRuntimeFactory;
private _diagnostics: AgentSessionRuntimeDiagnostic[];
private _modelFallbackMessage?: string;
constructor(
private _session: AgentSession,
private _services: AgentSessionServices,
private readonly createRuntime: CreateAgentSessionRuntimeFactory,
private _diagnostics: AgentSessionRuntimeDiagnostic[] = [],
private _modelFallbackMessage?: string,
) {}
_session: AgentSession,
_services: AgentSessionServices,
createRuntime: CreateAgentSessionRuntimeFactory,
_diagnostics: AgentSessionRuntimeDiagnostic[] = [],
_modelFallbackMessage?: string,
) {
this._session = _session;
this._services = _services;
this.createRuntime = createRuntime;
this._diagnostics = _diagnostics;
this._modelFallbackMessage = _modelFallbackMessage;
}
get services(): AgentSessionServices {
return this._services;
@@ -281,12 +293,11 @@ export class AgentSessionRuntime {
return { cancelled: false, selectedText };
}
const sourceManager = SessionManager.open(currentSessionFile, sessionDir);
const forkedSessionPath = sourceManager.createBranchedSession(targetLeafId);
const sessionManager = SessionManager.open(currentSessionFile, sessionDir);
const forkedSessionPath = sessionManager.createBranchedSession(targetLeafId);
if (!forkedSessionPath) {
throw new Error("Failed to create forked session");
}
const sessionManager = SessionManager.open(forkedSessionPath, sessionDir);
await this.teardownCurrent("fork", sessionManager.getSessionFile());
this.apply(
await this.createRuntime({
@@ -327,7 +338,7 @@ export class AgentSessionRuntime {
* @throws {MissingSessionCwdError} When the imported session cwd cannot be resolved and no override is provided.
*/
async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> {
const resolvedPath = resolve(inputPath);
const resolvedPath = resolvePath(inputPath);
if (!existsSync(resolvedPath)) {
throw new SessionImportFileNotFoundError(resolvedPath);
}
@@ -406,4 +417,4 @@ export {
type CreateAgentSessionServicesOptions,
createAgentSessionFromServices,
createAgentSessionServices,
} from "./agent-session-services.js";
} from "./agent-session-services.ts";

View File

@@ -1,14 +1,15 @@
import { join } from "node:path";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Model } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.js";
import { AuthStorage } from "./auth-storage.js";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js";
import { ModelRegistry } from "./model-registry.js";
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.js";
import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.js";
import type { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { AuthStorage } from "./auth-storage.ts";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { ModelRegistry } from "./model-registry.ts";
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.ts";
import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.ts";
import type { SessionManager } from "./session-manager.ts";
import { SettingsManager } from "./settings-manager.ts";
/**
* Non-fatal issues collected while creating services or sessions.
@@ -129,8 +130,8 @@ function applyExtensionFlagValues(
export async function createAgentSessionServices(
options: CreateAgentSessionServicesOptions,
): Promise<AgentSessionServices> {
const cwd = options.cwd;
const agentDir = options.agentDir ?? getAgentDir();
const cwd = resolvePath(options.cwd);
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir();
const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json"));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json"));

View File

@@ -14,7 +14,7 @@
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, resolve } from "node:path";
import { basename, dirname } from "node:path";
import type {
Agent,
AgentEvent,
@@ -31,12 +31,14 @@ import {
isContextOverflow,
modelsAreEqual,
resetApiProviders,
streamSimple,
} from "@earendil-works/pi-ai";
import { theme } from "../modes/interactive/theme/theme.js";
import { stripFrontmatter } from "../utils/frontmatter.js";
import { sleep } from "../utils/sleep.js";
import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js";
import { type BashResult, executeBashWithOperations } from "./bash-executor.js";
import { theme } from "../modes/interactive/theme/theme.ts";
import { stripFrontmatter } from "../utils/frontmatter.ts";
import { resolvePath } from "../utils/paths.ts";
import { sleep } from "../utils/sleep.ts";
import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts";
import { type BashResult, executeBashWithOperations } from "./bash-executor.ts";
import {
type CompactionResult,
calculateContextTokens,
@@ -46,10 +48,10 @@ import {
generateBranchSummary,
prepareCompaction,
shouldCompact,
} from "./compaction/index.js";
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
} from "./compaction/index.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts";
import {
type ContextUsage,
type ExtensionCommandContextActions,
@@ -74,21 +76,21 @@ import {
type TurnEndEvent,
type TurnStartEvent,
wrapRegisteredTools,
} from "./extensions/index.js";
import { emitSessionShutdownEvent } from "./extensions/runner.js";
import type { BashExecutionMessage, CustomMessage } from "./messages.js";
import type { ModelRegistry } from "./model-registry.js";
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js";
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.js";
import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js";
import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.js";
import type { SettingsManager } from "./settings-manager.js";
import type { SlashCommandInfo } from "./slash-commands.js";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.js";
import { type BashOperations, createLocalBashOperations } from "./tools/bash.js";
import { createAllToolDefinitions } from "./tools/index.js";
import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js";
} from "./extensions/index.ts";
import { emitSessionShutdownEvent } from "./extensions/runner.ts";
import type { BashExecutionMessage, CustomMessage } from "./messages.ts";
import type { ModelRegistry } from "./model-registry.ts";
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";
import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.ts";
import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts";
import type { SettingsManager } from "./settings-manager.ts";
import type { SlashCommandInfo } from "./slash-commands.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.ts";
import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts";
import { createAllToolDefinitions } from "./tools/index.ts";
import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts";
// ============================================================================
// Skill Block Parsing
@@ -119,7 +121,12 @@ export function parseSkillBlock(text: string): ParsedSkillBlock | null {
/** Session-specific events that extend the core AgentEvent */
export type AgentSessionEvent =
| AgentEvent
| Exclude<AgentEvent, { type: "agent_end" }>
| {
type: "agent_end";
messages: AgentMessage[];
willRetry: boolean;
}
| {
type: "queue_update";
steering: readonly string[];
@@ -179,6 +186,7 @@ export interface AgentSessionConfig {
export interface ExtensionBindings {
uiContext?: ExtensionUIContext;
commandContextActions?: ExtensionCommandContextActions;
abortHandler?: () => void;
shutdownHandler?: ShutdownHandler;
onError?: ExtensionErrorListener;
}
@@ -251,7 +259,6 @@ export class AgentSession {
// Event subscription state
private _unsubscribeAgent?: () => void;
private _eventListeners: AgentSessionEventListener[] = [];
private _agentEventQueue: Promise<void> = Promise.resolve();
/** Tracks pending steering messages for UI display. Removed when delivered. */
private _steeringMessages: string[] = [];
@@ -271,8 +278,6 @@ export class AgentSession {
// Retry state
private _retryAbortController: AbortController | undefined = undefined;
private _retryAttempt = 0;
private _retryPromise: Promise<void> | undefined = undefined;
private _retryResolve: (() => void) | undefined = undefined;
// Bash execution state
private _bashAbortController: AbortController | undefined = undefined;
@@ -293,6 +298,7 @@ export class AgentSession {
private _sessionStartEvent: SessionStartEvent;
private _extensionUIContext?: ExtensionUIContext;
private _extensionCommandContextActions?: ExtensionCommandContextActions;
private _extensionAbortHandler?: () => void;
private _extensionShutdownHandler?: ShutdownHandler;
private _extensionErrorListener?: ExtensionErrorListener;
private _extensionErrorUnsubscriber?: () => void;
@@ -367,6 +373,18 @@ export class AgentSession {
throw new Error(formatNoApiKeyFoundMessage(model.provider));
}
private async _getCompactionRequestAuth(model: Model<any>): Promise<{
apiKey?: string;
headers?: Record<string, string>;
}> {
if (this.agent.streamFn === streamSimple) {
return this._getRequiredRequestAuth(model);
}
const result = await this._modelRegistry.getApiKeyAndHeaders(model);
return result.ok ? { apiKey: result.apiKey, headers: result.headers } : {};
}
/**
* Install tool hooks once on the Agent instance.
*
@@ -382,8 +400,6 @@ export class AgentSession {
return undefined;
}
await this._agentEventQueue;
try {
return await runner.emitToolCall({
type: "tool_call",
@@ -450,54 +466,7 @@ export class AgentSession {
private _lastAssistantMessage: AssistantMessage | undefined = undefined;
/** Internal handler for agent events - shared by subscribe and reconnect */
private _handleAgentEvent = (event: AgentEvent): void => {
// Create retry promise synchronously before queueing async processing.
// Agent.emit() calls this handler synchronously, and prompt() calls waitForRetry()
// as soon as agent.prompt() resolves. If _retryPromise is created only inside
// _processAgentEvent, slow earlier queued events can delay agent_end processing
// and waitForRetry() can miss the in-flight retry.
this._createRetryPromiseForAgentEnd(event);
this._agentEventQueue = this._agentEventQueue.then(
() => this._processAgentEvent(event),
() => this._processAgentEvent(event),
);
// Keep queue alive if an event handler fails
this._agentEventQueue.catch(() => {});
};
private _createRetryPromiseForAgentEnd(event: AgentEvent): void {
if (event.type !== "agent_end" || this._retryPromise) {
return;
}
const settings = this.settingsManager.getRetrySettings();
if (!settings.enabled) {
return;
}
const lastAssistant = this._findLastAssistantInMessages(event.messages);
if (!lastAssistant || !this._isRetryableError(lastAssistant)) {
return;
}
this._retryPromise = new Promise((resolve) => {
this._retryResolve = resolve;
});
}
private _findLastAssistantInMessages(messages: AgentMessage[]): AssistantMessage | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role === "assistant") {
return message as AssistantMessage;
}
}
return undefined;
}
private async _processAgentEvent(event: AgentEvent): Promise<void> {
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
// When a user message starts, check if it's from either queue and remove it BEFORE emitting
// This ensures the UI sees the updated queue state
if (event.type === "message_start" && event.message.role === "user") {
@@ -524,7 +493,7 @@ export class AgentSession {
await this._emitExtensionEvent(event);
// Notify all listeners
this._emit(event);
this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
// Handle session persistence
if (event.type === "message_end") {
@@ -568,30 +537,21 @@ export class AgentSession {
}
}
}
};
// Check auto-retry and auto-compaction after agent completes
if (event.type === "agent_end" && this._lastAssistantMessage) {
const msg = this._lastAssistantMessage;
this._lastAssistantMessage = undefined;
private _willRetryAfterAgentEnd(event: Extract<AgentEvent, { type: "agent_end" }>): boolean {
const settings = this.settingsManager.getRetrySettings();
if (!settings.enabled || this._retryAttempt >= settings.maxRetries) {
return false;
}
// Check for retryable errors first (overloaded, rate limit, server errors)
if (this._isRetryableError(msg)) {
const didRetry = await this._handleRetryableError(msg);
if (didRetry) return; // Retry was initiated, don't proceed to compaction
for (let i = event.messages.length - 1; i >= 0; i--) {
const message = event.messages[i];
if (message.role === "assistant") {
return this._isRetryableError(message as AssistantMessage);
}
this._resolveRetry();
await this._checkCompaction(msg);
}
}
/** Resolve the pending retry promise */
private _resolveRetry(): void {
if (this._retryResolve) {
this._retryResolve();
this._retryResolve = undefined;
this._retryPromise = undefined;
}
return false;
}
/** Extract text content from a message */
@@ -617,7 +577,7 @@ export class AgentSession {
private _replaceMessageInPlace(target: AgentMessage, replacement: AgentMessage): void {
// Agent-core stores the finalized message object in its state before emitting message_end.
// SessionManager persistence happens later in _processAgentEvent() with event.message.
// SessionManager persistence happens later in _handleAgentEvent() with event.message.
// Mutating this object in place keeps agent state, later turn/agent events, listeners,
// and the eventual SessionManager.appendMessage(event.message) persistence in sync.
if (target === replacement) {
@@ -963,6 +923,41 @@ export class AgentSession {
// Prompting
// =========================================================================
private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {
try {
await this.agent.prompt(messages);
while (await this._handlePostAgentRun()) {
await this.agent.continue();
}
} finally {
this._flushPendingBashMessages();
}
}
private async _handlePostAgentRun(): Promise<boolean> {
const msg = this._lastAssistantMessage;
this._lastAssistantMessage = undefined;
if (!msg) {
return false;
}
if (this._isRetryableError(msg) && (await this._prepareRetry(msg))) {
return true;
}
if (msg.stopReason === "error" && this._retryAttempt > 0) {
this._emit({
type: "auto_retry_end",
success: false,
attempt: this._retryAttempt,
finalError: msg.errorMessage,
});
this._retryAttempt = 0;
}
return await this._checkCompaction(msg);
}
/**
* Send a prompt to the agent.
* - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
@@ -1053,8 +1048,15 @@ export class AgentSession {
// Check if we need to compact before sending (catches aborted responses)
const lastAssistant = this._findLastAssistantMessage();
if (lastAssistant) {
await this._checkCompaction(lastAssistant, false);
if (lastAssistant && (await this._checkCompaction(lastAssistant, false))) {
try {
await this.agent.continue();
while (await this._handlePostAgentRun()) {
await this.agent.continue();
}
} finally {
this._flushPendingBashMessages();
}
}
// Build messages array (custom message if any, then user message)
@@ -1114,8 +1116,7 @@ export class AgentSession {
}
preflightResult?.(true);
await this.agent.prompt(messages);
await this.waitForRetry();
await this._runAgentPrompt(messages);
}
/**
@@ -1301,7 +1302,7 @@ export class AgentSession {
this.agent.steer(appMessage);
}
} else if (options?.triggerTurn) {
await this.agent.prompt(appMessage);
await this._runAgentPrompt(appMessage);
} else {
this.agent.state.messages.push(appMessage);
this.sessionManager.appendCustomMessageEntry(
@@ -1626,7 +1627,7 @@ export class AgentSession {
throw new Error(formatNoModelSelectedMessage());
}
const { apiKey, headers } = await this._getRequiredRequestAuth(this.model);
const { apiKey, headers } = await this._getCompactionRequestAuth(this.model);
const pathEntries = this.sessionManager.getBranch();
const settings = this.settingsManager.getCompactionSettings();
@@ -1684,6 +1685,7 @@ export class AgentSession {
customInstructions,
this._compactionAbortController.signal,
this.thinkingLevel,
this.agent.streamFn,
);
summary = result.summary;
firstKeptEntryId = result.firstKeptEntryId;
@@ -1771,12 +1773,12 @@ export class AgentSession {
* @param assistantMessage The assistant message to check
* @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
*/
private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<void> {
private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<boolean> {
const settings = this.settingsManager.getCompactionSettings();
if (!settings.enabled) return;
if (!settings.enabled) return false;
// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return;
if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;
const contextWindow = this.model?.contextWindow ?? 0;
@@ -1794,7 +1796,7 @@ export class AgentSession {
const assistantIsFromBeforeCompaction =
compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime();
if (assistantIsFromBeforeCompaction) {
return;
return false;
}
// Case 1: Overflow - LLM returned context overflow error
@@ -1809,7 +1811,7 @@ export class AgentSession {
errorMessage:
"Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",
});
return;
return false;
}
this._overflowRecoveryAttempted = true;
@@ -1819,8 +1821,7 @@ export class AgentSession {
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
this.agent.state.messages = messages.slice(0, -1);
}
await this._runAutoCompaction("overflow", true);
return;
return await this._runAutoCompaction("overflow", true);
}
// Case 2: Threshold - context is getting large
@@ -1830,7 +1831,7 @@ export class AgentSession {
if (assistantMessage.stopReason === "error") {
const messages = this.agent.state.messages;
const estimate = estimateContextTokens(messages);
if (estimate.lastUsageIndex === null) return; // No usage data at all
if (estimate.lastUsageIndex === null) return false; // No usage data at all
// Verify the usage source is post-compaction. Kept pre-compaction messages
// have stale usage reflecting the old (larger) context and would falsely
// trigger compaction right after one just finished.
@@ -1840,21 +1841,22 @@ export class AgentSession {
usageMsg.role === "assistant" &&
(usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime()
) {
return;
return false;
}
contextTokens = estimate.tokens;
} else {
contextTokens = calculateContextTokens(assistantMessage.usage);
}
if (shouldCompact(contextTokens, contextWindow, settings)) {
await this._runAutoCompaction("threshold", false);
return await this._runAutoCompaction("threshold", false);
}
return false;
}
/**
* Internal: Run auto-compaction with events.
*/
private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<void> {
private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<boolean> {
const settings = this.settingsManager.getCompactionSettings();
this._emit({ type: "compaction_start", reason });
@@ -1869,21 +1871,28 @@ export class AgentSession {
aborted: false,
willRetry: false,
});
return;
return false;
}
const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model);
if (!authResult.ok || !authResult.apiKey) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
});
return;
let apiKey: string | undefined;
let headers: Record<string, string> | undefined;
if (this.agent.streamFn === streamSimple) {
const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model);
if (!authResult.ok || !authResult.apiKey) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
});
return false;
}
apiKey = authResult.apiKey;
headers = authResult.headers;
} else {
({ apiKey, headers } = await this._getCompactionRequestAuth(this.model));
}
const { apiKey, headers } = authResult;
const pathEntries = this.sessionManager.getBranch();
@@ -1896,7 +1905,7 @@ export class AgentSession {
aborted: false,
willRetry: false,
});
return;
return false;
}
let extensionCompaction: CompactionResult | undefined;
@@ -1919,7 +1928,7 @@ export class AgentSession {
aborted: true,
willRetry: false,
});
return;
return false;
}
if (extensionResult?.compaction) {
@@ -1949,6 +1958,7 @@ export class AgentSession {
undefined,
this._autoCompactionAbortController.signal,
this.thinkingLevel,
this.agent.streamFn,
);
summary = compactResult.summary;
firstKeptEntryId = compactResult.firstKeptEntryId;
@@ -1964,7 +1974,7 @@ export class AgentSession {
aborted: true,
willRetry: false,
});
return;
return false;
}
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
@@ -1999,17 +2009,12 @@ export class AgentSession {
if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {
this.agent.state.messages = messages.slice(0, -1);
}
setTimeout(() => {
this.agent.continue().catch(() => {});
}, 100);
} else if (this.agent.hasQueuedMessages()) {
// Auto-compaction can complete while follow-up/steering/custom messages are waiting.
// Kick the loop so queued messages are actually delivered.
setTimeout(() => {
this.agent.continue().catch(() => {});
}, 100);
return true;
}
// Auto-compaction can complete while follow-up/steering/custom messages are waiting.
// Continue once so queued messages are delivered.
return this.agent.hasQueuedMessages();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "compaction failed";
this._emit({
@@ -2023,6 +2028,7 @@ export class AgentSession {
? `Context overflow recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`,
});
return false;
} finally {
this._autoCompactionAbortController = undefined;
}
@@ -2047,6 +2053,9 @@ export class AgentSession {
if (bindings.commandContextActions !== undefined) {
this._extensionCommandContextActions = bindings.commandContextActions;
}
if (bindings.abortHandler !== undefined) {
this._extensionAbortHandler = bindings.abortHandler;
}
if (bindings.shutdownHandler !== undefined) {
this._extensionShutdownHandler = bindings.shutdownHandler;
}
@@ -2211,7 +2220,13 @@ export class AgentSession {
getModel: () => this.model,
isIdle: () => !this.isStreaming,
getSignal: () => this.agent.signal,
abort: () => this.abort(),
abort: () => {
if (this._extensionAbortHandler) {
this._extensionAbortHandler();
return;
}
void this.abort();
},
hasPendingMessages: () => this.pendingMessageCount > 0,
shutdown: () => {
this._extensionShutdownHandler?.();
@@ -2427,43 +2442,27 @@ export class AgentSession {
if (isContextOverflow(message, contextWindow)) return false;
const err = message.errorMessage;
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, request ended without sending chunks, HTTP/2 closed before response, 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|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test(
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, premature stream endings, HTTP/2 closed before response, 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|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test(
err,
);
}
/**
* Handle retryable errors with exponential backoff.
* @returns true if retry was initiated, false if max retries exceeded or disabled
* Prepare a retryable error for continuation with exponential backoff.
* @returns true if the caller should continue the agent, false otherwise
*/
private async _handleRetryableError(message: AssistantMessage): Promise<boolean> {
private async _prepareRetry(message: AssistantMessage): Promise<boolean> {
const settings = this.settingsManager.getRetrySettings();
if (!settings.enabled) {
this._resolveRetry();
return false;
}
// Retry promise is created synchronously in _handleAgentEvent for agent_end.
// Keep a defensive fallback here in case a future refactor bypasses that path.
if (!this._retryPromise) {
this._retryPromise = new Promise((resolve) => {
this._retryResolve = resolve;
});
}
this._retryAttempt++;
if (this._retryAttempt > settings.maxRetries) {
// Max retries exceeded, emit final failure and reset
this._emit({
type: "auto_retry_end",
success: false,
attempt: this._retryAttempt - 1,
finalError: message.errorMessage,
});
this._retryAttempt = 0;
this._resolveRetry(); // Resolve so waitForRetry() completes
// Preserve the completed attempt count so post-run handling can emit the final failure.
this._retryAttempt--;
return false;
}
@@ -2491,24 +2490,16 @@ export class AgentSession {
// Aborted during sleep - emit end event so UI can clean up
const attempt = this._retryAttempt;
this._retryAttempt = 0;
this._retryAbortController = undefined;
this._emit({
type: "auto_retry_end",
success: false,
attempt,
finalError: "Retry cancelled",
});
this._resolveRetry();
return false;
} finally {
this._retryAbortController = undefined;
}
this._retryAbortController = undefined;
// Retry via continue() - use setTimeout to break out of event handler chain
setTimeout(() => {
this.agent.continue().catch(() => {
// Retry failed - will be caught by next agent_end
});
}, 0);
return true;
}
@@ -2518,26 +2509,11 @@ export class AgentSession {
*/
abortRetry(): void {
this._retryAbortController?.abort();
// Note: _retryAttempt is reset in the catch block of _autoRetry
this._resolveRetry();
}
/**
* Wait for any in-progress retry to complete.
* Returns immediately if no retry is in progress.
*/
private async waitForRetry(): Promise<void> {
if (!this._retryPromise) {
return;
}
await this._retryPromise;
await this.agent.waitForIdle();
}
/** Whether auto-retry is currently in progress */
get isRetrying(): boolean {
return this._retryPromise !== undefined;
return this._retryAbortController !== undefined;
}
/** Whether auto-retry is enabled */
@@ -3026,7 +3002,10 @@ export class AgentSession {
* @returns The resolved output file path.
*/
exportToJsonl(outputPath?: string): string {
const filePath = resolve(outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`);
const filePath = resolvePath(
outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`,
process.cwd(),
);
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });

View File

@@ -1,5 +1,5 @@
import { join } from "node:path";
import { getDocsPath } from "../config.js";
import { getDocsPath } from "../config.ts";
const UNKNOWN_PROVIDER = "unknown";

View File

@@ -17,8 +17,9 @@ import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@earendil-w
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
import { getAgentDir } from "../config.js";
import { resolveConfigValue } from "./resolve-config-value.js";
import { getAgentDir } from "../config.ts";
import { normalizePath } from "../utils/paths.ts";
import { resolveConfigValue } from "./resolve-config-value.ts";
export type ApiKeyCredential = {
type: "api_key";
@@ -50,7 +51,11 @@ export interface AuthStorageBackend {
}
export class FileAuthStorageBackend implements AuthStorageBackend {
constructor(private authPath: string = join(getAgentDir(), "auth.json")) {}
private authPath: string;
constructor(authPath: string = join(getAgentDir(), "auth.json")) {
this.authPath = normalizePath(authPath);
}
private ensureParentDir(): void {
const dir = dirname(this.authPath);
@@ -194,8 +199,10 @@ export class AuthStorage {
private fallbackResolver?: (provider: string) => string | undefined;
private loadError: Error | null = null;
private errors: Error[] = [];
private storage: AuthStorageBackend;
private constructor(private storage: AuthStorageBackend) {
private constructor(storage: AuthStorageBackend) {
this.storage = storage;
this.reload();
}

View File

@@ -10,10 +10,10 @@ import { randomBytes } from "node:crypto";
import { createWriteStream, type WriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import stripAnsi from "strip-ansi";
import { sanitizeBinaryOutput } from "../utils/shell.js";
import type { BashOperations } from "./tools/bash.js";
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
import { stripAnsi } from "../utils/ansi.ts";
import { sanitizeBinaryOutput } from "../utils/shell.ts";
import type { BashOperations } from "./tools/bash.ts";
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.ts";
// ============================================================================
// Types

View File

@@ -13,9 +13,9 @@ import {
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "../messages.js";
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js";
import { estimateTokens } from "./compaction.js";
} from "../messages.ts";
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts";
import { estimateTokens } from "./compaction.ts";
import {
computeFileLists,
createFileOps,
@@ -24,7 +24,7 @@ import {
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
} from "./utils.ts";
// ============================================================================
// Types
@@ -44,7 +44,7 @@ export interface BranchSummaryDetails {
modifiedFiles: string[];
}
export type { FileOperations } from "./utils.js";
export type { FileOperations } from "./utils.ts";
export interface BranchPreparation {
/** Messages extracted for summarization, in chronological order */

View File

@@ -5,16 +5,16 @@
* and after compaction the session is reloaded.
*/
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@earendil-works/pi-ai";
import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import {
convertToLlm,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "../messages.js";
import { buildSessionContext, type CompactionEntry, type SessionEntry } from "../session-manager.js";
} from "../messages.ts";
import { buildSessionContext, type CompactionEntry, type SessionEntry } from "../session-manager.ts";
import {
computeFileLists,
createFileOps,
@@ -23,7 +23,7 @@ import {
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
} from "./utils.ts";
// ============================================================================
// File Operation Tracking
@@ -523,6 +523,34 @@ Use this EXACT format:
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
function createSummarizationOptions(
model: Model<any>,
maxTokens: number,
apiKey: string | undefined,
headers: Record<string, string> | undefined,
signal: AbortSignal | undefined,
thinkingLevel: ThinkingLevel | undefined,
): SimpleStreamOptions {
const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers };
if (model.reasoning && thinkingLevel && thinkingLevel !== "off") {
options.reasoning = thinkingLevel;
}
return options;
}
async function completeSummarization(
model: Model<any>,
context: Context,
options: SimpleStreamOptions,
streamFn?: StreamFn,
): Promise<AssistantMessage> {
if (!streamFn) {
return completeSimple(model, context, options);
}
const stream = await streamFn(model, context, options);
return stream.result();
}
/**
* Generate a summary of the conversation using the LLM.
* If previousSummary is provided, uses the update prompt to merge.
@@ -531,14 +559,18 @@ export async function generateSummary(
currentMessages: AgentMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
apiKey: string | undefined,
headers?: Record<string, string>,
signal?: AbortSignal,
customInstructions?: string,
previousSummary?: string,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
): Promise<string> {
const maxTokens = Math.floor(0.8 * reserveTokens);
const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
);
// Use update prompt if we have a previous summary, otherwise initial prompt
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
@@ -566,15 +598,13 @@ export async function generateSummary(
},
];
const completionOptions =
model.reasoning && thinkingLevel && thinkingLevel !== "off"
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers };
const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel);
const response = await completeSimple(
const response = await completeSummarization(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions,
streamFn,
);
if (response.stopReason === "error") {
@@ -717,11 +747,12 @@ Be concise. Focus on what's needed to understand the kept suffix.`;
export async function compact(
preparation: CompactionPreparation,
model: Model<any>,
apiKey: string,
apiKey: string | undefined,
headers?: Record<string, string>,
customInstructions?: string,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
): Promise<CompactionResult> {
const {
firstKeptEntryId,
@@ -751,6 +782,7 @@ export async function compact(
customInstructions,
previousSummary,
thinkingLevel,
streamFn,
)
: Promise.resolve("No prior history."),
generateTurnPrefixSummary(
@@ -761,6 +793,7 @@ export async function compact(
headers,
signal,
thinkingLevel,
streamFn,
),
]);
// Merge into single summary
@@ -777,6 +810,7 @@ export async function compact(
customInstructions,
previousSummary,
thinkingLevel,
streamFn,
);
}
@@ -803,12 +837,16 @@ async function generateTurnPrefixSummary(
messages: AgentMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
apiKey: string | undefined,
headers?: Record<string, string>,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
): Promise<string> {
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
const maxTokens = Math.min(
Math.floor(0.5 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
); // Smaller budget for turn prefix
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
@@ -820,12 +858,11 @@ async function generateTurnPrefixSummary(
},
];
const response = await completeSimple(
const response = await completeSummarization(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
model.reasoning && thinkingLevel && thinkingLevel !== "off"
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers },
createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel),
streamFn,
);
if (response.stopReason === "error") {

View File

@@ -2,6 +2,6 @@
* Compaction and summarization utilities.
*/
export * from "./branch-summarization.js";
export * from "./compaction.js";
export * from "./utils.js";
export * from "./branch-summarization.ts";
export * from "./compaction.ts";
export * from "./utils.ts";

View File

@@ -3,7 +3,7 @@
*/
import { spawn } from "node:child_process";
import { waitForChildProcess } from "../utils/child-process.js";
import { waitForChildProcess } from "../utils/child-process.ts";
/**
* Options for executing shell commands.

View File

@@ -1,11 +1,12 @@
import type { AgentState } from "@earendil-works/pi-agent-core";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { basename, join } from "path";
import { APP_NAME, getExportTemplateDir } from "../../config.js";
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.js";
import type { ToolDefinition } from "../extensions/types.js";
import type { SessionEntry } from "../session-manager.js";
import { SessionManager } from "../session-manager.js";
import { APP_NAME, getExportTemplateDir } from "../../config.ts";
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.ts";
import { normalizePath, resolvePath } from "../../utils/paths.ts";
import type { ToolDefinition } from "../extensions/types.ts";
import type { SessionEntry } from "../session-manager.ts";
import { SessionManager } from "../session-manager.ts";
/**
* Interface for rendering custom tools to HTML.
@@ -270,7 +271,7 @@ export async function exportSessionToHtml(
const html = generateHtml(sessionData, opts.themeName);
let outputPath = opts.outputPath;
let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined;
if (!outputPath) {
const sessionBasename = basename(sessionFile, ".jsonl");
outputPath = `${APP_NAME}-session-${sessionBasename}.html`;
@@ -286,12 +287,13 @@ export async function exportSessionToHtml(
*/
export async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise<string> {
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
const resolvedInputPath = resolvePath(inputPath);
if (!existsSync(inputPath)) {
throw new Error(`File not found: ${inputPath}`);
if (!existsSync(resolvedInputPath)) {
throw new Error(`File not found: ${resolvedInputPath}`);
}
const sm = SessionManager.open(inputPath);
const sm = SessionManager.open(resolvedInputPath);
const sessionData: SessionData = {
header: sm.getHeader(),
@@ -303,9 +305,9 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
const html = generateHtml(sessionData, opts.themeName);
let outputPath = opts.outputPath;
let outputPath = opts.outputPath ? normalizePath(opts.outputPath) : undefined;
if (!outputPath) {
const inputBasename = basename(inputPath, ".jsonl");
const inputBasename = basename(resolvedInputPath, ".jsonl");
outputPath = `${APP_NAME}-session-${inputBasename}.html`;
}

View File

@@ -910,7 +910,8 @@
'</div>';
};
let html = `<div class="tool-execution ${statusClass}">`;
const toolDomId = `tool-call-${escapeHtml(call.id)}`;
let html = `<div class="tool-execution ${statusClass}" id="${toolDomId}">`;
const args = call.arguments || {};
const name = call.name;
@@ -1445,6 +1446,16 @@
// Cache for rendered entry DOM nodes
const entryCache = new Map();
function getScrollTargetElementId(entryId) {
const entry = byId.get(entryId);
if (entry?.type === 'message' && entry.message.role === 'toolResult' && entry.message.toolCallId) {
// getElementById() matches the parsed DOM id attribute, whose HTML entities
// were already resolved from the escaped id rendered by renderToolCall().
return `tool-call-${entry.message.toolCallId}`;
}
return `entry-${entryId}`;
}
function renderEntryToNode(entry) {
// Check cache first
if (entryCache.has(entry.id)) {
@@ -1506,9 +1517,12 @@
if (scrollMode === 'bottom') {
content.scrollTop = content.scrollHeight;
} else if (scrollMode === 'target') {
// If scrollToEntryId is provided, scroll to that specific entry
// If scrollToEntryId is provided, scroll to that specific entry.
// Tool result entries are rendered inside their assistant tool-call block,
// so route them to the visible tool-call element instead.
const scrollTargetId = scrollToEntryId || targetId;
const targetEl = document.getElementById(`entry-${scrollTargetId}`);
const targetEl = document.getElementById(getScrollTargetElementId(scrollTargetId)) ||
document.getElementById(`entry-${scrollTargetId}`);
if (targetEl) {
targetEl.scrollIntoView({ block: 'center' });
// Briefly highlight the target message

View File

@@ -7,9 +7,9 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { Component } from "@earendil-works/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { ToolDefinition, ToolRenderContext } from "../extensions/types.js";
import { ansiLinesToHtml } from "./ansi-to-html.js";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import type { ToolDefinition, ToolRenderContext } from "../extensions/types.ts";
import { ansiLinesToHtml } from "./ansi-to-html.ts";
export interface ToolHtmlRendererDeps {
/** Function to look up tool definition by name */

View File

@@ -2,14 +2,14 @@
* Extension system for lifecycle events and custom tools.
*/
export type { SlashCommandInfo, SlashCommandSource } from "../slash-commands.js";
export type { SourceInfo } from "../source-info.js";
export type { SlashCommandInfo, SlashCommandSource } from "../slash-commands.ts";
export type { SourceInfo } from "../source-info.ts";
export {
createExtensionRuntime,
discoverAndLoadExtensions,
loadExtensionFromFactory,
loadExtensions,
} from "./loader.js";
} from "./loader.ts";
export type {
ExtensionErrorListener,
ForkHandler,
@@ -17,8 +17,8 @@ export type {
NewSessionHandler,
ShutdownHandler,
SwitchSessionHandler,
} from "./runner.js";
export { ExtensionRunner } from "./runner.js";
} from "./runner.ts";
export { ExtensionRunner } from "./runner.ts";
export type {
AfterProviderResponseEvent,
AgentEndEvent,
@@ -156,7 +156,7 @@ export type {
WorkingIndicatorOptions,
WriteToolCallEvent,
WriteToolResultEvent,
} from "./types.js";
} from "./types.ts";
// Type guards
export {
defineTool,
@@ -168,5 +168,5 @@ export {
isReadToolResult,
isToolCallEventType,
isWriteToolResult,
} from "./types.js";
export { wrapRegisteredTool, wrapRegisteredTools } from "./wrapper.js";
} from "./types.ts";
export { wrapRegisteredTool, wrapRegisteredTools } from "./wrapper.ts";

View File

@@ -5,7 +5,6 @@
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core";
@@ -20,14 +19,15 @@ import { createJiti } from "jiti";
import * as _bundledTypebox from "typebox";
import * as _bundledTypeboxCompile from "typebox/compile";
import * as _bundledTypeboxValue from "typebox/value";
import { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.js";
import { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.ts";
// NOTE: This import works because loader.ts exports are NOT re-exported from index.ts,
// avoiding a circular dependency. Extensions can import from @earendil-works/pi-coding-agent.
import * as _bundledPiCodingAgent from "../../index.js";
import { createEventBus, type EventBus } from "../event-bus.js";
import type { ExecOptions } from "../exec.js";
import { execCommand } from "../exec.js";
import { createSyntheticSourceInfo } from "../source-info.js";
import * as _bundledPiCodingAgent from "../../index.ts";
import { resolvePath } from "../../utils/paths.ts";
import { createEventBus, type EventBus } from "../event-bus.ts";
import type { ExecOptions } from "../exec.ts";
import { execCommand } from "../exec.ts";
import { createSyntheticSourceInfo } from "../source-info.ts";
import type {
Extension,
ExtensionAPI,
@@ -38,7 +38,7 @@ import type {
ProviderConfig,
RegisteredCommand,
ToolDefinition,
} from "./types.js";
} from "./types.ts";
/** Modules available to extensions via virtualModules (for compiled Bun binary) */
const VIRTUAL_MODULES: Record<string, unknown> = {
@@ -115,31 +115,6 @@ function getAliases(): Record<string, string> {
return _aliases;
}
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function expandPath(p: string): string {
const normalized = normalizeUnicodeSpaces(p);
if (normalized.startsWith("~/")) {
return path.join(os.homedir(), normalized.slice(2));
}
if (normalized.startsWith("~")) {
return path.join(os.homedir(), normalized.slice(1));
}
return normalized;
}
function resolvePath(extPath: string, cwd: string): string {
const expanded = expandPath(extPath);
if (path.isAbsolute(expanded)) {
return expanded;
}
return path.resolve(cwd, expanded);
}
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
/**
@@ -236,7 +211,7 @@ function createExtensionAPI(
shortcut: KeyId,
options: {
description?: string;
handler: (ctx: import("./types.js").ExtensionContext) => Promise<void> | void;
handler: (ctx: import("./types.ts").ExtensionContext) => Promise<void> | void;
},
): void {
runtime.assertActive();
@@ -396,7 +371,7 @@ async function loadExtension(
eventBus: EventBus,
runtime: ExtensionRuntime,
): Promise<{ extension: Extension | null; error: string | null }> {
const resolvedPath = resolvePath(extensionPath, cwd);
const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true });
try {
const factory = await loadExtensionModule(resolvedPath);
@@ -426,7 +401,8 @@ export async function loadExtensionFromFactory(
extensionPath = "<inline>",
): Promise<Extension> {
const extension = createExtension(extensionPath, extensionPath);
const api = createExtensionAPI(extension, runtime, cwd, eventBus);
const resolvedCwd = resolvePath(cwd);
const api = createExtensionAPI(extension, runtime, resolvedCwd, eventBus);
await factory(api);
return extension;
}
@@ -437,11 +413,12 @@ export async function loadExtensionFromFactory(
export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
const resolvedCwd = resolvePath(cwd);
const resolvedEventBus = eventBus ?? createEventBus();
const runtime = createExtensionRuntime();
for (const extPath of paths) {
const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime);
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, runtime);
if (error) {
errors.push({ path: extPath, error });
@@ -578,6 +555,8 @@ export async function discoverAndLoadExtensions(
agentDir: string = getAgentDir(),
eventBus?: EventBus,
): Promise<LoadExtensionsResult> {
const resolvedCwd = resolvePath(cwd);
const resolvedAgentDir = resolvePath(agentDir);
const allPaths: string[] = [];
const seen = new Set<string>();
@@ -592,16 +571,16 @@ export async function discoverAndLoadExtensions(
};
// 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/
const localExtDir = path.join(cwd, CONFIG_DIR_NAME, "extensions");
const localExtDir = path.join(resolvedCwd, CONFIG_DIR_NAME, "extensions");
addPaths(discoverExtensionsInDir(localExtDir));
// 2. Global extensions: agentDir/extensions/
const globalExtDir = path.join(agentDir, "extensions");
const globalExtDir = path.join(resolvedAgentDir, "extensions");
addPaths(discoverExtensionsInDir(globalExtDir));
// 3. Explicitly configured paths
for (const p of configuredPaths) {
const resolved = resolvePath(p, cwd);
const resolved = resolvePath(p, resolvedCwd, { normalizeUnicodeSpaces: true });
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
// Check for package.json with pi manifest or index.ts
const entries = resolveExtensionEntries(resolved);
@@ -617,5 +596,5 @@ export async function discoverAndLoadExtensions(
addPaths([resolved]);
}
return loadExtensions(allPaths, cwd, eventBus);
return loadExtensions(allPaths, resolvedCwd, eventBus);
}

View File

@@ -5,12 +5,12 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { ImageContent, Model } from "@earendil-works/pi-ai";
import type { KeyId } from "@earendil-works/pi-tui";
import { type Theme, theme } from "../../modes/interactive/theme/theme.js";
import type { ResourceDiagnostic } from "../diagnostics.js";
import type { KeybindingsConfig } from "../keybindings.js";
import type { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js";
import type { BuildSystemPromptOptions } from "../system-prompt.js";
import { type Theme, theme } from "../../modes/interactive/theme/theme.ts";
import type { ResourceDiagnostic } from "../diagnostics.ts";
import type { KeybindingsConfig } from "../keybindings.ts";
import type { ModelRegistry } from "../model-registry.ts";
import type { SessionManager } from "../session-manager.ts";
import type { BuildSystemPromptOptions } from "../system-prompt.ts";
import type {
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
@@ -55,7 +55,7 @@ import type {
ToolResultEventResult,
UserBashEvent,
UserBashEventResult,
} from "./types.js";
} from "./types.ts";
// Extension shortcuts compete with canonical keybinding ids from keybindings.json.
// Only editor-global shortcuts are reserved here. Picker-specific bindings are not.

View File

@@ -40,27 +40,27 @@ import type {
TUI,
} from "@earendil-works/pi-tui";
import type { Static, TSchema } from "typebox";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { BashResult } from "../bash-executor.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { EventBus } from "../event-bus.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { ReadonlyFooterDataProvider } from "../footer-data-provider.js";
import type { KeybindingsManager } from "../keybindings.js";
import type { CustomMessage } from "../messages.js";
import type { ModelRegistry } from "../model-registry.js";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import type { BashResult } from "../bash-executor.ts";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.ts";
import type { EventBus } from "../event-bus.ts";
import type { ExecOptions, ExecResult } from "../exec.ts";
import type { ReadonlyFooterDataProvider } from "../footer-data-provider.ts";
import type { KeybindingsManager } from "../keybindings.ts";
import type { CustomMessage } from "../messages.ts";
import type { ModelRegistry } from "../model-registry.ts";
import type {
BranchSummaryEntry,
CompactionEntry,
ReadonlySessionManager,
SessionEntry,
SessionManager,
} from "../session-manager.js";
import type { SlashCommandInfo } from "../slash-commands.js";
import type { SourceInfo } from "../source-info.js";
import type { BuildSystemPromptOptions } from "../system-prompt.js";
import type { BashOperations } from "../tools/bash.js";
import type { EditToolDetails } from "../tools/edit.js";
} from "../session-manager.ts";
import type { SlashCommandInfo } from "../slash-commands.ts";
import type { SourceInfo } from "../source-info.ts";
import type { BuildSystemPromptOptions } from "../system-prompt.ts";
import type { BashOperations } from "../tools/bash.ts";
import type { EditToolDetails } from "../tools/edit.ts";
import type {
BashToolDetails,
BashToolInput,
@@ -74,12 +74,12 @@ import type {
ReadToolDetails,
ReadToolInput,
WriteToolInput,
} from "../tools/index.js";
} from "../tools/index.ts";
export type { ExecOptions, ExecResult } from "../exec.js";
export type { BuildSystemPromptOptions } from "../system-prompt.js";
export type { ExecOptions, ExecResult } from "../exec.ts";
export type { BuildSystemPromptOptions } from "../system-prompt.ts";
export type { AgentToolResult, AgentToolUpdateCallback, ToolExecutionMode };
export type { AppKeybinding, KeybindingsManager } from "../keybindings.js";
export type { AppKeybinding, KeybindingsManager } from "../keybindings.ts";
// ============================================================================
// UI Context

View File

@@ -6,9 +6,9 @@
*/
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { wrapToolDefinition, wrapToolDefinitions } from "../tools/tool-definition-wrapper.js";
import type { ExtensionRunner } from "./runner.js";
import type { RegisteredTool } from "./types.js";
import { wrapToolDefinition, wrapToolDefinitions } from "../tools/tool-definition-wrapper.ts";
import type { ExtensionRunner } from "./runner.ts";
import type { RegisteredTool } from "./types.ts";
/**
* Wrap a RegisteredTool into an AgentTool.

View File

@@ -1,7 +1,7 @@
import { type ExecFileException, execFile, spawnSync } from "child_process";
import { existsSync, type FSWatcher, readFileSync, statSync, unwatchFile, watchFile } from "fs";
import { dirname, join, resolve } from "path";
import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.js";
import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.ts";
type GitPaths = {
repoDir: string;

View File

@@ -0,0 +1,55 @@
import * as undici from "undici";
export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000;
export const HTTP_IDLE_TIMEOUT_CHOICES = [
{ label: "30 sec", timeoutMs: 30_000 },
{ label: "1 min", timeoutMs: 60_000 },
{ label: "2 min", timeoutMs: 120_000 },
{ label: "5 min", timeoutMs: 300_000 },
{ label: "disabled", timeoutMs: 0 },
] as const;
export function parseHttpIdleTimeoutMs(value: unknown): number | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.toLowerCase() === "disabled") {
return 0;
}
if (trimmed.length === 0) {
return undefined;
}
return parseHttpIdleTimeoutMs(Number(trimmed));
}
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return undefined;
}
return Math.floor(value);
}
export function formatHttpIdleTimeoutMs(timeoutMs: number): string {
const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.timeoutMs === timeoutMs);
if (choice) {
return choice.label;
}
return `${timeoutMs / 1000} sec`;
}
export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void {
const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs);
if (normalizedTimeoutMs === undefined) {
throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`);
}
undici.setGlobalDispatcher(
new undici.EnvHttpProxyAgent({
allowH2: false,
bodyTimeout: normalizedTimeoutMs,
headersTimeout: normalizedTimeoutMs,
}),
);
// Keep fetch and the dispatcher on the same undici implementation. Node 26.0's
// bundled fetch can otherwise consume compressed responses through npm undici's
// dispatcher without decompressing them, causing response.json() failures.
undici.install?.();
}

View File

@@ -10,13 +10,13 @@ export {
type ModelCycleResult,
type PromptOptions,
type SessionStats,
} from "./agent-session.js";
} from "./agent-session.ts";
export {
AgentSessionRuntime,
type CreateAgentSessionRuntimeFactory,
type CreateAgentSessionRuntimeResult,
createAgentSessionRuntime,
} from "./agent-session-runtime.js";
} from "./agent-session-runtime.ts";
export {
type AgentSessionRuntimeDiagnostic,
type AgentSessionServices,
@@ -24,10 +24,10 @@ export {
type CreateAgentSessionServicesOptions,
createAgentSessionFromServices,
createAgentSessionServices,
} from "./agent-session-services.js";
export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.js";
export type { CompactionResult } from "./compaction/index.js";
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js";
} from "./agent-session-services.ts";
export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.ts";
export type { CompactionResult } from "./compaction/index.ts";
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.ts";
// Extensions system
export {
type AgentEndEvent,
@@ -73,5 +73,5 @@ export {
type TurnEndEvent,
type TurnStartEvent,
type WorkingIndicatorOptions,
} from "./extensions/index.js";
export { createSyntheticSourceInfo } from "./source-info.js";
} from "./extensions/index.ts";
export { createSyntheticSourceInfo } from "./source-info.ts";

View File

@@ -8,7 +8,7 @@ import {
} from "@earendil-works/pi-tui";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { getAgentDir } from "../config.js";
import { getAgentDir } from "../config.ts";
export interface AppKeybindings {
"app.interrupt": true;

View File

@@ -24,15 +24,16 @@ import { join } from "path";
import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { getAgentDir } from "../config.js";
import type { AuthStatus, AuthStorage } from "./auth-storage.js";
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.js";
import { getAgentDir } from "../config.ts";
import { normalizePath } from "../utils/paths.ts";
import type { AuthStatus, AuthStorage } from "./auth-storage.ts";
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts";
import {
clearConfigValueCache,
resolveConfigValueOrThrow,
resolveConfigValueUncached,
resolveHeadersOrThrow,
} from "./resolve-config-value.js";
} from "./resolve-config-value.ts";
// Schema for OpenRouter routing preferences
const PercentileCutoffsSchema = Type.Object({
@@ -334,11 +335,12 @@ export class ModelRegistry {
private modelRequestHeaders: Map<string, Record<string, string>> = new Map();
private registeredProviders: Map<string, ProviderConfigInput> = new Map();
private loadError: string | undefined = undefined;
readonly authStorage: AuthStorage;
private modelsJsonPath: string | undefined;
private constructor(
readonly authStorage: AuthStorage,
private modelsJsonPath: string | undefined,
) {
private constructor(authStorage: AuthStorage, modelsJsonPath: string | undefined) {
this.authStorage = authStorage;
this.modelsJsonPath = modelsJsonPath ? normalizePath(modelsJsonPath) : undefined;
this.loadModels();
}

View File

@@ -6,9 +6,9 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@earendil-works/pi-ai";
import chalk from "chalk";
import { minimatch } from "minimatch";
import { isValidThinkingLevel } from "../cli/args.js";
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
import type { ModelRegistry } from "./model-registry.js";
import { isValidThinkingLevel } from "../cli/args.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ModelRegistry } from "./model-registry.ts";
/** Default model IDs for each known provider */
export const defaultModelPerProvider: Record<KnownProvider, string> = {

View File

@@ -1,4 +1,4 @@
import { type ChildProcess, type ChildProcessByStdio, spawn, spawnSync } from "node:child_process";
import type { ChildProcess, ChildProcessByStdio } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
@@ -27,12 +27,12 @@ import type { Readable } from "node:stream";
import { globSync } from "glob";
import ignore from "ignore";
import { minimatch } from "minimatch";
import { CONFIG_DIR_NAME } from "../config.js";
import { shouldUseWindowsShell } from "../utils/child-process.js";
import { type GitSource, parseGitUrl } from "../utils/git.js";
import { canonicalizePath, isLocalPath } from "../utils/paths.js";
import { isStdoutTakenOver } from "./output-guard.js";
import type { PackageSource, SettingsManager } from "./settings-manager.js";
import { CONFIG_DIR_NAME } from "../config.ts";
import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts";
import { type GitSource, parseGitUrl } from "../utils/git.ts";
import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts";
import { isStdoutTakenOver } from "./output-guard.ts";
import type { PackageSource, SettingsManager } from "./settings-manager.ts";
const NETWORK_TIMEOUT_MS = 10000;
const UPDATE_CHECK_CONCURRENCY = 4;
@@ -763,8 +763,8 @@ export class DefaultPackageManager implements PackageManager {
private progressCallback: ProgressCallback | undefined;
constructor(options: PackageManagerOptions) {
this.cwd = options.cwd;
this.agentDir = options.agentDir;
this.cwd = resolvePath(options.cwd);
this.agentDir = resolvePath(options.agentDir);
this.settingsManager = options.settingsManager;
}
@@ -778,9 +778,21 @@ export class DefaultPackageManager implements PackageManager {
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const currentPackages = currentSettings.packages ?? [];
const normalizedSource = this.normalizePackageSourceForSettings(source, scope);
const exists = currentPackages.some((existing) => this.packageSourcesMatch(existing, source, scope));
if (exists) {
return false;
const matchIndex = currentPackages.findIndex((existing) => this.packageSourcesMatch(existing, source, scope));
if (matchIndex !== -1) {
const existing = currentPackages[matchIndex];
if (this.getPackageSourceString(existing) === normalizedSource) {
return false;
}
const nextPackages = [...currentPackages];
nextPackages[matchIndex] =
typeof existing === "string" ? normalizedSource : { ...existing, source: normalizedSource };
if (scope === "project") {
this.settingsManager.setProjectPackages(nextPackages);
} else {
this.settingsManager.setPackages(nextPackages);
}
return true;
}
const nextPackages = [...currentPackages, normalizedSource];
if (scope === "project") {
@@ -1084,7 +1096,7 @@ export class DefaultPackageManager implements PackageManager {
}
private async shouldUpdateNpmSource(source: NpmSource, scope: InstalledSourceScope): Promise<boolean> {
const installedPath = this.getNpmInstallPath(source, scope);
const installedPath = this.getManagedNpmInstallPath(source, scope);
const installedVersion = existsSync(installedPath) ? this.getInstalledNpmVersion(installedPath) : undefined;
if (!installedVersion) {
return true;
@@ -1114,13 +1126,9 @@ export class DefaultPackageManager implements PackageManager {
}
private async installNpmBatch(specs: string[], scope: InstalledSourceScope): Promise<void> {
if (scope === "user") {
await this.runNpmCommand(["install", "-g", ...specs]);
return;
}
const installRoot = this.getNpmInstallRoot(scope, false);
this.ensureNpmProject(installRoot);
await this.runNpmCommand(["install", ...specs, "--prefix", installRoot]);
await this.runNpmCommand(this.getNpmInstallArgs(specs, installRoot));
}
async checkForAvailableUpdates(): Promise<PackageUpdate[]> {
@@ -1221,13 +1229,14 @@ export class DefaultPackageManager implements PackageManager {
};
if (parsed.type === "npm") {
const installedPath = this.getNpmInstallPath(parsed, scope);
let installedPath = this.getNpmInstallPath(parsed, scope);
const needsInstall =
!existsSync(installedPath) ||
(parsed.pinned && !(await this.installedNpmMatchesPinnedVersion(parsed, installedPath)));
if (needsInstall) {
const installed = await installMissing();
if (!installed) continue;
installedPath = this.getNpmInstallPath(parsed, scope);
}
metadata.baseDir = installedPath;
this.collectPackageResources(installedPath, accumulator, filter, metadata);
@@ -1668,6 +1677,14 @@ export class DefaultPackageManager implements PackageManager {
return { command, args };
}
private getPackageManagerName(): string {
const npmCommand = this.getNpmCommand();
const commandParts = [npmCommand.command, ...npmCommand.args];
const separatorIndex = commandParts.lastIndexOf("--");
const packageManagerCommand = separatorIndex >= 0 ? commandParts[separatorIndex + 1] : npmCommand.command;
return packageManagerCommand ? basename(packageManagerCommand).replace(/\.(cmd|exe)$/i, "") : "";
}
private async runNpmCommand(args: string[], options?: { cwd?: string }): Promise<void> {
const npmCommand = this.getNpmCommand();
await this.runCommand(npmCommand.command, [...npmCommand.args, ...args], options);
@@ -1686,31 +1703,44 @@ export class DefaultPackageManager implements PackageManager {
return this.runCommandSync(npmCommand.command, [...npmCommand.args, ...args]);
}
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
if (scope === "user" && !temporary) {
await this.runNpmCommand(["install", "-g", source.spec]);
return;
private getNpmInstallArgs(specs: string[], installRoot: string): string[] {
const packageManagerName = this.getPackageManagerName();
if (packageManagerName === "bun") {
return ["install", ...specs, "--cwd", installRoot];
}
if (packageManagerName === "pnpm") {
return ["install", ...specs, "--prefix", installRoot, "--config.strict-dep-builds=false"];
}
return ["install", ...specs, "--prefix", installRoot];
}
private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
const installRoot = this.getNpmInstallRoot(scope, temporary);
this.ensureNpmProject(installRoot);
await this.runNpmCommand(["install", source.spec, "--prefix", installRoot]);
await this.runNpmCommand(this.getNpmInstallArgs([source.spec], installRoot));
}
private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> {
if (scope === "user") {
await this.runNpmCommand(["uninstall", "-g", source.name]);
return;
}
const installRoot = this.getNpmInstallRoot(scope, false);
if (!existsSync(installRoot)) {
return;
}
if (this.getPackageManagerName() === "bun") {
await this.runNpmCommand(["uninstall", source.name, "--cwd", installRoot]);
return;
}
await this.runNpmCommand(["uninstall", source.name, "--prefix", installRoot]);
}
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope);
if (existsSync(targetDir)) {
if (source.ref) {
await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD");
return;
}
const target = await this.getLocalGitUpdateTarget(targetDir);
await this.ensureGitRef(targetDir, target.fetchArgs, target.ref);
return;
}
const gitRoot = this.getGitInstallRoot(scope);
@@ -1737,23 +1767,26 @@ export class DefaultPackageManager implements PackageManager {
}
const target = await this.getLocalGitUpdateTarget(targetDir);
await this.ensureGitRef(targetDir, target.fetchArgs, target.ref);
}
private async ensureGitRef(targetDir: string, fetchArgs: string[], ref: string): Promise<void> {
// Fetch only the ref we will reset to, avoiding unrelated branch/tag noise.
await this.runCommand("git", target.fetchArgs, { cwd: targetDir });
await this.runCommand("git", fetchArgs, { cwd: targetDir });
const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], {
cwd: targetDir,
timeoutMs: NETWORK_TIMEOUT_MS,
});
const refreshedTargetHead = await this.runCommandCapture("git", ["rev-parse", target.ref], {
const targetHead = await this.runCommandCapture("git", ["rev-parse", ref], {
cwd: targetDir,
timeoutMs: NETWORK_TIMEOUT_MS,
});
if (localHead.trim() === refreshedTargetHead.trim()) {
if (localHead.trim() === targetHead.trim()) {
return;
}
await this.runCommand("git", ["reset", "--hard", target.ref], { cwd: targetDir });
await this.runCommand("git", ["reset", "--hard", ref], { cwd: targetDir });
// Clean untracked files (extensions should be pristine)
await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir });
@@ -1810,6 +1843,7 @@ export class DefaultPackageManager implements PackageManager {
if (!existsSync(installRoot)) {
mkdirSync(installRoot, { recursive: true });
}
markPathIgnoredByCloudSync(installRoot);
this.ensureGitIgnore(installRoot);
const packageJsonPath = join(installRoot, "package.json");
if (!existsSync(packageJsonPath)) {
@@ -1835,7 +1869,7 @@ export class DefaultPackageManager implements PackageManager {
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm");
}
return join(this.getGlobalNpmRoot(), "..");
return join(this.agentDir, "npm");
}
private getGlobalNpmRoot(): string {
@@ -1844,8 +1878,7 @@ export class DefaultPackageManager implements PackageManager {
if (this.globalNpmRoot && this.globalNpmRootCommandKey === commandKey) {
return this.globalNpmRoot;
}
const isBunPackageManager = npmCommand.command === "bun";
if (isBunPackageManager) {
if (this.getPackageManagerName() === "bun") {
const binDir = this.runNpmCommandSync(["pm", "bin", "-g"]).trim();
this.globalNpmRoot = join(dirname(binDir), "install", "global", "node_modules");
} else {
@@ -1855,14 +1888,45 @@ export class DefaultPackageManager implements PackageManager {
return this.globalNpmRoot;
}
private getNpmInstallPath(source: NpmSource, scope: SourceScope): string {
private getPnpmGlobalPackagePath(packageName: string): string | undefined {
if (this.getPackageManagerName() !== "pnpm") {
return undefined;
}
const output = this.runNpmCommandSync(["list", "-g", "--depth", "0", "--json"]);
const entries = JSON.parse(output) as Array<{ dependencies?: Record<string, { path?: string }> }>;
for (const entry of entries) {
const path = entry.dependencies?.[packageName]?.path;
if (path) return path;
}
return undefined;
}
private getManagedNpmInstallPath(source: NpmSource, scope: SourceScope): string {
if (scope === "temporary") {
return join(this.getTemporaryDir("npm"), "node_modules", source.name);
}
if (scope === "project") {
return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name);
}
return join(this.getGlobalNpmRoot(), source.name);
return join(this.agentDir, "npm", "node_modules", source.name);
}
private getLegacyGlobalNpmInstallPath(source: NpmSource): string | undefined {
try {
return this.getPnpmGlobalPackagePath(source.name) ?? join(this.getGlobalNpmRoot(), source.name);
} catch {
return undefined;
}
}
private getNpmInstallPath(source: NpmSource, scope: SourceScope): string {
const managedPath = this.getManagedNpmInstallPath(source, scope);
if (scope !== "user" || existsSync(managedPath)) {
return managedPath;
}
const legacyPath = this.getLegacyGlobalNpmInstallPath(source);
return legacyPath && existsSync(legacyPath) ? legacyPath : managedPath;
}
private getGitInstallPath(source: GitSource, scope: SourceScope): string {
@@ -1904,19 +1968,11 @@ export class DefaultPackageManager implements PackageManager {
}
private resolvePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return getHomeDir();
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
return resolve(this.cwd, trimmed);
return resolvePath(input, this.cwd, { homeDir: getHomeDir(), trim: true });
}
private resolvePathFromBase(input: string, baseDir: string): string {
const trimmed = input.trim();
if (trimmed === "~") return getHomeDir();
if (trimmed.startsWith("~/")) return join(getHomeDir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(getHomeDir(), trimmed.slice(1));
return resolve(baseDir, trimmed);
return resolvePath(input, baseDir, { homeDir: getHomeDir(), trim: true });
}
private collectPackageResources(
@@ -2366,11 +2422,11 @@ export class DefaultPackageManager implements PackageManager {
}
private spawnCommand(command: string, args: string[], options?: { cwd?: string }): ChildProcess {
return spawn(command, args, {
const env = getEnv();
return spawnProcess(command, args, {
cwd: options?.cwd,
stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit",
shell: shouldUseWindowsShell(command),
env: getEnv(),
env,
});
}
@@ -2380,11 +2436,11 @@ export class DefaultPackageManager implements PackageManager {
options?: { cwd?: string; env?: Record<string, string> },
): ChildProcessByStdio<null, Readable, Readable> {
const baseEnv = getEnv();
return spawn(command, args, {
const env = options?.env ? { ...baseEnv, ...options.env } : baseEnv;
return spawnProcess(command, args, {
cwd: options?.cwd,
stdio: ["ignore", "pipe", "pipe"],
shell: shouldUseWindowsShell(command),
env: options?.env ? { ...baseEnv, ...options.env } : baseEnv,
env,
});
}
@@ -2447,11 +2503,11 @@ export class DefaultPackageManager implements PackageManager {
}
private runCommandSync(command: string, args: string[]): string {
const result = spawnSync(command, args, {
const env = getEnv();
const result = spawnProcessSync(command, args, {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
shell: shouldUseWindowsShell(command),
env: getEnv(),
env,
});
if (result.error || result.status !== 0) {
throw new Error(

View File

@@ -1,9 +1,9 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { homedir } from "os";
import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
import { CONFIG_DIR_NAME } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
import { basename, dirname, join, resolve, sep } from "path";
import { CONFIG_DIR_NAME } from "../config.ts";
import { parseFrontmatter } from "../utils/frontmatter.ts";
import { resolvePath } from "../utils/paths.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
/**
* Represents a prompt template loaded from a markdown file
@@ -37,7 +37,7 @@ export function parseCommandArgs(argsString: string): string[] {
}
} else if (char === '"' || char === "'") {
inQuote = char;
} else if (char === " " || char === "\t") {
} else if (/\s/.test(char)) {
if (current) {
args.push(current);
current = "";
@@ -185,19 +185,6 @@ export interface LoadPromptTemplatesOptions {
includeDefaults: boolean;
}
function normalizePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
return trimmed;
}
function resolvePromptPath(p: string, cwd: string): string {
const normalized = normalizePath(p);
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
}
/**
* Load all prompt templates from:
* 1. Global: agentDir/prompts/
@@ -205,14 +192,14 @@ function resolvePromptPath(p: string, cwd: string): string {
* 3. Explicit prompt paths
*/
export function loadPromptTemplates(options: LoadPromptTemplatesOptions): PromptTemplate[] {
const resolvedCwd = options.cwd;
const resolvedAgentDir = options.agentDir;
const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(options.agentDir);
const promptPaths = options.promptPaths;
const includeDefaults = options.includeDefaults;
const templates: PromptTemplate[] = [];
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
const globalPromptsDir = join(resolvedAgentDir, "prompts");
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
const isUnderPath = (target: string, root: string): boolean => {
@@ -252,7 +239,7 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): Prompt
// 3. Load explicit prompt paths
for (const rawPath of promptPaths) {
const resolvedPath = resolvePromptPath(rawPath, resolvedCwd);
const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });
if (!existsSync(resolvedPath)) {
continue;
}
@@ -282,9 +269,11 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions): Prompt
export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string {
if (!text.startsWith("/")) return text;
const spaceIndex = text.indexOf(" ");
const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
if (!match) return text;
const templateName = match[1];
const argsString = match[2] ?? "";
const template = templates.find((t) => t.name === templateName);
if (template) {

View File

@@ -4,7 +4,7 @@
*/
import { execSync, spawnSync } from "child_process";
import { getShellConfig } from "../utils/shell.js";
import { getShellConfig } from "../utils/shell.ts";
// Cache for shell command results (persists for process lifetime)
const commandResultCache = new Map<string, string | undefined>();

View File

@@ -1,24 +1,23 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve, sep } from "node:path";
import chalk from "chalk";
import { CONFIG_DIR_NAME } from "../config.js";
import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js";
import type { ResourceDiagnostic } from "./diagnostics.js";
import { CONFIG_DIR_NAME } from "../config.ts";
import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.ts";
import type { ResourceDiagnostic } from "./diagnostics.ts";
export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.js";
export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
import { canonicalizePath, isLocalPath } from "../utils/paths.js";
import { createEventBus, type EventBus } from "./event-bus.js";
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.js";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.js";
import { DefaultPackageManager, type PathMetadata } from "./package-manager.js";
import type { PromptTemplate } from "./prompt-templates.js";
import { loadPromptTemplates } from "./prompt-templates.js";
import { SettingsManager } from "./settings-manager.js";
import type { Skill } from "./skills.js";
import { loadSkills } from "./skills.js";
import { createSourceInfo, type SourceInfo } from "./source-info.js";
import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
import { createEventBus, type EventBus } from "./event-bus.ts";
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts";
import { DefaultPackageManager, type PathMetadata } from "./package-manager.ts";
import type { PromptTemplate } from "./prompt-templates.ts";
import { loadPromptTemplates } from "./prompt-templates.ts";
import { SettingsManager } from "./settings-manager.ts";
import type { Skill } from "./skills.ts";
import { loadSkills } from "./skills.ts";
import { createSourceInfo, type SourceInfo } from "./source-info.ts";
export interface ResourceExtensionPaths {
skillPaths?: Array<{ path: string; metadata: PathMetadata }>;
@@ -77,8 +76,8 @@ export function loadProjectContextFiles(options: {
cwd: string;
agentDir: string;
}): Array<{ path: string; content: string }> {
const resolvedCwd = options.cwd;
const resolvedAgentDir = options.agentDir;
const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(options.agentDir);
const contextFiles: Array<{ path: string; content: string }> = [];
const seenPaths = new Set<string>();
@@ -205,8 +204,8 @@ export class DefaultResourceLoader implements ResourceLoader {
private lastThemePaths: string[];
constructor(options: DefaultResourceLoaderOptions) {
this.cwd = options.cwd;
this.agentDir = options.agentDir;
this.cwd = resolvePath(options.cwd);
this.agentDir = resolvePath(options.agentDir);
this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);
this.eventBus = options.eventBus ?? createEventBus();
this.packageManager = new DefaultPackageManager({
@@ -409,8 +408,11 @@ export class DefaultResourceLoader implements ResourceLoader {
}
for (const p of this.additionalExtensionPaths) {
if (isLocalPath(p) && !existsSync(p)) {
extensionsResult.errors.push({ path: p, error: `Extension path does not exist: ${p}` });
if (isLocalPath(p)) {
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved)) {
extensionsResult.errors.push({ path: resolved, error: `Extension path does not exist: ${resolved}` });
}
}
}
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
@@ -423,8 +425,11 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastSkillPaths = skillPaths;
this.updateSkillsFromPaths(skillPaths, metadataByPath);
for (const p of this.additionalSkillPaths) {
if (isLocalPath(p) && !existsSync(p) && !this.skillDiagnostics.some((d) => d.path === p)) {
this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: p });
if (isLocalPath(p)) {
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved) && !this.skillDiagnostics.some((d) => d.path === resolved)) {
this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: resolved });
}
}
}
@@ -435,8 +440,15 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastPromptPaths = promptPaths;
this.updatePromptsFromPaths(promptPaths, metadataByPath);
for (const p of this.additionalPromptTemplatePaths) {
if (isLocalPath(p) && !existsSync(p) && !this.promptDiagnostics.some((d) => d.path === p)) {
this.promptDiagnostics.push({ type: "error", message: "Prompt template path does not exist", path: p });
if (isLocalPath(p)) {
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved) && !this.promptDiagnostics.some((d) => d.path === resolved)) {
this.promptDiagnostics.push({
type: "error",
message: "Prompt template path does not exist",
path: resolved,
});
}
}
}
@@ -447,8 +459,9 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastThemePaths = themePaths;
this.updateThemesFromPaths(themePaths, metadataByPath);
for (const p of this.additionalThemePaths) {
if (!existsSync(p) && !this.themeDiagnostics.some((d) => d.path === p)) {
this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: p });
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved) && !this.themeDiagnostics.some((d) => d.path === resolved)) {
this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: resolved });
}
}
@@ -478,10 +491,15 @@ export class DefaultResourceLoader implements ResourceLoader {
private normalizeExtensionPaths(
entries: Array<{ path: string; metadata: PathMetadata }>,
): Array<{ path: string; metadata: PathMetadata }> {
return entries.map((entry) => ({
path: this.resolveResourcePath(entry.path),
metadata: entry.metadata,
}));
return entries.map((entry) => {
const metadata = entry.metadata.baseDir
? { ...entry.metadata, baseDir: this.resolveResourcePath(entry.metadata.baseDir) }
: entry.metadata;
return {
path: this.resolveResourcePath(entry.path),
metadata,
};
});
}
private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map<string, PathMetadata>): void {
@@ -674,16 +692,7 @@ export class DefaultResourceLoader implements ResourceLoader {
}
private resolveResourcePath(p: string): string {
const trimmed = p.trim();
let expanded = trimmed;
if (trimmed === "~") {
expanded = homedir();
} else if (trimmed.startsWith("~/")) {
expanded = join(homedir(), trimmed.slice(2));
} else if (trimmed.startsWith("~")) {
expanded = join(homedir(), trimmed.slice(1));
}
return resolve(this.cwd, expanded);
return resolvePath(p, this.cwd, { trim: true });
}
private loadThemes(
@@ -704,7 +713,7 @@ export class DefaultResourceLoader implements ResourceLoader {
}
for (const p of paths) {
const resolved = resolve(this.cwd, p);
const resolved = this.resolveResourcePath(p);
if (!existsSync(resolved)) {
diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved });
continue;

View File

@@ -1,21 +1,22 @@
import { join } from "node:path";
import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core";
import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.js";
import { AgentSession } from "./agent-session.js";
import { formatNoModelsAvailableMessage } from "./auth-guidance.js";
import { AuthStorage } from "./auth-storage.js";
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.js";
import { convertToLlm } from "./messages.js";
import { ModelRegistry } from "./model-registry.js";
import { findInitialModel } from "./model-resolver.js";
import type { ResourceLoader } from "./resource-loader.js";
import { DefaultResourceLoader } from "./resource-loader.js";
import { getDefaultSessionDir, SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import { isInstallTelemetryEnabled } from "./telemetry.js";
import { time } from "./timings.js";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import { AgentSession } from "./agent-session.ts";
import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";
import { AuthStorage } from "./auth-storage.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { convertToLlm } from "./messages.ts";
import { ModelRegistry } from "./model-registry.ts";
import { findInitialModel } from "./model-resolver.ts";
import type { ResourceLoader } from "./resource-loader.ts";
import { DefaultResourceLoader } from "./resource-loader.ts";
import { getDefaultSessionDir, SessionManager } from "./session-manager.ts";
import { SettingsManager } from "./settings-manager.ts";
import { isInstallTelemetryEnabled } from "./telemetry.ts";
import { time } from "./timings.ts";
import {
createBashTool,
createCodingTools,
@@ -28,7 +29,7 @@ import {
createWriteTool,
type ToolName,
withFileMutationQueue,
} from "./tools/index.js";
} from "./tools/index.ts";
export interface CreateAgentSessionOptions {
/** Working directory for project-local discovery. Default: process.cwd() */
@@ -91,7 +92,7 @@ export interface CreateAgentSessionResult {
// Re-exports
export * from "./agent-session-runtime.js";
export * from "./agent-session-runtime.ts";
export type {
ExtensionAPI,
ExtensionCommandContext,
@@ -100,10 +101,10 @@ export type {
SlashCommandInfo,
SlashCommandSource,
ToolDefinition,
} from "./extensions/index.js";
export type { PromptTemplate } from "./prompt-templates.js";
export type { Skill } from "./skills.js";
export type { Tool } from "./tools/index.js";
} from "./extensions/index.ts";
export type { PromptTemplate } from "./prompt-templates.ts";
export type { Skill } from "./skills.ts";
export type { Tool } from "./tools/index.ts";
export {
withFileMutationQueue,
@@ -184,15 +185,15 @@ function getAttributionHeaders(
* await loader.reload();
* const { session } = await createAgentSession({
* model: myModel,
* tools: [readTool, bashTool],
* tools: ["read", "bash"],
* resourceLoader: loader,
* sessionManager: SessionManager.inMemory(),
* });
* ```
*/
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
const cwd = options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd();
const agentDir = options.agentDir ?? getDefaultAgentDir();
const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd());
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir();
let resourceLoader = options.resourceLoader;
// Use provided or create AuthStorage and ModelRegistry

View File

@@ -1,4 +1,4 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import { type AgentMessage, uuidv7 } from "@earendil-works/pi-agent-core";
import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai";
import { randomUUID } from "crypto";
import {
@@ -15,15 +15,15 @@ import {
} from "fs";
import { readdir, readFile, stat } from "fs/promises";
import { join, resolve } from "path";
import { v7 as uuidv7 } from "uuid";
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js";
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
import {
type BashExecutionMessage,
type CustomMessage,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "./messages.js";
} from "./messages.ts";
export const CURRENT_SESSION_VERSION = 3;
@@ -426,8 +426,10 @@ export function buildSessionContext(
* Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.
*/
export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
const sessionDir = join(agentDir, "sessions", safePath);
const resolvedCwd = resolvePath(cwd);
const resolvedAgentDir = resolvePath(agentDir);
const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
const sessionDir = join(resolvedAgentDir, "sessions", safePath);
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
@@ -436,9 +438,10 @@ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultA
/** Exported for testing */
export function loadEntriesFromFile(filePath: string): FileEntry[] {
if (!existsSync(filePath)) return [];
const resolvedFilePath = normalizePath(filePath);
if (!existsSync(resolvedFilePath)) return [];
const content = readFileSync(filePath, "utf8");
const content = readFileSync(resolvedFilePath, "utf8");
const entries: FileEntry[] = [];
const lines = content.trim().split("\n");
@@ -479,10 +482,11 @@ function isValidSessionFile(filePath: string): boolean {
/** Exported for testing */
export function findMostRecentSession(sessionDir: string): string | null {
const resolvedSessionDir = normalizePath(sessionDir);
try {
const files = readdirSync(sessionDir)
const files = readdirSync(resolvedSessionDir)
.filter((f) => f.endsWith(".jsonl"))
.map((f) => join(sessionDir, f))
.map((f) => join(resolvedSessionDir, f))
.filter(isValidSessionFile)
.map((path) => ({ path, mtime: statSync(path).mtime }))
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
@@ -618,6 +622,48 @@ async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
export type SessionListProgress = (loaded: number, total: number) => void;
const MAX_CONCURRENT_SESSION_INFO_LOADS = 10;
async function buildSessionInfosWithConcurrency(
files: string[],
onLoaded: () => void,
): Promise<(SessionInfo | null)[]> {
const results: (SessionInfo | null)[] = new Array(files.length).fill(null);
const inFlight = new Set<Promise<void>>();
let nextIndex = 0;
const startNext = (): void => {
const index = nextIndex++;
const file = files[index];
if (!file) return;
let task: Promise<void>;
task = buildSessionInfo(file)
.then((info) => {
results[index] = info;
})
.catch(() => {
results[index] = null;
})
.finally(() => {
inFlight.delete(task);
onLoaded();
});
inFlight.add(task);
};
while (nextIndex < files.length || inFlight.size > 0) {
while (nextIndex < files.length && inFlight.size < MAX_CONCURRENT_SESSION_INFO_LOADS) {
startNext();
}
if (inFlight.size > 0) {
await Promise.race(inFlight);
}
}
return results;
}
async function listSessionsFromDir(
dir: string,
onProgress?: SessionListProgress,
@@ -635,14 +681,10 @@ async function listSessionsFromDir(
const total = progressTotal ?? files.length;
let loaded = 0;
const results = await Promise.all(
files.map(async (file) => {
const info = await buildSessionInfo(file);
loaded++;
onProgress?.(progressOffset + loaded, total);
return info;
}),
);
const results = await buildSessionInfosWithConcurrency(files, () => {
loaded++;
onProgress?.(progressOffset + loaded, total);
});
for (const info of results) {
if (info) {
sessions.push(info);
@@ -680,11 +722,11 @@ export class SessionManager {
private leafId: string | null = null;
private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
this.cwd = cwd;
this.sessionDir = sessionDir;
this.cwd = resolvePath(cwd);
this.sessionDir = normalizePath(sessionDir);
this.persist = persist;
if (persist && sessionDir && !existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
if (persist && this.sessionDir && !existsSync(this.sessionDir)) {
mkdirSync(this.sessionDir, { recursive: true });
}
if (sessionFile) {
@@ -696,7 +738,7 @@ export class SessionManager {
/** Switch to a different session file (used for resume and branching) */
setSessionFile(sessionFile: string): void {
this.sessionFile = resolve(sessionFile);
this.sessionFile = resolvePath(sessionFile);
if (existsSync(this.sessionFile)) {
this.fileEntries = loadEntriesFromFile(this.sessionFile);
@@ -1267,7 +1309,7 @@ export class SessionManager {
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
*/
static create(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
return new SessionManager(cwd, dir, undefined, true);
}
@@ -1278,13 +1320,14 @@ export class SessionManager {
* @param cwdOverride Optional cwd override instead of the session header cwd.
*/
static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager {
const resolvedPath = resolvePath(path);
// Extract cwd from session header if possible, otherwise use process.cwd()
const entries = loadEntriesFromFile(path);
const entries = loadEntriesFromFile(resolvedPath);
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
const cwd = cwdOverride ?? header?.cwd ?? process.cwd();
// If no sessionDir provided, derive from file's parent directory
const dir = sessionDir ?? resolve(path, "..");
return new SessionManager(cwd, dir, path, true);
const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, "..");
return new SessionManager(cwd, dir, resolvedPath, true);
}
/**
@@ -1293,7 +1336,7 @@ export class SessionManager {
* @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
*/
static continueRecent(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
const mostRecent = findMostRecentSession(dir);
if (mostRecent) {
return new SessionManager(cwd, dir, mostRecent, true);
@@ -1314,17 +1357,19 @@ export class SessionManager {
* @param sessionDir Optional session directory. If omitted, uses default for targetCwd.
*/
static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager {
const sourceEntries = loadEntriesFromFile(sourcePath);
const resolvedSourcePath = resolvePath(sourcePath);
const resolvedTargetCwd = resolvePath(targetCwd);
const sourceEntries = loadEntriesFromFile(resolvedSourcePath);
if (sourceEntries.length === 0) {
throw new Error(`Cannot fork: source session file is empty or invalid: ${sourcePath}`);
throw new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`);
}
const sourceHeader = sourceEntries.find((e) => e.type === "session") as SessionHeader | undefined;
if (!sourceHeader) {
throw new Error(`Cannot fork: source session has no header: ${sourcePath}`);
throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);
}
const dir = sessionDir ?? getDefaultSessionDir(targetCwd);
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
@@ -1341,8 +1386,8 @@ export class SessionManager {
version: CURRENT_SESSION_VERSION,
id: newSessionId,
timestamp,
cwd: targetCwd,
parentSession: sourcePath,
cwd: resolvedTargetCwd,
parentSession: resolvedSourcePath,
};
appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`);
@@ -1353,7 +1398,7 @@ export class SessionManager {
}
}
return new SessionManager(targetCwd, dir, newSessionFile, true);
return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);
}
/**
@@ -1363,7 +1408,7 @@ export class SessionManager {
* @param onProgress Optional callback for progress updates (loaded, total)
*/
static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]> {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
const sessions = await listSessionsFromDir(dir, onProgress);
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
return sessions;
@@ -1401,14 +1446,10 @@ export class SessionManager {
const sessions: SessionInfo[] = [];
const allFiles = dirFiles.flat();
const results = await Promise.all(
allFiles.map(async (file) => {
const info = await buildSessionInfo(file);
loaded++;
onProgress?.(loaded, totalFiles);
return info;
}),
);
const results = await buildSessionInfosWithConcurrency(allFiles, () => {
loaded++;
onProgress?.(loaded, totalFiles);
});
for (const info of results) {
if (info) {

View File

@@ -1,9 +1,10 @@
import type { Transport } from "@earendil-works/pi-ai";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts";
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts";
export interface CompactionSettings {
enabled?: boolean; // default: true
@@ -110,6 +111,7 @@ export interface Settings {
markdown?: MarkdownSettings;
warnings?: WarningSettings;
sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag)
httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it
}
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
@@ -159,8 +161,10 @@ export class FileSettingsStorage implements SettingsStorage {
private projectSettingsPath: string;
constructor(cwd: string, agentDir: string) {
this.globalSettingsPath = join(agentDir, "settings.json");
this.projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json");
const resolvedCwd = resolvePath(cwd);
const resolvedAgentDir = resolvePath(agentDir);
this.globalSettingsPath = join(resolvedAgentDir, "settings.json");
this.projectSettingsPath = join(resolvedCwd, CONFIG_DIR_NAME, "settings.json");
}
private acquireLockSyncWithRetry(path: string): () => void {
@@ -575,16 +579,7 @@ export class SettingsManager {
getSessionDir(): string | undefined {
const sessionDir = this.settings.sessionDir;
if (!sessionDir) {
return sessionDir;
}
if (sessionDir === "~") {
return homedir();
}
if (sessionDir.startsWith("~/")) {
return join(homedir(), sessionDir.slice(2));
}
return sessionDir;
return sessionDir ? normalizePath(sessionDir) : sessionDir;
}
getDefaultProvider(): string | undefined {
@@ -726,6 +721,27 @@ export class SettingsManager {
};
}
getHttpIdleTimeoutMs(): number {
const value = this.settings.httpIdleTimeoutMs;
const timeoutMs = parseHttpIdleTimeoutMs(value);
if (timeoutMs !== undefined) {
return timeoutMs;
}
if (value !== undefined) {
throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(value)}`);
}
return DEFAULT_HTTP_IDLE_TIMEOUT_MS;
}
setHttpIdleTimeoutMs(timeoutMs: number): void {
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(timeoutMs)}`);
}
this.globalSettings.httpIdleTimeoutMs = Math.floor(timeoutMs);
this.markModified("httpIdleTimeoutMs");
this.save();
}
getProviderRetrySettings(): { timeoutMs?: number; maxRetries?: number; maxRetryDelayMs: number } {
return {
timeoutMs: this.settings.retry?.provider?.timeoutMs,

View File

@@ -1,12 +1,11 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import ignore from "ignore";
import { homedir } from "os";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js";
import { canonicalizePath } from "../utils/paths.js";
import type { ResourceDiagnostic } from "./diagnostics.js";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
import { basename, dirname, join, relative, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { parseFrontmatter } from "../utils/frontmatter.ts";
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
import type { ResourceDiagnostic } from "./diagnostics.ts";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
/** Max name length per spec */
const MAX_NAME_LENGTH = 64;
@@ -90,13 +89,9 @@ export interface LoadSkillsResult {
* Validate skill name per Agent Skills spec.
* Returns array of validation error messages (empty if valid).
*/
function validateName(name: string, parentDirName: string): string[] {
function validateName(name: string): string[] {
const errors: string[] = [];
if (name !== parentDirName) {
errors.push(`name "${name}" does not match parent directory "${parentDirName}"`);
}
if (name.length > MAX_NAME_LENGTH) {
errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);
}
@@ -301,7 +296,7 @@ function loadSkillFromFile(
const name = frontmatter.name || parentDirName;
// Validate name
const nameErrors = validateName(name, parentDirName);
const nameErrors = validateName(name);
for (const error of nameErrors) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
@@ -385,28 +380,16 @@ export interface LoadSkillsOptions {
includeDefaults: boolean;
}
function normalizePath(input: string): string {
const trimmed = input.trim();
if (trimmed === "~") return homedir();
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
if (trimmed.startsWith("~")) return join(homedir(), trimmed.slice(1));
return trimmed;
}
function resolveSkillPath(p: string, cwd: string): string {
const normalized = normalizePath(p);
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
}
/**
* Load skills from all configured locations.
* Returns skills and any validation diagnostics.
*/
export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
const { cwd, agentDir, skillPaths, includeDefaults } = options;
const { agentDir, skillPaths, includeDefaults } = options;
// Resolve agentDir - if not provided, use default from config
const resolvedAgentDir = agentDir ?? getAgentDir();
const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(agentDir ?? getAgentDir());
const skillMap = new Map<string, Skill>();
const realPathSet = new Set<string>();
@@ -446,11 +429,11 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
if (includeDefaults) {
addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true));
addSkills(loadSkillsFromDirInternal(resolve(cwd, CONFIG_DIR_NAME, "skills"), "project", true));
addSkills(loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"), "project", true));
}
const userSkillsDir = join(resolvedAgentDir, "skills");
const projectSkillsDir = resolve(cwd, CONFIG_DIR_NAME, "skills");
const projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "skills");
const isUnderPath = (target: string, root: string): boolean => {
const normalizedRoot = resolve(root);
@@ -470,7 +453,7 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
};
for (const rawPath of skillPaths) {
const resolvedPath = resolveSkillPath(rawPath, cwd);
const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });
if (!existsSync(resolvedPath)) {
allDiagnostics.push({ type: "warning", message: "skill path does not exist", path: resolvedPath });
continue;

View File

@@ -1,5 +1,5 @@
import { APP_NAME } from "../config.js";
import type { SourceInfo } from "./source-info.js";
import { APP_NAME } from "../config.ts";
import type { SourceInfo } from "./source-info.ts";
export type SlashCommandSource = "extension" | "prompt" | "skill";

View File

@@ -1,4 +1,4 @@
import type { PathMetadata } from "./package-manager.js";
import type { PathMetadata } from "./package-manager.ts";
export type SourceScope = "user" | "project" | "temporary";
export type SourceOrigin = "package" | "top-level";

View File

@@ -2,8 +2,8 @@
* System prompt construction and project context loading
*/
import { getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
import { formatSkillsForPrompt, type Skill } from "./skills.js";
import { getDocsPath, getExamplesPath, getReadmePath } from "../config.ts";
import { formatSkillsForPrompt, type Skill } from "./skills.ts";
export interface BuildSystemPromptOptions {
/** Custom system prompt (replaces default). */
@@ -59,11 +59,12 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
// Append project context files
if (contextFiles.length > 0) {
prompt += "\n\n# Project Context\n\n";
prompt += "\n\n<project_context>\n\n";
prompt += "Project-specific instructions and guidelines:\n\n";
for (const { path: filePath, content } of contextFiles) {
prompt += `## ${filePath}\n\n${content}\n\n`;
prompt += `<project_instructions path="${filePath}">\n${content}\n</project_instructions>\n\n`;
}
prompt += "</project_context>\n";
}
// Append skills section (only if read tool is available)
@@ -142,6 +143,7 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi
- Main documentation: ${readmePath}
- Additional docs: ${docsPath}
- Examples: ${examplesPath} (extensions, custom tools, SDK)
- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory
- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)
- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing
- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`;
@@ -152,11 +154,12 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi
// Append project context files
if (contextFiles.length > 0) {
prompt += "\n\n# Project Context\n\n";
prompt += "\n\n<project_context>\n\n";
prompt += "Project-specific instructions and guidelines:\n\n";
for (const { path: filePath, content } of contextFiles) {
prompt += `## ${filePath}\n\n${content}\n\n`;
prompt += `<project_instructions path="${filePath}">\n${content}\n</project_instructions>\n\n`;
}
prompt += "</project_context>\n";
}
// Append skills section (only if read tool is available)

View File

@@ -1,4 +1,4 @@
import type { SettingsManager } from "./settings-manager.js";
import type { SettingsManager } from "./settings-manager.ts";
function isTruthyEnvFlag(value: string | undefined): boolean {
if (!value) return false;

View File

@@ -3,22 +3,22 @@ import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { spawn } from "child_process";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js";
import { theme } from "../../modes/interactive/theme/theme.js";
import { waitForChildProcess } from "../../utils/child-process.js";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.ts";
import { theme } from "../../modes/interactive/theme/theme.ts";
import { waitForChildProcess } from "../../utils/child-process.ts";
import {
getShellConfig,
getShellEnv,
killProcessTree,
trackDetachedChildPid,
untrackDetachedChildPid,
} from "../../utils/shell.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { OutputAccumulator } from "./output-accumulator.js";
import { getTextOutput, invalidArgText, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "./truncate.js";
} from "../../utils/shell.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { OutputAccumulator } from "./output-accumulator.ts";
import { getTextOutput, invalidArgText, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "./truncate.ts";
const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
@@ -76,6 +76,7 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas
detached: process.platform !== "win32",
env: env ?? getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
if (child.pid) trackDetachedChildPid(child.pid);
let timedOut = false;
@@ -199,7 +200,15 @@ function rebuildBashResultRenderComponent(
const state = component.state;
component.clear();
const output = getTextOutput(result as any, showImages).trim();
let output = getTextOutput(result as any, showImages).trim();
const truncation = result.details?.truncation;
const fullOutputPath = result.details?.fullOutputPath;
if (!options.isPartial && truncation?.truncated && fullOutputPath && output.endsWith("]")) {
const footerStart = output.lastIndexOf("\n\n[");
if (footerStart !== -1 && output.slice(footerStart).includes(fullOutputPath)) {
output = output.slice(0, footerStart).trimEnd();
}
}
if (output) {
const styledOutput = output
@@ -235,8 +244,6 @@ function rebuildBashResultRenderComponent(
}
}
const truncation = result.details?.truncation;
const fullOutputPath = result.details?.fullOutputPath;
if (truncation?.truncated || fullOutputPath) {
const warnings: string[] = [];
if (fullOutputPath) {

View File

@@ -6,7 +6,7 @@
import * as Diff from "diff";
import { constants } from "fs";
import { access, readFile } from "fs/promises";
import { resolveToCwd } from "./path-utils.js";
import { resolveToCwd } from "./path-utils.ts";
export function detectLineEnding(content: string): "\r\n" | "\n" {
const crlfIdx = content.indexOf("\r\n");
@@ -259,8 +259,16 @@ export function applyEditsToNormalizedContent(
return { baseContent, newContent };
}
/** Generate a standard unified patch. */
export function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines = 4): string {
return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
context: contextLines,
headerOptions: Diff.FILE_HEADERS_ONLY,
});
}
/**
* Generate a unified diff string with line numbers and context.
* Generate a display-oriented diff string with line numbers and context.
* Returns both the diff string and the first changed line number (in the new file).
*/
export function generateDiffString(

View File

@@ -3,8 +3,8 @@ import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
import { type Static, Type } from "typebox";
import { renderDiff } from "../../modes/interactive/components/diff.js";
import type { ToolDefinition } from "../extensions/types.js";
import { renderDiff } from "../../modes/interactive/components/diff.ts";
import type { ToolDefinition } from "../extensions/types.ts";
import {
applyEditsToNormalizedContent,
computeEditsDiff,
@@ -13,14 +13,15 @@ import {
type EditDiffError,
type EditDiffResult,
generateDiffString,
generateUnifiedPatch,
normalizeToLF,
restoreLineEndings,
stripBom,
} from "./edit-diff.js";
import { withFileMutationQueue } from "./file-mutation-queue.js";
import { resolveToCwd } from "./path-utils.js";
import { invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
} from "./edit-diff.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToCwd } from "./path-utils.ts";
import { invalidArgText, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
type EditPreview = EditDiffResult | EditDiffError;
@@ -57,8 +58,10 @@ type LegacyEditToolInput = EditToolInput & {
};
export interface EditToolDetails {
/** Unified diff of the changes made */
/** Display-oriented diff of the changes made */
diff: string;
/** Standard unified patch of the changes made */
patch: string;
/** Line number of the first change in the new file (for editor navigation) */
firstChangedLine?: number;
}
@@ -190,7 +193,7 @@ function getRenderablePreviewInput(args: RenderableEditArgs | undefined): { path
function formatEditCall(
args: RenderableEditArgs | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): string {
const invalidArg = invalidArgText(theme);
const rawPath = str(args?.file_path ?? args?.path);
@@ -203,7 +206,7 @@ function formatEditResult(
args: RenderableEditArgs | undefined,
preview: EditPreview | undefined,
result: EditToolResultLike,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
isError: boolean,
): string | undefined {
const rawPath = str(args?.file_path ?? args?.path);
@@ -231,7 +234,7 @@ function formatEditResult(
function getEditHeaderBg(
preview: EditPreview | undefined,
settledError: boolean | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): (text: string) => string {
if (preview) {
if ("error" in preview) {
@@ -248,7 +251,7 @@ function getEditHeaderBg(
function buildEditCallComponent(
component: EditCallRenderComponent,
args: RenderableEditArgs | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): EditCallRenderComponent {
component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme));
component.clear();
@@ -394,6 +397,7 @@ export function createEditToolDefinition(
}
const diffResult = generateDiffString(baseContent, newContent);
const patch = generateUnifiedPatch(path, baseContent, newContent);
resolve({
content: [
{
@@ -401,7 +405,7 @@ export function createEditToolDefinition(
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
},
],
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
});
} catch (error: unknown) {
// Clean up abort handler.

View File

@@ -5,13 +5,13 @@ import { spawn } from "child_process";
import { existsSync } from "fs";
import path from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { ensureTool } from "../../utils/tools-manager.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveToCwd } from "./path-utils.js";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import { ensureTool } from "../../utils/tools-manager.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveToCwd } from "./path-utils.ts";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
@@ -58,7 +58,7 @@ export interface FindToolOptions {
function formatFindCall(
args: { pattern: string; path?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
@@ -82,7 +82,7 @@ function formatFindResult(
details?: FindToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();

View File

@@ -5,12 +5,12 @@ import { spawn } from "child_process";
import { readFileSync, statSync } from "fs";
import path from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { ensureTool } from "../../utils/tools-manager.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveToCwd } from "./path-utils.js";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import { ensureTool } from "../../utils/tools-manager.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveToCwd } from "./path-utils.ts";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import {
DEFAULT_MAX_BYTES,
formatSize,
@@ -18,7 +18,7 @@ import {
type TruncationResult,
truncateHead,
truncateLine,
} from "./truncate.js";
} from "./truncate.ts";
const grepSchema = Type.Object({
pattern: Type.String({ description: "Search pattern (regex or literal string)" }),
@@ -66,7 +66,7 @@ export interface GrepToolOptions {
function formatGrepCall(
args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
@@ -90,7 +90,7 @@ function formatGrepResult(
details?: GrepToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();

View File

@@ -8,7 +8,7 @@ export {
createBashTool,
createBashToolDefinition,
createLocalBashOperations,
} from "./bash.js";
} from "./bash.ts";
export {
createEditTool,
createEditToolDefinition,
@@ -16,8 +16,8 @@ export {
type EditToolDetails,
type EditToolInput,
type EditToolOptions,
} from "./edit.js";
export { withFileMutationQueue } from "./file-mutation-queue.js";
} from "./edit.ts";
export { withFileMutationQueue } from "./file-mutation-queue.ts";
export {
createFindTool,
createFindToolDefinition,
@@ -25,7 +25,7 @@ export {
type FindToolDetails,
type FindToolInput,
type FindToolOptions,
} from "./find.js";
} from "./find.ts";
export {
createGrepTool,
createGrepToolDefinition,
@@ -33,7 +33,7 @@ export {
type GrepToolDetails,
type GrepToolInput,
type GrepToolOptions,
} from "./grep.js";
} from "./grep.ts";
export {
createLsTool,
createLsToolDefinition,
@@ -41,7 +41,7 @@ export {
type LsToolDetails,
type LsToolInput,
type LsToolOptions,
} from "./ls.js";
} from "./ls.ts";
export {
createReadTool,
createReadToolDefinition,
@@ -49,7 +49,7 @@ export {
type ReadToolDetails,
type ReadToolInput,
type ReadToolOptions,
} from "./read.js";
} from "./read.ts";
export {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
@@ -59,24 +59,24 @@ export {
truncateHead,
truncateLine,
truncateTail,
} from "./truncate.js";
} from "./truncate.ts";
export {
createWriteTool,
createWriteToolDefinition,
type WriteOperations,
type WriteToolInput,
type WriteToolOptions,
} from "./write.js";
} from "./write.ts";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { ToolDefinition } from "../extensions/types.js";
import { type BashToolOptions, createBashTool, createBashToolDefinition } from "./bash.js";
import { createEditTool, createEditToolDefinition, type EditToolOptions } from "./edit.js";
import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.js";
import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.js";
import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.js";
import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.js";
import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.js";
import type { ToolDefinition } from "../extensions/types.ts";
import { type BashToolOptions, createBashTool, createBashToolDefinition } from "./bash.ts";
import { createEditTool, createEditToolDefinition, type EditToolOptions } from "./edit.ts";
import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.ts";
import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.ts";
import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.ts";
import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.ts";
import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.ts";
export type Tool = AgentTool<any>;
export type ToolDef = ToolDefinition<any, any>;

View File

@@ -3,12 +3,12 @@ import { Text } from "@earendil-works/pi-tui";
import { existsSync, readdirSync, statSync } from "fs";
import nodePath from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveToCwd } from "./path-utils.js";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveToCwd } from "./path-utils.ts";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
const lsSchema = Type.Object({
path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })),
@@ -50,7 +50,7 @@ export interface LsToolOptions {
function formatLsCall(
args: { path?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): string {
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
@@ -69,7 +69,7 @@ function formatLsResult(
details?: LsToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();

View File

@@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
import { createWriteStream, type WriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.ts";
export interface OutputAccumulatorOptions {
maxLines?: number;
@@ -45,8 +45,10 @@ export class OutputAccumulator {
private tailStartsAtLineBoundary = true;
private totalRawBytes = 0;
private totalDecodedBytes = 0;
private totalLines = 1;
private completedLines = 0;
private totalLines = 0;
private currentLineBytes = 0;
private hasOpenLine = false;
private finished = false;
private tempFilePath: string | undefined;
@@ -164,10 +166,14 @@ export class OutputAccumulator {
}
if (newlines === 0) {
this.currentLineBytes += bytes;
this.hasOpenLine = true;
} else {
this.totalLines += newlines;
this.currentLineBytes = byteLength(text.slice(lastNewline + 1));
this.completedLines += newlines;
const tail = text.slice(lastNewline + 1);
this.currentLineBytes = byteLength(tail);
this.hasOpenLine = tail.length > 0;
}
this.totalLines = this.completedLines + (this.hasOpenLine ? 1 : 0);
}
private trimTail(): void {

View File

@@ -1,12 +1,7 @@
import { accessSync, constants } from "node:fs";
import * as os from "node:os";
import { isAbsolute, resolve as resolvePath } from "node:path";
import { normalizePath, resolvePath } from "../../utils/paths.ts";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
const NARROW_NO_BREAK_SPACE = "\u202F";
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function tryMacOSScreenshotPath(filePath: string): string {
return filePath.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`);
@@ -32,19 +27,8 @@ function fileExists(filePath: string): boolean {
}
}
function normalizeAtPrefix(filePath: string): string {
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
}
export function expandPath(filePath: string): string {
const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath));
if (normalized === "~") {
return os.homedir();
}
if (normalized.startsWith("~/")) {
return os.homedir() + normalized.slice(1);
}
return normalized;
return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
}
/**
@@ -52,11 +36,7 @@ export function expandPath(filePath: string): string {
* Handles ~ expansion and absolute paths.
*/
export function resolveToCwd(filePath: string, cwd: string): string {
const expanded = expandPath(filePath);
if (isAbsolute(expanded)) {
return expanded;
}
return resolvePath(cwd, expanded);
return resolvePath(filePath, cwd, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
}
export function resolveReadPath(filePath: string, cwd: string): string {

View File

@@ -5,17 +5,17 @@ import { Text } from "@earendil-works/pi-tui";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
import { type Static, Type } from "typebox";
import { getReadmePath } from "../../config.js";
import { keyHint, keyText } from "../../modes/interactive/components/keybinding-hints.js";
import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.js";
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { resolveReadPath } from "./path-utils.js";
import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
import { getReadmePath } from "../../config.ts";
import { keyHint, keyText } from "../../modes/interactive/components/keybinding-hints.ts";
import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.ts";
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.ts";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts";
import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveReadPath } from "./path-utils.ts";
import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
const readSchema = Type.Object({
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),

View File

@@ -1,8 +1,8 @@
import * as os from "node:os";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import { getCapabilities, getImageDimensions, imageFallback } from "@earendil-works/pi-tui";
import stripAnsi from "strip-ansi";
import { sanitizeBinaryOutput } from "../../utils/shell.js";
import { stripAnsi } from "../../utils/ansi.ts";
import { sanitizeBinaryOutput } from "../../utils/shell.ts";
export function shortenPath(path: unknown): string {
if (typeof path !== "string") return "";

View File

@@ -1,5 +1,5 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { ExtensionContext, ToolDefinition } from "../extensions/types.js";
import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";
/** Wrap a ToolDefinition into an AgentTool for the core runtime. */
export function wrapToolDefinition<TDetails = unknown>(

View File

@@ -44,6 +44,17 @@ export interface TruncationOptions {
maxBytes?: number;
}
function splitLinesForCounting(content: string): string[] {
if (content.length === 0) {
return [];
}
const lines = content.split("\n");
if (content.endsWith("\n")) {
lines.pop();
}
return lines;
}
/**
* Format bytes as human-readable size.
*/
@@ -69,7 +80,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const lines = content.split("\n");
const lines = splitLinesForCounting(content);
const totalLines = lines.length;
// Check if no truncation needed
@@ -159,7 +170,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const lines = content.split("\n");
const lines = splitLinesForCounting(content);
const totalLines = lines.length;
// Check if no truncation needed

View File

@@ -3,13 +3,13 @@ import { Container, Text } from "@earendil-works/pi-tui";
import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises";
import { dirname } from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { withFileMutationQueue } from "./file-mutation-queue.js";
import { resolveToCwd } from "./path-utils.js";
import { invalidArgText, normalizeDisplayText, replaceTabs, shortenPath, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToCwd } from "./path-utils.ts";
import { invalidArgText, normalizeDisplayText, replaceTabs, shortenPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
const writeSchema = Type.Object({
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
@@ -131,7 +131,7 @@ function trimTrailingEmptyLines(lines: string[]): string[] {
function formatWriteCall(
args: { path?: string; file_path?: string; content?: string } | undefined,
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
cache: WriteHighlightCache | undefined,
): string {
const rawPath = str(args?.file_path ?? args?.path);
@@ -163,7 +163,7 @@ function formatWriteCall(
function formatWriteResult(
result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean },
theme: typeof import("../../modes/interactive/theme/theme.js").theme,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): string | undefined {
if (!result.isError) {
return undefined;

View File

@@ -1,7 +1,7 @@
// Core session management
// Config paths
export { getAgentDir, VERSION } from "./config.js";
export { getAgentDir, VERSION } from "./config.ts";
export {
AgentSession,
type AgentSessionConfig,
@@ -12,7 +12,7 @@ export {
type PromptOptions,
parseSkillBlock,
type SessionStats,
} from "./core/agent-session.js";
} from "./core/agent-session.ts";
// Auth and model registry
export {
type ApiKeyCredential,
@@ -23,7 +23,7 @@ export {
FileAuthStorageBackend,
InMemoryAuthStorageBackend,
type OAuthCredential,
} from "./core/auth-storage.js";
} from "./core/auth-storage.ts";
// Compaction
export {
type BranchPreparation,
@@ -46,8 +46,8 @@ export {
prepareBranchEntries,
serializeConversation,
shouldCompact,
} from "./core/compaction/index.js";
export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.js";
} from "./core/compaction/index.ts";
export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.ts";
// Extension system
export type {
AgentEndEvent,
@@ -128,7 +128,7 @@ export type {
WidgetPlacement,
WorkingIndicatorOptions,
WriteToolCallEvent,
} from "./core/extensions/index.js";
} from "./core/extensions/index.ts";
export {
createExtensionRuntime,
defineTool,
@@ -144,11 +144,11 @@ export {
isWriteToolResult,
wrapRegisteredTool,
wrapRegisteredTools,
} from "./core/extensions/index.js";
} from "./core/extensions/index.ts";
// Footer data provider (git branch + extension statuses - data not otherwise available to extensions)
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js";
export { convertToLlm } from "./core/messages.js";
export { ModelRegistry } from "./core/model-registry.js";
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.ts";
export { convertToLlm } from "./core/messages.ts";
export { ModelRegistry } from "./core/model-registry.ts";
export type {
PackageManager,
PathMetadata,
@@ -156,10 +156,10 @@ export type {
ProgressEvent,
ResolvedPaths,
ResolvedResource,
} from "./core/package-manager.js";
export { DefaultPackageManager } from "./core/package-manager.js";
export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js";
export { DefaultResourceLoader, loadProjectContextFiles } from "./core/resource-loader.js";
} from "./core/package-manager.ts";
export { DefaultPackageManager } from "./core/package-manager.ts";
export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.ts";
export { DefaultResourceLoader, loadProjectContextFiles } from "./core/resource-loader.ts";
// SDK for programmatic usage
export {
AgentSessionRuntime,
@@ -187,7 +187,7 @@ export {
createReadTool,
createWriteTool,
type PromptTemplate,
} from "./core/sdk.js";
} from "./core/sdk.ts";
export {
type BranchSummaryEntry,
buildSessionContext,
@@ -210,14 +210,14 @@ export {
SessionManager,
type SessionMessageEntry,
type ThinkingLevelChangeEntry,
} from "./core/session-manager.js";
} from "./core/session-manager.ts";
export {
type CompactionSettings,
type ImageSettings,
type PackageSource,
type RetrySettings,
SettingsManager,
} from "./core/settings-manager.js";
} from "./core/settings-manager.ts";
// Skills
export {
formatSkillsForPrompt,
@@ -227,8 +227,8 @@ export {
loadSkillsFromDir,
type Skill,
type SkillFrontmatter,
} from "./core/skills.js";
export { createSyntheticSourceInfo } from "./core/source-info.js";
} from "./core/skills.ts";
export { createSyntheticSourceInfo } from "./core/source-info.ts";
// Tools
export {
type BashOperations,
@@ -278,9 +278,9 @@ export {
type WriteToolInput,
type WriteToolOptions,
withFileMutationQueue,
} from "./core/tools/index.js";
} from "./core/tools/index.ts";
// Main entry point
export { type MainOptions, main } from "./main.js";
export { type MainOptions, main } from "./main.ts";
// Run modes for programmatic SDK usage
export {
InteractiveMode,
@@ -295,7 +295,7 @@ export {
type RpcSessionState,
runPrintMode,
runRpcMode,
} from "./modes/index.js";
} from "./modes/index.ts";
// UI components for extensions
export {
ArminComponent,
@@ -334,7 +334,7 @@ export {
UserMessageComponent,
UserMessageSelectorComponent,
type VisualTruncateResult,
} from "./modes/interactive/components/index.js";
} from "./modes/interactive/components/index.ts";
// Theme utilities for custom tools and extensions
export {
getLanguageFromPath,
@@ -345,9 +345,10 @@ export {
initTheme,
Theme,
type ThemeColor,
} from "./modes/interactive/theme/theme.js";
} from "./modes/interactive/theme/theme.ts";
// Clipboard utilities
export { copyToClipboard } from "./utils/clipboard.js";
export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.js";
export { copyToClipboard } from "./utils/clipboard.ts";
export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts";
export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts";
// Shell utilities
export { getShellConfig } from "./utils/shell.js";
export { getShellConfig } from "./utils/shell.ts";

View File

@@ -5,47 +5,48 @@
* createAgentSession() options. The SDK does the heavy lifting.
*/
import { resolve } from "node:path";
import { createInterface } from "node:readline";
import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai";
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
import chalk from "chalk";
import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.js";
import { processFileArguments } from "./cli/file-processor.js";
import { buildInitialMessage } from "./cli/initial-message.js";
import { listModels } from "./cli/list-models.js";
import { selectSession } from "./cli/session-picker.js";
import { ENV_SESSION_DIR, expandTildePath, getAgentDir, VERSION } from "./config.js";
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.js";
import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts";
import { processFileArguments } from "./cli/file-processor.ts";
import { buildInitialMessage } from "./cli/initial-message.ts";
import { listModels } from "./cli/list-models.ts";
import { selectSession } from "./cli/session-picker.ts";
import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts";
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts";
import {
type AgentSessionRuntimeDiagnostic,
createAgentSessionFromServices,
createAgentSessionServices,
} from "./core/agent-session-services.js";
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.js";
import { AuthStorage } from "./core/auth-storage.js";
import { exportFromFile } from "./core/export-html/index.js";
import type { ExtensionFactory } from "./core/extensions/types.js";
import { KeybindingsManager } from "./core/keybindings.js";
import type { ModelRegistry } from "./core/model-registry.js";
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
import { restoreStdout, takeOverStdout } from "./core/output-guard.js";
import type { CreateAgentSessionOptions } from "./core/sdk.js";
} from "./core/agent-session-services.ts";
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
import { AuthStorage } from "./core/auth-storage.ts";
import { exportFromFile } from "./core/export-html/index.ts";
import type { ExtensionFactory } from "./core/extensions/types.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { KeybindingsManager } from "./core/keybindings.ts";
import type { ModelRegistry } from "./core/model-registry.ts";
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts";
import { restoreStdout, takeOverStdout } from "./core/output-guard.ts";
import type { CreateAgentSessionOptions } from "./core/sdk.ts";
import {
formatMissingSessionCwdPrompt,
getMissingSessionCwdIssue,
MissingSessionCwdError,
type SessionCwdIssue,
} from "./core/session-cwd.js";
import { SessionManager } from "./core/session-manager.js";
import { SettingsManager } from "./core/settings-manager.js";
import { printTimings, resetTimings, time } from "./core/timings.js";
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.js";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.js";
import { isLocalPath } from "./utils/paths.js";
} from "./core/session-cwd.ts";
import { SessionManager } from "./core/session-manager.ts";
import { SettingsManager } from "./core/settings-manager.ts";
import { printTimings, resetTimings, time } from "./core/timings.ts";
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts";
import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts";
/**
* Read all content from piped stdin.
@@ -145,9 +146,9 @@ type ResolvedSession =
* If it looks like a path, use as-is. Otherwise try to match as session ID prefix.
*/
async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise<ResolvedSession> {
// If it looks like a file path, use as-is
// If it looks like a file path, resolve it before handing it to the session manager.
if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) {
return { type: "path", path: sessionArg };
return { type: "path", path: resolvePath(sessionArg, cwd) };
}
// Try to match as session ID in current project first
@@ -379,7 +380,7 @@ function buildSessionOptions(
}
function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined {
return paths?.map((value) => (isLocalPath(value) ? resolve(cwd, value) : value));
return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));
}
async function promptForMissingSessionCwd(
@@ -428,6 +429,10 @@ export async function main(args: string[], options?: MainOptions) {
process.env.PI_SKIP_VERSION_CHECK = "1";
}
if (process.platform === "win32") {
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
}
if (await handlePackageCommand(args)) {
return;
}
@@ -495,7 +500,7 @@ export async function main(args: string[], options?: MainOptions) {
// sessionDir lookup during session selection.
const envSessionDir = process.env[ENV_SESSION_DIR];
const sessionDir =
parsed.sessionDir ??
(parsed.sessionDir ? normalizePath(parsed.sessionDir) : undefined) ??
(envSessionDir ? expandTildePath(envSessionDir) : undefined) ??
startupSettingsManager.getSessionDir();
let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager);
@@ -612,6 +617,7 @@ export async function main(args: string[], options?: MainOptions) {
});
const { services, session, modelFallbackMessage } = runtime;
const { settingsManager, modelRegistry, resourceLoader } = services;
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
if (parsed.help) {
const extensionFlags = resourceLoader
@@ -651,7 +657,6 @@ export async function main(args: string[], options?: MainOptions) {
await showDeprecationWarnings(deprecationWarnings);
}
const scopedModels = [...session.scopedModels];
time("resolveModelScope");
reportDiagnostics(runtime.diagnostics);
if (runtime.diagnostics.some((diagnostic) => diagnostic.type === "error")) {
@@ -674,16 +679,6 @@ export async function main(args: string[], options?: MainOptions) {
printTimings();
await runRpcMode(runtime);
} else if (appMode === "interactive") {
if (scopedModels.length > 0 && (parsed.verbose || !settingsManager.getQuietStartup())) {
const modelList = scopedModels
.map((sm) => {
const thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : "";
return `${sm.model.id}${thinkingStr}`;
})
.join(", ");
console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
}
const interactiveMode = new InteractiveMode(runtime, {
migratedProviders,
modelFallbackMessage,

View File

@@ -5,8 +5,8 @@
import chalk from "chalk";
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.js";
import { migrateKeybindingsConfig } from "./core/keybindings.js";
import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.ts";
import { migrateKeybindingsConfig } from "./core/keybindings.ts";
const MIGRATION_GUIDE_URL =
"https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration";

View File

@@ -2,8 +2,8 @@
* Run modes for the coding agent.
*/
export { InteractiveMode, type InteractiveModeOptions } from "./interactive/interactive-mode.js";
export { type PrintModeOptions, runPrintMode } from "./print-mode.js";
export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client.js";
export { runRpcMode } from "./rpc/rpc-mode.js";
export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc/rpc-types.js";
export { InteractiveMode, type InteractiveModeOptions } from "./interactive/interactive-mode.ts";
export { type PrintModeOptions, runPrintMode } from "./print-mode.ts";
export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client.ts";
export { runRpcMode } from "./rpc/rpc-mode.ts";
export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc/rpc-types.ts";

View File

@@ -3,7 +3,7 @@
*/
import type { Component, TUI } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.js";
import { theme } from "../theme/theme.ts";
// XBM image: 31x36 pixels, LSB first, 1=background, 0=foreground
const WIDTH = 31;

View File

@@ -1,6 +1,6 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { Container, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
import { getMarkdownTheme, theme } from "../theme/theme.js";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
const OSC133_ZONE_START = "\x1b]133;A\x07";
const OSC133_ZONE_END = "\x1b]133;B\x07";

View File

@@ -3,17 +3,17 @@
*/
import { Container, Loader, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import stripAnsi from "strip-ansi";
import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
type TruncationResult,
truncateTail,
} from "../../../core/tools/truncate.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint, keyText } from "./keybinding-hints.js";
import { truncateToVisualLines } from "./visual-truncate.js";
} from "../../../core/tools/truncate.ts";
import { stripAnsi } from "../../../utils/ansi.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, keyText } from "./keybinding-hints.ts";
import { truncateToVisualLines } from "./visual-truncate.ts";
// Preview line limit when not expanded (matches tool execution behavior)
const PREVIEW_LINES = 20;

View File

@@ -1,7 +1,7 @@
import { CancellableLoader, Container, Loader, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import type { Theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js";
import type { Theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
/** Loader wrapped with borders for extension UI */
export class BorderedLoader extends Container {

View File

@@ -1,7 +1,7 @@
import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
import type { BranchSummaryMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
import { keyText } from "./keybinding-hints.js";
import type { BranchSummaryMessage } from "../../../core/messages.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
import { keyText } from "./keybinding-hints.ts";
/**
* Component that renders a branch summary message with collapsed/expanded state.

View File

@@ -1,7 +1,7 @@
import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
import type { CompactionSummaryMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
import { keyText } from "./keybinding-hints.js";
import type { CompactionSummaryMessage } from "../../../core/messages.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
import { keyText } from "./keybinding-hints.ts";
/**
* Component that renders a compaction message with collapsed/expanded state.

View File

@@ -15,12 +15,12 @@ import {
truncateToWidth,
visibleWidth,
} from "@earendil-works/pi-tui";
import { CONFIG_DIR_NAME } from "../../../config.js";
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.js";
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { rawKeyHint } from "./keybinding-hints.js";
import { CONFIG_DIR_NAME } from "../../../config.ts";
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts";
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { rawKeyHint } from "./keybinding-hints.ts";
type ResourceType = "extensions" | "skills" | "prompts" | "themes";
@@ -201,7 +201,7 @@ class ResourceList implements Component, Focusable {
private filteredItems: FlatEntry[] = [];
private selectedIndex = 0;
private searchInput: Input;
private maxVisible = 15;
private maxVisible: number;
private settingsManager: SettingsManager;
private cwd: string;
private agentDir: string;
@@ -219,12 +219,21 @@ class ResourceList implements Component, Focusable {
this.searchInput.focused = value;
}
constructor(groups: ResourceGroup[], settingsManager: SettingsManager, cwd: string, agentDir: string) {
constructor(
groups: ResourceGroup[],
settingsManager: SettingsManager,
cwd: string,
agentDir: string,
terminalHeight?: number,
) {
this.groups = groups;
this.settingsManager = settingsManager;
this.cwd = cwd;
this.agentDir = agentDir;
this.searchInput = new Input();
// 8 lines of chrome: top spacer + top border + spacer + header (2 lines) + spacer + bottom spacer + bottom border
const chrome = 8;
this.maxVisible = Math.max(5, (terminalHeight ?? 24) - chrome);
this.buildFlatList();
this.filteredItems = [...this.flatItems];
}
@@ -588,6 +597,7 @@ export class ConfigSelectorComponent extends Container implements Focusable {
onClose: () => void,
onExit: () => void,
requestRender: () => void,
terminalHeight?: number,
) {
super();
@@ -601,7 +611,7 @@ export class ConfigSelectorComponent extends Container implements Focusable {
this.addChild(new Spacer(1));
// Resource list
this.resourceList = new ResourceList(groups, settingsManager, cwd, agentDir);
this.resourceList = new ResourceList(groups, settingsManager, cwd, agentDir, terminalHeight);
this.resourceList.onCancel = onClose;
this.resourceList.onExit = onExit;
this.resourceList.onToggle = () => requestRender();

View File

@@ -7,13 +7,14 @@ import type { TUI } from "@earendil-works/pi-tui";
export class CountdownTimer {
private intervalId: ReturnType<typeof setInterval> | undefined;
private remainingSeconds: number;
private tui: TUI | undefined;
private onTick: (seconds: number) => void;
private onExpire: () => void;
constructor(
timeoutMs: number,
private tui: TUI | undefined,
private onTick: (seconds: number) => void,
private onExpire: () => void,
) {
constructor(timeoutMs: number, tui: TUI | undefined, onTick: (seconds: number) => void, onExpire: () => void) {
this.tui = tui;
this.onTick = onTick;
this.onExpire = onExpire;
this.remainingSeconds = Math.ceil(timeoutMs / 1000);
this.onTick(this.remainingSeconds);

View File

@@ -1,5 +1,5 @@
import { Editor, type EditorOptions, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.js";
import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.ts";
/**
* Custom editor that handles app-level keybindings for coding-agent.

View File

@@ -1,9 +1,9 @@
import type { TextContent } from "@earendil-works/pi-ai";
import type { Component } from "@earendil-works/pi-tui";
import { Box, Container, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
import type { MessageRenderer } from "../../../core/extensions/types.js";
import type { CustomMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
import type { MessageRenderer } from "../../../core/extensions/types.ts";
import type { CustomMessage } from "../../../core/messages.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
/**
* Component that renders a custom message entry from extensions.

View File

@@ -5,7 +5,7 @@
*/
import type { Component, TUI } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.js";
import { theme } from "../theme/theme.ts";
// 32x32 RGB image of dax, hex encoded (3 bytes per pixel)
const DAX_HEX =

View File

@@ -1,5 +1,5 @@
import * as Diff from "diff";
import { theme } from "../theme/theme.js";
import { theme } from "../theme/theme.ts";
/**
* Parse diff line to extract prefix, line number, and content.

View File

@@ -1,5 +1,5 @@
import type { Component } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.js";
import { theme } from "../theme/theme.ts";
/**
* Dynamic border component that adjusts to viewport width.

View File

@@ -1,8 +1,8 @@
import * as fs from "node:fs";
import { Container, Image, Spacer, Text } from "@earendil-works/pi-tui";
import { getBundledInteractiveAssetPath } from "../../../config.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { getBundledInteractiveAssetPath } from "../../../config.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
const BLOG_URL = "https://mariozechner.at/posts/2026-04-08-ive-sold-out/";
const IMAGE_FILENAME = "clankolas.png";

View File

@@ -3,7 +3,7 @@
* Supports Ctrl+G for external editor.
*/
import { spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -17,10 +17,10 @@ import {
Text,
type TUI,
} from "@earendil-works/pi-tui";
import type { KeybindingsManager } from "../../../core/keybindings.js";
import { getEditorTheme, theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js";
import type { KeybindingsManager } from "../../../core/keybindings.ts";
import { getEditorTheme, theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
export class ExtensionEditorComponent extends Container implements Focusable {
private editor: Editor;
@@ -110,7 +110,7 @@ export class ExtensionEditorComponent extends Container implements Focusable {
this.editor.handleInput(keyData);
}
private openExternalEditor(): void {
private async openExternalEditor(): Promise<void> {
const editorCmd = process.env.VISUAL || process.env.EDITOR;
if (!editorCmd) {
return;
@@ -124,12 +124,21 @@ export class ExtensionEditorComponent extends Container implements Focusable {
this.tui.stop();
const [editor, ...editorArgs] = editorCmd.split(" ");
const result = spawnSync(editor, [...editorArgs, tmpFile], {
stdio: "inherit",
shell: process.platform === "win32",
process.stdout.write(`Launching external editor: ${editorCmd}\nPi will resume when the editor exits.\n`);
// Do not use spawnSync here. On Windows, synchronous child_process calls can keep
// Node/libuv's console input read active after tui.stop() pauses stdin, racing
// vim/nvim for the console input buffer until Ctrl+C cancels the pending read.
const status = await new Promise<number | null>((resolve) => {
const child = spawn(editor, [...editorArgs, tmpFile], {
stdio: "inherit",
shell: process.platform === "win32",
});
child.on("error", () => resolve(null));
child.on("close", (code) => resolve(code));
});
if (result.status === 0) {
if (status === 0) {
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
this.editor.setText(newContent);
}

View File

@@ -3,10 +3,10 @@
*/
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.js";
import { CountdownTimer } from "./countdown-timer.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js";
import { theme } from "../theme/theme.ts";
import { CountdownTimer } from "./countdown-timer.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
export interface ExtensionInputOptions {
tui?: TUI;

View File

@@ -4,14 +4,15 @@
*/
import { Container, getKeybindings, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.js";
import { CountdownTimer } from "./countdown-timer.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint, rawKeyHint } from "./keybinding-hints.js";
import { theme } from "../theme/theme.ts";
import { CountdownTimer } from "./countdown-timer.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
export interface ExtensionSelectorOptions {
tui?: TUI;
timeout?: number;
onToggleToolsExpanded?: () => void;
}
export class ExtensionSelectorComponent extends Container {
@@ -23,6 +24,7 @@ export class ExtensionSelectorComponent extends Container {
private titleText: Text;
private baseTitle: string;
private countdown: CountdownTimer | undefined;
private onToggleToolsExpanded: (() => void) | undefined;
constructor(
title: string,
@@ -36,6 +38,7 @@ export class ExtensionSelectorComponent extends Container {
this.options = options;
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
this.onToggleToolsExpanded = opts?.onToggleToolsExpanded;
this.baseTitle = title;
this.addChild(new DynamicBorder());
@@ -87,7 +90,9 @@ export class ExtensionSelectorComponent extends Container {
handleInput(keyData: string): void {
const kb = getKeybindings();
if (kb.matches(keyData, "tui.select.up") || keyData === "k") {
if (kb.matches(keyData, "app.tools.expand")) {
this.onToggleToolsExpanded?.();
} else if (kb.matches(keyData, "tui.select.up") || keyData === "k") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.updateList();
} else if (kb.matches(keyData, "tui.select.down") || keyData === "j") {

View File

@@ -1,7 +1,7 @@
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import type { AgentSession } from "../../../core/agent-session.js";
import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.js";
import { theme } from "../theme/theme.js";
import type { AgentSession } from "../../../core/agent-session.ts";
import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts";
import { theme } from "../theme/theme.ts";
/**
* Sanitize text for display in a single-line status.
@@ -16,7 +16,7 @@ function sanitizeStatusText(text: string): string {
}
/**
* Format token counts (similar to web-ui)
* Format token counts for compact footer display.
*/
function formatTokens(count: number): string {
if (count < 1000) return count.toString();
@@ -32,11 +32,13 @@ function formatTokens(count: number): string {
*/
export class FooterComponent implements Component {
private autoCompactEnabled = true;
private session: AgentSession;
private footerData: ReadonlyFooterDataProvider;
constructor(
private session: AgentSession,
private footerData: ReadonlyFooterDataProvider,
) {}
constructor(session: AgentSession, footerData: ReadonlyFooterDataProvider) {
this.session = session;
this.footerData = footerData;
}
setSession(session: AgentSession): void {
this.session = session;

View File

@@ -1,32 +1,32 @@
// UI Components for extensions
export { ArminComponent } from "./armin.js";
export { AssistantMessageComponent } from "./assistant-message.js";
export { BashExecutionComponent } from "./bash-execution.js";
export { BorderedLoader } from "./bordered-loader.js";
export { BranchSummaryMessageComponent } from "./branch-summary-message.js";
export { CompactionSummaryMessageComponent } from "./compaction-summary-message.js";
export { CustomEditor } from "./custom-editor.js";
export { CustomMessageComponent } from "./custom-message.js";
export { DaxnutsComponent } from "./daxnuts.js";
export { type RenderDiffOptions, renderDiff } from "./diff.js";
export { DynamicBorder } from "./dynamic-border.js";
export { ExtensionEditorComponent } from "./extension-editor.js";
export { ExtensionInputComponent } from "./extension-input.js";
export { ExtensionSelectorComponent } from "./extension-selector.js";
export { FooterComponent } from "./footer.js";
export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.js";
export { LoginDialogComponent } from "./login-dialog.js";
export { ModelSelectorComponent } from "./model-selector.js";
export { OAuthSelectorComponent } from "./oauth-selector.js";
export { type ModelsCallbacks, type ModelsConfig, ScopedModelsSelectorComponent } from "./scoped-models-selector.js";
export { SessionSelectorComponent } from "./session-selector.js";
export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.js";
export { ShowImagesSelectorComponent } from "./show-images-selector.js";
export { SkillInvocationMessageComponent } from "./skill-invocation-message.js";
export { ThemeSelectorComponent } from "./theme-selector.js";
export { ThinkingSelectorComponent } from "./thinking-selector.js";
export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.js";
export { TreeSelectorComponent } from "./tree-selector.js";
export { UserMessageComponent } from "./user-message.js";
export { UserMessageSelectorComponent } from "./user-message-selector.js";
export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.js";
export { ArminComponent } from "./armin.ts";
export { AssistantMessageComponent } from "./assistant-message.ts";
export { BashExecutionComponent } from "./bash-execution.ts";
export { BorderedLoader } from "./bordered-loader.ts";
export { BranchSummaryMessageComponent } from "./branch-summary-message.ts";
export { CompactionSummaryMessageComponent } from "./compaction-summary-message.ts";
export { CustomEditor } from "./custom-editor.ts";
export { CustomMessageComponent } from "./custom-message.ts";
export { DaxnutsComponent } from "./daxnuts.ts";
export { type RenderDiffOptions, renderDiff } from "./diff.ts";
export { DynamicBorder } from "./dynamic-border.ts";
export { ExtensionEditorComponent } from "./extension-editor.ts";
export { ExtensionInputComponent } from "./extension-input.ts";
export { ExtensionSelectorComponent } from "./extension-selector.ts";
export { FooterComponent } from "./footer.ts";
export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.ts";
export { LoginDialogComponent } from "./login-dialog.ts";
export { ModelSelectorComponent } from "./model-selector.ts";
export { OAuthSelectorComponent } from "./oauth-selector.ts";
export { type ModelsCallbacks, type ModelsConfig, ScopedModelsSelectorComponent } from "./scoped-models-selector.ts";
export { SessionSelectorComponent } from "./session-selector.ts";
export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.ts";
export { ShowImagesSelectorComponent } from "./show-images-selector.ts";
export { SkillInvocationMessageComponent } from "./skill-invocation-message.ts";
export { ThemeSelectorComponent } from "./theme-selector.ts";
export { ThinkingSelectorComponent } from "./thinking-selector.ts";
export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.ts";
export { TreeSelectorComponent } from "./tree-selector.ts";
export { UserMessageComponent } from "./user-message.ts";
export { UserMessageSelectorComponent } from "./user-message-selector.ts";
export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.ts";

View File

@@ -3,7 +3,7 @@
*/
import { getKeybindings, type Keybinding, type KeyId } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.js";
import { theme } from "../theme/theme.ts";
export interface KeyTextFormatOptions {
capitalize?: boolean;

View File

@@ -1,9 +1,9 @@
import { getOAuthProviders } from "@earendil-works/pi-ai/oauth";
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import { exec } from "child_process";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
/**
* Login dialog component - replaces editor during OAuth login flow
@@ -15,6 +15,7 @@ export class LoginDialogComponent extends Container implements Focusable {
private abortController = new AbortController();
private inputResolver?: (value: string) => void;
private inputRejecter?: (error: Error) => void;
private onComplete: (success: boolean, message?: string) => void;
// Focusable implementation - propagate to input for IME cursor positioning
private _focused = false;
@@ -29,12 +30,13 @@ export class LoginDialogComponent extends Container implements Focusable {
constructor(
tui: TUI,
providerId: string,
private onComplete: (success: boolean, message?: string) => void,
onComplete: (success: boolean, message?: string) => void,
providerNameOverride?: string,
titleOverride?: string,
) {
super();
this.tui = tui;
this.onComplete = onComplete;
const providerInfo = getOAuthProviders().find((p) => p.id === providerId);
const providerName = providerNameOverride || providerInfo?.name || providerId;

View File

@@ -9,11 +9,11 @@ import {
Text,
type TUI,
} from "@earendil-works/pi-tui";
import type { ModelRegistry } from "../../../core/model-registry.js";
import type { SettingsManager } from "../../../core/settings-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js";
import type { ModelRegistry } from "../../../core/model-registry.ts";
import type { SettingsManager } from "../../../core/settings-manager.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
interface ModelItem {
provider: string;

View File

@@ -7,9 +7,9 @@ import {
Spacer,
TruncatedText,
} from "@earendil-works/pi-tui";
import type { AuthStatus, AuthStorage } from "../../../core/auth-storage.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import type { AuthStatus, AuthStorage } from "../../../core/auth-storage.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
export type AuthSelectorProvider = {
id: string;

View File

@@ -10,9 +10,9 @@ import {
Spacer,
Text,
} from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyText } from "./keybinding-hints.js";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyText } from "./keybinding-hints.ts";
// EnabledIds: null = all enabled (no filter), string[] = explicit ordered list
type EnabledIds = string[] | null;

View File

@@ -1,5 +1,5 @@
import { fuzzyMatch } from "@earendil-works/pi-tui";
import type { SessionInfo } from "../../../core/session-manager.js";
import type { SessionInfo } from "../../../core/session-manager.ts";
export type SortMode = "threaded" | "recent" | "relevance";

View File

@@ -13,13 +13,13 @@ import {
truncateToWidth,
visibleWidth,
} from "@earendil-works/pi-tui";
import { KeybindingsManager } from "../../../core/keybindings.js";
import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.js";
import { canonicalizePath as _canonicalizePath } from "../../../utils/paths.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint, keyText } from "./keybinding-hints.js";
import { filterAndSortSessions, hasSessionName, type NameFilter, type SortMode } from "./session-selector-search.js";
import { KeybindingsManager } from "../../../core/keybindings.ts";
import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.ts";
import { canonicalizePath as _canonicalizePath } from "../../../utils/paths.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, keyText } from "./keybinding-hints.ts";
import { filterAndSortSessions, hasSessionName, type NameFilter, type SortMode } from "./session-selector-search.ts";
type SessionScope = "current" | "all";

View File

@@ -11,10 +11,11 @@ import {
Spacer,
Text,
} from "@earendil-works/pi-tui";
import type { WarningSettings } from "../../../core/settings-manager.js";
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyDisplayText } from "./keybinding-hints.js";
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts";
import type { WarningSettings } from "../../../core/settings-manager.ts";
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyDisplayText } from "./keybinding-hints.ts";
const SETTINGS_SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,
@@ -40,6 +41,7 @@ export interface SettingsConfig {
steeringMode: "all" | "one-at-a-time";
followUpMode: "all" | "one-at-a-time";
transport: Transport;
httpIdleTimeoutMs: number;
thinkingLevel: ThinkingLevel;
availableThinkingLevels: ThinkingLevel[];
currentTheme: string;
@@ -68,6 +70,7 @@ export interface SettingsCallbacks {
onSteeringModeChange: (mode: "all" | "one-at-a-time") => void;
onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void;
onTransportChange: (transport: Transport) => void;
onHttpIdleTimeoutMsChange: (timeoutMs: number) => void;
onThinkingLevelChange: (level: ThinkingLevel) => void;
onThemeChange: (theme: string) => void;
onThemePreview?: (theme: string) => void;
@@ -238,6 +241,14 @@ export class SettingsSelectorComponent extends Container {
currentValue: config.transport,
values: ["sse", "websocket", "websocket-cached", "auto"],
},
{
id: "http-idle-timeout",
label: "HTTP idle timeout",
description:
"Maximum idle gap while waiting for HTTP headers or body chunks. Disable for local models that pause longer than five minutes.",
currentValue: formatHttpIdleTimeoutMs(config.httpIdleTimeoutMs),
values: HTTP_IDLE_TIMEOUT_CHOICES.map((choice) => choice.label),
},
{
id: "hide-thinking",
label: "Hide thinking",
@@ -482,6 +493,13 @@ export class SettingsSelectorComponent extends Container {
case "transport":
callbacks.onTransportChange(newValue as Transport);
break;
case "http-idle-timeout": {
const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.label === newValue);
if (choice) {
callbacks.onHttpIdleTimeoutMsChange(choice.timeoutMs);
}
break;
}
case "hide-thinking":
callbacks.onHideThinkingBlockChange(newValue === "true");
break;

View File

@@ -1,6 +1,6 @@
import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui";
import { getSelectListTheme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { getSelectListTheme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
const SHOW_IMAGES_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,

View File

@@ -1,7 +1,7 @@
import { Box, Markdown, type MarkdownTheme, Text } from "@earendil-works/pi-tui";
import type { ParsedSkillBlock } from "../../../core/agent-session.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
import { keyText } from "./keybinding-hints.js";
import type { ParsedSkillBlock } from "../../../core/agent-session.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
import { keyText } from "./keybinding-hints.ts";
/**
* Component that renders a skill invocation message with collapsed/expanded state.

View File

@@ -1,6 +1,6 @@
import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui";
import { getAvailableThemes, getSelectListTheme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { getAvailableThemes, getSelectListTheme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
const THEME_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,

View File

@@ -1,7 +1,7 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui";
import { getSelectListTheme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { getSelectListTheme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
const THINKING_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,

View File

@@ -1,9 +1,9 @@
import { Box, type Component, Container, getCapabilities, Image, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.js";
import { createAllToolDefinitions, type ToolName } from "../../../core/tools/index.js";
import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.js";
import { convertToPng } from "../../../utils/image-convert.js";
import { theme } from "../theme/theme.js";
import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.ts";
import { createAllToolDefinitions, type ToolName } from "../../../core/tools/index.ts";
import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.ts";
import { convertToPng } from "../../../utils/image-convert.ts";
import { theme } from "../theme/theme.ts";
export interface ToolExecutionOptions {
showImages?: boolean;

View File

@@ -9,10 +9,10 @@ import {
TruncatedText,
truncateToWidth,
} from "@earendil-works/pi-tui";
import type { SessionTreeNode } from "../../../core/session-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint, keyText } from "./keybinding-hints.js";
import type { SessionTreeNode } from "../../../core/session-manager.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, keyText } from "./keybinding-hints.ts";
/** Gutter info: position (displayIndent where connector was) and whether to show │ */
interface GutterInfo {
@@ -1056,7 +1056,11 @@ class TreeList implements Component {
/** Component that displays the current search query */
class SearchLine implements Component {
constructor(private treeList: TreeList) {}
private treeList: TreeList;
constructor(treeList: TreeList) {
this.treeList = treeList;
}
invalidate(): void {}

View File

@@ -1,6 +1,6 @@
import { type Component, Container, getKeybindings, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
interface UserMessageItem {
id: string; // Entry ID in the session

View File

@@ -1,5 +1,5 @@
import { Box, Container, Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
import { getMarkdownTheme, theme } from "../theme/theme.js";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
const OSC133_ZONE_START = "\x1b]133;A\x07";
const OSC133_ZONE_END = "\x1b]133;B\x07";

View File

@@ -57,9 +57,9 @@ import {
getDocsPath,
getShareViewerUrl,
VERSION,
} from "../../config.js";
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js";
import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js";
} from "../../config.ts";
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts";
import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts";
import type {
AutocompleteProviderFactory,
EditorFactory,
@@ -69,57 +69,58 @@ import type {
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionWidgetOptions,
} from "../../core/extensions/index.js";
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js";
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.js";
import { createCompactionSummaryMessage } from "../../core/messages.js";
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js";
import { DefaultPackageManager } from "../../core/package-manager.js";
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.js";
import type { ResourceDiagnostic } from "../../core/resource-loader.js";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.js";
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js";
import type { SourceInfo } from "../../core/source-info.js";
import { isInstallTelemetryEnabled } from "../../core/telemetry.js";
import type { TruncationResult } from "../../core/tools/truncate.js";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
import { copyToClipboard } from "../../utils/clipboard.js";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
import { parseGitUrl } from "../../utils/git.js";
import { getCwdRelativePath } from "../../utils/paths.js";
import { getPiUserAgent } from "../../utils/pi-user-agent.js";
import { killTrackedDetachedChildren } from "../../utils/shell.js";
import { ensureTool } from "../../utils/tools-manager.js";
import { checkForNewPiVersion } from "../../utils/version-check.js";
import { ArminComponent } from "./components/armin.js";
import { AssistantMessageComponent } from "./components/assistant-message.js";
import { BashExecutionComponent } from "./components/bash-execution.js";
import { BorderedLoader } from "./components/bordered-loader.js";
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.js";
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js";
import { CountdownTimer } from "./components/countdown-timer.js";
import { CustomEditor } from "./components/custom-editor.js";
import { CustomMessageComponent } from "./components/custom-message.js";
import { DaxnutsComponent } from "./components/daxnuts.js";
import { DynamicBorder } from "./components/dynamic-border.js";
import { EarendilAnnouncementComponent } from "./components/earendil-announcement.js";
import { ExtensionEditorComponent } from "./components/extension-editor.js";
import { ExtensionInputComponent } from "./components/extension-input.js";
import { ExtensionSelectorComponent } from "./components/extension-selector.js";
import { FooterComponent } from "./components/footer.js";
import { formatKeyText, keyDisplayText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.js";
import { LoginDialogComponent } from "./components/login-dialog.js";
import { ModelSelectorComponent } from "./components/model-selector.js";
import { type AuthSelectorProvider, OAuthSelectorComponent } from "./components/oauth-selector.js";
import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js";
import { SessionSelectorComponent } from "./components/session-selector.js";
import { SettingsSelectorComponent } from "./components/settings-selector.js";
import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.js";
import { ToolExecutionComponent } from "./components/tool-execution.js";
import { TreeSelectorComponent } from "./components/tree-selector.js";
import { UserMessageComponent } from "./components/user-message.js";
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
} from "../../core/extensions/index.ts";
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts";
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts";
import { createCompactionSummaryMessage } from "../../core/messages.ts";
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts";
import { DefaultPackageManager } from "../../core/package-manager.ts";
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts";
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
import { type SessionContext, SessionManager } from "../../core/session-manager.ts";
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
import type { SourceInfo } from "../../core/source-info.ts";
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
import type { TruncationResult } from "../../core/tools/truncate.ts";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts";
import { copyToClipboard } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
import { parseGitUrl } from "../../utils/git.ts";
import { getCwdRelativePath } from "../../utils/paths.ts";
import { getPiUserAgent } from "../../utils/pi-user-agent.ts";
import { killTrackedDetachedChildren } from "../../utils/shell.ts";
import { ensureTool } from "../../utils/tools-manager.ts";
import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts";
import { ArminComponent } from "./components/armin.ts";
import { AssistantMessageComponent } from "./components/assistant-message.ts";
import { BashExecutionComponent } from "./components/bash-execution.ts";
import { BorderedLoader } from "./components/bordered-loader.ts";
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts";
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts";
import { CountdownTimer } from "./components/countdown-timer.ts";
import { CustomEditor } from "./components/custom-editor.ts";
import { CustomMessageComponent } from "./components/custom-message.ts";
import { DaxnutsComponent } from "./components/daxnuts.ts";
import { DynamicBorder } from "./components/dynamic-border.ts";
import { EarendilAnnouncementComponent } from "./components/earendil-announcement.ts";
import { ExtensionEditorComponent } from "./components/extension-editor.ts";
import { ExtensionInputComponent } from "./components/extension-input.ts";
import { ExtensionSelectorComponent } from "./components/extension-selector.ts";
import { FooterComponent } from "./components/footer.ts";
import { formatKeyText, keyDisplayText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.ts";
import { LoginDialogComponent } from "./components/login-dialog.ts";
import { ModelSelectorComponent } from "./components/model-selector.ts";
import { type AuthSelectorProvider, OAuthSelectorComponent } from "./components/oauth-selector.ts";
import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.ts";
import { SessionSelectorComponent } from "./components/session-selector.ts";
import { SettingsSelectorComponent } from "./components/settings-selector.ts";
import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts";
import { ToolExecutionComponent } from "./components/tool-execution.ts";
import { TreeSelectorComponent } from "./components/tree-selector.ts";
import { UserMessageComponent } from "./components/user-message.ts";
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
import {
getAvailableThemes,
getAvailableThemesWithPaths,
@@ -135,7 +136,7 @@ import {
Theme,
type ThemeColor,
theme,
} from "./theme/theme.js";
} from "./theme/theme.ts";
/** Interface for components that can be expanded/collapsed */
interface Expandable {
@@ -147,14 +148,19 @@ function isExpandable(obj: unknown): obj is Expandable {
}
class ExpandableText extends Text implements Expandable {
private readonly getCollapsedText: () => string;
private readonly getExpandedText: () => string;
constructor(
private readonly getCollapsedText: () => string,
private readonly getExpandedText: () => string,
getCollapsedText: () => string,
getExpandedText: () => string,
expanded = false,
paddingX = 0,
paddingY = 0,
) {
super(expanded ? getExpandedText() : getCollapsedText(), paddingX, paddingY);
this.getCollapsedText = getCollapsedText;
this.getExpandedText = getExpandedText;
}
setExpanded(expanded: boolean): void {
@@ -334,6 +340,8 @@ export class InteractiveMode {
// Custom header from extension (undefined = use built-in header)
private customHeader: (Component & { dispose?(): void }) | undefined = undefined;
private options: InteractiveModeOptions;
// Convenience accessors
private get session(): AgentSession {
return this.runtimeHost.session;
@@ -348,11 +356,9 @@ export class InteractiveMode {
return this.session.settingsManager;
}
constructor(
runtimeHost: AgentSessionRuntime,
private options: InteractiveModeOptions = {},
) {
constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) {
this.runtimeHost = runtimeHost;
this.options = options;
this.runtimeHost.setBeforeSessionInvalidate(() => {
this.resetExtensionUI();
});
@@ -572,6 +578,21 @@ export class InteractiveMode {
const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
this.fdPath = fdPath;
if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) {
const modelList = this.session.scopedModels
.map((sm) => {
const thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : "";
return `${sm.model.id}${thinkingStr}`;
})
.join(", ");
const cycleKeys = this.keybindings.getKeys("app.model.cycleForward");
const cycleHint =
cycleKeys.length > 0
? theme.fg("muted", ` (${formatKeyText(cycleKeys.join("/"), { capitalize: true })} to cycle)`)
: "";
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
}
// Add header container as first child
this.ui.addChild(this.headerContainer);
@@ -696,9 +717,9 @@ export class InteractiveMode {
await this.init();
// Start version check asynchronously
checkForNewPiVersion(this.version).then((newVersion) => {
if (newVersion) {
this.showNewVersionNotification(newVersion);
checkForNewPiVersion(this.version).then((newRelease) => {
if (newRelease) {
this.showNewVersionNotification(newRelease);
}
});
@@ -1463,6 +1484,9 @@ export class InteractiveMode {
const uiContext = this.createExtensionUIContext();
await this.session.bindExtensions({
uiContext,
abortHandler: () => {
this.restoreQueuedMessagesToEditor({ abort: true });
},
commandContextActions: {
waitForIdle: () => this.session.agent.waitForIdle(),
newSession: async (options) => {
@@ -1543,6 +1567,7 @@ export class InteractiveMode {
}
private applyRuntimeSettings(): void {
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
this.footer.setSession(this.session);
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
this.footerDataProvider.setCwd(this.sessionManager.getCwd());
@@ -1612,7 +1637,9 @@ export class InteractiveMode {
model: this.session.model,
isIdle: () => !this.session.isStreaming,
signal: this.session.agent.signal,
abort: () => this.session.abort(),
abort: () => {
this.restoreQueuedMessagesToEditor({ abort: true });
},
hasPendingMessages: () => this.session.pendingMessageCount > 0,
shutdown: () => {
this.shutdownRequested = true;
@@ -2024,7 +2051,7 @@ export class InteractiveMode {
this.hideExtensionSelector();
resolve(undefined);
},
{ tui: this.ui, timeout: opts?.timeout },
{ tui: this.ui, timeout: opts?.timeout, onToggleToolsExpanded: () => this.toggleToolOutputExpansion() },
);
this.editorContainer.clear();
@@ -3244,6 +3271,36 @@ export class InteractiveMode {
process.exit(129);
}
/**
* Last-resort handler for uncaught exceptions. The TUI puts stdin into raw
* mode and hides the cursor; without this handler, an uncaught throw from
* anywhere (e.g. an extension's async `ChildProcess.on("exit")` callback)
* tears down the process while leaving the terminal in raw mode with no
* cursor, requiring `stty sane && reset` to recover.
*
* Unlike emergencyTerminalExit, the terminal is still alive here, so we
* call ui.stop() to restore cooked mode, the cursor, and disable bracketed
* paste / Kitty / modifyOtherKeys sequences.
*/
private uncaughtCrash(error: Error): never {
if (this.isShuttingDown) {
process.exit(1);
}
this.isShuttingDown = true;
try {
this.unregisterSignalHandlers();
} catch {}
try {
killTrackedDetachedChildren();
} catch {}
try {
this.ui.stop();
} catch {}
console.error("pi exiting due to uncaughtException:");
console.error(error);
process.exit(1);
}
/**
* Check if shutdown was requested and perform shutdown if so.
*/
@@ -3282,6 +3339,13 @@ export class InteractiveMode {
process.stderr.on("error", terminalErrorHandler);
this.signalCleanupHandlers.push(() => process.stdout.off("error", terminalErrorHandler));
this.signalCleanupHandlers.push(() => process.stderr.off("error", terminalErrorHandler));
// Restore the terminal before the process dies on any uncaught throw.
// Without this, an unhandled exception from extension code (or anywhere
// in pi) leaves the terminal in raw mode with no cursor.
const uncaughtExceptionHandler = (error: Error) => this.uncaughtCrash(error);
process.prependListener("uncaughtException", uncaughtExceptionHandler);
this.signalCleanupHandlers.push(() => process.off("uncaughtException", uncaughtExceptionHandler));
}
private unregisterSignalHandlers(): void {
@@ -3445,7 +3509,7 @@ export class InteractiveMode {
this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
}
private openExternalEditor(): void {
private async openExternalEditor(): Promise<void> {
// Determine editor (respect $VISUAL, then $EDITOR)
const editorCmd = process.env.VISUAL || process.env.EDITOR;
if (!editorCmd) {
@@ -3466,14 +3530,22 @@ export class InteractiveMode {
// Split by space to support editor arguments (e.g., "code --wait")
const [editor, ...editorArgs] = editorCmd.split(" ");
// Spawn editor synchronously with inherited stdio for interactive editing
const result = spawnSync(editor, [...editorArgs, tmpFile], {
stdio: "inherit",
shell: process.platform === "win32",
process.stdout.write(`Launching external editor: ${editorCmd}\nPi will resume when the editor exits.\n`);
// Do not use spawnSync here. On Windows, synchronous child_process calls can keep
// Node/libuv's console input read active after ui.stop() pauses stdin, racing
// vim/nvim for the console input buffer until Ctrl+C cancels the pending read.
const status = await new Promise<number | null>((resolve) => {
const child = spawn(editor, [...editorArgs, tmpFile], {
stdio: "inherit",
shell: process.platform === "win32",
});
child.on("error", () => resolve(null));
child.on("close", (code) => resolve(code));
});
// On successful exit (status 0), replace editor content
if (result.status === 0) {
if (status === 0) {
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
this.editor.setText(newContent);
}
@@ -3505,6 +3577,7 @@ export class InteractiveMode {
showError(errorMessage: string): void {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
this.chatContainer.addChild(new Spacer(1));
this.ui.requestRender();
}
@@ -3514,24 +3587,31 @@ export class InteractiveMode {
this.ui.requestRender();
}
showNewVersionNotification(newVersion: string): void {
showNewVersionNotification(release: LatestPiRelease): void {
const action = theme.fg("accent", `${APP_NAME} update`);
const updateInstruction = theme.fg("muted", `New version ${newVersion} is available. Run `) + action;
const changelogUrl = "https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md";
const updateInstruction = theme.fg("muted", `New version ${release.version} is available. Run `) + action;
const changelogUrl = "https://pi.dev/changelog";
const changelogLink = getCapabilities().hyperlinks
? hyperlink(theme.fg("accent", "open changelog"), changelogUrl)
: theme.fg("accent", changelogUrl);
const changelogLine = theme.fg("muted", "Changelog: ") + changelogLink;
const note = release.note?.trim();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.chatContainer.addChild(
new Text(
`${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}\n${changelogLine}`,
1,
0,
),
new Text(`${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}`, 1, 0),
);
if (note) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Markdown(note, 1, 0, this.getMarkdownThemeWithSettings(), {
color: (text) => theme.fg("muted", text),
}),
);
this.chatContainer.addChild(new Spacer(1));
}
this.chatContainer.addChild(new Text(changelogLine, 1, 0));
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.ui.requestRender();
}
@@ -3768,6 +3848,7 @@ export class InteractiveMode {
steeringMode: this.session.steeringMode,
followUpMode: this.session.followUpMode,
transport: this.settingsManager.getTransport(),
httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(),
thinkingLevel: this.session.thinkingLevel,
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
currentTheme: this.settingsManager.getTheme() || "dark",
@@ -3826,6 +3907,11 @@ export class InteractiveMode {
this.settingsManager.setTransport(transport);
this.session.agent.transport = transport;
},
onHttpIdleTimeoutMsChange: (timeoutMs) => {
this.settingsManager.setHttpIdleTimeoutMs(timeoutMs);
configureHttpDispatcher(timeoutMs);
this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`);
},
onThinkingLevelChange: (level) => {
this.session.setThinkingLevel(level);
this.footer.invalidate();
@@ -4813,6 +4899,7 @@ export class InteractiveMode {
try {
await this.session.reload();
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
this.keybindings.reload();
const activeHeader = this.customHeader ?? this.builtInHeader;
if (isExpandable(activeHeader)) {

View File

@@ -7,6 +7,7 @@
"green": "#b5bd68",
"red": "#cc6666",
"yellow": "#ffff00",
"text": "#d4d4d4",
"gray": "#808080",
"dimGray": "#666666",
"darkGray": "#505050",
@@ -28,19 +29,19 @@
"warning": "yellow",
"muted": "gray",
"dim": "dimGray",
"text": "",
"text": "text",
"thinkingText": "gray",
"selectedBg": "selectedBg",
"userMessageBg": "userMsgBg",
"userMessageText": "",
"userMessageText": "text",
"customMessageBg": "customMsgBg",
"customMessageText": "",
"customMessageText": "text",
"customMessageLabel": "#9575cd",
"toolPendingBg": "toolPendingBg",
"toolSuccessBg": "toolSuccessBg",
"toolErrorBg": "toolErrorBg",
"toolTitle": "",
"toolTitle": "text",
"toolOutput": "gray",
"mdHeading": "#f0c674",

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