1
import { join } from "node:path";2
import { Agent, type AgentMessage, setDefaultStreamFn, type ThinkingLevel } from "@earendil-works/pi-agent-core";3
import type { ModelsSimpleStreamOptions } from "@earendil-works/pi-ai";4
import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai/compat";5
import { getAgentDir } from "../config.ts";6
import { resolvePath } from "../utils/paths.ts";7
import { AgentSession } from "./agent-session.ts";8
import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";9
import { CacheWarmer } from "./cache-warmer.ts";10
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";11
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";12
import { convertToLlm } from "./messages.ts";13
import { findInitialModel } from "./model-resolver.ts";14
import { ModelRuntime } from "./model-runtime.ts";15
import { mergeProviderAttributionHeaders } from "./provider-attribution.ts";16
import type { ResourceLoader } from "./resource-loader.ts";17
import { DefaultResourceLoader } from "./resource-loader.ts";18
import { getDefaultSessionDir, SessionManager } from "./session-manager.ts";19
import { DEFAULT_TOOL_NAMES, SettingsManager } from "./settings-manager.ts";20
import { time } from "./timings.ts";21
import {22
createBashTool,23
createCodingTools,24
createEditTool,25
createFindTool,26
createGrepTool,27
createLsTool,28
createPowerShellTool,29
createReadOnlyTools,30
createReadTool,31
createWriteTool,32
withFileMutationQueue,33
} from "./tools/index.ts";34
import { getBranchSelection } from "./virtual-models.ts";36
// Preserve the pre-0.81 fallback for extensions that construct Agent instances37
// or invoke low-level agent loops without supplying streamFn. Agent core remains38
// provider-agnostic and does not import pi-ai/compat itself.39
setDefaultStreamFn(streamSimple);41
export interface CreateAgentSessionOptions {42
/** Working directory for project-local discovery. Default: process.cwd() */43
cwd?: string;44
/** Global config directory. Default: ~/.pi/agent */45
agentDir?: string;47
/** Canonical model/auth runtime. Defaults to a runtime using agentDir/auth.json and models.json. */48
modelRuntime?: ModelRuntime;50
/** Model to use. Default: from settings, else first available */51
model?: Model<any>;52
/** Thinking level. Default: from settings, else 'medium' (clamped to model capabilities) */53
thinkingLevel?: ThinkingLevel;54
/** Models available for cycling (Ctrl+P in interactive mode) */55
scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;57
/**58
* Optional default tool suppression mode when no explicit allowlist is provided.59
*60
* - "all": start with no tools enabled61
* - "builtin": disable the default built-in tools (read, bash, edit, write)62
* but keep extension/custom tools enabled63
*/64
noTools?: "all" | "builtin";65
/**66
* Optional allowlist of tool names.67
*68
* When omitted, pi uses the resolved `defaultTools` setting for the initial69
* selection when configured. Otherwise it enables the default built-in tools70
* (read, bash, edit, write). Extension/custom tools remain enabled unless71
* `noTools` changes that default. When provided, only the listed tool names are72
* enabled.73
*/74
tools?: string[];75
/** Optional denylist of tool names to disable. Applies after `tools` when both are provided. */76
excludeTools?: string[];77
/** Custom tools to register (in addition to built-in tools). */78
customTools?: ToolDefinition[];80
/** Resource loader. When omitted, DefaultResourceLoader is used. */81
resourceLoader?: ResourceLoader;83
/** Session manager. Default: SessionManager.create(cwd) */84
sessionManager?: SessionManager;86
/** Settings manager. Default: SettingsManager.create(cwd, agentDir) */87
settingsManager?: SettingsManager;88
/** Session start event metadata for extension runtime startup. */89
sessionStartEvent?: SessionStartEvent;90
}92
/** Result from createAgentSession */93
export interface CreateAgentSessionResult {94
/** The created session */95
session: AgentSession;96
/** Extensions result (for UI context setup in interactive mode) */97
extensionsResult: LoadExtensionsResult;98
/** Warning if session was restored with a different model than saved */99
modelFallbackMessage?: string;100
}102
// Re-exports104
export * from "./agent-session-runtime.ts";105
export type {106
ExtensionAPI,107
ExtensionCommandContext,108
ExtensionContext,109
ExtensionFactory,110
InlineExtension,111
SlashCommandInfo,112
SlashCommandSource,113
ToolDefinition,114
} from "./extensions/index.ts";115
export type { PromptTemplate } from "./prompt-templates.ts";116
export type { Skill } from "./skills.ts";117
export type { Tool } from "./tools/index.ts";119
export {120
withFileMutationQueue,121
// Tool factories (for custom cwd)122
createCodingTools,123
createReadOnlyTools,124
createReadTool,125
createBashTool,126
createEditTool,127
createWriteTool,128
createGrepTool,129
createFindTool,130
createLsTool,131
createPowerShellTool,132
};134
// Helper Functions136
function getDefaultAgentDir(): string {137
return getAgentDir();138
}140
/**141
* Create an AgentSession with the specified options.142
*143
* @example144
* ```typescript145
* // Minimal - uses defaults146
* const { session } = await createAgentSession();147
*148
* // With explicit model149
* import { getModel } from '@earendil-works/pi-ai';150
* const { session } = await createAgentSession({151
* model: getModel('anthropic', 'claude-opus-4-5'),152
* thinkingLevel: 'high',153
* });154
*155
* // Continue previous session156
* const { session, modelFallbackMessage } = await createAgentSession({157
* continueSession: true,158
* });159
*160
* // Full control161
* const loader = new DefaultResourceLoader({162
* cwd: process.cwd(),163
* agentDir: getAgentDir(),164
* settingsManager: SettingsManager.create(),165
* });166
* await loader.reload();167
* const { session } = await createAgentSession({168
* model: myModel,169
* tools: ["read", "bash"],170
* resourceLoader: loader,171
* sessionManager: SessionManager.inMemory(),172
* });173
* ```174
*/175
export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {176
const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd());177
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir();178
let resourceLoader = options.resourceLoader;180
const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined;181
const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined;182
const modelRuntime = options.modelRuntime ?? (await ModelRuntime.create({ authPath, modelsPath }));184
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);185
const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir));187
if (!resourceLoader) {188
resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });189
await resourceLoader.reload();190
time("resourceLoader.reload");191
}193
// Check if session has existing data to restore194
const existingSession = sessionManager.buildSessionContext();195
const hasExistingSession = existingSession.messages.length > 0;196
const hasThinkingEntry = sessionManager.getBranch().some((entry) => entry.type === "thinking_level_change");198
let model = options.model;199
let modelFallbackMessage: string | undefined;201
// Assistant messages name the physical model that answered, so a virtual selection is only in202
// model_change entries.203
const sessionModel = getBranchSelection(sessionManager.getBranch(), (provider, modelId) =>204
modelRuntime.getModel(provider, modelId),205
);207
// If session has data, try to restore model from it208
if (!model && hasExistingSession && sessionModel) {209
const restoredModel = modelRuntime.getModel(sessionModel.provider, sessionModel.modelId);210
if (restoredModel && modelRuntime.hasConfiguredAuth(restoredModel.provider)) {211
model = restoredModel;212
}213
if (!model) {214
modelFallbackMessage = `Could not restore model ${sessionModel.provider}/${sessionModel.modelId}`;215
}216
}218
// If still no model, use findInitialModel (checks settings default, then provider defaults)219
if (!model) {220
const result = await findInitialModel({221
scopedModels: [],222
isContinuing: hasExistingSession,223
defaultProvider: settingsManager.getDefaultProvider(),224
defaultModelId: settingsManager.getDefaultModel(),225
defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(),226
modelThinkingLevels: settingsManager.getAllModelThinkingLevels(),227
modelRuntime,228
});229
model = result.model;230
if (!model) {231
modelFallbackMessage = formatNoModelsAvailableMessage();232
} else if (modelFallbackMessage) {233
modelFallbackMessage += `. Using ${model.provider}/${model.id}`;234
}235
}237
let thinkingLevel = options.thinkingLevel;239
// If session has data, restore thinking level from it240
if (thinkingLevel === undefined && hasExistingSession) {241
thinkingLevel = hasThinkingEntry242
? (existingSession.thinkingLevel as ThinkingLevel)243
: (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL);244
}246
// Fall back to per-model override, then global default247
if (thinkingLevel === undefined && model) {248
const perModel = settingsManager.getModelThinkingLevel(model.provider, model.id);249
if (perModel) {250
thinkingLevel = perModel;251
}252
}253
if (thinkingLevel === undefined) {254
thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;255
}257
// Clamp to model capabilities258
if (!model) {259
thinkingLevel = "off";260
} else {261
thinkingLevel = clampThinkingLevel(model, thinkingLevel) as ThinkingLevel;262
}264
const configuredDefaultToolNames = settingsManager.getDefaultTools();265
const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined);266
const excludedToolNames = options.excludeTools;267
const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined;268
const initialActiveToolNames = (269
options.tools ?? (options.noTools ? [] : (configuredDefaultToolNames ?? DEFAULT_TOOL_NAMES))270
).filter((name) => !excludedToolNameSet?.has(name));272
// Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)273
const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => {274
const converted = convertToLlm(messages);275
// Check setting dynamically so mid-session changes take effect276
if (!settingsManager.getBlockImages()) {277
return converted;278
}279
// Filter out ImageContent from all messages, replacing with text placeholder280
return converted.map((msg) => {281
if (msg.role === "user" || msg.role === "toolResult") {282
const content = msg.content;283
if (Array.isArray(content)) {284
const hasImages = content.some((c) => c.type === "image");285
if (hasImages) {286
const filteredContent = content287
.map((c) =>288
c.type === "image" ? { type: "text" as const, text: "Image reading is disabled." } : c,289
)290
.filter(291
(c, i, arr) =>292
// Dedupe consecutive "Image reading is disabled." texts293
!(294
c.type === "text" &&295
c.text === "Image reading is disabled." &&296
i > 0 &&297
arr[i - 1].type === "text" &&298
(arr[i - 1] as { type: "text"; text: string }).text === "Image reading is disabled."299
),300
);301
return { ...msg, content: filteredContent };302
}303
}304
}305
return msg;306
});307
};309
const extensionRunnerRef: { current?: ExtensionRunner } = {};310
const cacheWarmer = new CacheWarmer(311
modelRuntime,312
sessionManager,313
() => settingsManager.getCacheWarmingMode(),314
async (event) => extensionRunnerRef.current?.emitCacheWarmingDecision(event) ?? event.action,315
);316
const buildRequestOptions = (317
requestModel: Model<any>,318
options: ModelsSimpleStreamOptions = {},319
): ModelsSimpleStreamOptions => {320
const providerRetrySettings = settingsManager.getProviderRetrySettings();321
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();322
const effectiveTimeoutMs = httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs;323
const headerRunner = extensionRunnerRef.current;324
return {325
...options,326
timeoutMs: options.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs,327
websocketConnectTimeoutMs: options.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs(),328
maxRetries: options.maxRetries ?? providerRetrySettings.maxRetries,329
maxRetryDelayMs: options.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,330
transformHeaders: async (requestHeaders) => {331
const headers = mergeProviderAttributionHeaders(332
requestModel,333
settingsManager,334
options.sessionId,335
requestHeaders,336
);337
return headerRunner?.hasHandlers("before_provider_headers")338
? headerRunner.emitBeforeProviderHeaders(headers ?? {})339
: (headers ?? {});340
},341
};342
};343
// Warm only requests for the selected model. Requests a virtual selection routed, or that an344
// extension redirected, may not be repeated by the next request, so warming them could be wasted.345
const cacheContextIsCurrent = (requestModel: Model<any>) => {346
const messages = agent.state.messages;347
return () => {348
const currentModel = agent.state.model;349
const currentMessages = agent.state.messages;350
return (351
currentModel.provider === requestModel.provider &&352
currentModel.id === requestModel.id &&353
messages.length <= currentMessages.length &&354
messages.every((message, index) => currentMessages[index] === message)355
);356
};357
};358
const transformProviderPayload = async (payload: unknown) => {359
const runner = extensionRunnerRef.current;360
if (!runner?.hasHandlers("before_provider_request")) return payload;361
return runner.emitBeforeProviderRequest(payload);362
};363
const handleProviderResponse: NonNullable<ModelsSimpleStreamOptions["onResponse"]> = async (response) => {364
const runner = extensionRunnerRef.current;365
if (!runner?.hasHandlers("after_provider_response")) return;366
await runner.emit({367
type: "after_provider_response",368
status: response.status,369
headers: response.headers,370
});371
};372
const handleProviderStreamEvent: NonNullable<ModelsSimpleStreamOptions["onProviderStreamEvent"]> = async (373
data,374
model,375
) => {376
const runner = extensionRunnerRef.current;377
if (!runner?.hasHandlers("provider_stream_event")) return;378
await runner.emit({379
data,380
type: "provider_stream_event",381
provider: model.provider,382
api: model.api,383
model: model.id,384
});385
};387
const agent = new Agent({388
initialState: {389
systemPrompt: "",390
model,391
thinkingLevel,392
tools: [],393
messages: existingSession.messages,394
},395
convertToLlm: convertToLlmWithBlockImages,396
streamFn: async (model, context, options) => {397
const requestOptions = buildRequestOptions(model, options);398
// Compaction and summaries use their own routing ids; only session requests399
// replace the cache entry, so warming restarts from them. Keep warming while400
// the current transcript still extends the request's prefix. Agent state may401
// shallow-copy the messages array or refresh the model object without changing402
// the provider request, so top-level object identity is not a valid cache key.403
if (options?.sessionId === sessionManager.getSessionId()) {404
cacheWarmer.start({ model, context, options: requestOptions }, cacheContextIsCurrent(model));405
}406
return modelRuntime.streamSimple(model, context, requestOptions);407
},408
onPayload: transformProviderPayload,409
onResponse: handleProviderResponse,410
onProviderStreamEvent: handleProviderStreamEvent,411
sessionId: sessionManager.getSessionId(),412
transformContext: async (messages) => {413
const runner = extensionRunnerRef.current;414
if (!runner) return messages;415
return runner.emitContext(messages);416
},417
steeringMode: settingsManager.getSteeringMode(),418
followUpMode: settingsManager.getFollowUpMode(),419
transport: settingsManager.getTransport(),420
thinkingBudgets: settingsManager.getThinkingBudgets(),421
maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs,422
});424
// Restore missing settings metadata for older sessions.425
if (hasExistingSession) {426
if (!hasThinkingEntry) {427
sessionManager.appendThinkingLevelChange(thinkingLevel);428
}429
} else {430
// Save initial model and thinking level for new sessions so they can be restored on resume431
if (model) {432
sessionManager.appendModelChange(model.provider, model.id);433
}434
sessionManager.appendThinkingLevelChange(thinkingLevel);435
}437
const session = new AgentSession({438
agent,439
sessionManager,440
settingsManager,441
cwd,442
scopedModels: options.scopedModels,443
resourceLoader,444
customTools: options.customTools,445
modelRuntime,446
cacheWarmer,447
initialActiveToolNames,448
usesDefaultTools: options.tools === undefined && !options.noTools,449
allowedToolNames,450
excludedToolNames,451
extensionRunnerRef,452
sessionStartEvent: options.sessionStartEvent,453
});455
const extensionsResult = resourceLoader.getExtensions();457
return {458
session,459
extensionsResult,460
modelFallbackMessage,461
};462
}