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

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