@mariozechner/pi-coding-agent 0.30.1 → 0.31.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.
Files changed (301) hide show
  1. package/CHANGELOG.md +251 -2
  2. package/README.md +105 -84
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +5 -1
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/file-processor.d.ts +3 -3
  7. package/dist/cli/file-processor.d.ts.map +1 -1
  8. package/dist/cli/file-processor.js +7 -10
  9. package/dist/cli/file-processor.js.map +1 -1
  10. package/dist/config.d.ts +9 -0
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +18 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/core/agent-session.d.ts +73 -34
  15. package/dist/core/agent-session.d.ts.map +1 -1
  16. package/dist/core/agent-session.js +464 -210
  17. package/dist/core/agent-session.js.map +1 -1
  18. package/dist/core/auth-storage.d.ts +2 -7
  19. package/dist/core/auth-storage.d.ts.map +1 -1
  20. package/dist/core/auth-storage.js +4 -52
  21. package/dist/core/auth-storage.js.map +1 -1
  22. package/dist/core/bash-executor.d.ts +2 -2
  23. package/dist/core/bash-executor.d.ts.map +1 -1
  24. package/dist/core/bash-executor.js +2 -2
  25. package/dist/core/bash-executor.js.map +1 -1
  26. package/dist/core/compaction/branch-summarization.d.ts +84 -0
  27. package/dist/core/compaction/branch-summarization.d.ts.map +1 -0
  28. package/dist/core/compaction/branch-summarization.js +233 -0
  29. package/dist/core/compaction/branch-summarization.js.map +1 -0
  30. package/dist/core/{compaction.d.ts → compaction/compaction.d.ts} +38 -19
  31. package/dist/core/compaction/compaction.d.ts.map +1 -0
  32. package/dist/core/compaction/compaction.js +558 -0
  33. package/dist/core/compaction/compaction.js.map +1 -0
  34. package/dist/core/compaction/index.d.ts +7 -0
  35. package/dist/core/compaction/index.d.ts.map +1 -0
  36. package/dist/core/compaction/index.js +7 -0
  37. package/dist/core/compaction/index.js.map +1 -0
  38. package/dist/core/compaction/utils.d.ts +35 -0
  39. package/dist/core/compaction/utils.d.ts.map +1 -0
  40. package/dist/core/compaction/utils.js +138 -0
  41. package/dist/core/compaction/utils.js.map +1 -0
  42. package/dist/core/custom-tools/index.d.ts +2 -1
  43. package/dist/core/custom-tools/index.d.ts.map +1 -1
  44. package/dist/core/custom-tools/index.js +1 -0
  45. package/dist/core/custom-tools/index.js.map +1 -1
  46. package/dist/core/custom-tools/loader.d.ts.map +1 -1
  47. package/dist/core/custom-tools/loader.js +13 -80
  48. package/dist/core/custom-tools/loader.js.map +1 -1
  49. package/dist/core/custom-tools/types.d.ts +84 -59
  50. package/dist/core/custom-tools/types.d.ts.map +1 -1
  51. package/dist/core/custom-tools/types.js.map +1 -1
  52. package/dist/core/custom-tools/wrapper.d.ts +15 -0
  53. package/dist/core/custom-tools/wrapper.d.ts.map +1 -0
  54. package/dist/core/custom-tools/wrapper.js +23 -0
  55. package/dist/core/custom-tools/wrapper.js.map +1 -0
  56. package/dist/core/exec.d.ts +29 -0
  57. package/dist/core/exec.d.ts.map +1 -0
  58. package/dist/core/exec.js +71 -0
  59. package/dist/core/exec.js.map +1 -0
  60. package/dist/core/export-html/index.d.ts +17 -0
  61. package/dist/core/export-html/index.d.ts.map +1 -0
  62. package/dist/core/export-html/index.js +171 -0
  63. package/dist/core/export-html/index.js.map +1 -0
  64. package/dist/core/export-html/template.css +781 -0
  65. package/dist/core/export-html/template.html +54 -0
  66. package/dist/core/export-html/template.js +1185 -0
  67. package/dist/core/export-html/vendor/highlight.min.js +1213 -0
  68. package/dist/core/export-html/vendor/marked.min.js +6 -0
  69. package/dist/core/hooks/index.d.ts +4 -4
  70. package/dist/core/hooks/index.d.ts.map +1 -1
  71. package/dist/core/hooks/index.js +3 -3
  72. package/dist/core/hooks/index.js.map +1 -1
  73. package/dist/core/hooks/loader.d.ts +40 -5
  74. package/dist/core/hooks/loader.d.ts.map +1 -1
  75. package/dist/core/hooks/loader.js +43 -10
  76. package/dist/core/hooks/loader.js.map +1 -1
  77. package/dist/core/hooks/runner.d.ts +94 -18
  78. package/dist/core/hooks/runner.d.ts.map +1 -1
  79. package/dist/core/hooks/runner.js +199 -120
  80. package/dist/core/hooks/runner.js.map +1 -1
  81. package/dist/core/hooks/tool-wrapper.d.ts +1 -1
  82. package/dist/core/hooks/tool-wrapper.d.ts.map +1 -1
  83. package/dist/core/hooks/tool-wrapper.js +36 -19
  84. package/dist/core/hooks/tool-wrapper.js.map +1 -1
  85. package/dist/core/hooks/types.d.ts +407 -96
  86. package/dist/core/hooks/types.d.ts.map +1 -1
  87. package/dist/core/hooks/types.js.map +1 -1
  88. package/dist/core/index.d.ts +4 -3
  89. package/dist/core/index.d.ts.map +1 -1
  90. package/dist/core/index.js.map +1 -1
  91. package/dist/core/messages.d.ts +44 -12
  92. package/dist/core/messages.d.ts.map +1 -1
  93. package/dist/core/messages.js +82 -34
  94. package/dist/core/messages.js.map +1 -1
  95. package/dist/core/model-registry.d.ts +5 -5
  96. package/dist/core/model-registry.d.ts.map +1 -1
  97. package/dist/core/model-registry.js +7 -7
  98. package/dist/core/model-registry.js.map +1 -1
  99. package/dist/core/model-resolver.d.ts +7 -7
  100. package/dist/core/model-resolver.d.ts.map +1 -1
  101. package/dist/core/model-resolver.js +45 -14
  102. package/dist/core/model-resolver.js.map +1 -1
  103. package/dist/core/sdk.d.ts +7 -10
  104. package/dist/core/sdk.d.ts.map +1 -1
  105. package/dist/core/sdk.js +88 -32
  106. package/dist/core/sdk.js.map +1 -1
  107. package/dist/core/session-manager.d.ts +202 -36
  108. package/dist/core/session-manager.d.ts.map +1 -1
  109. package/dist/core/session-manager.js +565 -133
  110. package/dist/core/session-manager.js.map +1 -1
  111. package/dist/core/settings-manager.d.ts +9 -3
  112. package/dist/core/settings-manager.d.ts.map +1 -1
  113. package/dist/core/settings-manager.js +13 -12
  114. package/dist/core/settings-manager.js.map +1 -1
  115. package/dist/core/system-prompt.d.ts.map +1 -1
  116. package/dist/core/system-prompt.js +6 -3
  117. package/dist/core/system-prompt.js.map +1 -1
  118. package/dist/core/tools/bash.d.ts +1 -1
  119. package/dist/core/tools/bash.d.ts.map +1 -1
  120. package/dist/core/tools/bash.js.map +1 -1
  121. package/dist/core/tools/edit-diff.d.ts +33 -0
  122. package/dist/core/tools/edit-diff.d.ts.map +1 -0
  123. package/dist/core/tools/edit-diff.js +171 -0
  124. package/dist/core/tools/edit-diff.js.map +1 -0
  125. package/dist/core/tools/edit.d.ts +7 -1
  126. package/dist/core/tools/edit.d.ts.map +1 -1
  127. package/dist/core/tools/edit.js +20 -95
  128. package/dist/core/tools/edit.js.map +1 -1
  129. package/dist/core/tools/find.d.ts +1 -1
  130. package/dist/core/tools/find.d.ts.map +1 -1
  131. package/dist/core/tools/find.js.map +1 -1
  132. package/dist/core/tools/grep.d.ts +1 -1
  133. package/dist/core/tools/grep.d.ts.map +1 -1
  134. package/dist/core/tools/grep.js.map +1 -1
  135. package/dist/core/tools/index.d.ts +1 -1
  136. package/dist/core/tools/index.d.ts.map +1 -1
  137. package/dist/core/tools/index.js.map +1 -1
  138. package/dist/core/tools/ls.d.ts +1 -1
  139. package/dist/core/tools/ls.d.ts.map +1 -1
  140. package/dist/core/tools/ls.js.map +1 -1
  141. package/dist/core/tools/read.d.ts +1 -1
  142. package/dist/core/tools/read.d.ts.map +1 -1
  143. package/dist/core/tools/read.js.map +1 -1
  144. package/dist/core/tools/write.d.ts +1 -1
  145. package/dist/core/tools/write.d.ts.map +1 -1
  146. package/dist/core/tools/write.js.map +1 -1
  147. package/dist/index.d.ts +8 -7
  148. package/dist/index.d.ts.map +1 -1
  149. package/dist/index.js +5 -5
  150. package/dist/index.js.map +1 -1
  151. package/dist/main.d.ts.map +1 -1
  152. package/dist/main.js +25 -25
  153. package/dist/main.js.map +1 -1
  154. package/dist/migrations.d.ts +28 -0
  155. package/dist/migrations.d.ts.map +1 -0
  156. package/dist/migrations.js +125 -0
  157. package/dist/migrations.js.map +1 -0
  158. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  159. package/dist/modes/interactive/components/assistant-message.js +3 -4
  160. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  161. package/dist/modes/interactive/components/bash-execution.d.ts +1 -1
  162. package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
  163. package/dist/modes/interactive/components/bash-execution.js +6 -2
  164. package/dist/modes/interactive/components/bash-execution.js.map +1 -1
  165. package/dist/modes/interactive/components/bordered-loader.d.ts +12 -0
  166. package/dist/modes/interactive/components/bordered-loader.d.ts.map +1 -0
  167. package/dist/modes/interactive/components/bordered-loader.js +30 -0
  168. package/dist/modes/interactive/components/bordered-loader.js.map +1 -0
  169. package/dist/modes/interactive/components/branch-summary-message.d.ts +14 -0
  170. package/dist/modes/interactive/components/branch-summary-message.d.ts.map +1 -0
  171. package/dist/modes/interactive/components/branch-summary-message.js +35 -0
  172. package/dist/modes/interactive/components/branch-summary-message.js.map +1 -0
  173. package/dist/modes/interactive/components/compaction-summary-message.d.ts +14 -0
  174. package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -0
  175. package/dist/modes/interactive/components/compaction-summary-message.js +36 -0
  176. package/dist/modes/interactive/components/compaction-summary-message.js.map +1 -0
  177. package/dist/modes/interactive/components/dynamic-border.d.ts +5 -1
  178. package/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  179. package/dist/modes/interactive/components/dynamic-border.js +5 -1
  180. package/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  181. package/dist/modes/interactive/components/footer.d.ts +12 -6
  182. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  183. package/dist/modes/interactive/components/footer.js +57 -25
  184. package/dist/modes/interactive/components/footer.js.map +1 -1
  185. package/dist/modes/interactive/components/hook-editor.d.ts +15 -0
  186. package/dist/modes/interactive/components/hook-editor.d.ts.map +1 -0
  187. package/dist/modes/interactive/components/hook-editor.js +95 -0
  188. package/dist/modes/interactive/components/hook-editor.js.map +1 -0
  189. package/dist/modes/interactive/components/hook-message.d.ts +18 -0
  190. package/dist/modes/interactive/components/hook-message.d.ts.map +1 -0
  191. package/dist/modes/interactive/components/hook-message.js +80 -0
  192. package/dist/modes/interactive/components/hook-message.js.map +1 -0
  193. package/dist/modes/interactive/components/model-selector.d.ts +3 -3
  194. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  195. package/dist/modes/interactive/components/model-selector.js +1 -1
  196. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  197. package/dist/modes/interactive/components/tool-execution.d.ts +15 -2
  198. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  199. package/dist/modes/interactive/components/tool-execution.js +70 -21
  200. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  201. package/dist/modes/interactive/components/tree-selector.d.ts +52 -0
  202. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -0
  203. package/dist/modes/interactive/components/tree-selector.js +745 -0
  204. package/dist/modes/interactive/components/tree-selector.js.map +1 -0
  205. package/dist/modes/interactive/components/user-message-selector.d.ts +3 -3
  206. package/dist/modes/interactive/components/user-message-selector.d.ts.map +1 -1
  207. package/dist/modes/interactive/components/user-message-selector.js +1 -1
  208. package/dist/modes/interactive/components/user-message-selector.js.map +1 -1
  209. package/dist/modes/interactive/components/user-message.d.ts +1 -1
  210. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  211. package/dist/modes/interactive/components/user-message.js +2 -5
  212. package/dist/modes/interactive/components/user-message.js.map +1 -1
  213. package/dist/modes/interactive/interactive-mode.d.ts +29 -12
  214. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  215. package/dist/modes/interactive/interactive-mode.js +589 -208
  216. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  217. package/dist/modes/interactive/theme/dark.json +13 -1
  218. package/dist/modes/interactive/theme/light.json +13 -1
  219. package/dist/modes/interactive/theme/theme-schema.json +34 -0
  220. package/dist/modes/interactive/theme/theme.d.ts +20 -2
  221. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  222. package/dist/modes/interactive/theme/theme.js +135 -2
  223. package/dist/modes/interactive/theme/theme.js.map +1 -1
  224. package/dist/modes/print-mode.d.ts +3 -3
  225. package/dist/modes/print-mode.d.ts.map +1 -1
  226. package/dist/modes/print-mode.js +26 -20
  227. package/dist/modes/print-mode.js.map +1 -1
  228. package/dist/modes/rpc/rpc-client.d.ts +13 -10
  229. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  230. package/dist/modes/rpc/rpc-client.js +11 -10
  231. package/dist/modes/rpc/rpc-client.js.map +1 -1
  232. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  233. package/dist/modes/rpc/rpc-mode.js +88 -35
  234. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  235. package/dist/modes/rpc/rpc-types.d.ts +30 -11
  236. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  237. package/dist/modes/rpc/rpc-types.js.map +1 -1
  238. package/dist/utils/shell.d.ts +4 -2
  239. package/dist/utils/shell.d.ts.map +1 -1
  240. package/dist/utils/shell.js +36 -7
  241. package/dist/utils/shell.js.map +1 -1
  242. package/dist/utils/tools-manager.d.ts +1 -1
  243. package/dist/utils/tools-manager.d.ts.map +1 -1
  244. package/dist/utils/tools-manager.js +2 -2
  245. package/dist/utils/tools-manager.js.map +1 -1
  246. package/docs/compaction.md +388 -0
  247. package/docs/custom-tools.md +146 -43
  248. package/docs/extension-loading.md +1004 -0
  249. package/docs/hooks.md +562 -596
  250. package/docs/rpc.md +33 -19
  251. package/docs/sdk.md +93 -21
  252. package/docs/session-tree-plan.md +441 -0
  253. package/docs/session.md +172 -21
  254. package/docs/skills.md +2 -0
  255. package/docs/theme.md +31 -2
  256. package/docs/tree.md +197 -0
  257. package/docs/tui.md +343 -0
  258. package/examples/README.md +1 -9
  259. package/examples/custom-tools/hello/index.ts +4 -3
  260. package/examples/custom-tools/question/index.ts +4 -4
  261. package/examples/custom-tools/subagent/index.ts +7 -6
  262. package/examples/custom-tools/todo/index.ts +11 -5
  263. package/examples/hooks/README.md +29 -71
  264. package/examples/hooks/auto-commit-on-exit.ts +8 -9
  265. package/examples/hooks/confirm-destructive.ts +29 -30
  266. package/examples/hooks/custom-compaction.ts +20 -21
  267. package/examples/hooks/dirty-repo-guard.ts +41 -40
  268. package/examples/hooks/file-trigger.ts +10 -5
  269. package/examples/hooks/git-checkpoint.ts +16 -12
  270. package/examples/hooks/handoff.ts +150 -0
  271. package/examples/hooks/permission-gate.ts +1 -1
  272. package/examples/hooks/protected-paths.ts +1 -1
  273. package/examples/hooks/qna.ts +119 -0
  274. package/examples/hooks/snake.ts +343 -0
  275. package/examples/hooks/status-line.ts +40 -0
  276. package/examples/sdk/01-minimal.ts +1 -1
  277. package/examples/sdk/02-custom-model.ts +1 -1
  278. package/examples/sdk/03-custom-prompt.ts +1 -1
  279. package/examples/sdk/04-skills.ts +1 -1
  280. package/examples/sdk/05-tools.ts +4 -4
  281. package/examples/sdk/06-hooks.ts +1 -1
  282. package/examples/sdk/07-context-files.ts +1 -1
  283. package/examples/sdk/08-slash-commands.ts +6 -1
  284. package/examples/sdk/09-api-keys-and-oauth.ts +1 -1
  285. package/examples/sdk/10-settings.ts +1 -1
  286. package/examples/sdk/11-sessions.ts +1 -1
  287. package/examples/sdk/12-full-control.ts +4 -7
  288. package/package.json +6 -6
  289. package/dist/core/compaction.d.ts.map +0 -1
  290. package/dist/core/compaction.js +0 -412
  291. package/dist/core/compaction.js.map +0 -1
  292. package/dist/core/export-html.d.ts +0 -23
  293. package/dist/core/export-html.d.ts.map +0 -1
  294. package/dist/core/export-html.js +0 -1185
  295. package/dist/core/export-html.js.map +0 -1
  296. package/dist/modes/interactive/components/compaction.d.ts +0 -15
  297. package/dist/modes/interactive/components/compaction.d.ts.map +0 -1
  298. package/dist/modes/interactive/components/compaction.js +0 -41
  299. package/dist/modes/interactive/components/compaction.js.map +0 -1
  300. package/docs/hooks-v2.md +0 -385
  301. package/docs/session-tree.md +0 -452
@@ -13,11 +13,12 @@
13
13
  * Modes use this class and add their own I/O layer on top.
14
14
  */
15
15
  import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
16
- import { getModelsPath } from "../config.js";
16
+ import { getAuthPath } from "../config.js";
17
17
  import { executeBash as executeBashCommand } from "./bash-executor.js";
18
- import { calculateContextTokens, compact, prepareCompaction, shouldCompact } from "./compaction.js";
19
- import { exportSessionToHtml } from "./export-html.js";
18
+ import { calculateContextTokens, collectEntriesForBranchSummary, compact, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js";
19
+ import { exportSessionToHtml } from "./export-html/index.js";
20
20
  import { expandSlashCommand } from "./slash-commands.js";
21
+ /** Internal marker for hook messages queued through the agent loop */
21
22
  // ============================================================================
22
23
  // Constants
23
24
  // ============================================================================
@@ -40,18 +41,20 @@ export class AgentSession {
40
41
  // Message queue state
41
42
  _queuedMessages = [];
42
43
  // Compaction state
43
- _compactionAbortController = null;
44
- _autoCompactionAbortController = null;
44
+ _compactionAbortController = undefined;
45
+ _autoCompactionAbortController = undefined;
46
+ // Branch summarization state
47
+ _branchSummaryAbortController = undefined;
45
48
  // Retry state
46
- _retryAbortController = null;
49
+ _retryAbortController = undefined;
47
50
  _retryAttempt = 0;
48
- _retryPromise = null;
49
- _retryResolve = null;
51
+ _retryPromise = undefined;
52
+ _retryResolve = undefined;
50
53
  // Bash execution state
51
- _bashAbortController = null;
54
+ _bashAbortController = undefined;
52
55
  _pendingBashMessages = [];
53
56
  // Hook system
54
- _hookRunner = null;
57
+ _hookRunner = undefined;
55
58
  _turnIndex = 0;
56
59
  // Custom tools for session lifecycle
57
60
  _customTools = [];
@@ -64,10 +67,13 @@ export class AgentSession {
64
67
  this.settingsManager = config.settingsManager;
65
68
  this._scopedModels = config.scopedModels ?? [];
66
69
  this._fileCommands = config.fileCommands ?? [];
67
- this._hookRunner = config.hookRunner ?? null;
70
+ this._hookRunner = config.hookRunner;
68
71
  this._customTools = config.customTools ?? [];
69
72
  this._skillsSettings = config.skillsSettings;
70
73
  this._modelRegistry = config.modelRegistry;
74
+ // Always subscribe to agent events for internal handling
75
+ // (session persistence, hooks, auto-compaction, retry logic)
76
+ this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
71
77
  }
72
78
  /** Model registry for API key resolution and model discovery */
73
79
  get modelRegistry() {
@@ -83,7 +89,7 @@ export class AgentSession {
83
89
  }
84
90
  }
85
91
  // Track last assistant message for auto-compaction check
86
- _lastAssistantMessage = null;
92
+ _lastAssistantMessage = undefined;
87
93
  /** Internal handler for agent events - shared by subscribe and reconnect */
88
94
  _handleAgentEvent = async (event) => {
89
95
  // When a user message starts, check if it's from the queue and remove it BEFORE emitting
@@ -105,7 +111,18 @@ export class AgentSession {
105
111
  this._emit(event);
106
112
  // Handle session persistence
107
113
  if (event.type === "message_end") {
108
- this.sessionManager.saveMessage(event.message);
114
+ // Check if this is a hook message
115
+ if (event.message.role === "hookMessage") {
116
+ // Persist as CustomMessageEntry
117
+ this.sessionManager.appendCustomMessageEntry(event.message.customType, event.message.content, event.message.display, event.message.details);
118
+ }
119
+ else if (event.message.role === "user" ||
120
+ event.message.role === "assistant" ||
121
+ event.message.role === "toolResult") {
122
+ // Regular LLM message - persist as SessionMessageEntry
123
+ this.sessionManager.appendMessage(event.message);
124
+ }
125
+ // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
109
126
  // Track assistant message for auto-compaction (checked on agent_end)
110
127
  if (event.message.role === "assistant") {
111
128
  this._lastAssistantMessage = event.message;
@@ -114,7 +131,7 @@ export class AgentSession {
114
131
  // Check auto-retry and auto-compaction after agent completes
115
132
  if (event.type === "agent_end" && this._lastAssistantMessage) {
116
133
  const msg = this._lastAssistantMessage;
117
- this._lastAssistantMessage = null;
134
+ this._lastAssistantMessage = undefined;
118
135
  // Check for retryable errors first (overloaded, rate limit, server errors)
119
136
  if (this._isRetryableError(msg)) {
120
137
  const didRetry = await this._handleRetryableError(msg);
@@ -139,8 +156,8 @@ export class AgentSession {
139
156
  _resolveRetry() {
140
157
  if (this._retryResolve) {
141
158
  this._retryResolve();
142
- this._retryResolve = null;
143
- this._retryPromise = null;
159
+ this._retryResolve = undefined;
160
+ this._retryPromise = undefined;
144
161
  }
145
162
  }
146
163
  /** Extract text content from a message */
@@ -162,7 +179,7 @@ export class AgentSession {
162
179
  return msg;
163
180
  }
164
181
  }
165
- return null;
182
+ return undefined;
166
183
  }
167
184
  /** Emit hook events based on agent events */
168
185
  async _emitHookEvent(event) {
@@ -201,10 +218,6 @@ export class AgentSession {
201
218
  */
202
219
  subscribe(listener) {
203
220
  this._eventListeners.push(listener);
204
- // Set up agent subscription if not already done
205
- if (!this._unsubscribeAgent) {
206
- this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
207
- }
208
221
  // Return unsubscribe function for this specific listener
209
222
  return () => {
210
223
  const index = this._eventListeners.indexOf(listener);
@@ -248,7 +261,7 @@ export class AgentSession {
248
261
  get state() {
249
262
  return this.agent.state;
250
263
  }
251
- /** Current model (may be null if not yet selected) */
264
+ /** Current model (may be undefined if not yet selected) */
252
265
  get model() {
253
266
  return this.agent.state.model;
254
267
  }
@@ -262,7 +275,7 @@ export class AgentSession {
262
275
  }
263
276
  /** Whether auto-compaction is currently running */
264
277
  get isCompacting() {
265
- return this._autoCompactionAbortController !== null || this._compactionAbortController !== null;
278
+ return this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined;
266
279
  }
267
280
  /** All messages including custom types like BashExecutionMessage */
268
281
  get messages() {
@@ -272,9 +285,9 @@ export class AgentSession {
272
285
  get queueMode() {
273
286
  return this.agent.getQueueMode();
274
287
  }
275
- /** Current session file path, or null if sessions are disabled */
288
+ /** Current session file path, or undefined if sessions are disabled */
276
289
  get sessionFile() {
277
- return this.sessionManager.isPersisted() ? this.sessionManager.getSessionFile() : null;
290
+ return this.sessionManager.getSessionFile();
278
291
  }
279
292
  /** Current session ID */
280
293
  get sessionId() {
@@ -294,6 +307,7 @@ export class AgentSession {
294
307
  /**
295
308
  * Send a prompt to the agent.
296
309
  * - Validates model and API key before sending
310
+ * - Handles hook commands (registered via pi.registerCommand)
297
311
  * - Expands file-based slash commands by default
298
312
  * @throws Error if no model selected or no API key available
299
313
  */
@@ -301,28 +315,91 @@ export class AgentSession {
301
315
  // Flush any pending bash messages before the new prompt
302
316
  this._flushPendingBashMessages();
303
317
  const expandCommands = options?.expandSlashCommands ?? true;
318
+ // Handle hook commands first (if enabled and text is a slash command)
319
+ if (expandCommands && text.startsWith("/")) {
320
+ const handled = await this._tryExecuteHookCommand(text);
321
+ if (handled) {
322
+ // Hook command executed, no prompt to send
323
+ return;
324
+ }
325
+ }
304
326
  // Validate model
305
327
  if (!this.model) {
306
328
  throw new Error("No model selected.\n\n" +
307
- `Use /login, set an API key environment variable, or create ${getModelsPath()}\n\n` +
329
+ `Use /login, set an API key environment variable, or create ${getAuthPath()}\n\n` +
308
330
  "Then use /model to select a model.");
309
331
  }
310
332
  // Validate API key
311
333
  const apiKey = await this._modelRegistry.getApiKey(this.model);
312
334
  if (!apiKey) {
313
335
  throw new Error(`No API key found for ${this.model.provider}.\n\n` +
314
- `Use /login, set an API key environment variable, or create ${getModelsPath()}`);
336
+ `Use /login, set an API key environment variable, or create ${getAuthPath()}`);
315
337
  }
316
338
  // Check if we need to compact before sending (catches aborted responses)
317
339
  const lastAssistant = this._findLastAssistantMessage();
318
340
  if (lastAssistant) {
319
341
  await this._checkCompaction(lastAssistant, false);
320
342
  }
321
- // Expand slash commands if requested
343
+ // Expand file-based slash commands if requested
322
344
  const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text;
323
- await this.agent.prompt(expandedText, options?.attachments);
345
+ // Build messages array (hook message if any, then user message)
346
+ const messages = [];
347
+ // Add user message
348
+ const userContent = [{ type: "text", text: expandedText }];
349
+ if (options?.images) {
350
+ userContent.push(...options.images);
351
+ }
352
+ messages.push({
353
+ role: "user",
354
+ content: userContent,
355
+ timestamp: Date.now(),
356
+ });
357
+ // Emit before_agent_start hook event
358
+ if (this._hookRunner) {
359
+ const result = await this._hookRunner.emitBeforeAgentStart(expandedText, options?.images);
360
+ if (result?.message) {
361
+ messages.push({
362
+ role: "hookMessage",
363
+ customType: result.message.customType,
364
+ content: result.message.content,
365
+ display: result.message.display,
366
+ details: result.message.details,
367
+ timestamp: Date.now(),
368
+ });
369
+ }
370
+ }
371
+ await this.agent.prompt(messages);
324
372
  await this.waitForRetry();
325
373
  }
374
+ /**
375
+ * Try to execute a hook command. Returns true if command was found and executed.
376
+ */
377
+ async _tryExecuteHookCommand(text) {
378
+ if (!this._hookRunner)
379
+ return false;
380
+ // Parse command name and args
381
+ const spaceIndex = text.indexOf(" ");
382
+ const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
383
+ const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
384
+ const command = this._hookRunner.getCommand(commandName);
385
+ if (!command)
386
+ return false;
387
+ // Get command context from hook runner (includes session control methods)
388
+ const ctx = this._hookRunner.createCommandContext();
389
+ try {
390
+ await command.handler(args, ctx);
391
+ return true;
392
+ }
393
+ catch (err) {
394
+ // Emit error via hook runner
395
+ this._hookRunner.emitError({
396
+ hookPath: `command:${commandName}`,
397
+ event: "command",
398
+ error: err instanceof Error ? err.message : String(err),
399
+ });
400
+ return true;
401
+ }
402
+ }
326
403
  /**
327
404
  * Queue a message to be sent after the current response completes.
328
405
  * Use when agent is currently streaming.
@@ -335,6 +412,40 @@ export class AgentSession {
335
412
  timestamp: Date.now(),
336
413
  });
337
414
  }
415
+ /**
416
+ * Send a hook message to the session. Creates a CustomMessageEntry.
417
+ *
418
+ * Handles three cases:
419
+ * - Streaming: queues message, processed when loop pulls from queue
420
+ * - Not streaming + triggerTurn: appends to state/session, starts new turn
421
+ * - Not streaming + no trigger: appends to state/session, no turn
422
+ *
423
+ * @param message Hook message with customType, content, display, details
424
+ * @param triggerTurn If true and not streaming, triggers a new LLM turn
425
+ */
426
+ async sendHookMessage(message, triggerTurn) {
427
+ const appMessage = {
428
+ role: "hookMessage",
429
+ customType: message.customType,
430
+ content: message.content,
431
+ display: message.display,
432
+ details: message.details,
433
+ timestamp: Date.now(),
434
+ };
435
+ if (this.isStreaming) {
436
+ // Queue for processing by agent loop
437
+ await this.agent.queueMessage(appMessage);
438
+ }
439
+ else if (triggerTurn) {
440
+ // Send as prompt - agent loop will emit message events
441
+ await this.agent.prompt(appMessage);
442
+ }
443
+ else {
444
+ // Just append to agent state and session, no turn
445
+ this.agent.appendMessage(appMessage);
446
+ this.sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details);
447
+ }
448
+ }
338
449
  /**
339
450
  * Clear queued messages and return them.
340
451
  * Useful for restoring to editor when user aborts.
@@ -365,22 +476,19 @@ export class AgentSession {
365
476
  await this.agent.waitForIdle();
366
477
  }
367
478
  /**
368
- * Reset agent and session to start fresh.
479
+ * Start a new session, optionally with initial messages and parent tracking.
369
480
  * Clears all messages and starts a new session.
370
481
  * Listeners are preserved and will continue receiving events.
371
- * @returns true if reset completed, false if cancelled by hook
482
+ * @param options - Optional initial messages and parent session path
483
+ * @returns true if completed, false if cancelled by hook
372
484
  */
373
- async reset() {
485
+ async newSession(options) {
374
486
  const previousSessionFile = this.sessionFile;
375
- const entries = this.sessionManager.getEntries();
376
- // Emit before_new event (can be cancelled)
377
- if (this._hookRunner?.hasHandlers("session")) {
487
+ // Emit session_before_switch event with reason "new" (can be cancelled)
488
+ if (this._hookRunner?.hasHandlers("session_before_switch")) {
378
489
  const result = (await this._hookRunner.emit({
379
- type: "session",
380
- entries,
381
- sessionFile: this.sessionFile,
382
- previousSessionFile: null,
383
- reason: "before_new",
490
+ type: "session_before_switch",
491
+ reason: "new",
384
492
  }));
385
493
  if (result?.cancel) {
386
494
  return false;
@@ -389,22 +497,19 @@ export class AgentSession {
389
497
  this._disconnectFromAgent();
390
498
  await this.abort();
391
499
  this.agent.reset();
392
- this.sessionManager.reset();
500
+ this.sessionManager.newSession(options);
393
501
  this._queuedMessages = [];
394
502
  this._reconnectToAgent();
395
- // Emit session event with reason "new" to hooks
503
+ // Emit session_switch event with reason "new" to hooks
396
504
  if (this._hookRunner) {
397
- this._hookRunner.setSessionFile(this.sessionFile);
398
505
  await this._hookRunner.emit({
399
- type: "session",
400
- entries: [],
401
- sessionFile: this.sessionFile,
402
- previousSessionFile,
506
+ type: "session_switch",
403
507
  reason: "new",
508
+ previousSessionFile,
404
509
  });
405
510
  }
406
511
  // Emit session event to custom tools
407
- await this._emitToolSessionEvent("new", previousSessionFile);
512
+ await this.emitCustomToolSessionEvent("switch", previousSessionFile);
408
513
  return true;
409
514
  }
410
515
  // =========================================================================
@@ -421,7 +526,7 @@ export class AgentSession {
421
526
  throw new Error(`No API key for ${model.provider}/${model.id}`);
422
527
  }
423
528
  this.agent.setModel(model);
424
- this.sessionManager.saveModelChange(model.provider, model.id);
529
+ this.sessionManager.appendModelChange(model.provider, model.id);
425
530
  this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
426
531
  // Re-clamp thinking level for new model's capabilities
427
532
  this.setThinkingLevel(this.thinkingLevel);
@@ -430,7 +535,7 @@ export class AgentSession {
430
535
  * Cycle to next/previous model.
431
536
  * Uses scoped models (from --models flag) if available, otherwise all available models.
432
537
  * @param direction - "forward" (default) or "backward"
433
- * @returns The new model info, or null if only one model available
538
+ * @returns The new model info, or undefined if only one model available
434
539
  */
435
540
  async cycleModel(direction = "forward") {
436
541
  if (this._scopedModels.length > 0) {
@@ -440,7 +545,7 @@ export class AgentSession {
440
545
  }
441
546
  async _cycleScopedModel(direction) {
442
547
  if (this._scopedModels.length <= 1)
443
- return null;
548
+ return undefined;
444
549
  const currentModel = this.model;
445
550
  let currentIndex = this._scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));
446
551
  if (currentIndex === -1)
@@ -455,7 +560,7 @@ export class AgentSession {
455
560
  }
456
561
  // Apply model
457
562
  this.agent.setModel(next.model);
458
- this.sessionManager.saveModelChange(next.model.provider, next.model.id);
563
+ this.sessionManager.appendModelChange(next.model.provider, next.model.id);
459
564
  this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
460
565
  // Apply thinking level (setThinkingLevel clamps to model capabilities)
461
566
  this.setThinkingLevel(next.thinkingLevel);
@@ -464,7 +569,7 @@ export class AgentSession {
464
569
  async _cycleAvailableModel(direction) {
465
570
  const availableModels = await this._modelRegistry.getAvailable();
466
571
  if (availableModels.length <= 1)
467
- return null;
572
+ return undefined;
468
573
  const currentModel = this.model;
469
574
  let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel));
470
575
  if (currentIndex === -1)
@@ -477,7 +582,7 @@ export class AgentSession {
477
582
  throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
478
583
  }
479
584
  this.agent.setModel(nextModel);
480
- this.sessionManager.saveModelChange(nextModel.provider, nextModel.id);
585
+ this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
481
586
  this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
482
587
  // Re-clamp thinking level for new model's capabilities
483
588
  this.setThinkingLevel(this.thinkingLevel);
@@ -506,16 +611,16 @@ export class AgentSession {
506
611
  effectiveLevel = "high";
507
612
  }
508
613
  this.agent.setThinkingLevel(effectiveLevel);
509
- this.sessionManager.saveThinkingLevelChange(effectiveLevel);
614
+ this.sessionManager.appendThinkingLevelChange(effectiveLevel);
510
615
  this.settingsManager.setDefaultThinkingLevel(effectiveLevel);
511
616
  }
512
617
  /**
513
618
  * Cycle to next thinking level.
514
- * @returns New level, or null if model doesn't support thinking
619
+ * @returns New level, or undefined if model doesn't support thinking
515
620
  */
516
621
  cycleThinkingLevel() {
517
622
  if (!this.supportsThinking())
518
- return null;
623
+ return undefined;
519
624
  const levels = this.getAvailableThinkingLevels();
520
625
  const currentIndex = levels.indexOf(this.thinkingLevel);
521
626
  const nextIndex = (currentIndex + 1) % levels.length;
@@ -572,76 +677,79 @@ export class AgentSession {
572
677
  if (!apiKey) {
573
678
  throw new Error(`No API key for ${this.model.provider}`);
574
679
  }
575
- const entries = this.sessionManager.getEntries();
680
+ const pathEntries = this.sessionManager.getBranch();
576
681
  const settings = this.settingsManager.getCompactionSettings();
577
- const preparation = prepareCompaction(entries, settings);
682
+ const preparation = prepareCompaction(pathEntries, settings);
578
683
  if (!preparation) {
579
- throw new Error("Already compacted");
580
- }
581
- // Find previous compaction summary if any
582
- let previousSummary;
583
- for (let i = entries.length - 1; i >= 0; i--) {
584
- if (entries[i].type === "compaction") {
585
- previousSummary = entries[i].summary;
586
- break;
684
+ // Check why we can't compact
685
+ const lastEntry = pathEntries[pathEntries.length - 1];
686
+ if (lastEntry?.type === "compaction") {
687
+ throw new Error("Already compacted");
587
688
  }
689
+ throw new Error("Nothing to compact (session too small)");
588
690
  }
589
- let compactionEntry;
691
+ let hookCompaction;
590
692
  let fromHook = false;
591
- if (this._hookRunner?.hasHandlers("session")) {
693
+ if (this._hookRunner?.hasHandlers("session_before_compact")) {
592
694
  const result = (await this._hookRunner.emit({
593
- type: "session",
594
- entries,
595
- sessionFile: this.sessionFile,
596
- previousSessionFile: null,
597
- reason: "before_compact",
598
- cutPoint: preparation.cutPoint,
599
- previousSummary,
600
- messagesToSummarize: [...preparation.messagesToSummarize],
601
- messagesToKeep: [...preparation.messagesToKeep],
602
- tokensBefore: preparation.tokensBefore,
695
+ type: "session_before_compact",
696
+ preparation,
697
+ branchEntries: pathEntries,
603
698
  customInstructions,
604
- model: this.model,
605
- resolveApiKey: async (m) => (await this._modelRegistry.getApiKey(m)) ?? undefined,
606
699
  signal: this._compactionAbortController.signal,
607
700
  }));
608
701
  if (result?.cancel) {
609
702
  throw new Error("Compaction cancelled");
610
703
  }
611
- if (result?.compactionEntry) {
612
- compactionEntry = result.compactionEntry;
704
+ if (result?.compaction) {
705
+ hookCompaction = result.compaction;
613
706
  fromHook = true;
614
707
  }
615
708
  }
616
- if (!compactionEntry) {
617
- compactionEntry = await compact(entries, this.model, settings, apiKey, this._compactionAbortController.signal, customInstructions);
709
+ let summary;
710
+ let firstKeptEntryId;
711
+ let tokensBefore;
712
+ let details;
713
+ if (hookCompaction) {
714
+ // Hook provided compaction content
715
+ summary = hookCompaction.summary;
716
+ firstKeptEntryId = hookCompaction.firstKeptEntryId;
717
+ tokensBefore = hookCompaction.tokensBefore;
718
+ details = hookCompaction.details;
719
+ }
720
+ else {
721
+ // Generate compaction result
722
+ const result = await compact(preparation, this.model, apiKey, customInstructions, this._compactionAbortController.signal);
723
+ summary = result.summary;
724
+ firstKeptEntryId = result.firstKeptEntryId;
725
+ tokensBefore = result.tokensBefore;
726
+ details = result.details;
618
727
  }
619
728
  if (this._compactionAbortController.signal.aborted) {
620
729
  throw new Error("Compaction cancelled");
621
730
  }
622
- this.sessionManager.saveCompaction(compactionEntry);
731
+ this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook);
623
732
  const newEntries = this.sessionManager.getEntries();
624
733
  const sessionContext = this.sessionManager.buildSessionContext();
625
734
  this.agent.replaceMessages(sessionContext.messages);
626
- if (this._hookRunner) {
735
+ // Get the saved compaction entry for the hook
736
+ const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
737
+ if (this._hookRunner && savedCompactionEntry) {
627
738
  await this._hookRunner.emit({
628
- type: "session",
629
- entries: newEntries,
630
- sessionFile: this.sessionFile,
631
- previousSessionFile: null,
632
- reason: "compact",
633
- compactionEntry,
634
- tokensBefore: compactionEntry.tokensBefore,
739
+ type: "session_compact",
740
+ compactionEntry: savedCompactionEntry,
635
741
  fromHook,
636
742
  });
637
743
  }
638
744
  return {
639
- tokensBefore: compactionEntry.tokensBefore,
640
- summary: compactionEntry.summary,
745
+ summary,
746
+ firstKeptEntryId,
747
+ tokensBefore,
748
+ details,
641
749
  };
642
750
  }
643
751
  finally {
644
- this._compactionAbortController = null;
752
+ this._compactionAbortController = undefined;
645
753
  this._reconnectToAgent();
646
754
  }
647
755
  }
@@ -652,6 +760,12 @@ export class AgentSession {
652
760
  this._compactionAbortController?.abort();
653
761
  this._autoCompactionAbortController?.abort();
654
762
  }
763
+ /**
764
+ * Cancel in-progress branch summarization.
765
+ */
766
+ abortBranchSummary() {
767
+ this._branchSummaryAbortController?.abort();
768
+ }
655
769
  /**
656
770
  * Check if compaction is needed and run it.
657
771
  * Called after agent_end and before prompt submission.
@@ -700,82 +814,80 @@ export class AgentSession {
700
814
  this._autoCompactionAbortController = new AbortController();
701
815
  try {
702
816
  if (!this.model) {
703
- this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
817
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
704
818
  return;
705
819
  }
706
820
  const apiKey = await this._modelRegistry.getApiKey(this.model);
707
821
  if (!apiKey) {
708
- this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
822
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
709
823
  return;
710
824
  }
711
- const entries = this.sessionManager.getEntries();
712
- const preparation = prepareCompaction(entries, settings);
825
+ const pathEntries = this.sessionManager.getBranch();
826
+ const preparation = prepareCompaction(pathEntries, settings);
713
827
  if (!preparation) {
714
- this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
828
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
715
829
  return;
716
830
  }
717
- // Find previous compaction summary if any
718
- let previousSummary;
719
- for (let i = entries.length - 1; i >= 0; i--) {
720
- if (entries[i].type === "compaction") {
721
- previousSummary = entries[i].summary;
722
- break;
723
- }
724
- }
725
- let compactionEntry;
831
+ let hookCompaction;
726
832
  let fromHook = false;
727
- if (this._hookRunner?.hasHandlers("session")) {
833
+ if (this._hookRunner?.hasHandlers("session_before_compact")) {
728
834
  const hookResult = (await this._hookRunner.emit({
729
- type: "session",
730
- entries,
731
- sessionFile: this.sessionFile,
732
- previousSessionFile: null,
733
- reason: "before_compact",
734
- cutPoint: preparation.cutPoint,
735
- previousSummary,
736
- messagesToSummarize: [...preparation.messagesToSummarize],
737
- messagesToKeep: [...preparation.messagesToKeep],
738
- tokensBefore: preparation.tokensBefore,
835
+ type: "session_before_compact",
836
+ preparation,
837
+ branchEntries: pathEntries,
739
838
  customInstructions: undefined,
740
- model: this.model,
741
- resolveApiKey: async (m) => (await this._modelRegistry.getApiKey(m)) ?? undefined,
742
839
  signal: this._autoCompactionAbortController.signal,
743
840
  }));
744
841
  if (hookResult?.cancel) {
745
- this._emit({ type: "auto_compaction_end", result: null, aborted: true, willRetry: false });
842
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
746
843
  return;
747
844
  }
748
- if (hookResult?.compactionEntry) {
749
- compactionEntry = hookResult.compactionEntry;
845
+ if (hookResult?.compaction) {
846
+ hookCompaction = hookResult.compaction;
750
847
  fromHook = true;
751
848
  }
752
849
  }
753
- if (!compactionEntry) {
754
- compactionEntry = await compact(entries, this.model, settings, apiKey, this._autoCompactionAbortController.signal);
850
+ let summary;
851
+ let firstKeptEntryId;
852
+ let tokensBefore;
853
+ let details;
854
+ if (hookCompaction) {
855
+ // Hook provided compaction content
856
+ summary = hookCompaction.summary;
857
+ firstKeptEntryId = hookCompaction.firstKeptEntryId;
858
+ tokensBefore = hookCompaction.tokensBefore;
859
+ details = hookCompaction.details;
860
+ }
861
+ else {
862
+ // Generate compaction result
863
+ const compactResult = await compact(preparation, this.model, apiKey, undefined, this._autoCompactionAbortController.signal);
864
+ summary = compactResult.summary;
865
+ firstKeptEntryId = compactResult.firstKeptEntryId;
866
+ tokensBefore = compactResult.tokensBefore;
867
+ details = compactResult.details;
755
868
  }
756
869
  if (this._autoCompactionAbortController.signal.aborted) {
757
- this._emit({ type: "auto_compaction_end", result: null, aborted: true, willRetry: false });
870
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
758
871
  return;
759
872
  }
760
- this.sessionManager.saveCompaction(compactionEntry);
873
+ this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook);
761
874
  const newEntries = this.sessionManager.getEntries();
762
875
  const sessionContext = this.sessionManager.buildSessionContext();
763
876
  this.agent.replaceMessages(sessionContext.messages);
764
- if (this._hookRunner) {
877
+ // Get the saved compaction entry for the hook
878
+ const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
879
+ if (this._hookRunner && savedCompactionEntry) {
765
880
  await this._hookRunner.emit({
766
- type: "session",
767
- entries: newEntries,
768
- sessionFile: this.sessionFile,
769
- previousSessionFile: null,
770
- reason: "compact",
771
- compactionEntry,
772
- tokensBefore: compactionEntry.tokensBefore,
881
+ type: "session_compact",
882
+ compactionEntry: savedCompactionEntry,
773
883
  fromHook,
774
884
  });
775
885
  }
776
886
  const result = {
777
- tokensBefore: compactionEntry.tokensBefore,
778
- summary: compactionEntry.summary,
887
+ summary,
888
+ firstKeptEntryId,
889
+ tokensBefore,
890
+ details,
779
891
  };
780
892
  this._emit({ type: "auto_compaction_end", result, aborted: false, willRetry });
781
893
  if (willRetry) {
@@ -790,13 +902,13 @@ export class AgentSession {
790
902
  }
791
903
  }
792
904
  catch (error) {
793
- this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
905
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
794
906
  if (reason === "overflow") {
795
907
  throw new Error(`Context overflow: ${error instanceof Error ? error.message : "compaction failed"}. Your input may be too large for the context window.`);
796
908
  }
797
909
  }
798
910
  finally {
799
- this._autoCompactionAbortController = null;
911
+ this._autoCompactionAbortController = undefined;
800
912
  }
801
913
  }
802
914
  /**
@@ -876,7 +988,7 @@ export class AgentSession {
876
988
  // Aborted during sleep - emit end event so UI can clean up
877
989
  const attempt = this._retryAttempt;
878
990
  this._retryAttempt = 0;
879
- this._retryAbortController = null;
991
+ this._retryAbortController = undefined;
880
992
  this._emit({
881
993
  type: "auto_retry_end",
882
994
  success: false,
@@ -886,7 +998,7 @@ export class AgentSession {
886
998
  this._resolveRetry();
887
999
  return false;
888
1000
  }
889
- this._retryAbortController = null;
1001
+ this._retryAbortController = undefined;
890
1002
  // Retry via continue() - use setTimeout to break out of event handler chain
891
1003
  setTimeout(() => {
892
1004
  this.agent.continue().catch(() => {
@@ -930,7 +1042,7 @@ export class AgentSession {
930
1042
  }
931
1043
  /** Whether auto-retry is currently in progress */
932
1044
  get isRetrying() {
933
- return this._retryPromise !== null;
1045
+ return this._retryPromise !== undefined;
934
1046
  }
935
1047
  /** Whether auto-retry is enabled */
936
1048
  get autoRetryEnabled() {
@@ -978,12 +1090,12 @@ export class AgentSession {
978
1090
  // Add to agent state immediately
979
1091
  this.agent.appendMessage(bashMessage);
980
1092
  // Save to session
981
- this.sessionManager.saveMessage(bashMessage);
1093
+ this.sessionManager.appendMessage(bashMessage);
982
1094
  }
983
1095
  return result;
984
1096
  }
985
1097
  finally {
986
- this._bashAbortController = null;
1098
+ this._bashAbortController = undefined;
987
1099
  }
988
1100
  }
989
1101
  /**
@@ -994,7 +1106,7 @@ export class AgentSession {
994
1106
  }
995
1107
  /** Whether a bash command is currently running */
996
1108
  get isBashRunning() {
997
- return this._bashAbortController !== null;
1109
+ return this._bashAbortController !== undefined;
998
1110
  }
999
1111
  /** Whether there are pending bash messages waiting to be flushed */
1000
1112
  get hasPendingBashMessages() {
@@ -1011,7 +1123,7 @@ export class AgentSession {
1011
1123
  // Add to agent state
1012
1124
  this.agent.appendMessage(bashMessage);
1013
1125
  // Save to session
1014
- this.sessionManager.saveMessage(bashMessage);
1126
+ this.sessionManager.appendMessage(bashMessage);
1015
1127
  }
1016
1128
  this._pendingBashMessages = [];
1017
1129
  }
@@ -1025,16 +1137,13 @@ export class AgentSession {
1025
1137
  * @returns true if switch completed, false if cancelled by hook
1026
1138
  */
1027
1139
  async switchSession(sessionPath) {
1028
- const previousSessionFile = this.sessionFile;
1029
- const oldEntries = this.sessionManager.getEntries();
1030
- // Emit before_switch event (can be cancelled)
1031
- if (this._hookRunner?.hasHandlers("session")) {
1140
+ const previousSessionFile = this.sessionManager.getSessionFile();
1141
+ // Emit session_before_switch event (can be cancelled)
1142
+ if (this._hookRunner?.hasHandlers("session_before_switch")) {
1032
1143
  const result = (await this._hookRunner.emit({
1033
- type: "session",
1034
- entries: oldEntries,
1035
- sessionFile: this.sessionFile,
1036
- previousSessionFile: null,
1037
- reason: "before_switch",
1144
+ type: "session_before_switch",
1145
+ reason: "resume",
1146
+ targetSessionFile: sessionPath,
1038
1147
  }));
1039
1148
  if (result?.cancel) {
1040
1149
  return false;
@@ -1046,21 +1155,17 @@ export class AgentSession {
1046
1155
  // Set new session
1047
1156
  this.sessionManager.setSessionFile(sessionPath);
1048
1157
  // Reload messages
1049
- const entries = this.sessionManager.getEntries();
1050
1158
  const sessionContext = this.sessionManager.buildSessionContext();
1051
- // Emit session event to hooks
1159
+ // Emit session_switch event to hooks
1052
1160
  if (this._hookRunner) {
1053
- this._hookRunner.setSessionFile(sessionPath);
1054
1161
  await this._hookRunner.emit({
1055
- type: "session",
1056
- entries,
1057
- sessionFile: sessionPath,
1162
+ type: "session_switch",
1163
+ reason: "resume",
1058
1164
  previousSessionFile,
1059
- reason: "switch",
1060
1165
  });
1061
1166
  }
1062
1167
  // Emit session event to custom tools
1063
- await this._emitToolSessionEvent("switch", previousSessionFile);
1168
+ await this.emitCustomToolSessionEvent("switch", previousSessionFile);
1064
1169
  this.agent.replaceMessages(sessionContext.messages);
1065
1170
  // Restore model if saved
1066
1171
  if (sessionContext.model) {
@@ -1078,81 +1183,215 @@ export class AgentSession {
1078
1183
  return true;
1079
1184
  }
1080
1185
  /**
1081
- * Create a branch from a specific entry index.
1186
+ * Create a branch from a specific entry.
1082
1187
  * Emits before_branch/branch session events to hooks.
1083
1188
  *
1084
- * @param entryIndex Index into session entries to branch from
1189
+ * @param entryId ID of the entry to branch from
1085
1190
  * @returns Object with:
1086
1191
  * - selectedText: The text of the selected user message (for editor pre-fill)
1087
1192
  * - cancelled: True if a hook cancelled the branch
1088
1193
  */
1089
- async branch(entryIndex) {
1194
+ async branch(entryId) {
1090
1195
  const previousSessionFile = this.sessionFile;
1091
- const entries = this.sessionManager.getEntries();
1092
- const selectedEntry = entries[entryIndex];
1196
+ const selectedEntry = this.sessionManager.getEntry(entryId);
1093
1197
  if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
1094
- throw new Error("Invalid entry index for branching");
1198
+ throw new Error("Invalid entry ID for branching");
1095
1199
  }
1096
1200
  const selectedText = this._extractUserMessageText(selectedEntry.message.content);
1097
1201
  let skipConversationRestore = false;
1098
- // Emit before_branch event (can be cancelled)
1099
- if (this._hookRunner?.hasHandlers("session")) {
1202
+ // Emit session_before_branch event (can be cancelled)
1203
+ if (this._hookRunner?.hasHandlers("session_before_branch")) {
1100
1204
  const result = (await this._hookRunner.emit({
1101
- type: "session",
1102
- entries,
1103
- sessionFile: this.sessionFile,
1104
- previousSessionFile: null,
1105
- reason: "before_branch",
1106
- targetTurnIndex: entryIndex,
1205
+ type: "session_before_branch",
1206
+ entryId,
1107
1207
  }));
1108
1208
  if (result?.cancel) {
1109
1209
  return { selectedText, cancelled: true };
1110
1210
  }
1111
1211
  skipConversationRestore = result?.skipConversationRestore ?? false;
1112
1212
  }
1113
- // Create branched session (returns null in --no-session mode)
1114
- const newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex);
1115
- // Update session file if we have one (file-based mode)
1116
- if (newSessionFile !== null) {
1117
- this.sessionManager.setSessionFile(newSessionFile);
1213
+ if (!selectedEntry.parentId) {
1214
+ this.sessionManager.newSession();
1215
+ }
1216
+ else {
1217
+ this.sessionManager.createBranchedSession(selectedEntry.parentId);
1118
1218
  }
1119
1219
  // Reload messages from entries (works for both file and in-memory mode)
1120
- const newEntries = this.sessionManager.getEntries();
1121
1220
  const sessionContext = this.sessionManager.buildSessionContext();
1122
- // Emit branch event to hooks (after branch completes)
1221
+ // Emit session_branch event to hooks (after branch completes)
1123
1222
  if (this._hookRunner) {
1124
- this._hookRunner.setSessionFile(newSessionFile);
1125
1223
  await this._hookRunner.emit({
1126
- type: "session",
1127
- entries: newEntries,
1128
- sessionFile: newSessionFile,
1224
+ type: "session_branch",
1129
1225
  previousSessionFile,
1130
- reason: "branch",
1131
- targetTurnIndex: entryIndex,
1132
1226
  });
1133
1227
  }
1134
1228
  // Emit session event to custom tools (with reason "branch")
1135
- await this._emitToolSessionEvent("branch", previousSessionFile);
1229
+ await this.emitCustomToolSessionEvent("branch", previousSessionFile);
1136
1230
  if (!skipConversationRestore) {
1137
1231
  this.agent.replaceMessages(sessionContext.messages);
1138
1232
  }
1139
1233
  return { selectedText, cancelled: false };
1140
1234
  }
1235
+ // =========================================================================
1236
+ // Tree Navigation
1237
+ // =========================================================================
1238
+ /**
1239
+ * Navigate to a different node in the session tree.
1240
+ * Unlike branch() which creates a new session file, this stays in the same file.
1241
+ *
1242
+ * @param targetId The entry ID to navigate to
1243
+ * @param options.summarize Whether user wants to summarize abandoned branch
1244
+ * @param options.customInstructions Custom instructions for summarizer
1245
+ * @returns Result with editorText (if user message) and cancelled status
1246
+ */
1247
+ async navigateTree(targetId, options = {}) {
1248
+ const oldLeafId = this.sessionManager.getLeafId();
1249
+ // No-op if already at target
1250
+ if (targetId === oldLeafId) {
1251
+ return { cancelled: false };
1252
+ }
1253
+ // Model required for summarization
1254
+ if (options.summarize && !this.model) {
1255
+ throw new Error("No model available for summarization");
1256
+ }
1257
+ const targetEntry = this.sessionManager.getEntry(targetId);
1258
+ if (!targetEntry) {
1259
+ throw new Error(`Entry ${targetId} not found`);
1260
+ }
1261
+ // Collect entries to summarize (from old leaf to common ancestor)
1262
+ const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(this.sessionManager, oldLeafId, targetId);
1263
+ // Prepare event data
1264
+ const preparation = {
1265
+ targetId,
1266
+ oldLeafId,
1267
+ commonAncestorId,
1268
+ entriesToSummarize,
1269
+ userWantsSummary: options.summarize ?? false,
1270
+ };
1271
+ // Set up abort controller for summarization
1272
+ this._branchSummaryAbortController = new AbortController();
1273
+ let hookSummary;
1274
+ let fromHook = false;
1275
+ // Emit session_before_tree event
1276
+ if (this._hookRunner?.hasHandlers("session_before_tree")) {
1277
+ const result = (await this._hookRunner.emit({
1278
+ type: "session_before_tree",
1279
+ preparation,
1280
+ signal: this._branchSummaryAbortController.signal,
1281
+ }));
1282
+ if (result?.cancel) {
1283
+ return { cancelled: true };
1284
+ }
1285
+ if (result?.summary && options.summarize) {
1286
+ hookSummary = result.summary;
1287
+ fromHook = true;
1288
+ }
1289
+ }
1290
+ // Run default summarizer if needed
1291
+ let summaryText;
1292
+ let summaryDetails;
1293
+ if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
1294
+ const model = this.model;
1295
+ const apiKey = await this._modelRegistry.getApiKey(model);
1296
+ if (!apiKey) {
1297
+ throw new Error(`No API key for ${model.provider}`);
1298
+ }
1299
+ const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
1300
+ const result = await generateBranchSummary(entriesToSummarize, {
1301
+ model,
1302
+ apiKey,
1303
+ signal: this._branchSummaryAbortController.signal,
1304
+ customInstructions: options.customInstructions,
1305
+ reserveTokens: branchSummarySettings.reserveTokens,
1306
+ });
1307
+ this._branchSummaryAbortController = undefined;
1308
+ if (result.aborted) {
1309
+ return { cancelled: true, aborted: true };
1310
+ }
1311
+ if (result.error) {
1312
+ throw new Error(result.error);
1313
+ }
1314
+ summaryText = result.summary;
1315
+ summaryDetails = {
1316
+ readFiles: result.readFiles || [],
1317
+ modifiedFiles: result.modifiedFiles || [],
1318
+ };
1319
+ }
1320
+ else if (hookSummary) {
1321
+ summaryText = hookSummary.summary;
1322
+ summaryDetails = hookSummary.details;
1323
+ }
1324
+ // Determine the new leaf position based on target type
1325
+ let newLeafId;
1326
+ let editorText;
1327
+ if (targetEntry.type === "message" && targetEntry.message.role === "user") {
1328
+ // User message: leaf = parent (null if root), text goes to editor
1329
+ newLeafId = targetEntry.parentId;
1330
+ editorText = this._extractUserMessageText(targetEntry.message.content);
1331
+ }
1332
+ else if (targetEntry.type === "custom_message") {
1333
+ // Custom message: leaf = parent (null if root), text goes to editor
1334
+ newLeafId = targetEntry.parentId;
1335
+ editorText =
1336
+ typeof targetEntry.content === "string"
1337
+ ? targetEntry.content
1338
+ : targetEntry.content
1339
+ .filter((c) => c.type === "text")
1340
+ .map((c) => c.text)
1341
+ .join("");
1342
+ }
1343
+ else {
1344
+ // Non-user message: leaf = selected node
1345
+ newLeafId = targetId;
1346
+ }
1347
+ // Switch leaf (with or without summary)
1348
+ // Summary is attached at the navigation target position (newLeafId), not the old branch
1349
+ let summaryEntry;
1350
+ if (summaryText) {
1351
+ // Create summary at target position (can be null for root)
1352
+ const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromHook);
1353
+ summaryEntry = this.sessionManager.getEntry(summaryId);
1354
+ }
1355
+ else if (newLeafId === null) {
1356
+ // No summary, navigating to root - reset leaf
1357
+ this.sessionManager.resetLeaf();
1358
+ }
1359
+ else {
1360
+ // No summary, navigating to non-root
1361
+ this.sessionManager.branch(newLeafId);
1362
+ }
1363
+ // Update agent state
1364
+ const sessionContext = this.sessionManager.buildSessionContext();
1365
+ this.agent.replaceMessages(sessionContext.messages);
1366
+ // Emit session_tree event
1367
+ if (this._hookRunner) {
1368
+ await this._hookRunner.emit({
1369
+ type: "session_tree",
1370
+ newLeafId: this.sessionManager.getLeafId(),
1371
+ oldLeafId,
1372
+ summaryEntry,
1373
+ fromHook: summaryText ? fromHook : undefined,
1374
+ });
1375
+ }
1376
+ // Emit to custom tools
1377
+ await this.emitCustomToolSessionEvent("tree", this.sessionFile);
1378
+ this._branchSummaryAbortController = undefined;
1379
+ return { editorText, cancelled: false, summaryEntry };
1380
+ }
1141
1381
  /**
1142
1382
  * Get all user messages from session for branch selector.
1143
1383
  */
1144
1384
  getUserMessagesForBranching() {
1145
1385
  const entries = this.sessionManager.getEntries();
1146
1386
  const result = [];
1147
- for (let i = 0; i < entries.length; i++) {
1148
- const entry = entries[i];
1387
+ for (const entry of entries) {
1149
1388
  if (entry.type !== "message")
1150
1389
  continue;
1151
1390
  if (entry.message.role !== "user")
1152
1391
  continue;
1153
1392
  const text = this._extractUserMessageText(entry.message.content);
1154
1393
  if (text) {
1155
- result.push({ entryIndex: i, text });
1394
+ result.push({ entryId: entry.id, text });
1156
1395
  }
1157
1396
  }
1158
1397
  return result;
@@ -1226,22 +1465,30 @@ export class AgentSession {
1226
1465
  /**
1227
1466
  * Get text content of last assistant message.
1228
1467
  * Useful for /copy command.
1229
- * @returns Text content, or null if no assistant message exists
1468
+ * @returns Text content, or undefined if no assistant message exists
1230
1469
  */
1231
1470
  getLastAssistantText() {
1232
1471
  const lastAssistant = this.messages
1233
1472
  .slice()
1234
1473
  .reverse()
1235
- .find((m) => m.role === "assistant");
1474
+ .find((m) => {
1475
+ if (m.role !== "assistant")
1476
+ return false;
1477
+ const msg = m;
1478
+ // Skip aborted messages with no content
1479
+ if (msg.stopReason === "aborted" && msg.content.length === 0)
1480
+ return false;
1481
+ return true;
1482
+ });
1236
1483
  if (!lastAssistant)
1237
- return null;
1484
+ return undefined;
1238
1485
  let text = "";
1239
1486
  for (const content of lastAssistant.content) {
1240
1487
  if (content.type === "text") {
1241
1488
  text += content.text;
1242
1489
  }
1243
1490
  }
1244
- return text.trim() || null;
1491
+ return text.trim() || undefined;
1245
1492
  }
1246
1493
  // =========================================================================
1247
1494
  // Hook System
@@ -1266,19 +1513,26 @@ export class AgentSession {
1266
1513
  }
1267
1514
  /**
1268
1515
  * Emit session event to all custom tools.
1269
- * Called on session switch, branch, and clear.
1516
+ * Called on session switch, branch, tree navigation, and shutdown.
1270
1517
  */
1271
- async _emitToolSessionEvent(reason, previousSessionFile) {
1272
- const event = {
1273
- entries: this.sessionManager.getEntries(),
1274
- sessionFile: this.sessionFile,
1275
- previousSessionFile,
1276
- reason,
1518
+ async emitCustomToolSessionEvent(reason, previousSessionFile) {
1519
+ if (!this._customTools)
1520
+ return;
1521
+ const event = { reason, previousSessionFile };
1522
+ const ctx = {
1523
+ sessionManager: this.sessionManager,
1524
+ modelRegistry: this._modelRegistry,
1525
+ model: this.agent.state.model,
1526
+ isIdle: () => !this.isStreaming,
1527
+ hasQueuedMessages: () => this.queuedMessageCount > 0,
1528
+ abort: () => {
1529
+ this.abort();
1530
+ },
1277
1531
  };
1278
1532
  for (const { tool } of this._customTools) {
1279
1533
  if (tool.onSession) {
1280
1534
  try {
1281
- await tool.onSession(event);
1535
+ await tool.onSession(event, ctx);
1282
1536
  }
1283
1537
  catch (_err) {
1284
1538
  // Silently ignore tool errors during session events