@oh-my-pi/pi-coding-agent 13.9.2 → 13.9.3

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/examples/sdk/02-custom-model.ts +2 -1
  3. package/package.json +7 -7
  4. package/src/cli/args.ts +6 -5
  5. package/src/cli/list-models.ts +2 -2
  6. package/src/commands/launch.ts +3 -3
  7. package/src/config/model-registry.ts +85 -39
  8. package/src/config/model-resolver.ts +47 -21
  9. package/src/config/settings-schema.ts +56 -2
  10. package/src/discovery/helpers.ts +2 -2
  11. package/src/extensibility/custom-tools/types.ts +2 -0
  12. package/src/extensibility/extensions/loader.ts +3 -2
  13. package/src/extensibility/extensions/types.ts +10 -7
  14. package/src/extensibility/hooks/types.ts +2 -0
  15. package/src/main.ts +5 -22
  16. package/src/memories/index.ts +7 -3
  17. package/src/modes/components/footer.ts +10 -8
  18. package/src/modes/components/model-selector.ts +33 -38
  19. package/src/modes/components/settings-defs.ts +31 -2
  20. package/src/modes/components/settings-selector.ts +16 -5
  21. package/src/modes/components/status-line/context-thresholds.ts +68 -0
  22. package/src/modes/components/status-line/segments.ts +11 -12
  23. package/src/modes/components/thinking-selector.ts +7 -7
  24. package/src/modes/components/tree-selector.ts +3 -2
  25. package/src/modes/controllers/command-controller.ts +11 -26
  26. package/src/modes/controllers/event-controller.ts +16 -3
  27. package/src/modes/controllers/input-controller.ts +4 -2
  28. package/src/modes/controllers/selector-controller.ts +5 -4
  29. package/src/modes/interactive-mode.ts +2 -2
  30. package/src/modes/rpc/rpc-client.ts +5 -10
  31. package/src/modes/rpc/rpc-types.ts +5 -5
  32. package/src/modes/theme/theme.ts +8 -3
  33. package/src/priority.json +1 -0
  34. package/src/prompts/system/auto-handoff-threshold-focus.md +1 -0
  35. package/src/prompts/system/system-prompt.md +18 -2
  36. package/src/prompts/tools/hashline.md +139 -83
  37. package/src/sdk.ts +22 -14
  38. package/src/session/agent-session.ts +259 -117
  39. package/src/session/agent-storage.ts +14 -14
  40. package/src/session/compaction/compaction.ts +500 -13
  41. package/src/session/messages.ts +12 -1
  42. package/src/session/session-manager.ts +77 -19
  43. package/src/slash-commands/builtin-registry.ts +48 -0
  44. package/src/task/agents.ts +3 -2
  45. package/src/task/executor.ts +2 -2
  46. package/src/task/types.ts +2 -1
  47. package/src/thinking.ts +87 -0
  48. package/src/tools/browser.ts +15 -6
  49. package/src/tools/fetch.ts +118 -100
  50. package/src/web/search/providers/exa.ts +74 -3
@@ -24,15 +24,17 @@ import {
24
24
  type AgentState,
25
25
  type AgentTool,
26
26
  INTENT_FIELD,
27
+ ThinkingLevel,
27
28
  } from "@oh-my-pi/pi-agent-core";
28
29
  import type {
29
30
  AssistantMessage,
31
+ Effort,
30
32
  ImageContent,
31
33
  Message,
32
34
  Model,
33
35
  ProviderSessionState,
36
+ ServiceTier,
34
37
  TextContent,
35
- ThinkingLevel,
36
38
  ToolCall,
37
39
  ToolChoice,
38
40
  Usage,
@@ -40,11 +42,10 @@ import type {
40
42
  } from "@oh-my-pi/pi-ai";
41
43
  import {
42
44
  calculateRateLimitBackoffMs,
43
- getAvailableThinkingLevels,
45
+ getSupportedEfforts,
44
46
  isContextOverflow,
45
47
  modelsAreEqual,
46
48
  parseRateLimitReason,
47
- supportsXhigh,
48
49
  } from "@oh-my-pi/pi-ai";
49
50
  import { abortableSleep, getAgentDbPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
50
51
  import type { AsyncJob, AsyncJobManager } from "../async";
@@ -87,6 +88,7 @@ import { executePython as executePythonCommand, type PythonResult } from "../ipy
87
88
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
88
89
  import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../patch";
89
90
  import type { PlanModeState } from "../plan-mode/state";
91
+ import autoHandoffThresholdFocusPrompt from "../prompts/system/auto-handoff-threshold-focus.md" with { type: "text" };
90
92
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
91
93
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
92
94
  import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool-decision-reminder.md" with {
@@ -94,6 +96,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
94
96
  };
95
97
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
96
98
  import type { SecretObfuscator } from "../secrets/obfuscator";
99
+ import { resolveThinkingLevelForModel, toReasoningEffort } from "../thinking";
97
100
  import type { CheckpointState } from "../tools/checkpoint";
98
101
  import { outputMeta } from "../tools/output-meta";
99
102
  import { resolveToCwd } from "../tools/path-utils";
@@ -130,9 +133,10 @@ import { getLatestCompactionEntry } from "./session-manager";
130
133
  /** Session-specific events that extend the core AgentEvent */
131
134
  export type AgentSessionEvent =
132
135
  | AgentEvent
133
- | { type: "auto_compaction_start"; reason: "threshold" | "overflow" }
136
+ | { type: "auto_compaction_start"; reason: "threshold" | "overflow"; action: "context-full" | "handoff" }
134
137
  | {
135
138
  type: "auto_compaction_end";
139
+ action: "context-full" | "handoff";
136
140
  result: CompactionResult | undefined;
137
141
  aborted: boolean;
138
142
  willRetry: boolean;
@@ -163,7 +167,9 @@ export interface AgentSessionConfig {
163
167
  /** Async background jobs launched by tools */
164
168
  asyncJobManager?: AsyncJobManager;
165
169
  /** Models to cycle through with Ctrl+P (from --models flag) */
166
- scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;
170
+ scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
171
+ /** Initial session thinking selector. */
172
+ thinkingLevel?: ThinkingLevel;
167
173
  /** Prompt templates for expansion */
168
174
  promptTemplates?: PromptTemplate[];
169
175
  /** File-based slash commands for expansion */
@@ -205,12 +211,14 @@ export interface PromptOptions {
205
211
  toolChoice?: ToolChoice;
206
212
  /** Send as developer/system message instead of user. Providers that support it use the developer role; others fall back to user. */
207
213
  synthetic?: boolean;
214
+ /** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */
215
+ skipCompactionCheck?: boolean;
208
216
  }
209
217
 
210
218
  /** Result from cycleModel() */
211
219
  export interface ModelCycleResult {
212
220
  model: Model;
213
- thinkingLevel: ThinkingLevel;
221
+ thinkingLevel: ThinkingLevel | undefined;
214
222
  /** Whether cycling through scoped models (--models flag) or all available */
215
223
  isScoped: boolean;
216
224
  }
@@ -218,7 +226,7 @@ export interface ModelCycleResult {
218
226
  /** Result from cycleRoleModels() */
219
227
  export interface RoleModelCycleResult {
220
228
  model: Model;
221
- thinkingLevel: ThinkingLevel;
229
+ thinkingLevel: ThinkingLevel | undefined;
222
230
  role: ModelRole;
223
231
  }
224
232
 
@@ -245,6 +253,12 @@ export interface SessionStats {
245
253
  /** Result from handoff() */
246
254
  export interface HandoffResult {
247
255
  document: string;
256
+ savedPath?: string;
257
+ }
258
+
259
+ interface HandoffOptions {
260
+ autoTriggered?: boolean;
261
+ signal?: AbortSignal;
248
262
  }
249
263
 
250
264
  /** Internal marker for hook messages queued through the agent loop */
@@ -254,6 +268,8 @@ export interface HandoffResult {
254
268
 
255
269
  /** Standard thinking levels */
256
270
 
271
+ const AUTO_HANDOFF_THRESHOLD_FOCUS = renderPromptTemplate(autoHandoffThresholdFocusPrompt);
272
+
257
273
  const noOpUIContext: ExtensionUIContext = {
258
274
  select: async (_title, _options, _dialogOptions) => undefined,
259
275
  confirm: async (_title, _message, _dialogOptions) => false,
@@ -292,7 +308,8 @@ export class AgentSession {
292
308
  readonly settings: Settings;
293
309
 
294
310
  #asyncJobManager: AsyncJobManager | undefined = undefined;
295
- #scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;
311
+ #scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
312
+ #thinkingLevel: ThinkingLevel | undefined;
296
313
  #promptTemplates: PromptTemplate[];
297
314
  #slashCommands: FileSlashCommand[];
298
315
 
@@ -393,6 +410,7 @@ export class AgentSession {
393
410
  this.settings = config.settings;
394
411
  this.#asyncJobManager = config.asyncJobManager;
395
412
  this.#scopedModels = config.scopedModels ?? [];
413
+ this.#thinkingLevel = config.thinkingLevel;
396
414
  this.#promptTemplates = config.promptTemplates ?? [];
397
415
  this.#slashCommands = config.slashCommands ?? [];
398
416
  this.#extensionRunner = config.extensionRunner;
@@ -1395,10 +1413,15 @@ export class AgentSession {
1395
1413
  };
1396
1414
  await this.#extensionRunner.emit(extensionEvent);
1397
1415
  } else if (event.type === "auto_compaction_start") {
1398
- await this.#extensionRunner.emit({ type: "auto_compaction_start", reason: event.reason });
1416
+ await this.#extensionRunner.emit({
1417
+ type: "auto_compaction_start",
1418
+ reason: event.reason,
1419
+ action: event.action,
1420
+ });
1399
1421
  } else if (event.type === "auto_compaction_end") {
1400
1422
  await this.#extensionRunner.emit({
1401
1423
  type: "auto_compaction_end",
1424
+ action: event.action,
1402
1425
  result: event.result,
1403
1426
  aborted: event.aborted,
1404
1427
  willRetry: event.willRetry,
@@ -1526,8 +1549,12 @@ export class AgentSession {
1526
1549
  }
1527
1550
 
1528
1551
  /** Current thinking level */
1529
- get thinkingLevel(): ThinkingLevel {
1530
- return this.agent.state.thinkingLevel;
1552
+ get thinkingLevel(): ThinkingLevel | undefined {
1553
+ return this.#thinkingLevel;
1554
+ }
1555
+
1556
+ get serviceTier(): ServiceTier | undefined {
1557
+ return this.agent.serviceTier;
1531
1558
  }
1532
1559
 
1533
1560
  /** Whether agent is currently streaming a response */
@@ -1702,7 +1729,7 @@ export class AgentSession {
1702
1729
  }
1703
1730
 
1704
1731
  /** Scoped models for cycling (from --models flag) */
1705
- get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel: ThinkingLevel }> {
1732
+ get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel }> {
1706
1733
  return this.#scopedModels;
1707
1734
  }
1708
1735
 
@@ -1967,7 +1994,9 @@ export class AgentSession {
1967
1994
  async #promptWithMessage(
1968
1995
  message: AgentMessage,
1969
1996
  expandedText: string,
1970
- options?: Pick<PromptOptions, "toolChoice" | "images"> & { skipPostPromptRecoveryWait?: boolean },
1997
+ options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
1998
+ skipPostPromptRecoveryWait?: boolean;
1999
+ },
1971
2000
  ): Promise<void> {
1972
2001
  this.#promptInFlight = true;
1973
2002
  const generation = this.#promptGeneration;
@@ -1999,7 +2028,7 @@ export class AgentSession {
1999
2028
 
2000
2029
  // Check if we need to compact before sending (catches aborted responses)
2001
2030
  const lastAssistant = this.#findLastAssistantMessage();
2002
- if (lastAssistant) {
2031
+ if (lastAssistant && !options?.skipCompactionCheck) {
2003
2032
  await this.#checkCompaction(lastAssistant, false);
2004
2033
  }
2005
2034
 
@@ -2520,6 +2549,7 @@ export class AgentSession {
2520
2549
  this.#pendingNextTurnMessages = [];
2521
2550
 
2522
2551
  this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
2552
+ this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
2523
2553
 
2524
2554
  this.#todoReminderCount = 0;
2525
2555
  this.#planReferenceSent = false;
@@ -2629,7 +2659,7 @@ export class AgentSession {
2629
2659
  this.settings.setModelRole(role, this.#formatRoleModelValue(role, model));
2630
2660
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
2631
2661
 
2632
- // Re-clamp thinking level for new model's capabilities without persisting settings
2662
+ // Re-apply the current thinking level for the newly selected model
2633
2663
  this.setThinkingLevel(this.thinkingLevel);
2634
2664
  }
2635
2665
 
@@ -2648,7 +2678,7 @@ export class AgentSession {
2648
2678
  this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
2649
2679
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
2650
2680
 
2651
- // Re-clamp thinking level for new model's capabilities without persisting settings
2681
+ // Re-apply the current thinking level for the newly selected model
2652
2682
  this.setThinkingLevel(this.thinkingLevel);
2653
2683
  }
2654
2684
 
@@ -2733,9 +2763,9 @@ export class AgentSession {
2733
2763
  return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
2734
2764
  }
2735
2765
 
2736
- async #getScopedModelsWithApiKey(): Promise<Array<{ model: Model; thinkingLevel: ThinkingLevel }>> {
2766
+ async #getScopedModelsWithApiKey(): Promise<Array<{ model: Model; thinkingLevel?: ThinkingLevel }>> {
2737
2767
  const apiKeysByProvider = new Map<string, string | undefined>();
2738
- const result: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];
2768
+ const result: Array<{ model: Model; thinkingLevel?: ThinkingLevel }> = [];
2739
2769
 
2740
2770
  for (const scoped of this.#scopedModels) {
2741
2771
  const provider = scoped.model.provider;
@@ -2773,7 +2803,7 @@ export class AgentSession {
2773
2803
  this.settings.setModelRole("default", this.#formatRoleModelValue("default", next.model));
2774
2804
  this.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`);
2775
2805
 
2776
- // Apply thinking level (setThinkingLevel clamps to model capabilities)
2806
+ // Apply the scoped model's configured thinking level
2777
2807
  this.setThinkingLevel(next.thinkingLevel);
2778
2808
 
2779
2809
  return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
@@ -2801,7 +2831,7 @@ export class AgentSession {
2801
2831
  this.settings.setModelRole("default", this.#formatRoleModelValue("default", nextModel));
2802
2832
  this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
2803
2833
 
2804
- // Re-clamp thinking level for new model's capabilities without persisting settings
2834
+ // Re-apply the current thinking level for the newly selected model
2805
2835
  this.setThinkingLevel(this.thinkingLevel);
2806
2836
 
2807
2837
  return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
@@ -2820,21 +2850,18 @@ export class AgentSession {
2820
2850
 
2821
2851
  /**
2822
2852
  * Set thinking level.
2823
- * Clamps to model capabilities based on available thinking levels.
2824
- * Saves to session and settings only if the level actually changes.
2853
+ * Saves the effective metadata-clamped level to session and settings only if it changes.
2825
2854
  */
2826
- setThinkingLevel(level: ThinkingLevel, persist: boolean = false): void {
2827
- const availableLevels = this.getAvailableThinkingLevels();
2828
- const effectiveLevel = availableLevels.includes(level) ? level : this.#clampThinkingLevel(level, availableLevels);
2829
-
2830
- // Only persist if actually changing
2831
- const isChanging = effectiveLevel !== this.agent.state.thinkingLevel;
2855
+ setThinkingLevel(level: ThinkingLevel | undefined, persist: boolean = false): void {
2856
+ const effectiveLevel = resolveThinkingLevelForModel(this.model, level);
2857
+ const isChanging = effectiveLevel !== this.#thinkingLevel;
2832
2858
 
2833
- this.agent.setThinkingLevel(effectiveLevel);
2859
+ this.#thinkingLevel = effectiveLevel;
2860
+ this.agent.setThinkingLevel(toReasoningEffort(effectiveLevel));
2834
2861
 
2835
2862
  if (isChanging) {
2836
2863
  this.sessionManager.appendThinkingLevelChange(effectiveLevel);
2837
- if (persist) {
2864
+ if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) {
2838
2865
  this.settings.set("defaultThinkingLevel", effectiveLevel);
2839
2866
  }
2840
2867
  }
@@ -2844,57 +2871,48 @@ export class AgentSession {
2844
2871
  * Cycle to next thinking level.
2845
2872
  * @returns New level, or undefined if model doesn't support thinking
2846
2873
  */
2847
- cycleThinkingLevel(): ThinkingLevel | undefined {
2848
- if (!this.supportsThinking()) return undefined;
2874
+ cycleThinkingLevel(): Effort | undefined {
2875
+ if (!this.model?.reasoning) return undefined;
2849
2876
 
2850
2877
  const levels = this.getAvailableThinkingLevels();
2851
- const currentIndex = levels.indexOf(this.thinkingLevel);
2878
+ const currentIndex =
2879
+ this.thinkingLevel && this.thinkingLevel !== ThinkingLevel.Off && this.thinkingLevel !== ThinkingLevel.Inherit
2880
+ ? levels.indexOf(this.thinkingLevel)
2881
+ : -1;
2852
2882
  const nextIndex = (currentIndex + 1) % levels.length;
2853
2883
  const nextLevel = levels[nextIndex];
2884
+ if (!nextLevel) return undefined;
2854
2885
 
2855
2886
  this.setThinkingLevel(nextLevel);
2856
2887
  return nextLevel;
2857
2888
  }
2858
2889
 
2859
- /**
2860
- * Get available thinking levels for current model.
2861
- * The provider will clamp to what the specific model supports internally.
2862
- */
2863
- getAvailableThinkingLevels(): ReadonlyArray<ThinkingLevel> {
2864
- if (!this.supportsThinking()) return ["off"];
2865
- return getAvailableThinkingLevels(this.supportsXhighThinking());
2890
+ isFastModeEnabled(): boolean {
2891
+ return this.serviceTier === "priority";
2866
2892
  }
2867
2893
 
2868
- /**
2869
- * Check if current model supports xhigh thinking level.
2870
- */
2871
- supportsXhighThinking(): boolean {
2872
- return this.model ? supportsXhigh(this.model) : false;
2894
+ setServiceTier(serviceTier: ServiceTier | undefined): void {
2895
+ if (this.serviceTier === serviceTier) return;
2896
+ this.agent.serviceTier = serviceTier;
2897
+ this.sessionManager.appendServiceTierChange(serviceTier ?? null);
2873
2898
  }
2874
2899
 
2875
- /**
2876
- * Check if current model supports thinking/reasoning.
2877
- */
2878
- supportsThinking(): boolean {
2879
- return !!this.model?.reasoning;
2900
+ setFastMode(enabled: boolean): void {
2901
+ this.setServiceTier(enabled ? "priority" : undefined);
2880
2902
  }
2881
2903
 
2882
- #clampThinkingLevel(level: ThinkingLevel, availableLevels: ReadonlyArray<ThinkingLevel>): ThinkingLevel {
2883
- const ordered = getAvailableThinkingLevels(true);
2884
- const available = new Set(availableLevels);
2885
- const requestedIndex = ordered.indexOf(level);
2886
- if (requestedIndex === -1) {
2887
- return availableLevels[0] ?? "off";
2888
- }
2889
- for (let i = requestedIndex; i < ordered.length; i++) {
2890
- const candidate = ordered[i];
2891
- if (available.has(candidate)) return candidate;
2892
- }
2893
- for (let i = requestedIndex - 1; i >= 0; i--) {
2894
- const candidate = ordered[i];
2895
- if (available.has(candidate)) return candidate;
2896
- }
2897
- return availableLevels[0] ?? "off";
2904
+ toggleFastMode(): boolean {
2905
+ const enabled = !this.isFastModeEnabled();
2906
+ this.setFastMode(enabled);
2907
+ return enabled;
2908
+ }
2909
+
2910
+ /**
2911
+ * Get available thinking levels for current model.
2912
+ */
2913
+ getAvailableThinkingLevels(): ReadonlyArray<Effort> {
2914
+ if (!this.model) return [];
2915
+ return getSupportedEfforts(this.model);
2898
2916
  }
2899
2917
 
2900
2918
  // =========================================================================
@@ -3042,13 +3060,14 @@ export class AgentSession {
3042
3060
  apiKey,
3043
3061
  customInstructions,
3044
3062
  this.#compactionAbortController.signal,
3045
- { promptOverride: hookPrompt, extraContext: hookContext },
3063
+ { promptOverride: hookPrompt, extraContext: hookContext, remoteInstructions: this.#baseSystemPrompt },
3046
3064
  );
3047
3065
  summary = result.summary;
3048
3066
  shortSummary = result.shortSummary;
3049
3067
  firstKeptEntryId = result.firstKeptEntryId;
3050
3068
  tokensBefore = result.tokensBefore;
3051
3069
  details = result.details;
3070
+ preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) };
3052
3071
  }
3053
3072
 
3054
3073
  if (this.#compactionAbortController.signal.aborted) {
@@ -3104,11 +3123,12 @@ export class AgentSession {
3104
3123
  }
3105
3124
 
3106
3125
  /**
3107
- * Cancel in-progress compaction (manual or auto).
3126
+ * Cancel in-progress context maintenance (manual compaction, auto-compaction, or auto-handoff).
3108
3127
  */
3109
3128
  abortCompaction(): void {
3110
3129
  this.#compactionAbortController?.abort();
3111
3130
  this.#autoCompactionAbortController?.abort();
3131
+ this.#handoffAbortController?.abort();
3112
3132
  }
3113
3133
 
3114
3134
  /**
@@ -3139,9 +3159,10 @@ export class AgentSession {
3139
3159
  * waits for completion, then starts a fresh session with the handoff as context.
3140
3160
  *
3141
3161
  * @param customInstructions Optional focus for the handoff document
3162
+ * @param options Handoff execution options
3142
3163
  * @returns The handoff document text, or undefined if cancelled/failed
3143
3164
  */
3144
- async handoff(customInstructions?: string): Promise<HandoffResult | undefined> {
3165
+ async handoff(customInstructions?: string, options?: HandoffOptions): Promise<HandoffResult | undefined> {
3145
3166
  const entries = this.sessionManager.getBranch();
3146
3167
  const messageCount = entries.filter(e => e.type === "message").length;
3147
3168
 
@@ -3152,6 +3173,24 @@ export class AgentSession {
3152
3173
  this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
3153
3174
 
3154
3175
  this.#handoffAbortController = new AbortController();
3176
+ const handoffAbortController = this.#handoffAbortController;
3177
+ const handoffSignal = handoffAbortController.signal;
3178
+ const sourceSignal = options?.signal;
3179
+ const onHandoffAbort = () => {
3180
+ this.agent.abort();
3181
+ };
3182
+ handoffSignal.addEventListener("abort", onHandoffAbort, { once: true });
3183
+ const onSourceAbort = () => {
3184
+ if (!handoffSignal.aborted) {
3185
+ handoffAbortController.abort();
3186
+ }
3187
+ };
3188
+ if (sourceSignal) {
3189
+ sourceSignal.addEventListener("abort", onSourceAbort, { once: true });
3190
+ if (sourceSignal.aborted) {
3191
+ onSourceAbort();
3192
+ }
3193
+ }
3155
3194
 
3156
3195
  // Build the handoff prompt
3157
3196
  let handoffPrompt = `Write a comprehensive handoff document that will allow another instance of yourself to seamlessly continue this work. The document should capture everything needed to resume without access to this conversation.
@@ -3192,42 +3231,58 @@ Be thorough - include exact file paths, function names, error messages, and tech
3192
3231
 
3193
3232
  // Create a promise that resolves when the agent completes
3194
3233
  let handoffText: string | undefined;
3195
- const completionPromise = new Promise<void>((resolve, reject) => {
3196
- const unsubscribe = this.subscribe(event => {
3197
- if (this.#handoffAbortController?.signal.aborted) {
3198
- unsubscribe();
3199
- reject(new Error("Handoff cancelled"));
3200
- return;
3201
- }
3202
-
3203
- if (event.type === "agent_end") {
3204
- unsubscribe();
3205
- // Extract text from the last assistant message
3206
- const messages = this.agent.state.messages;
3207
- for (let i = messages.length - 1; i >= 0; i--) {
3208
- const msg = messages[i];
3209
- if (msg.role === "assistant") {
3210
- const content = (msg as AssistantMessage).content;
3211
- const textParts = content
3212
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
3213
- .map(c => c.text);
3214
- if (textParts.length > 0) {
3215
- handoffText = textParts.join("\n");
3216
- break;
3217
- }
3234
+ const { promise: completionPromise, resolve: resolveCompletion } = Promise.withResolvers<void>();
3235
+ let handoffCancelled = false;
3236
+ let unsubscribe: (() => void) | undefined;
3237
+ const onCompletionAbort = () => {
3238
+ unsubscribe?.();
3239
+ handoffCancelled = true;
3240
+ resolveCompletion();
3241
+ };
3242
+ if (handoffSignal.aborted) {
3243
+ onCompletionAbort();
3244
+ } else {
3245
+ handoffSignal.addEventListener("abort", onCompletionAbort, { once: true });
3246
+ }
3247
+ unsubscribe = this.subscribe(event => {
3248
+ if (event.type === "agent_end") {
3249
+ unsubscribe?.();
3250
+ handoffSignal.removeEventListener("abort", onCompletionAbort);
3251
+ // Extract text from the last assistant message
3252
+ const messages = this.agent.state.messages;
3253
+ for (let i = messages.length - 1; i >= 0; i--) {
3254
+ const msg = messages[i];
3255
+ if (msg.role === "assistant") {
3256
+ const content = (msg as AssistantMessage).content;
3257
+ const textParts = content
3258
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
3259
+ .map(c => c.text);
3260
+ if (textParts.length > 0) {
3261
+ handoffText = textParts.join("\n");
3262
+ break;
3218
3263
  }
3219
3264
  }
3220
- resolve();
3221
3265
  }
3222
- });
3266
+ resolveCompletion();
3267
+ }
3223
3268
  });
3224
3269
 
3225
3270
  try {
3226
3271
  // Send the prompt and wait for completion
3227
- await this.prompt(handoffPrompt, { expandPromptTemplates: false, synthetic: true });
3272
+ if (handoffSignal.aborted) {
3273
+ throw new Error("Handoff cancelled");
3274
+ }
3275
+ await this.prompt(handoffPrompt, {
3276
+ expandPromptTemplates: false,
3277
+ synthetic: true,
3278
+ skipCompactionCheck: true,
3279
+ });
3228
3280
  await completionPromise;
3229
3281
 
3230
- if (!handoffText || this.#handoffAbortController.signal.aborted) {
3282
+ if (handoffCancelled || handoffSignal.aborted) {
3283
+ throw new Error("Handoff cancelled");
3284
+ }
3285
+ if (!handoffText) {
3231
3286
  return undefined;
3232
3287
  }
3233
3288
 
@@ -3245,26 +3300,49 @@ Be thorough - include exact file paths, function names, error messages, and tech
3245
3300
  // Inject the handoff document as a custom message
3246
3301
  const handoffContent = `<handoff-context>\n${handoffText}\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`;
3247
3302
  this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent");
3303
+ let savedPath: string | undefined;
3304
+ if (options?.autoTriggered && this.settings.get("compaction.handoffSaveToDisk")) {
3305
+ const artifactsDir = this.sessionManager.getArtifactsDir();
3306
+ if (artifactsDir) {
3307
+ const fileTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
3308
+ const handoffFilePath = path.join(artifactsDir, `handoff-${fileTimestamp}.md`);
3309
+ try {
3310
+ await Bun.write(handoffFilePath, `${handoffText}\n`);
3311
+ savedPath = handoffFilePath;
3312
+ } catch (error) {
3313
+ logger.warn("Failed to save handoff document to disk", {
3314
+ path: handoffFilePath,
3315
+ error: error instanceof Error ? error.message : String(error),
3316
+ });
3317
+ }
3318
+ } else {
3319
+ logger.debug("Skipping handoff document save because session is not persisted");
3320
+ }
3321
+ }
3248
3322
 
3249
3323
  // Rebuild agent messages from session
3250
3324
  const sessionContext = this.sessionManager.buildSessionContext();
3251
3325
  this.agent.replaceMessages(sessionContext.messages);
3252
3326
  this.#syncTodoPhasesFromBranch();
3253
3327
 
3254
- return { document: handoffText };
3328
+ return { document: handoffText, savedPath };
3255
3329
  } finally {
3330
+ unsubscribe?.();
3331
+ handoffSignal.removeEventListener("abort", onCompletionAbort);
3332
+ handoffSignal.removeEventListener("abort", onHandoffAbort);
3333
+ sourceSignal?.removeEventListener("abort", onSourceAbort);
3256
3334
  this.#handoffAbortController = undefined;
3257
3335
  }
3258
3336
  }
3259
3337
 
3260
3338
  /**
3261
- * Check if compaction or context promotion is needed and run it.
3339
+ * Check if context maintenance or promotion is needed and run it.
3262
3340
  * Called after agent_end and before prompt submission.
3263
3341
  *
3264
3342
  * Three cases (in order):
3265
- * 1. Overflow + promotion: promote to larger model, retry without compacting
3266
- * 2. Overflow + no promotion target: compact, auto-retry on same model
3267
- * 3. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)
3343
+ * 1. Overflow + promotion: promote to larger model, retry without maintenance
3344
+ * 2. Overflow + no promotion target: run context maintenance, auto-retry on same model
3345
+ * 3. Threshold: Context over threshold, run context maintenance (no auto-retry)
3268
3346
  *
3269
3347
  * @param assistantMessage The assistant message to check
3270
3348
  * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
@@ -3305,13 +3383,13 @@ Be thorough - include exact file paths, function names, error messages, and tech
3305
3383
 
3306
3384
  // No promotion target available fall through to compaction
3307
3385
  const compactionSettings = this.settings.getGroup("compaction");
3308
- if (compactionSettings.enabled) {
3386
+ if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
3309
3387
  await this.#runAutoCompaction("overflow", true);
3310
3388
  }
3311
3389
  return;
3312
3390
  }
3313
3391
  const compactionSettings = this.settings.getGroup("compaction");
3314
- if (!compactionSettings.enabled) return;
3392
+ if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
3315
3393
 
3316
3394
  // Case 2: Threshold - turn succeeded but context is getting large
3317
3395
  // Skip if this was an error (non-overflow errors don't have usage data)
@@ -3654,8 +3732,11 @@ Be thorough - include exact file paths, function names, error messages, and tech
3654
3732
  async #runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<void> {
3655
3733
  const compactionSettings = this.settings.getGroup("compaction");
3656
3734
 
3735
+ if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
3657
3736
  const generation = this.#promptGeneration;
3658
- await this.#emitSessionEvent({ type: "auto_compaction_start", reason });
3737
+ let action: "context-full" | "handoff" =
3738
+ compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
3739
+ await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
3659
3740
  // Properly abort and null existing controller before replacing
3660
3741
  if (this.#autoCompactionAbortController) {
3661
3742
  this.#autoCompactionAbortController.abort();
@@ -3663,9 +3744,45 @@ Be thorough - include exact file paths, function names, error messages, and tech
3663
3744
  this.#autoCompactionAbortController = new AbortController();
3664
3745
 
3665
3746
  try {
3747
+ if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
3748
+ const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
3749
+ const handoffResult = await this.handoff(handoffFocus, {
3750
+ autoTriggered: true,
3751
+ signal: this.#autoCompactionAbortController.signal,
3752
+ });
3753
+ if (!handoffResult) {
3754
+ const aborted = this.#autoCompactionAbortController.signal.aborted;
3755
+ if (aborted) {
3756
+ await this.#emitSessionEvent({
3757
+ type: "auto_compaction_end",
3758
+ action,
3759
+ result: undefined,
3760
+ aborted: true,
3761
+ willRetry: false,
3762
+ });
3763
+ return;
3764
+ }
3765
+ logger.warn("Auto-handoff returned no document; falling back to context-full maintenance", {
3766
+ reason,
3767
+ });
3768
+ action = "context-full";
3769
+ }
3770
+ if (handoffResult) {
3771
+ await this.#emitSessionEvent({
3772
+ type: "auto_compaction_end",
3773
+ action,
3774
+ result: undefined,
3775
+ aborted: false,
3776
+ willRetry: false,
3777
+ });
3778
+ return;
3779
+ }
3780
+ }
3781
+
3666
3782
  if (!this.model) {
3667
3783
  await this.#emitSessionEvent({
3668
3784
  type: "auto_compaction_end",
3785
+ action,
3669
3786
  result: undefined,
3670
3787
  aborted: false,
3671
3788
  willRetry: false,
@@ -3677,6 +3794,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3677
3794
  if (availableModels.length === 0) {
3678
3795
  await this.#emitSessionEvent({
3679
3796
  type: "auto_compaction_end",
3797
+ action,
3680
3798
  result: undefined,
3681
3799
  aborted: false,
3682
3800
  willRetry: false,
@@ -3690,10 +3808,18 @@ Be thorough - include exact file paths, function names, error messages, and tech
3690
3808
  if (!preparation) {
3691
3809
  await this.#emitSessionEvent({
3692
3810
  type: "auto_compaction_end",
3811
+ action,
3693
3812
  result: undefined,
3694
3813
  aborted: false,
3695
3814
  willRetry: false,
3696
3815
  });
3816
+ if (!willRetry && this.agent.hasQueuedMessages()) {
3817
+ this.#scheduleAgentContinue({
3818
+ delayMs: 100,
3819
+ generation,
3820
+ shouldContinue: () => this.agent.hasQueuedMessages(),
3821
+ });
3822
+ }
3697
3823
  return;
3698
3824
  }
3699
3825
 
@@ -3715,6 +3841,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3715
3841
  if (hookResult?.cancel) {
3716
3842
  await this.#emitSessionEvent({
3717
3843
  type: "auto_compaction_end",
3844
+ action,
3718
3845
  result: undefined,
3719
3846
  aborted: true,
3720
3847
  willRetry: false,
@@ -3774,7 +3901,11 @@ Be thorough - include exact file paths, function names, error messages, and tech
3774
3901
  apiKey,
3775
3902
  undefined,
3776
3903
  this.#autoCompactionAbortController.signal,
3777
- { promptOverride: hookPrompt, extraContext: hookContext },
3904
+ {
3905
+ promptOverride: hookPrompt,
3906
+ extraContext: hookContext,
3907
+ remoteInstructions: this.#baseSystemPrompt,
3908
+ },
3778
3909
  );
3779
3910
  break;
3780
3911
  } catch (error) {
@@ -3843,11 +3974,13 @@ Be thorough - include exact file paths, function names, error messages, and tech
3843
3974
  firstKeptEntryId = compactResult.firstKeptEntryId;
3844
3975
  tokensBefore = compactResult.tokensBefore;
3845
3976
  details = compactResult.details;
3977
+ preserveData = { ...(preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
3846
3978
  }
3847
3979
 
3848
3980
  if (this.#autoCompactionAbortController.signal.aborted) {
3849
3981
  await this.#emitSessionEvent({
3850
3982
  type: "auto_compaction_end",
3983
+ action,
3851
3984
  result: undefined,
3852
3985
  aborted: true,
3853
3986
  willRetry: false,
@@ -3891,7 +4024,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3891
4024
  details,
3892
4025
  preserveData,
3893
4026
  };
3894
- await this.#emitSessionEvent({ type: "auto_compaction_end", result, aborted: false, willRetry });
4027
+ await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
3895
4028
 
3896
4029
  if (!willRetry && compactionSettings.autoContinue !== false) {
3897
4030
  await this.#promptWithMessage(
@@ -3927,6 +4060,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3927
4060
  if (this.#autoCompactionAbortController?.signal.aborted) {
3928
4061
  await this.#emitSessionEvent({
3929
4062
  type: "auto_compaction_end",
4063
+ action,
3930
4064
  result: undefined,
3931
4065
  aborted: true,
3932
4066
  willRetry: false,
@@ -3936,6 +4070,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3936
4070
  const errorMessage = error instanceof Error ? error.message : "compaction failed";
3937
4071
  await this.#emitSessionEvent({
3938
4072
  type: "auto_compaction_end",
4073
+ action,
3939
4074
  result: undefined,
3940
4075
  aborted: false,
3941
4076
  willRetry: false,
@@ -3954,11 +4089,14 @@ Be thorough - include exact file paths, function names, error messages, and tech
3954
4089
  */
3955
4090
  setAutoCompactionEnabled(enabled: boolean): void {
3956
4091
  this.settings.set("compaction.enabled", enabled);
4092
+ if (enabled && this.settings.get("compaction.strategy") === "off") {
4093
+ this.settings.set("compaction.strategy", "context-full");
4094
+ }
3957
4095
  }
3958
4096
 
3959
4097
  /** Whether auto-compaction is enabled */
3960
4098
  get autoCompactionEnabled(): boolean {
3961
- return this.settings.get("compaction.enabled");
4099
+ return this.settings.get("compaction.enabled") && this.settings.get("compaction.strategy") !== "off";
3962
4100
  }
3963
4101
 
3964
4102
  // =========================================================================
@@ -4488,18 +4626,22 @@ Be thorough - include exact file paths, function names, error messages, and tech
4488
4626
  }
4489
4627
 
4490
4628
  const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
4491
- const defaultThinkingLevel = (this.settings.get("defaultThinkingLevel") ?? "off") as ThinkingLevel;
4629
+ const hasServiceTierEntry = this.sessionManager.getBranch().some(entry => entry.type === "service_tier_change");
4630
+ const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
4492
4631
 
4493
4632
  if (hasThinkingEntry) {
4494
- // Restore thinking level if saved (setThinkingLevel clamps to model capabilities)
4495
- this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel);
4633
+ this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel | undefined);
4496
4634
  } else {
4497
- const availableLevels = this.getAvailableThinkingLevels();
4498
- const effectiveLevel = availableLevels.includes(defaultThinkingLevel)
4499
- ? defaultThinkingLevel
4500
- : this.#clampThinkingLevel(defaultThinkingLevel, availableLevels);
4501
- this.agent.setThinkingLevel(effectiveLevel);
4502
- this.sessionManager.appendThinkingLevelChange(effectiveLevel);
4635
+ const effectiveDefaultThinkingLevel = resolveThinkingLevelForModel(this.model, defaultThinkingLevel);
4636
+ this.#thinkingLevel = effectiveDefaultThinkingLevel;
4637
+ this.agent.setThinkingLevel(toReasoningEffort(effectiveDefaultThinkingLevel));
4638
+ this.sessionManager.appendThinkingLevelChange(effectiveDefaultThinkingLevel);
4639
+ }
4640
+
4641
+ if (hasServiceTierEntry) {
4642
+ this.agent.serviceTier = sessionContext.serviceTier;
4643
+ } else {
4644
+ this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
4503
4645
  }
4504
4646
 
4505
4647
  this.#reconnectToAgent();
@@ -5016,7 +5158,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
5016
5158
 
5017
5159
  // Include model and thinking level
5018
5160
  const model = this.agent.state.model;
5019
- const thinkingLevel = this.agent.state.thinkingLevel;
5161
+ const thinkingLevel = this.#thinkingLevel;
5020
5162
  lines.push("## Configuration\n");
5021
5163
  lines.push(`Model: ${model.provider}/${model.id}`);
5022
5164
  lines.push(`Thinking Level: ${thinkingLevel}`);