@oh-my-pi/pi-coding-agent 1.337.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1228 -0
- package/README.md +1041 -0
- package/docs/compaction.md +403 -0
- package/docs/custom-tools.md +541 -0
- package/docs/extension-loading.md +1004 -0
- package/docs/hooks.md +867 -0
- package/docs/rpc.md +1040 -0
- package/docs/sdk.md +994 -0
- package/docs/session-tree-plan.md +441 -0
- package/docs/session.md +240 -0
- package/docs/skills.md +290 -0
- package/docs/theme.md +637 -0
- package/docs/tree.md +197 -0
- package/docs/tui.md +341 -0
- package/examples/README.md +21 -0
- package/examples/custom-tools/README.md +124 -0
- package/examples/custom-tools/hello/index.ts +20 -0
- package/examples/custom-tools/question/index.ts +84 -0
- package/examples/custom-tools/subagent/README.md +172 -0
- package/examples/custom-tools/subagent/agents/planner.md +37 -0
- package/examples/custom-tools/subagent/agents/reviewer.md +35 -0
- package/examples/custom-tools/subagent/agents/scout.md +50 -0
- package/examples/custom-tools/subagent/agents/worker.md +24 -0
- package/examples/custom-tools/subagent/agents.ts +156 -0
- package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
- package/examples/custom-tools/subagent/commands/implement.md +10 -0
- package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
- package/examples/custom-tools/subagent/index.ts +1002 -0
- package/examples/custom-tools/todo/index.ts +212 -0
- package/examples/hooks/README.md +56 -0
- package/examples/hooks/auto-commit-on-exit.ts +49 -0
- package/examples/hooks/confirm-destructive.ts +59 -0
- package/examples/hooks/custom-compaction.ts +116 -0
- package/examples/hooks/dirty-repo-guard.ts +52 -0
- package/examples/hooks/file-trigger.ts +41 -0
- package/examples/hooks/git-checkpoint.ts +53 -0
- package/examples/hooks/handoff.ts +150 -0
- package/examples/hooks/permission-gate.ts +34 -0
- package/examples/hooks/protected-paths.ts +30 -0
- package/examples/hooks/qna.ts +119 -0
- package/examples/hooks/snake.ts +343 -0
- package/examples/hooks/status-line.ts +40 -0
- package/examples/sdk/01-minimal.ts +22 -0
- package/examples/sdk/02-custom-model.ts +49 -0
- package/examples/sdk/03-custom-prompt.ts +44 -0
- package/examples/sdk/04-skills.ts +44 -0
- package/examples/sdk/05-tools.ts +90 -0
- package/examples/sdk/06-hooks.ts +61 -0
- package/examples/sdk/07-context-files.ts +36 -0
- package/examples/sdk/08-slash-commands.ts +42 -0
- package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
- package/examples/sdk/10-settings.ts +38 -0
- package/examples/sdk/11-sessions.ts +48 -0
- package/examples/sdk/12-full-control.ts +95 -0
- package/examples/sdk/README.md +154 -0
- package/package.json +81 -0
- package/src/cli/args.ts +246 -0
- package/src/cli/file-processor.ts +72 -0
- package/src/cli/list-models.ts +104 -0
- package/src/cli/plugin-cli.ts +650 -0
- package/src/cli/session-picker.ts +41 -0
- package/src/cli.ts +10 -0
- package/src/commands/init.md +20 -0
- package/src/config.ts +159 -0
- package/src/core/agent-session.ts +1900 -0
- package/src/core/auth-storage.ts +236 -0
- package/src/core/bash-executor.ts +196 -0
- package/src/core/compaction/branch-summarization.ts +343 -0
- package/src/core/compaction/compaction.ts +742 -0
- package/src/core/compaction/index.ts +7 -0
- package/src/core/compaction/utils.ts +154 -0
- package/src/core/custom-tools/index.ts +21 -0
- package/src/core/custom-tools/loader.ts +248 -0
- package/src/core/custom-tools/types.ts +169 -0
- package/src/core/custom-tools/wrapper.ts +28 -0
- package/src/core/exec.ts +129 -0
- package/src/core/export-html/index.ts +211 -0
- package/src/core/export-html/template.css +781 -0
- package/src/core/export-html/template.html +54 -0
- package/src/core/export-html/template.js +1185 -0
- package/src/core/export-html/vendor/highlight.min.js +1213 -0
- package/src/core/export-html/vendor/marked.min.js +6 -0
- package/src/core/hooks/index.ts +16 -0
- package/src/core/hooks/loader.ts +312 -0
- package/src/core/hooks/runner.ts +434 -0
- package/src/core/hooks/tool-wrapper.ts +99 -0
- package/src/core/hooks/types.ts +773 -0
- package/src/core/index.ts +52 -0
- package/src/core/mcp/client.ts +158 -0
- package/src/core/mcp/config.ts +154 -0
- package/src/core/mcp/index.ts +45 -0
- package/src/core/mcp/loader.ts +68 -0
- package/src/core/mcp/manager.ts +181 -0
- package/src/core/mcp/tool-bridge.ts +148 -0
- package/src/core/mcp/transports/http.ts +316 -0
- package/src/core/mcp/transports/index.ts +6 -0
- package/src/core/mcp/transports/stdio.ts +252 -0
- package/src/core/mcp/types.ts +220 -0
- package/src/core/messages.ts +189 -0
- package/src/core/model-registry.ts +317 -0
- package/src/core/model-resolver.ts +393 -0
- package/src/core/plugins/doctor.ts +59 -0
- package/src/core/plugins/index.ts +38 -0
- package/src/core/plugins/installer.ts +189 -0
- package/src/core/plugins/loader.ts +338 -0
- package/src/core/plugins/manager.ts +672 -0
- package/src/core/plugins/parser.ts +105 -0
- package/src/core/plugins/paths.ts +32 -0
- package/src/core/plugins/types.ts +190 -0
- package/src/core/sdk.ts +760 -0
- package/src/core/session-manager.ts +1128 -0
- package/src/core/settings-manager.ts +443 -0
- package/src/core/skills.ts +437 -0
- package/src/core/slash-commands.ts +248 -0
- package/src/core/system-prompt.ts +439 -0
- package/src/core/timings.ts +25 -0
- package/src/core/tools/ask.ts +211 -0
- package/src/core/tools/bash-interceptor.ts +120 -0
- package/src/core/tools/bash.ts +250 -0
- package/src/core/tools/context.ts +32 -0
- package/src/core/tools/edit-diff.ts +475 -0
- package/src/core/tools/edit.ts +208 -0
- package/src/core/tools/exa/company.ts +59 -0
- package/src/core/tools/exa/index.ts +64 -0
- package/src/core/tools/exa/linkedin.ts +59 -0
- package/src/core/tools/exa/logger.ts +56 -0
- package/src/core/tools/exa/mcp-client.ts +368 -0
- package/src/core/tools/exa/render.ts +196 -0
- package/src/core/tools/exa/researcher.ts +90 -0
- package/src/core/tools/exa/search.ts +337 -0
- package/src/core/tools/exa/types.ts +168 -0
- package/src/core/tools/exa/websets.ts +248 -0
- package/src/core/tools/find.ts +261 -0
- package/src/core/tools/grep.ts +555 -0
- package/src/core/tools/index.ts +202 -0
- package/src/core/tools/ls.ts +140 -0
- package/src/core/tools/lsp/client.ts +605 -0
- package/src/core/tools/lsp/config.ts +147 -0
- package/src/core/tools/lsp/edits.ts +101 -0
- package/src/core/tools/lsp/index.ts +804 -0
- package/src/core/tools/lsp/render.ts +447 -0
- package/src/core/tools/lsp/rust-analyzer.ts +145 -0
- package/src/core/tools/lsp/types.ts +463 -0
- package/src/core/tools/lsp/utils.ts +486 -0
- package/src/core/tools/notebook.ts +229 -0
- package/src/core/tools/path-utils.ts +61 -0
- package/src/core/tools/read.ts +240 -0
- package/src/core/tools/renderers.ts +540 -0
- package/src/core/tools/task/agents.ts +153 -0
- package/src/core/tools/task/artifacts.ts +114 -0
- package/src/core/tools/task/bundled-agents/browser.md +71 -0
- package/src/core/tools/task/bundled-agents/explore.md +82 -0
- package/src/core/tools/task/bundled-agents/plan.md +54 -0
- package/src/core/tools/task/bundled-agents/reviewer.md +59 -0
- package/src/core/tools/task/bundled-agents/task.md +53 -0
- package/src/core/tools/task/bundled-commands/architect-plan.md +10 -0
- package/src/core/tools/task/bundled-commands/implement-with-critic.md +11 -0
- package/src/core/tools/task/bundled-commands/implement.md +11 -0
- package/src/core/tools/task/commands.ts +213 -0
- package/src/core/tools/task/discovery.ts +208 -0
- package/src/core/tools/task/executor.ts +367 -0
- package/src/core/tools/task/index.ts +388 -0
- package/src/core/tools/task/model-resolver.ts +115 -0
- package/src/core/tools/task/parallel.ts +38 -0
- package/src/core/tools/task/render.ts +232 -0
- package/src/core/tools/task/types.ts +99 -0
- package/src/core/tools/truncate.ts +265 -0
- package/src/core/tools/web-fetch.ts +2370 -0
- package/src/core/tools/web-search/auth.ts +193 -0
- package/src/core/tools/web-search/index.ts +537 -0
- package/src/core/tools/web-search/providers/anthropic.ts +198 -0
- package/src/core/tools/web-search/providers/exa.ts +302 -0
- package/src/core/tools/web-search/providers/perplexity.ts +195 -0
- package/src/core/tools/web-search/render.ts +182 -0
- package/src/core/tools/web-search/types.ts +180 -0
- package/src/core/tools/write.ts +99 -0
- package/src/index.ts +176 -0
- package/src/main.ts +464 -0
- package/src/migrations.ts +135 -0
- package/src/modes/index.ts +43 -0
- package/src/modes/interactive/components/armin.ts +382 -0
- package/src/modes/interactive/components/assistant-message.ts +86 -0
- package/src/modes/interactive/components/bash-execution.ts +196 -0
- package/src/modes/interactive/components/bordered-loader.ts +41 -0
- package/src/modes/interactive/components/branch-summary-message.ts +42 -0
- package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
- package/src/modes/interactive/components/custom-editor.ts +122 -0
- package/src/modes/interactive/components/diff.ts +147 -0
- package/src/modes/interactive/components/dynamic-border.ts +25 -0
- package/src/modes/interactive/components/footer.ts +381 -0
- package/src/modes/interactive/components/hook-editor.ts +117 -0
- package/src/modes/interactive/components/hook-input.ts +64 -0
- package/src/modes/interactive/components/hook-message.ts +96 -0
- package/src/modes/interactive/components/hook-selector.ts +91 -0
- package/src/modes/interactive/components/model-selector.ts +247 -0
- package/src/modes/interactive/components/oauth-selector.ts +120 -0
- package/src/modes/interactive/components/plugin-settings.ts +479 -0
- package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
- package/src/modes/interactive/components/session-selector.ts +204 -0
- package/src/modes/interactive/components/settings-selector.ts +453 -0
- package/src/modes/interactive/components/show-images-selector.ts +45 -0
- package/src/modes/interactive/components/theme-selector.ts +62 -0
- package/src/modes/interactive/components/thinking-selector.ts +64 -0
- package/src/modes/interactive/components/tool-execution.ts +675 -0
- package/src/modes/interactive/components/tree-selector.ts +866 -0
- package/src/modes/interactive/components/user-message-selector.ts +159 -0
- package/src/modes/interactive/components/user-message.ts +18 -0
- package/src/modes/interactive/components/visual-truncate.ts +50 -0
- package/src/modes/interactive/components/welcome.ts +183 -0
- package/src/modes/interactive/interactive-mode.ts +2516 -0
- package/src/modes/interactive/theme/dark.json +101 -0
- package/src/modes/interactive/theme/light.json +98 -0
- package/src/modes/interactive/theme/theme-schema.json +308 -0
- package/src/modes/interactive/theme/theme.ts +998 -0
- package/src/modes/print-mode.ts +128 -0
- package/src/modes/rpc/rpc-client.ts +527 -0
- package/src/modes/rpc/rpc-mode.ts +483 -0
- package/src/modes/rpc/rpc-types.ts +203 -0
- package/src/utils/changelog.ts +99 -0
- package/src/utils/clipboard.ts +265 -0
- package/src/utils/fuzzy.ts +108 -0
- package/src/utils/mime.ts +30 -0
- package/src/utils/shell.ts +276 -0
- package/src/utils/tools-manager.ts +274 -0
|
@@ -0,0 +1,1900 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentSession - Core abstraction for agent lifecycle and session management.
|
|
3
|
+
*
|
|
4
|
+
* This class is shared between all run modes (interactive, print, rpc).
|
|
5
|
+
* It encapsulates:
|
|
6
|
+
* - Agent state access
|
|
7
|
+
* - Event subscription with automatic session persistence
|
|
8
|
+
* - Model and thinking level management
|
|
9
|
+
* - Compaction (manual and auto)
|
|
10
|
+
* - Bash execution
|
|
11
|
+
* - Session switching and branching
|
|
12
|
+
*
|
|
13
|
+
* Modes use this class and add their own I/O layer on top.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Agent, AgentEvent, AgentMessage, AgentState, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
17
|
+
import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
|
|
18
|
+
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
|
|
19
|
+
import { getAuthPath } from "../config.js";
|
|
20
|
+
import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js";
|
|
21
|
+
import {
|
|
22
|
+
type CompactionResult,
|
|
23
|
+
calculateContextTokens,
|
|
24
|
+
collectEntriesForBranchSummary,
|
|
25
|
+
compact,
|
|
26
|
+
generateBranchSummary,
|
|
27
|
+
prepareCompaction,
|
|
28
|
+
shouldCompact,
|
|
29
|
+
} from "./compaction/index.js";
|
|
30
|
+
import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/index.js";
|
|
31
|
+
import { exportSessionToHtml } from "./export-html/index.js";
|
|
32
|
+
import type {
|
|
33
|
+
HookRunner,
|
|
34
|
+
SessionBeforeBranchResult,
|
|
35
|
+
SessionBeforeCompactResult,
|
|
36
|
+
SessionBeforeSwitchResult,
|
|
37
|
+
SessionBeforeTreeResult,
|
|
38
|
+
TreePreparation,
|
|
39
|
+
TurnEndEvent,
|
|
40
|
+
TurnStartEvent,
|
|
41
|
+
} from "./hooks/index.js";
|
|
42
|
+
import type { BashExecutionMessage, HookMessage } from "./messages.js";
|
|
43
|
+
import type { ModelRegistry } from "./model-registry.js";
|
|
44
|
+
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
|
|
45
|
+
import type { SettingsManager, SkillsSettings } from "./settings-manager.js";
|
|
46
|
+
import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js";
|
|
47
|
+
|
|
48
|
+
/** Session-specific events that extend the core AgentEvent */
|
|
49
|
+
export type AgentSessionEvent =
|
|
50
|
+
| AgentEvent
|
|
51
|
+
| { type: "auto_compaction_start"; reason: "threshold" | "overflow" }
|
|
52
|
+
| { type: "auto_compaction_end"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean }
|
|
53
|
+
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
|
|
54
|
+
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string };
|
|
55
|
+
|
|
56
|
+
/** Listener function for agent session events */
|
|
57
|
+
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Types
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
export interface AgentSessionConfig {
|
|
64
|
+
agent: Agent;
|
|
65
|
+
sessionManager: SessionManager;
|
|
66
|
+
settingsManager: SettingsManager;
|
|
67
|
+
/** Models to cycle through with Ctrl+P (from --models flag) */
|
|
68
|
+
scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
|
|
69
|
+
/** File-based slash commands for expansion */
|
|
70
|
+
fileCommands?: FileSlashCommand[];
|
|
71
|
+
/** Hook runner (created in main.ts with wrapped tools) */
|
|
72
|
+
hookRunner?: HookRunner;
|
|
73
|
+
/** Custom tools for session lifecycle events */
|
|
74
|
+
customTools?: LoadedCustomTool[];
|
|
75
|
+
skillsSettings?: Required<SkillsSettings>;
|
|
76
|
+
/** Model registry for API key resolution and model discovery */
|
|
77
|
+
modelRegistry: ModelRegistry;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Options for AgentSession.prompt() */
|
|
81
|
+
export interface PromptOptions {
|
|
82
|
+
/** Whether to expand file-based slash commands (default: true) */
|
|
83
|
+
expandSlashCommands?: boolean;
|
|
84
|
+
/** Image attachments */
|
|
85
|
+
images?: ImageContent[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Result from cycleModel() */
|
|
89
|
+
export interface ModelCycleResult {
|
|
90
|
+
model: Model<any>;
|
|
91
|
+
thinkingLevel: ThinkingLevel;
|
|
92
|
+
/** Whether cycling through scoped models (--models flag) or all available */
|
|
93
|
+
isScoped: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Session statistics for /session command */
|
|
97
|
+
export interface SessionStats {
|
|
98
|
+
sessionFile: string | undefined;
|
|
99
|
+
sessionId: string;
|
|
100
|
+
userMessages: number;
|
|
101
|
+
assistantMessages: number;
|
|
102
|
+
toolCalls: number;
|
|
103
|
+
toolResults: number;
|
|
104
|
+
totalMessages: number;
|
|
105
|
+
tokens: {
|
|
106
|
+
input: number;
|
|
107
|
+
output: number;
|
|
108
|
+
cacheRead: number;
|
|
109
|
+
cacheWrite: number;
|
|
110
|
+
total: number;
|
|
111
|
+
};
|
|
112
|
+
cost: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Internal marker for hook messages queued through the agent loop */
|
|
116
|
+
// ============================================================================
|
|
117
|
+
// Constants
|
|
118
|
+
// ============================================================================
|
|
119
|
+
|
|
120
|
+
/** Standard thinking levels */
|
|
121
|
+
const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"];
|
|
122
|
+
|
|
123
|
+
/** Thinking levels including xhigh (for supported models) */
|
|
124
|
+
const THINKING_LEVELS_WITH_XHIGH: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
125
|
+
|
|
126
|
+
// ============================================================================
|
|
127
|
+
// AgentSession Class
|
|
128
|
+
// ============================================================================
|
|
129
|
+
|
|
130
|
+
export class AgentSession {
|
|
131
|
+
readonly agent: Agent;
|
|
132
|
+
readonly sessionManager: SessionManager;
|
|
133
|
+
readonly settingsManager: SettingsManager;
|
|
134
|
+
|
|
135
|
+
private _scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
|
|
136
|
+
private _fileCommands: FileSlashCommand[];
|
|
137
|
+
|
|
138
|
+
// Event subscription state
|
|
139
|
+
private _unsubscribeAgent?: () => void;
|
|
140
|
+
private _eventListeners: AgentSessionEventListener[] = [];
|
|
141
|
+
|
|
142
|
+
// Message queue state
|
|
143
|
+
private _queuedMessages: string[] = [];
|
|
144
|
+
|
|
145
|
+
// Compaction state
|
|
146
|
+
private _compactionAbortController: AbortController | undefined = undefined;
|
|
147
|
+
private _autoCompactionAbortController: AbortController | undefined = undefined;
|
|
148
|
+
|
|
149
|
+
// Branch summarization state
|
|
150
|
+
private _branchSummaryAbortController: AbortController | undefined = undefined;
|
|
151
|
+
|
|
152
|
+
// Retry state
|
|
153
|
+
private _retryAbortController: AbortController | undefined = undefined;
|
|
154
|
+
private _retryAttempt = 0;
|
|
155
|
+
private _retryPromise: Promise<void> | undefined = undefined;
|
|
156
|
+
private _retryResolve: (() => void) | undefined = undefined;
|
|
157
|
+
|
|
158
|
+
// Bash execution state
|
|
159
|
+
private _bashAbortController: AbortController | undefined = undefined;
|
|
160
|
+
private _pendingBashMessages: BashExecutionMessage[] = [];
|
|
161
|
+
|
|
162
|
+
// Hook system
|
|
163
|
+
private _hookRunner: HookRunner | undefined = undefined;
|
|
164
|
+
private _turnIndex = 0;
|
|
165
|
+
|
|
166
|
+
// Custom tools for session lifecycle
|
|
167
|
+
private _customTools: LoadedCustomTool[] = [];
|
|
168
|
+
|
|
169
|
+
private _skillsSettings: Required<SkillsSettings> | undefined;
|
|
170
|
+
|
|
171
|
+
// Model registry for API key resolution
|
|
172
|
+
private _modelRegistry: ModelRegistry;
|
|
173
|
+
|
|
174
|
+
constructor(config: AgentSessionConfig) {
|
|
175
|
+
this.agent = config.agent;
|
|
176
|
+
this.sessionManager = config.sessionManager;
|
|
177
|
+
this.settingsManager = config.settingsManager;
|
|
178
|
+
this._scopedModels = config.scopedModels ?? [];
|
|
179
|
+
this._fileCommands = config.fileCommands ?? [];
|
|
180
|
+
this._hookRunner = config.hookRunner;
|
|
181
|
+
this._customTools = config.customTools ?? [];
|
|
182
|
+
this._skillsSettings = config.skillsSettings;
|
|
183
|
+
this._modelRegistry = config.modelRegistry;
|
|
184
|
+
|
|
185
|
+
// Always subscribe to agent events for internal handling
|
|
186
|
+
// (session persistence, hooks, auto-compaction, retry logic)
|
|
187
|
+
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Model registry for API key resolution and model discovery */
|
|
191
|
+
get modelRegistry(): ModelRegistry {
|
|
192
|
+
return this._modelRegistry;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// =========================================================================
|
|
196
|
+
// Event Subscription
|
|
197
|
+
// =========================================================================
|
|
198
|
+
|
|
199
|
+
/** Emit an event to all listeners */
|
|
200
|
+
private _emit(event: AgentSessionEvent): void {
|
|
201
|
+
for (const l of this._eventListeners) {
|
|
202
|
+
l(event);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Track last assistant message for auto-compaction check
|
|
207
|
+
private _lastAssistantMessage: AssistantMessage | undefined = undefined;
|
|
208
|
+
|
|
209
|
+
/** Internal handler for agent events - shared by subscribe and reconnect */
|
|
210
|
+
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
|
|
211
|
+
// When a user message starts, check if it's from the queue and remove it BEFORE emitting
|
|
212
|
+
// This ensures the UI sees the updated queue state
|
|
213
|
+
if (event.type === "message_start" && event.message.role === "user" && this._queuedMessages.length > 0) {
|
|
214
|
+
// Extract text content from the message
|
|
215
|
+
const messageText = this._getUserMessageText(event.message);
|
|
216
|
+
if (messageText && this._queuedMessages.includes(messageText)) {
|
|
217
|
+
// Remove the first occurrence of this message from the queue
|
|
218
|
+
const index = this._queuedMessages.indexOf(messageText);
|
|
219
|
+
if (index !== -1) {
|
|
220
|
+
this._queuedMessages.splice(index, 1);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Emit to hooks first
|
|
226
|
+
await this._emitHookEvent(event);
|
|
227
|
+
|
|
228
|
+
// Notify all listeners
|
|
229
|
+
this._emit(event);
|
|
230
|
+
|
|
231
|
+
// Handle session persistence
|
|
232
|
+
if (event.type === "message_end") {
|
|
233
|
+
// Check if this is a hook message
|
|
234
|
+
if (event.message.role === "hookMessage") {
|
|
235
|
+
// Persist as CustomMessageEntry
|
|
236
|
+
this.sessionManager.appendCustomMessageEntry(
|
|
237
|
+
event.message.customType,
|
|
238
|
+
event.message.content,
|
|
239
|
+
event.message.display,
|
|
240
|
+
event.message.details,
|
|
241
|
+
);
|
|
242
|
+
} else if (
|
|
243
|
+
event.message.role === "user" ||
|
|
244
|
+
event.message.role === "assistant" ||
|
|
245
|
+
event.message.role === "toolResult"
|
|
246
|
+
) {
|
|
247
|
+
// Regular LLM message - persist as SessionMessageEntry
|
|
248
|
+
this.sessionManager.appendMessage(event.message);
|
|
249
|
+
}
|
|
250
|
+
// Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
|
|
251
|
+
|
|
252
|
+
// Track assistant message for auto-compaction (checked on agent_end)
|
|
253
|
+
if (event.message.role === "assistant") {
|
|
254
|
+
this._lastAssistantMessage = event.message;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Check auto-retry and auto-compaction after agent completes
|
|
259
|
+
if (event.type === "agent_end" && this._lastAssistantMessage) {
|
|
260
|
+
const msg = this._lastAssistantMessage;
|
|
261
|
+
this._lastAssistantMessage = undefined;
|
|
262
|
+
|
|
263
|
+
// Check for retryable errors first (overloaded, rate limit, server errors)
|
|
264
|
+
if (this._isRetryableError(msg)) {
|
|
265
|
+
const didRetry = await this._handleRetryableError(msg);
|
|
266
|
+
if (didRetry) return; // Retry was initiated, don't proceed to compaction
|
|
267
|
+
} else if (this._retryAttempt > 0) {
|
|
268
|
+
// Previous retry succeeded - emit success event and reset counter
|
|
269
|
+
this._emit({
|
|
270
|
+
type: "auto_retry_end",
|
|
271
|
+
success: true,
|
|
272
|
+
attempt: this._retryAttempt,
|
|
273
|
+
});
|
|
274
|
+
this._retryAttempt = 0;
|
|
275
|
+
// Resolve the retry promise so waitForRetry() completes
|
|
276
|
+
this._resolveRetry();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await this._checkCompaction(msg);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** Resolve the pending retry promise */
|
|
284
|
+
private _resolveRetry(): void {
|
|
285
|
+
if (this._retryResolve) {
|
|
286
|
+
this._retryResolve();
|
|
287
|
+
this._retryResolve = undefined;
|
|
288
|
+
this._retryPromise = undefined;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Extract text content from a message */
|
|
293
|
+
private _getUserMessageText(message: Message): string {
|
|
294
|
+
if (message.role !== "user") return "";
|
|
295
|
+
const content = message.content;
|
|
296
|
+
if (typeof content === "string") return content;
|
|
297
|
+
const textBlocks = content.filter((c) => c.type === "text");
|
|
298
|
+
return textBlocks.map((c) => (c as TextContent).text).join("");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Find the last assistant message in agent state (including aborted ones) */
|
|
302
|
+
private _findLastAssistantMessage(): AssistantMessage | undefined {
|
|
303
|
+
const messages = this.agent.state.messages;
|
|
304
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
305
|
+
const msg = messages[i];
|
|
306
|
+
if (msg.role === "assistant") {
|
|
307
|
+
return msg as AssistantMessage;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Emit hook events based on agent events */
|
|
314
|
+
private async _emitHookEvent(event: AgentEvent): Promise<void> {
|
|
315
|
+
if (!this._hookRunner) return;
|
|
316
|
+
|
|
317
|
+
if (event.type === "agent_start") {
|
|
318
|
+
this._turnIndex = 0;
|
|
319
|
+
await this._hookRunner.emit({ type: "agent_start" });
|
|
320
|
+
} else if (event.type === "agent_end") {
|
|
321
|
+
await this._hookRunner.emit({ type: "agent_end", messages: event.messages });
|
|
322
|
+
} else if (event.type === "turn_start") {
|
|
323
|
+
const hookEvent: TurnStartEvent = {
|
|
324
|
+
type: "turn_start",
|
|
325
|
+
turnIndex: this._turnIndex,
|
|
326
|
+
timestamp: Date.now(),
|
|
327
|
+
};
|
|
328
|
+
await this._hookRunner.emit(hookEvent);
|
|
329
|
+
} else if (event.type === "turn_end") {
|
|
330
|
+
const hookEvent: TurnEndEvent = {
|
|
331
|
+
type: "turn_end",
|
|
332
|
+
turnIndex: this._turnIndex,
|
|
333
|
+
message: event.message,
|
|
334
|
+
toolResults: event.toolResults,
|
|
335
|
+
};
|
|
336
|
+
await this._hookRunner.emit(hookEvent);
|
|
337
|
+
this._turnIndex++;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Subscribe to agent events.
|
|
343
|
+
* Session persistence is handled internally (saves messages on message_end).
|
|
344
|
+
* Multiple listeners can be added. Returns unsubscribe function for this listener.
|
|
345
|
+
*/
|
|
346
|
+
subscribe(listener: AgentSessionEventListener): () => void {
|
|
347
|
+
this._eventListeners.push(listener);
|
|
348
|
+
|
|
349
|
+
// Return unsubscribe function for this specific listener
|
|
350
|
+
return () => {
|
|
351
|
+
const index = this._eventListeners.indexOf(listener);
|
|
352
|
+
if (index !== -1) {
|
|
353
|
+
this._eventListeners.splice(index, 1);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Temporarily disconnect from agent events.
|
|
360
|
+
* User listeners are preserved and will receive events again after resubscribe().
|
|
361
|
+
* Used internally during operations that need to pause event processing.
|
|
362
|
+
*/
|
|
363
|
+
private _disconnectFromAgent(): void {
|
|
364
|
+
if (this._unsubscribeAgent) {
|
|
365
|
+
this._unsubscribeAgent();
|
|
366
|
+
this._unsubscribeAgent = undefined;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Reconnect to agent events after _disconnectFromAgent().
|
|
372
|
+
* Preserves all existing listeners.
|
|
373
|
+
*/
|
|
374
|
+
private _reconnectToAgent(): void {
|
|
375
|
+
if (this._unsubscribeAgent) return; // Already connected
|
|
376
|
+
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Remove all listeners and disconnect from agent.
|
|
381
|
+
* Call this when completely done with the session.
|
|
382
|
+
*/
|
|
383
|
+
dispose(): void {
|
|
384
|
+
this._disconnectFromAgent();
|
|
385
|
+
this._eventListeners = [];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// =========================================================================
|
|
389
|
+
// Read-only State Access
|
|
390
|
+
// =========================================================================
|
|
391
|
+
|
|
392
|
+
/** Full agent state */
|
|
393
|
+
get state(): AgentState {
|
|
394
|
+
return this.agent.state;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Current model (may be undefined if not yet selected) */
|
|
398
|
+
get model(): Model<any> | undefined {
|
|
399
|
+
return this.agent.state.model;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Current thinking level */
|
|
403
|
+
get thinkingLevel(): ThinkingLevel {
|
|
404
|
+
return this.agent.state.thinkingLevel;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Whether agent is currently streaming a response */
|
|
408
|
+
get isStreaming(): boolean {
|
|
409
|
+
return this.agent.state.isStreaming;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Whether auto-compaction is currently running */
|
|
413
|
+
get isCompacting(): boolean {
|
|
414
|
+
return this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** All messages including custom types like BashExecutionMessage */
|
|
418
|
+
get messages(): AgentMessage[] {
|
|
419
|
+
return this.agent.state.messages;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Current queue mode */
|
|
423
|
+
get queueMode(): "all" | "one-at-a-time" {
|
|
424
|
+
return this.agent.getQueueMode();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Current session file path, or undefined if sessions are disabled */
|
|
428
|
+
get sessionFile(): string | undefined {
|
|
429
|
+
return this.sessionManager.getSessionFile();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Current session ID */
|
|
433
|
+
get sessionId(): string {
|
|
434
|
+
return this.sessionManager.getSessionId();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Scoped models for cycling (from --models flag) */
|
|
438
|
+
get scopedModels(): ReadonlyArray<{ model: Model<any>; thinkingLevel: ThinkingLevel }> {
|
|
439
|
+
return this._scopedModels;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** File-based slash commands */
|
|
443
|
+
get fileCommands(): ReadonlyArray<FileSlashCommand> {
|
|
444
|
+
return this._fileCommands;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// =========================================================================
|
|
448
|
+
// Prompting
|
|
449
|
+
// =========================================================================
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Send a prompt to the agent.
|
|
453
|
+
* - Validates model and API key before sending
|
|
454
|
+
* - Handles hook commands (registered via pi.registerCommand)
|
|
455
|
+
* - Expands file-based slash commands by default
|
|
456
|
+
* @throws Error if no model selected or no API key available
|
|
457
|
+
*/
|
|
458
|
+
async prompt(text: string, options?: PromptOptions): Promise<void> {
|
|
459
|
+
// Flush any pending bash messages before the new prompt
|
|
460
|
+
this._flushPendingBashMessages();
|
|
461
|
+
|
|
462
|
+
const expandCommands = options?.expandSlashCommands ?? true;
|
|
463
|
+
|
|
464
|
+
// Handle hook commands first (if enabled and text is a slash command)
|
|
465
|
+
if (expandCommands && text.startsWith("/")) {
|
|
466
|
+
const handled = await this._tryExecuteHookCommand(text);
|
|
467
|
+
if (handled) {
|
|
468
|
+
// Hook command executed, no prompt to send
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Validate model
|
|
474
|
+
if (!this.model) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
"No model selected.\n\n" +
|
|
477
|
+
`Use /login, set an API key environment variable, or create ${getAuthPath()}\n\n` +
|
|
478
|
+
"Then use /model to select a model.",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Validate API key
|
|
483
|
+
const apiKey = await this._modelRegistry.getApiKey(this.model);
|
|
484
|
+
if (!apiKey) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`No API key found for ${this.model.provider}.\n\n` +
|
|
487
|
+
`Use /login, set an API key environment variable, or create ${getAuthPath()}`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Check if we need to compact before sending (catches aborted responses)
|
|
492
|
+
const lastAssistant = this._findLastAssistantMessage();
|
|
493
|
+
if (lastAssistant) {
|
|
494
|
+
await this._checkCompaction(lastAssistant, false);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Expand file-based slash commands if requested
|
|
498
|
+
const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text;
|
|
499
|
+
|
|
500
|
+
// Build messages array (hook message if any, then user message)
|
|
501
|
+
const messages: AgentMessage[] = [];
|
|
502
|
+
|
|
503
|
+
// Add user message
|
|
504
|
+
const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
|
|
505
|
+
if (options?.images) {
|
|
506
|
+
userContent.push(...options.images);
|
|
507
|
+
}
|
|
508
|
+
messages.push({
|
|
509
|
+
role: "user",
|
|
510
|
+
content: userContent,
|
|
511
|
+
timestamp: Date.now(),
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// Emit before_agent_start hook event
|
|
515
|
+
if (this._hookRunner) {
|
|
516
|
+
const result = await this._hookRunner.emitBeforeAgentStart(expandedText, options?.images);
|
|
517
|
+
if (result?.message) {
|
|
518
|
+
messages.push({
|
|
519
|
+
role: "hookMessage",
|
|
520
|
+
customType: result.message.customType,
|
|
521
|
+
content: result.message.content,
|
|
522
|
+
display: result.message.display,
|
|
523
|
+
details: result.message.details,
|
|
524
|
+
timestamp: Date.now(),
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
await this.agent.prompt(messages);
|
|
530
|
+
await this.waitForRetry();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Try to execute a hook command. Returns true if command was found and executed.
|
|
535
|
+
*/
|
|
536
|
+
private async _tryExecuteHookCommand(text: string): Promise<boolean> {
|
|
537
|
+
if (!this._hookRunner) return false;
|
|
538
|
+
|
|
539
|
+
// Parse command name and args
|
|
540
|
+
const spaceIndex = text.indexOf(" ");
|
|
541
|
+
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
|
542
|
+
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
|
|
543
|
+
|
|
544
|
+
const command = this._hookRunner.getCommand(commandName);
|
|
545
|
+
if (!command) return false;
|
|
546
|
+
|
|
547
|
+
// Get command context from hook runner (includes session control methods)
|
|
548
|
+
const ctx = this._hookRunner.createCommandContext();
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
await command.handler(args, ctx);
|
|
552
|
+
return true;
|
|
553
|
+
} catch (err) {
|
|
554
|
+
// Emit error via hook runner
|
|
555
|
+
this._hookRunner.emitError({
|
|
556
|
+
hookPath: `command:${commandName}`,
|
|
557
|
+
event: "command",
|
|
558
|
+
error: err instanceof Error ? err.message : String(err),
|
|
559
|
+
});
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Queue a message to be sent after the current response completes.
|
|
566
|
+
* Use when agent is currently streaming.
|
|
567
|
+
*/
|
|
568
|
+
async queueMessage(text: string): Promise<void> {
|
|
569
|
+
this._queuedMessages.push(text);
|
|
570
|
+
await this.agent.queueMessage({
|
|
571
|
+
role: "user",
|
|
572
|
+
content: [{ type: "text", text }],
|
|
573
|
+
timestamp: Date.now(),
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Send a hook message to the session. Creates a CustomMessageEntry.
|
|
579
|
+
*
|
|
580
|
+
* Handles three cases:
|
|
581
|
+
* - Streaming: queues message, processed when loop pulls from queue
|
|
582
|
+
* - Not streaming + triggerTurn: appends to state/session, starts new turn
|
|
583
|
+
* - Not streaming + no trigger: appends to state/session, no turn
|
|
584
|
+
*
|
|
585
|
+
* @param message Hook message with customType, content, display, details
|
|
586
|
+
* @param triggerTurn If true and not streaming, triggers a new LLM turn
|
|
587
|
+
*/
|
|
588
|
+
async sendHookMessage<T = unknown>(
|
|
589
|
+
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
|
|
590
|
+
triggerTurn?: boolean,
|
|
591
|
+
): Promise<void> {
|
|
592
|
+
const appMessage = {
|
|
593
|
+
role: "hookMessage" as const,
|
|
594
|
+
customType: message.customType,
|
|
595
|
+
content: message.content,
|
|
596
|
+
display: message.display,
|
|
597
|
+
details: message.details,
|
|
598
|
+
timestamp: Date.now(),
|
|
599
|
+
} satisfies HookMessage<T>;
|
|
600
|
+
if (this.isStreaming) {
|
|
601
|
+
// Queue for processing by agent loop
|
|
602
|
+
await this.agent.queueMessage(appMessage);
|
|
603
|
+
} else if (triggerTurn) {
|
|
604
|
+
// Send as prompt - agent loop will emit message events
|
|
605
|
+
await this.agent.prompt(appMessage);
|
|
606
|
+
} else {
|
|
607
|
+
// Just append to agent state and session, no turn
|
|
608
|
+
this.agent.appendMessage(appMessage);
|
|
609
|
+
this.sessionManager.appendCustomMessageEntry(
|
|
610
|
+
message.customType,
|
|
611
|
+
message.content,
|
|
612
|
+
message.display,
|
|
613
|
+
message.details,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Clear queued messages and return them.
|
|
620
|
+
* Useful for restoring to editor when user aborts.
|
|
621
|
+
*/
|
|
622
|
+
clearQueue(): string[] {
|
|
623
|
+
const queued = [...this._queuedMessages];
|
|
624
|
+
this._queuedMessages = [];
|
|
625
|
+
this.agent.clearMessageQueue();
|
|
626
|
+
return queued;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Number of messages currently queued */
|
|
630
|
+
get queuedMessageCount(): number {
|
|
631
|
+
return this._queuedMessages.length;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Get queued messages (read-only) */
|
|
635
|
+
getQueuedMessages(): readonly string[] {
|
|
636
|
+
return this._queuedMessages;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
get skillsSettings(): Required<SkillsSettings> | undefined {
|
|
640
|
+
return this._skillsSettings;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Abort current operation and wait for agent to become idle.
|
|
645
|
+
*/
|
|
646
|
+
async abort(): Promise<void> {
|
|
647
|
+
this.abortRetry();
|
|
648
|
+
this.agent.abort();
|
|
649
|
+
await this.agent.waitForIdle();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Start a new session, optionally with initial messages and parent tracking.
|
|
654
|
+
* Clears all messages and starts a new session.
|
|
655
|
+
* Listeners are preserved and will continue receiving events.
|
|
656
|
+
* @param options - Optional initial messages and parent session path
|
|
657
|
+
* @returns true if completed, false if cancelled by hook
|
|
658
|
+
*/
|
|
659
|
+
async newSession(options?: NewSessionOptions): Promise<boolean> {
|
|
660
|
+
const previousSessionFile = this.sessionFile;
|
|
661
|
+
|
|
662
|
+
// Emit session_before_switch event with reason "new" (can be cancelled)
|
|
663
|
+
if (this._hookRunner?.hasHandlers("session_before_switch")) {
|
|
664
|
+
const result = (await this._hookRunner.emit({
|
|
665
|
+
type: "session_before_switch",
|
|
666
|
+
reason: "new",
|
|
667
|
+
})) as SessionBeforeSwitchResult | undefined;
|
|
668
|
+
|
|
669
|
+
if (result?.cancel) {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
this._disconnectFromAgent();
|
|
675
|
+
await this.abort();
|
|
676
|
+
this.agent.reset();
|
|
677
|
+
this.sessionManager.newSession(options);
|
|
678
|
+
this._queuedMessages = [];
|
|
679
|
+
this._reconnectToAgent();
|
|
680
|
+
|
|
681
|
+
// Emit session_switch event with reason "new" to hooks
|
|
682
|
+
if (this._hookRunner) {
|
|
683
|
+
await this._hookRunner.emit({
|
|
684
|
+
type: "session_switch",
|
|
685
|
+
reason: "new",
|
|
686
|
+
previousSessionFile,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Emit session event to custom tools
|
|
691
|
+
await this.emitCustomToolSessionEvent("switch", previousSessionFile);
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// =========================================================================
|
|
696
|
+
// Model Management
|
|
697
|
+
// =========================================================================
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Set model directly.
|
|
701
|
+
* Validates API key, saves to session and settings.
|
|
702
|
+
* @throws Error if no API key available for the model
|
|
703
|
+
*/
|
|
704
|
+
async setModel(model: Model<any>): Promise<void> {
|
|
705
|
+
const apiKey = await this._modelRegistry.getApiKey(model);
|
|
706
|
+
if (!apiKey) {
|
|
707
|
+
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
this.agent.setModel(model);
|
|
711
|
+
this.sessionManager.appendModelChange(model.provider, model.id);
|
|
712
|
+
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
|
|
713
|
+
|
|
714
|
+
// Re-clamp thinking level for new model's capabilities
|
|
715
|
+
this.setThinkingLevel(this.thinkingLevel);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Cycle to next/previous model.
|
|
720
|
+
* Uses scoped models (from --models flag) if available, otherwise all available models.
|
|
721
|
+
* @param direction - "forward" (default) or "backward"
|
|
722
|
+
* @returns The new model info, or undefined if only one model available
|
|
723
|
+
*/
|
|
724
|
+
async cycleModel(direction: "forward" | "backward" = "forward"): Promise<ModelCycleResult | undefined> {
|
|
725
|
+
if (this._scopedModels.length > 0) {
|
|
726
|
+
return this._cycleScopedModel(direction);
|
|
727
|
+
}
|
|
728
|
+
return this._cycleAvailableModel(direction);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
|
|
732
|
+
if (this._scopedModels.length <= 1) return undefined;
|
|
733
|
+
|
|
734
|
+
const currentModel = this.model;
|
|
735
|
+
let currentIndex = this._scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));
|
|
736
|
+
|
|
737
|
+
if (currentIndex === -1) currentIndex = 0;
|
|
738
|
+
const len = this._scopedModels.length;
|
|
739
|
+
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
|
|
740
|
+
const next = this._scopedModels[nextIndex];
|
|
741
|
+
|
|
742
|
+
// Validate API key
|
|
743
|
+
const apiKey = await this._modelRegistry.getApiKey(next.model);
|
|
744
|
+
if (!apiKey) {
|
|
745
|
+
throw new Error(`No API key for ${next.model.provider}/${next.model.id}`);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Apply model
|
|
749
|
+
this.agent.setModel(next.model);
|
|
750
|
+
this.sessionManager.appendModelChange(next.model.provider, next.model.id);
|
|
751
|
+
this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
|
|
752
|
+
|
|
753
|
+
// Apply thinking level (setThinkingLevel clamps to model capabilities)
|
|
754
|
+
this.setThinkingLevel(next.thinkingLevel);
|
|
755
|
+
|
|
756
|
+
return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private async _cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
|
|
760
|
+
const availableModels = await this._modelRegistry.getAvailable();
|
|
761
|
+
if (availableModels.length <= 1) return undefined;
|
|
762
|
+
|
|
763
|
+
const currentModel = this.model;
|
|
764
|
+
let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel));
|
|
765
|
+
|
|
766
|
+
if (currentIndex === -1) currentIndex = 0;
|
|
767
|
+
const len = availableModels.length;
|
|
768
|
+
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
|
|
769
|
+
const nextModel = availableModels[nextIndex];
|
|
770
|
+
|
|
771
|
+
const apiKey = await this._modelRegistry.getApiKey(nextModel);
|
|
772
|
+
if (!apiKey) {
|
|
773
|
+
throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
this.agent.setModel(nextModel);
|
|
777
|
+
this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
|
|
778
|
+
this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
|
|
779
|
+
|
|
780
|
+
// Re-clamp thinking level for new model's capabilities
|
|
781
|
+
this.setThinkingLevel(this.thinkingLevel);
|
|
782
|
+
|
|
783
|
+
return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Get all available models with valid API keys.
|
|
788
|
+
*/
|
|
789
|
+
async getAvailableModels(): Promise<Model<any>[]> {
|
|
790
|
+
return this._modelRegistry.getAvailable();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// =========================================================================
|
|
794
|
+
// Thinking Level Management
|
|
795
|
+
// =========================================================================
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Set thinking level.
|
|
799
|
+
* Clamps to model capabilities: "off" if no reasoning, "high" if xhigh unsupported.
|
|
800
|
+
* Saves to session and settings.
|
|
801
|
+
*/
|
|
802
|
+
setThinkingLevel(level: ThinkingLevel): void {
|
|
803
|
+
let effectiveLevel = level;
|
|
804
|
+
if (!this.supportsThinking()) {
|
|
805
|
+
effectiveLevel = "off";
|
|
806
|
+
} else if (level === "xhigh" && !this.supportsXhighThinking()) {
|
|
807
|
+
effectiveLevel = "high";
|
|
808
|
+
}
|
|
809
|
+
this.agent.setThinkingLevel(effectiveLevel);
|
|
810
|
+
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
|
|
811
|
+
this.settingsManager.setDefaultThinkingLevel(effectiveLevel);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Cycle to next thinking level.
|
|
816
|
+
* @returns New level, or undefined if model doesn't support thinking
|
|
817
|
+
*/
|
|
818
|
+
cycleThinkingLevel(): ThinkingLevel | undefined {
|
|
819
|
+
if (!this.supportsThinking()) return undefined;
|
|
820
|
+
|
|
821
|
+
const levels = this.getAvailableThinkingLevels();
|
|
822
|
+
const currentIndex = levels.indexOf(this.thinkingLevel);
|
|
823
|
+
const nextIndex = (currentIndex + 1) % levels.length;
|
|
824
|
+
const nextLevel = levels[nextIndex];
|
|
825
|
+
|
|
826
|
+
this.setThinkingLevel(nextLevel);
|
|
827
|
+
return nextLevel;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Get available thinking levels for current model.
|
|
832
|
+
*/
|
|
833
|
+
getAvailableThinkingLevels(): ThinkingLevel[] {
|
|
834
|
+
return this.supportsXhighThinking() ? THINKING_LEVELS_WITH_XHIGH : THINKING_LEVELS;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Check if current model supports xhigh thinking level.
|
|
839
|
+
*/
|
|
840
|
+
supportsXhighThinking(): boolean {
|
|
841
|
+
return this.model ? supportsXhigh(this.model) : false;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Check if current model supports thinking/reasoning.
|
|
846
|
+
*/
|
|
847
|
+
supportsThinking(): boolean {
|
|
848
|
+
return !!this.model?.reasoning;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// =========================================================================
|
|
852
|
+
// Queue Mode Management
|
|
853
|
+
// =========================================================================
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Set message queue mode.
|
|
857
|
+
* Saves to settings.
|
|
858
|
+
*/
|
|
859
|
+
setQueueMode(mode: "all" | "one-at-a-time"): void {
|
|
860
|
+
this.agent.setQueueMode(mode);
|
|
861
|
+
this.settingsManager.setQueueMode(mode);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// =========================================================================
|
|
865
|
+
// Compaction
|
|
866
|
+
// =========================================================================
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Manually compact the session context.
|
|
870
|
+
* Aborts current agent operation first.
|
|
871
|
+
* @param customInstructions Optional instructions for the compaction summary
|
|
872
|
+
*/
|
|
873
|
+
async compact(customInstructions?: string): Promise<CompactionResult> {
|
|
874
|
+
this._disconnectFromAgent();
|
|
875
|
+
await this.abort();
|
|
876
|
+
this._compactionAbortController = new AbortController();
|
|
877
|
+
|
|
878
|
+
try {
|
|
879
|
+
if (!this.model) {
|
|
880
|
+
throw new Error("No model selected");
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const apiKey = await this._modelRegistry.getApiKey(this.model);
|
|
884
|
+
if (!apiKey) {
|
|
885
|
+
throw new Error(`No API key for ${this.model.provider}`);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const pathEntries = this.sessionManager.getBranch();
|
|
889
|
+
const settings = this.settingsManager.getCompactionSettings();
|
|
890
|
+
|
|
891
|
+
const preparation = prepareCompaction(pathEntries, settings);
|
|
892
|
+
if (!preparation) {
|
|
893
|
+
// Check why we can't compact
|
|
894
|
+
const lastEntry = pathEntries[pathEntries.length - 1];
|
|
895
|
+
if (lastEntry?.type === "compaction") {
|
|
896
|
+
throw new Error("Already compacted");
|
|
897
|
+
}
|
|
898
|
+
throw new Error("Nothing to compact (session too small)");
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
let hookCompaction: CompactionResult | undefined;
|
|
902
|
+
let fromHook = false;
|
|
903
|
+
|
|
904
|
+
if (this._hookRunner?.hasHandlers("session_before_compact")) {
|
|
905
|
+
const result = (await this._hookRunner.emit({
|
|
906
|
+
type: "session_before_compact",
|
|
907
|
+
preparation,
|
|
908
|
+
branchEntries: pathEntries,
|
|
909
|
+
customInstructions,
|
|
910
|
+
signal: this._compactionAbortController.signal,
|
|
911
|
+
})) as SessionBeforeCompactResult | undefined;
|
|
912
|
+
|
|
913
|
+
if (result?.cancel) {
|
|
914
|
+
throw new Error("Compaction cancelled");
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (result?.compaction) {
|
|
918
|
+
hookCompaction = result.compaction;
|
|
919
|
+
fromHook = true;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
let summary: string;
|
|
924
|
+
let firstKeptEntryId: string;
|
|
925
|
+
let tokensBefore: number;
|
|
926
|
+
let details: unknown;
|
|
927
|
+
|
|
928
|
+
if (hookCompaction) {
|
|
929
|
+
// Hook provided compaction content
|
|
930
|
+
summary = hookCompaction.summary;
|
|
931
|
+
firstKeptEntryId = hookCompaction.firstKeptEntryId;
|
|
932
|
+
tokensBefore = hookCompaction.tokensBefore;
|
|
933
|
+
details = hookCompaction.details;
|
|
934
|
+
} else {
|
|
935
|
+
// Generate compaction result
|
|
936
|
+
const result = await compact(
|
|
937
|
+
preparation,
|
|
938
|
+
this.model,
|
|
939
|
+
apiKey,
|
|
940
|
+
customInstructions,
|
|
941
|
+
this._compactionAbortController.signal,
|
|
942
|
+
);
|
|
943
|
+
summary = result.summary;
|
|
944
|
+
firstKeptEntryId = result.firstKeptEntryId;
|
|
945
|
+
tokensBefore = result.tokensBefore;
|
|
946
|
+
details = result.details;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (this._compactionAbortController.signal.aborted) {
|
|
950
|
+
throw new Error("Compaction cancelled");
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook);
|
|
954
|
+
const newEntries = this.sessionManager.getEntries();
|
|
955
|
+
const sessionContext = this.sessionManager.buildSessionContext();
|
|
956
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
957
|
+
|
|
958
|
+
// Get the saved compaction entry for the hook
|
|
959
|
+
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
|
|
960
|
+
| CompactionEntry
|
|
961
|
+
| undefined;
|
|
962
|
+
|
|
963
|
+
if (this._hookRunner && savedCompactionEntry) {
|
|
964
|
+
await this._hookRunner.emit({
|
|
965
|
+
type: "session_compact",
|
|
966
|
+
compactionEntry: savedCompactionEntry,
|
|
967
|
+
fromHook,
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return {
|
|
972
|
+
summary,
|
|
973
|
+
firstKeptEntryId,
|
|
974
|
+
tokensBefore,
|
|
975
|
+
details,
|
|
976
|
+
};
|
|
977
|
+
} finally {
|
|
978
|
+
this._compactionAbortController = undefined;
|
|
979
|
+
this._reconnectToAgent();
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Cancel in-progress compaction (manual or auto).
|
|
985
|
+
*/
|
|
986
|
+
abortCompaction(): void {
|
|
987
|
+
this._compactionAbortController?.abort();
|
|
988
|
+
this._autoCompactionAbortController?.abort();
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Cancel in-progress branch summarization.
|
|
993
|
+
*/
|
|
994
|
+
abortBranchSummary(): void {
|
|
995
|
+
this._branchSummaryAbortController?.abort();
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Check if compaction is needed and run it.
|
|
1000
|
+
* Called after agent_end and before prompt submission.
|
|
1001
|
+
*
|
|
1002
|
+
* Two cases:
|
|
1003
|
+
* 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry
|
|
1004
|
+
* 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)
|
|
1005
|
+
*
|
|
1006
|
+
* @param assistantMessage The assistant message to check
|
|
1007
|
+
* @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
|
|
1008
|
+
*/
|
|
1009
|
+
private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<void> {
|
|
1010
|
+
const settings = this.settingsManager.getCompactionSettings();
|
|
1011
|
+
if (!settings.enabled) return;
|
|
1012
|
+
|
|
1013
|
+
// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
|
|
1014
|
+
if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return;
|
|
1015
|
+
|
|
1016
|
+
const contextWindow = this.model?.contextWindow ?? 0;
|
|
1017
|
+
|
|
1018
|
+
// Case 1: Overflow - LLM returned context overflow error
|
|
1019
|
+
if (isContextOverflow(assistantMessage, contextWindow)) {
|
|
1020
|
+
// Remove the error message from agent state (it IS saved to session for history,
|
|
1021
|
+
// but we don't want it in context for the retry)
|
|
1022
|
+
const messages = this.agent.state.messages;
|
|
1023
|
+
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
|
1024
|
+
this.agent.replaceMessages(messages.slice(0, -1));
|
|
1025
|
+
}
|
|
1026
|
+
await this._runAutoCompaction("overflow", true);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Case 2: Threshold - turn succeeded but context is getting large
|
|
1031
|
+
// Skip if this was an error (non-overflow errors don't have usage data)
|
|
1032
|
+
if (assistantMessage.stopReason === "error") return;
|
|
1033
|
+
|
|
1034
|
+
const contextTokens = calculateContextTokens(assistantMessage.usage);
|
|
1035
|
+
if (shouldCompact(contextTokens, contextWindow, settings)) {
|
|
1036
|
+
await this._runAutoCompaction("threshold", false);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Internal: Run auto-compaction with events.
|
|
1042
|
+
*/
|
|
1043
|
+
private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<void> {
|
|
1044
|
+
const settings = this.settingsManager.getCompactionSettings();
|
|
1045
|
+
|
|
1046
|
+
this._emit({ type: "auto_compaction_start", reason });
|
|
1047
|
+
this._autoCompactionAbortController = new AbortController();
|
|
1048
|
+
|
|
1049
|
+
try {
|
|
1050
|
+
if (!this.model) {
|
|
1051
|
+
this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const apiKey = await this._modelRegistry.getApiKey(this.model);
|
|
1056
|
+
if (!apiKey) {
|
|
1057
|
+
this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const pathEntries = this.sessionManager.getBranch();
|
|
1062
|
+
|
|
1063
|
+
const preparation = prepareCompaction(pathEntries, settings);
|
|
1064
|
+
if (!preparation) {
|
|
1065
|
+
this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
let hookCompaction: CompactionResult | undefined;
|
|
1070
|
+
let fromHook = false;
|
|
1071
|
+
|
|
1072
|
+
if (this._hookRunner?.hasHandlers("session_before_compact")) {
|
|
1073
|
+
const hookResult = (await this._hookRunner.emit({
|
|
1074
|
+
type: "session_before_compact",
|
|
1075
|
+
preparation,
|
|
1076
|
+
branchEntries: pathEntries,
|
|
1077
|
+
customInstructions: undefined,
|
|
1078
|
+
signal: this._autoCompactionAbortController.signal,
|
|
1079
|
+
})) as SessionBeforeCompactResult | undefined;
|
|
1080
|
+
|
|
1081
|
+
if (hookResult?.cancel) {
|
|
1082
|
+
this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
if (hookResult?.compaction) {
|
|
1087
|
+
hookCompaction = hookResult.compaction;
|
|
1088
|
+
fromHook = true;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
let summary: string;
|
|
1093
|
+
let firstKeptEntryId: string;
|
|
1094
|
+
let tokensBefore: number;
|
|
1095
|
+
let details: unknown;
|
|
1096
|
+
|
|
1097
|
+
if (hookCompaction) {
|
|
1098
|
+
// Hook provided compaction content
|
|
1099
|
+
summary = hookCompaction.summary;
|
|
1100
|
+
firstKeptEntryId = hookCompaction.firstKeptEntryId;
|
|
1101
|
+
tokensBefore = hookCompaction.tokensBefore;
|
|
1102
|
+
details = hookCompaction.details;
|
|
1103
|
+
} else {
|
|
1104
|
+
// Generate compaction result
|
|
1105
|
+
const compactResult = await compact(
|
|
1106
|
+
preparation,
|
|
1107
|
+
this.model,
|
|
1108
|
+
apiKey,
|
|
1109
|
+
undefined,
|
|
1110
|
+
this._autoCompactionAbortController.signal,
|
|
1111
|
+
);
|
|
1112
|
+
summary = compactResult.summary;
|
|
1113
|
+
firstKeptEntryId = compactResult.firstKeptEntryId;
|
|
1114
|
+
tokensBefore = compactResult.tokensBefore;
|
|
1115
|
+
details = compactResult.details;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (this._autoCompactionAbortController.signal.aborted) {
|
|
1119
|
+
this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook);
|
|
1124
|
+
const newEntries = this.sessionManager.getEntries();
|
|
1125
|
+
const sessionContext = this.sessionManager.buildSessionContext();
|
|
1126
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
1127
|
+
|
|
1128
|
+
// Get the saved compaction entry for the hook
|
|
1129
|
+
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
|
|
1130
|
+
| CompactionEntry
|
|
1131
|
+
| undefined;
|
|
1132
|
+
|
|
1133
|
+
if (this._hookRunner && savedCompactionEntry) {
|
|
1134
|
+
await this._hookRunner.emit({
|
|
1135
|
+
type: "session_compact",
|
|
1136
|
+
compactionEntry: savedCompactionEntry,
|
|
1137
|
+
fromHook,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const result: CompactionResult = {
|
|
1142
|
+
summary,
|
|
1143
|
+
firstKeptEntryId,
|
|
1144
|
+
tokensBefore,
|
|
1145
|
+
details,
|
|
1146
|
+
};
|
|
1147
|
+
this._emit({ type: "auto_compaction_end", result, aborted: false, willRetry });
|
|
1148
|
+
|
|
1149
|
+
if (willRetry) {
|
|
1150
|
+
const messages = this.agent.state.messages;
|
|
1151
|
+
const lastMsg = messages[messages.length - 1];
|
|
1152
|
+
if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {
|
|
1153
|
+
this.agent.replaceMessages(messages.slice(0, -1));
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
setTimeout(() => {
|
|
1157
|
+
this.agent.continue().catch(() => {});
|
|
1158
|
+
}, 100);
|
|
1159
|
+
}
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
|
|
1162
|
+
|
|
1163
|
+
if (reason === "overflow") {
|
|
1164
|
+
throw new Error(
|
|
1165
|
+
`Context overflow: ${
|
|
1166
|
+
error instanceof Error ? error.message : "compaction failed"
|
|
1167
|
+
}. Your input may be too large for the context window.`,
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
} finally {
|
|
1171
|
+
this._autoCompactionAbortController = undefined;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Toggle auto-compaction setting.
|
|
1177
|
+
*/
|
|
1178
|
+
setAutoCompactionEnabled(enabled: boolean): void {
|
|
1179
|
+
this.settingsManager.setCompactionEnabled(enabled);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** Whether auto-compaction is enabled */
|
|
1183
|
+
get autoCompactionEnabled(): boolean {
|
|
1184
|
+
return this.settingsManager.getCompactionEnabled();
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// =========================================================================
|
|
1188
|
+
// Auto-Retry
|
|
1189
|
+
// =========================================================================
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Check if an error is retryable (overloaded, rate limit, server errors).
|
|
1193
|
+
* Context overflow errors are NOT retryable (handled by compaction instead).
|
|
1194
|
+
*/
|
|
1195
|
+
private _isRetryableError(message: AssistantMessage): boolean {
|
|
1196
|
+
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
1197
|
+
|
|
1198
|
+
// Context overflow is handled by compaction, not retry
|
|
1199
|
+
const contextWindow = this.model?.contextWindow ?? 0;
|
|
1200
|
+
if (isContextOverflow(message, contextWindow)) return false;
|
|
1201
|
+
|
|
1202
|
+
const err = message.errorMessage;
|
|
1203
|
+
// Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection error
|
|
1204
|
+
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error/i.test(
|
|
1205
|
+
err,
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Handle retryable errors with exponential backoff.
|
|
1211
|
+
* @returns true if retry was initiated, false if max retries exceeded or disabled
|
|
1212
|
+
*/
|
|
1213
|
+
private async _handleRetryableError(message: AssistantMessage): Promise<boolean> {
|
|
1214
|
+
const settings = this.settingsManager.getRetrySettings();
|
|
1215
|
+
if (!settings.enabled) return false;
|
|
1216
|
+
|
|
1217
|
+
this._retryAttempt++;
|
|
1218
|
+
|
|
1219
|
+
// Create retry promise on first attempt so waitForRetry() can await it
|
|
1220
|
+
if (this._retryAttempt === 1 && !this._retryPromise) {
|
|
1221
|
+
this._retryPromise = new Promise((resolve) => {
|
|
1222
|
+
this._retryResolve = resolve;
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
if (this._retryAttempt > settings.maxRetries) {
|
|
1227
|
+
// Max retries exceeded, emit final failure and reset
|
|
1228
|
+
this._emit({
|
|
1229
|
+
type: "auto_retry_end",
|
|
1230
|
+
success: false,
|
|
1231
|
+
attempt: this._retryAttempt - 1,
|
|
1232
|
+
finalError: message.errorMessage,
|
|
1233
|
+
});
|
|
1234
|
+
this._retryAttempt = 0;
|
|
1235
|
+
this._resolveRetry(); // Resolve so waitForRetry() completes
|
|
1236
|
+
return false;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
|
|
1240
|
+
|
|
1241
|
+
this._emit({
|
|
1242
|
+
type: "auto_retry_start",
|
|
1243
|
+
attempt: this._retryAttempt,
|
|
1244
|
+
maxAttempts: settings.maxRetries,
|
|
1245
|
+
delayMs,
|
|
1246
|
+
errorMessage: message.errorMessage || "Unknown error",
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
// Remove error message from agent state (keep in session for history)
|
|
1250
|
+
const messages = this.agent.state.messages;
|
|
1251
|
+
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
|
1252
|
+
this.agent.replaceMessages(messages.slice(0, -1));
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Wait with exponential backoff (abortable)
|
|
1256
|
+
this._retryAbortController = new AbortController();
|
|
1257
|
+
try {
|
|
1258
|
+
await this._sleep(delayMs, this._retryAbortController.signal);
|
|
1259
|
+
} catch {
|
|
1260
|
+
// Aborted during sleep - emit end event so UI can clean up
|
|
1261
|
+
const attempt = this._retryAttempt;
|
|
1262
|
+
this._retryAttempt = 0;
|
|
1263
|
+
this._retryAbortController = undefined;
|
|
1264
|
+
this._emit({
|
|
1265
|
+
type: "auto_retry_end",
|
|
1266
|
+
success: false,
|
|
1267
|
+
attempt,
|
|
1268
|
+
finalError: "Retry cancelled",
|
|
1269
|
+
});
|
|
1270
|
+
this._resolveRetry();
|
|
1271
|
+
return false;
|
|
1272
|
+
}
|
|
1273
|
+
this._retryAbortController = undefined;
|
|
1274
|
+
|
|
1275
|
+
// Retry via continue() - use setTimeout to break out of event handler chain
|
|
1276
|
+
setTimeout(() => {
|
|
1277
|
+
this.agent.continue().catch(() => {
|
|
1278
|
+
// Retry failed - will be caught by next agent_end
|
|
1279
|
+
});
|
|
1280
|
+
}, 0);
|
|
1281
|
+
|
|
1282
|
+
return true;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Sleep helper that respects abort signal.
|
|
1287
|
+
*/
|
|
1288
|
+
private _sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
1289
|
+
return new Promise((resolve, reject) => {
|
|
1290
|
+
if (signal?.aborted) {
|
|
1291
|
+
reject(new Error("Aborted"));
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const timeout = setTimeout(resolve, ms);
|
|
1296
|
+
|
|
1297
|
+
signal?.addEventListener("abort", () => {
|
|
1298
|
+
clearTimeout(timeout);
|
|
1299
|
+
reject(new Error("Aborted"));
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Cancel in-progress retry.
|
|
1306
|
+
*/
|
|
1307
|
+
abortRetry(): void {
|
|
1308
|
+
this._retryAbortController?.abort();
|
|
1309
|
+
this._retryAttempt = 0;
|
|
1310
|
+
this._resolveRetry();
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Wait for any in-progress retry to complete.
|
|
1315
|
+
* Returns immediately if no retry is in progress.
|
|
1316
|
+
*/
|
|
1317
|
+
private async waitForRetry(): Promise<void> {
|
|
1318
|
+
if (this._retryPromise) {
|
|
1319
|
+
await this._retryPromise;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/** Whether auto-retry is currently in progress */
|
|
1324
|
+
get isRetrying(): boolean {
|
|
1325
|
+
return this._retryPromise !== undefined;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/** Whether auto-retry is enabled */
|
|
1329
|
+
get autoRetryEnabled(): boolean {
|
|
1330
|
+
return this.settingsManager.getRetryEnabled();
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Toggle auto-retry setting.
|
|
1335
|
+
*/
|
|
1336
|
+
setAutoRetryEnabled(enabled: boolean): void {
|
|
1337
|
+
this.settingsManager.setRetryEnabled(enabled);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// =========================================================================
|
|
1341
|
+
// Bash Execution
|
|
1342
|
+
// =========================================================================
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Execute a bash command.
|
|
1346
|
+
* Adds result to agent context and session.
|
|
1347
|
+
* @param command The bash command to execute
|
|
1348
|
+
* @param onChunk Optional streaming callback for output
|
|
1349
|
+
*/
|
|
1350
|
+
async executeBash(command: string, onChunk?: (chunk: string) => void): Promise<BashResult> {
|
|
1351
|
+
this._bashAbortController = new AbortController();
|
|
1352
|
+
|
|
1353
|
+
try {
|
|
1354
|
+
const result = await executeBashCommand(command, {
|
|
1355
|
+
onChunk,
|
|
1356
|
+
signal: this._bashAbortController.signal,
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
// Create and save message
|
|
1360
|
+
const bashMessage: BashExecutionMessage = {
|
|
1361
|
+
role: "bashExecution",
|
|
1362
|
+
command,
|
|
1363
|
+
output: result.output,
|
|
1364
|
+
exitCode: result.exitCode,
|
|
1365
|
+
cancelled: result.cancelled,
|
|
1366
|
+
truncated: result.truncated,
|
|
1367
|
+
fullOutputPath: result.fullOutputPath,
|
|
1368
|
+
timestamp: Date.now(),
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
|
|
1372
|
+
if (this.isStreaming) {
|
|
1373
|
+
// Queue for later - will be flushed on agent_end
|
|
1374
|
+
this._pendingBashMessages.push(bashMessage);
|
|
1375
|
+
} else {
|
|
1376
|
+
// Add to agent state immediately
|
|
1377
|
+
this.agent.appendMessage(bashMessage);
|
|
1378
|
+
|
|
1379
|
+
// Save to session
|
|
1380
|
+
this.sessionManager.appendMessage(bashMessage);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
return result;
|
|
1384
|
+
} finally {
|
|
1385
|
+
this._bashAbortController = undefined;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Cancel running bash command.
|
|
1391
|
+
*/
|
|
1392
|
+
abortBash(): void {
|
|
1393
|
+
this._bashAbortController?.abort();
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/** Whether a bash command is currently running */
|
|
1397
|
+
get isBashRunning(): boolean {
|
|
1398
|
+
return this._bashAbortController !== undefined;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/** Whether there are pending bash messages waiting to be flushed */
|
|
1402
|
+
get hasPendingBashMessages(): boolean {
|
|
1403
|
+
return this._pendingBashMessages.length > 0;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Flush pending bash messages to agent state and session.
|
|
1408
|
+
* Called after agent turn completes to maintain proper message ordering.
|
|
1409
|
+
*/
|
|
1410
|
+
private _flushPendingBashMessages(): void {
|
|
1411
|
+
if (this._pendingBashMessages.length === 0) return;
|
|
1412
|
+
|
|
1413
|
+
for (const bashMessage of this._pendingBashMessages) {
|
|
1414
|
+
// Add to agent state
|
|
1415
|
+
this.agent.appendMessage(bashMessage);
|
|
1416
|
+
|
|
1417
|
+
// Save to session
|
|
1418
|
+
this.sessionManager.appendMessage(bashMessage);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
this._pendingBashMessages = [];
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// =========================================================================
|
|
1425
|
+
// Session Management
|
|
1426
|
+
// =========================================================================
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Switch to a different session file.
|
|
1430
|
+
* Aborts current operation, loads messages, restores model/thinking.
|
|
1431
|
+
* Listeners are preserved and will continue receiving events.
|
|
1432
|
+
* @returns true if switch completed, false if cancelled by hook
|
|
1433
|
+
*/
|
|
1434
|
+
async switchSession(sessionPath: string): Promise<boolean> {
|
|
1435
|
+
const previousSessionFile = this.sessionManager.getSessionFile();
|
|
1436
|
+
|
|
1437
|
+
// Emit session_before_switch event (can be cancelled)
|
|
1438
|
+
if (this._hookRunner?.hasHandlers("session_before_switch")) {
|
|
1439
|
+
const result = (await this._hookRunner.emit({
|
|
1440
|
+
type: "session_before_switch",
|
|
1441
|
+
reason: "resume",
|
|
1442
|
+
targetSessionFile: sessionPath,
|
|
1443
|
+
})) as SessionBeforeSwitchResult | undefined;
|
|
1444
|
+
|
|
1445
|
+
if (result?.cancel) {
|
|
1446
|
+
return false;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
this._disconnectFromAgent();
|
|
1451
|
+
await this.abort();
|
|
1452
|
+
this._queuedMessages = [];
|
|
1453
|
+
|
|
1454
|
+
// Set new session
|
|
1455
|
+
this.sessionManager.setSessionFile(sessionPath);
|
|
1456
|
+
|
|
1457
|
+
// Reload messages
|
|
1458
|
+
const sessionContext = this.sessionManager.buildSessionContext();
|
|
1459
|
+
|
|
1460
|
+
// Emit session_switch event to hooks
|
|
1461
|
+
if (this._hookRunner) {
|
|
1462
|
+
await this._hookRunner.emit({
|
|
1463
|
+
type: "session_switch",
|
|
1464
|
+
reason: "resume",
|
|
1465
|
+
previousSessionFile,
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// Emit session event to custom tools
|
|
1470
|
+
await this.emitCustomToolSessionEvent("switch", previousSessionFile);
|
|
1471
|
+
|
|
1472
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
1473
|
+
|
|
1474
|
+
// Restore model if saved
|
|
1475
|
+
if (sessionContext.model) {
|
|
1476
|
+
const availableModels = await this._modelRegistry.getAvailable();
|
|
1477
|
+
const match = availableModels.find(
|
|
1478
|
+
(m) => m.provider === sessionContext.model!.provider && m.id === sessionContext.model!.modelId,
|
|
1479
|
+
);
|
|
1480
|
+
if (match) {
|
|
1481
|
+
this.agent.setModel(match);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// Restore thinking level if saved (setThinkingLevel clamps to model capabilities)
|
|
1486
|
+
if (sessionContext.thinkingLevel) {
|
|
1487
|
+
this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
this._reconnectToAgent();
|
|
1491
|
+
return true;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Create a branch from a specific entry.
|
|
1496
|
+
* Emits before_branch/branch session events to hooks.
|
|
1497
|
+
*
|
|
1498
|
+
* @param entryId ID of the entry to branch from
|
|
1499
|
+
* @returns Object with:
|
|
1500
|
+
* - selectedText: The text of the selected user message (for editor pre-fill)
|
|
1501
|
+
* - cancelled: True if a hook cancelled the branch
|
|
1502
|
+
*/
|
|
1503
|
+
async branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> {
|
|
1504
|
+
const previousSessionFile = this.sessionFile;
|
|
1505
|
+
const selectedEntry = this.sessionManager.getEntry(entryId);
|
|
1506
|
+
|
|
1507
|
+
if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
|
|
1508
|
+
throw new Error("Invalid entry ID for branching");
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const selectedText = this._extractUserMessageText(selectedEntry.message.content);
|
|
1512
|
+
|
|
1513
|
+
let skipConversationRestore = false;
|
|
1514
|
+
|
|
1515
|
+
// Emit session_before_branch event (can be cancelled)
|
|
1516
|
+
if (this._hookRunner?.hasHandlers("session_before_branch")) {
|
|
1517
|
+
const result = (await this._hookRunner.emit({
|
|
1518
|
+
type: "session_before_branch",
|
|
1519
|
+
entryId,
|
|
1520
|
+
})) as SessionBeforeBranchResult | undefined;
|
|
1521
|
+
|
|
1522
|
+
if (result?.cancel) {
|
|
1523
|
+
return { selectedText, cancelled: true };
|
|
1524
|
+
}
|
|
1525
|
+
skipConversationRestore = result?.skipConversationRestore ?? false;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
if (!selectedEntry.parentId) {
|
|
1529
|
+
this.sessionManager.newSession();
|
|
1530
|
+
} else {
|
|
1531
|
+
this.sessionManager.createBranchedSession(selectedEntry.parentId);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// Reload messages from entries (works for both file and in-memory mode)
|
|
1535
|
+
const sessionContext = this.sessionManager.buildSessionContext();
|
|
1536
|
+
|
|
1537
|
+
// Emit session_branch event to hooks (after branch completes)
|
|
1538
|
+
if (this._hookRunner) {
|
|
1539
|
+
await this._hookRunner.emit({
|
|
1540
|
+
type: "session_branch",
|
|
1541
|
+
previousSessionFile,
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Emit session event to custom tools (with reason "branch")
|
|
1546
|
+
await this.emitCustomToolSessionEvent("branch", previousSessionFile);
|
|
1547
|
+
|
|
1548
|
+
if (!skipConversationRestore) {
|
|
1549
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
return { selectedText, cancelled: false };
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// =========================================================================
|
|
1556
|
+
// Tree Navigation
|
|
1557
|
+
// =========================================================================
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* Navigate to a different node in the session tree.
|
|
1561
|
+
* Unlike branch() which creates a new session file, this stays in the same file.
|
|
1562
|
+
*
|
|
1563
|
+
* @param targetId The entry ID to navigate to
|
|
1564
|
+
* @param options.summarize Whether user wants to summarize abandoned branch
|
|
1565
|
+
* @param options.customInstructions Custom instructions for summarizer
|
|
1566
|
+
* @returns Result with editorText (if user message) and cancelled status
|
|
1567
|
+
*/
|
|
1568
|
+
async navigateTree(
|
|
1569
|
+
targetId: string,
|
|
1570
|
+
options: { summarize?: boolean; customInstructions?: string } = {},
|
|
1571
|
+
): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> {
|
|
1572
|
+
const oldLeafId = this.sessionManager.getLeafId();
|
|
1573
|
+
|
|
1574
|
+
// No-op if already at target
|
|
1575
|
+
if (targetId === oldLeafId) {
|
|
1576
|
+
return { cancelled: false };
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// Model required for summarization
|
|
1580
|
+
if (options.summarize && !this.model) {
|
|
1581
|
+
throw new Error("No model available for summarization");
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const targetEntry = this.sessionManager.getEntry(targetId);
|
|
1585
|
+
if (!targetEntry) {
|
|
1586
|
+
throw new Error(`Entry ${targetId} not found`);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// Collect entries to summarize (from old leaf to common ancestor)
|
|
1590
|
+
const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
|
|
1591
|
+
this.sessionManager,
|
|
1592
|
+
oldLeafId,
|
|
1593
|
+
targetId,
|
|
1594
|
+
);
|
|
1595
|
+
|
|
1596
|
+
// Prepare event data
|
|
1597
|
+
const preparation: TreePreparation = {
|
|
1598
|
+
targetId,
|
|
1599
|
+
oldLeafId,
|
|
1600
|
+
commonAncestorId,
|
|
1601
|
+
entriesToSummarize,
|
|
1602
|
+
userWantsSummary: options.summarize ?? false,
|
|
1603
|
+
};
|
|
1604
|
+
|
|
1605
|
+
// Set up abort controller for summarization
|
|
1606
|
+
this._branchSummaryAbortController = new AbortController();
|
|
1607
|
+
let hookSummary: { summary: string; details?: unknown } | undefined;
|
|
1608
|
+
let fromHook = false;
|
|
1609
|
+
|
|
1610
|
+
// Emit session_before_tree event
|
|
1611
|
+
if (this._hookRunner?.hasHandlers("session_before_tree")) {
|
|
1612
|
+
const result = (await this._hookRunner.emit({
|
|
1613
|
+
type: "session_before_tree",
|
|
1614
|
+
preparation,
|
|
1615
|
+
signal: this._branchSummaryAbortController.signal,
|
|
1616
|
+
})) as SessionBeforeTreeResult | undefined;
|
|
1617
|
+
|
|
1618
|
+
if (result?.cancel) {
|
|
1619
|
+
return { cancelled: true };
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
if (result?.summary && options.summarize) {
|
|
1623
|
+
hookSummary = result.summary;
|
|
1624
|
+
fromHook = true;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// Run default summarizer if needed
|
|
1629
|
+
let summaryText: string | undefined;
|
|
1630
|
+
let summaryDetails: unknown;
|
|
1631
|
+
if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
|
|
1632
|
+
const model = this.model!;
|
|
1633
|
+
const apiKey = await this._modelRegistry.getApiKey(model);
|
|
1634
|
+
if (!apiKey) {
|
|
1635
|
+
throw new Error(`No API key for ${model.provider}`);
|
|
1636
|
+
}
|
|
1637
|
+
const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
|
|
1638
|
+
const result = await generateBranchSummary(entriesToSummarize, {
|
|
1639
|
+
model,
|
|
1640
|
+
apiKey,
|
|
1641
|
+
signal: this._branchSummaryAbortController.signal,
|
|
1642
|
+
customInstructions: options.customInstructions,
|
|
1643
|
+
reserveTokens: branchSummarySettings.reserveTokens,
|
|
1644
|
+
});
|
|
1645
|
+
this._branchSummaryAbortController = undefined;
|
|
1646
|
+
if (result.aborted) {
|
|
1647
|
+
return { cancelled: true, aborted: true };
|
|
1648
|
+
}
|
|
1649
|
+
if (result.error) {
|
|
1650
|
+
throw new Error(result.error);
|
|
1651
|
+
}
|
|
1652
|
+
summaryText = result.summary;
|
|
1653
|
+
summaryDetails = {
|
|
1654
|
+
readFiles: result.readFiles || [],
|
|
1655
|
+
modifiedFiles: result.modifiedFiles || [],
|
|
1656
|
+
};
|
|
1657
|
+
} else if (hookSummary) {
|
|
1658
|
+
summaryText = hookSummary.summary;
|
|
1659
|
+
summaryDetails = hookSummary.details;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// Determine the new leaf position based on target type
|
|
1663
|
+
let newLeafId: string | null;
|
|
1664
|
+
let editorText: string | undefined;
|
|
1665
|
+
|
|
1666
|
+
if (targetEntry.type === "message" && targetEntry.message.role === "user") {
|
|
1667
|
+
// User message: leaf = parent (null if root), text goes to editor
|
|
1668
|
+
newLeafId = targetEntry.parentId;
|
|
1669
|
+
editorText = this._extractUserMessageText(targetEntry.message.content);
|
|
1670
|
+
} else if (targetEntry.type === "custom_message") {
|
|
1671
|
+
// Custom message: leaf = parent (null if root), text goes to editor
|
|
1672
|
+
newLeafId = targetEntry.parentId;
|
|
1673
|
+
editorText =
|
|
1674
|
+
typeof targetEntry.content === "string"
|
|
1675
|
+
? targetEntry.content
|
|
1676
|
+
: targetEntry.content
|
|
1677
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
1678
|
+
.map((c) => c.text)
|
|
1679
|
+
.join("");
|
|
1680
|
+
} else {
|
|
1681
|
+
// Non-user message: leaf = selected node
|
|
1682
|
+
newLeafId = targetId;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// Switch leaf (with or without summary)
|
|
1686
|
+
// Summary is attached at the navigation target position (newLeafId), not the old branch
|
|
1687
|
+
let summaryEntry: BranchSummaryEntry | undefined;
|
|
1688
|
+
if (summaryText) {
|
|
1689
|
+
// Create summary at target position (can be null for root)
|
|
1690
|
+
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromHook);
|
|
1691
|
+
summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
|
|
1692
|
+
} else if (newLeafId === null) {
|
|
1693
|
+
// No summary, navigating to root - reset leaf
|
|
1694
|
+
this.sessionManager.resetLeaf();
|
|
1695
|
+
} else {
|
|
1696
|
+
// No summary, navigating to non-root
|
|
1697
|
+
this.sessionManager.branch(newLeafId);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// Update agent state
|
|
1701
|
+
const sessionContext = this.sessionManager.buildSessionContext();
|
|
1702
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
1703
|
+
|
|
1704
|
+
// Emit session_tree event
|
|
1705
|
+
if (this._hookRunner) {
|
|
1706
|
+
await this._hookRunner.emit({
|
|
1707
|
+
type: "session_tree",
|
|
1708
|
+
newLeafId: this.sessionManager.getLeafId(),
|
|
1709
|
+
oldLeafId,
|
|
1710
|
+
summaryEntry,
|
|
1711
|
+
fromHook: summaryText ? fromHook : undefined,
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// Emit to custom tools
|
|
1716
|
+
await this.emitCustomToolSessionEvent("tree", this.sessionFile);
|
|
1717
|
+
|
|
1718
|
+
this._branchSummaryAbortController = undefined;
|
|
1719
|
+
return { editorText, cancelled: false, summaryEntry };
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* Get all user messages from session for branch selector.
|
|
1724
|
+
*/
|
|
1725
|
+
getUserMessagesForBranching(): Array<{ entryId: string; text: string }> {
|
|
1726
|
+
const entries = this.sessionManager.getEntries();
|
|
1727
|
+
const result: Array<{ entryId: string; text: string }> = [];
|
|
1728
|
+
|
|
1729
|
+
for (const entry of entries) {
|
|
1730
|
+
if (entry.type !== "message") continue;
|
|
1731
|
+
if (entry.message.role !== "user") continue;
|
|
1732
|
+
|
|
1733
|
+
const text = this._extractUserMessageText(entry.message.content);
|
|
1734
|
+
if (text) {
|
|
1735
|
+
result.push({ entryId: entry.id, text });
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
return result;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
private _extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {
|
|
1743
|
+
if (typeof content === "string") return content;
|
|
1744
|
+
if (Array.isArray(content)) {
|
|
1745
|
+
return content
|
|
1746
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
1747
|
+
.map((c) => c.text)
|
|
1748
|
+
.join("");
|
|
1749
|
+
}
|
|
1750
|
+
return "";
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/**
|
|
1754
|
+
* Get session statistics.
|
|
1755
|
+
*/
|
|
1756
|
+
getSessionStats(): SessionStats {
|
|
1757
|
+
const state = this.state;
|
|
1758
|
+
const userMessages = state.messages.filter((m) => m.role === "user").length;
|
|
1759
|
+
const assistantMessages = state.messages.filter((m) => m.role === "assistant").length;
|
|
1760
|
+
const toolResults = state.messages.filter((m) => m.role === "toolResult").length;
|
|
1761
|
+
|
|
1762
|
+
let toolCalls = 0;
|
|
1763
|
+
let totalInput = 0;
|
|
1764
|
+
let totalOutput = 0;
|
|
1765
|
+
let totalCacheRead = 0;
|
|
1766
|
+
let totalCacheWrite = 0;
|
|
1767
|
+
let totalCost = 0;
|
|
1768
|
+
|
|
1769
|
+
for (const message of state.messages) {
|
|
1770
|
+
if (message.role === "assistant") {
|
|
1771
|
+
const assistantMsg = message as AssistantMessage;
|
|
1772
|
+
toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length;
|
|
1773
|
+
totalInput += assistantMsg.usage.input;
|
|
1774
|
+
totalOutput += assistantMsg.usage.output;
|
|
1775
|
+
totalCacheRead += assistantMsg.usage.cacheRead;
|
|
1776
|
+
totalCacheWrite += assistantMsg.usage.cacheWrite;
|
|
1777
|
+
totalCost += assistantMsg.usage.cost.total;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
return {
|
|
1782
|
+
sessionFile: this.sessionFile,
|
|
1783
|
+
sessionId: this.sessionId,
|
|
1784
|
+
userMessages,
|
|
1785
|
+
assistantMessages,
|
|
1786
|
+
toolCalls,
|
|
1787
|
+
toolResults,
|
|
1788
|
+
totalMessages: state.messages.length,
|
|
1789
|
+
tokens: {
|
|
1790
|
+
input: totalInput,
|
|
1791
|
+
output: totalOutput,
|
|
1792
|
+
cacheRead: totalCacheRead,
|
|
1793
|
+
cacheWrite: totalCacheWrite,
|
|
1794
|
+
total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
|
|
1795
|
+
},
|
|
1796
|
+
cost: totalCost,
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
/**
|
|
1801
|
+
* Export session to HTML.
|
|
1802
|
+
* @param outputPath Optional output path (defaults to session directory)
|
|
1803
|
+
* @returns Path to exported file
|
|
1804
|
+
*/
|
|
1805
|
+
exportToHtml(outputPath?: string): string {
|
|
1806
|
+
const themeName = this.settingsManager.getTheme();
|
|
1807
|
+
return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName });
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// =========================================================================
|
|
1811
|
+
// Utilities
|
|
1812
|
+
// =========================================================================
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* Get text content of last assistant message.
|
|
1816
|
+
* Useful for /copy command.
|
|
1817
|
+
* @returns Text content, or undefined if no assistant message exists
|
|
1818
|
+
*/
|
|
1819
|
+
getLastAssistantText(): string | undefined {
|
|
1820
|
+
const lastAssistant = this.messages
|
|
1821
|
+
.slice()
|
|
1822
|
+
.reverse()
|
|
1823
|
+
.find((m) => {
|
|
1824
|
+
if (m.role !== "assistant") return false;
|
|
1825
|
+
const msg = m as AssistantMessage;
|
|
1826
|
+
// Skip aborted messages with no content
|
|
1827
|
+
if (msg.stopReason === "aborted" && msg.content.length === 0) return false;
|
|
1828
|
+
return true;
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
if (!lastAssistant) return undefined;
|
|
1832
|
+
|
|
1833
|
+
let text = "";
|
|
1834
|
+
for (const content of (lastAssistant as AssistantMessage).content) {
|
|
1835
|
+
if (content.type === "text") {
|
|
1836
|
+
text += content.text;
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
return text.trim() || undefined;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// =========================================================================
|
|
1844
|
+
// Hook System
|
|
1845
|
+
// =========================================================================
|
|
1846
|
+
|
|
1847
|
+
/**
|
|
1848
|
+
* Check if hooks have handlers for a specific event type.
|
|
1849
|
+
*/
|
|
1850
|
+
hasHookHandlers(eventType: string): boolean {
|
|
1851
|
+
return this._hookRunner?.hasHandlers(eventType) ?? false;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
/**
|
|
1855
|
+
* Get the hook runner (for setting UI context and error handlers).
|
|
1856
|
+
*/
|
|
1857
|
+
get hookRunner(): HookRunner | undefined {
|
|
1858
|
+
return this._hookRunner;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
/**
|
|
1862
|
+
* Get custom tools (for setting UI context in modes).
|
|
1863
|
+
*/
|
|
1864
|
+
get customTools(): LoadedCustomTool[] {
|
|
1865
|
+
return this._customTools;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* Emit session event to all custom tools.
|
|
1870
|
+
* Called on session switch, branch, tree navigation, and shutdown.
|
|
1871
|
+
*/
|
|
1872
|
+
async emitCustomToolSessionEvent(
|
|
1873
|
+
reason: CustomToolSessionEvent["reason"],
|
|
1874
|
+
previousSessionFile?: string | undefined,
|
|
1875
|
+
): Promise<void> {
|
|
1876
|
+
if (!this._customTools) return;
|
|
1877
|
+
|
|
1878
|
+
const event: CustomToolSessionEvent = { reason, previousSessionFile };
|
|
1879
|
+
const ctx: CustomToolContext = {
|
|
1880
|
+
sessionManager: this.sessionManager,
|
|
1881
|
+
modelRegistry: this._modelRegistry,
|
|
1882
|
+
model: this.agent.state.model,
|
|
1883
|
+
isIdle: () => !this.isStreaming,
|
|
1884
|
+
hasQueuedMessages: () => this.queuedMessageCount > 0,
|
|
1885
|
+
abort: () => {
|
|
1886
|
+
this.abort();
|
|
1887
|
+
},
|
|
1888
|
+
};
|
|
1889
|
+
|
|
1890
|
+
for (const { tool } of this._customTools) {
|
|
1891
|
+
if (tool.onSession) {
|
|
1892
|
+
try {
|
|
1893
|
+
await tool.onSession(event, ctx);
|
|
1894
|
+
} catch (_err) {
|
|
1895
|
+
// Silently ignore tool errors during session events
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|