@oh-my-pi/pi-coding-agent 1.337.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/CHANGELOG.md +1228 -0
  2. package/README.md +1041 -0
  3. package/docs/compaction.md +403 -0
  4. package/docs/custom-tools.md +541 -0
  5. package/docs/extension-loading.md +1004 -0
  6. package/docs/hooks.md +867 -0
  7. package/docs/rpc.md +1040 -0
  8. package/docs/sdk.md +994 -0
  9. package/docs/session-tree-plan.md +441 -0
  10. package/docs/session.md +240 -0
  11. package/docs/skills.md +290 -0
  12. package/docs/theme.md +637 -0
  13. package/docs/tree.md +197 -0
  14. package/docs/tui.md +341 -0
  15. package/examples/README.md +21 -0
  16. package/examples/custom-tools/README.md +124 -0
  17. package/examples/custom-tools/hello/index.ts +20 -0
  18. package/examples/custom-tools/question/index.ts +84 -0
  19. package/examples/custom-tools/subagent/README.md +172 -0
  20. package/examples/custom-tools/subagent/agents/planner.md +37 -0
  21. package/examples/custom-tools/subagent/agents/reviewer.md +35 -0
  22. package/examples/custom-tools/subagent/agents/scout.md +50 -0
  23. package/examples/custom-tools/subagent/agents/worker.md +24 -0
  24. package/examples/custom-tools/subagent/agents.ts +156 -0
  25. package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
  26. package/examples/custom-tools/subagent/commands/implement.md +10 -0
  27. package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
  28. package/examples/custom-tools/subagent/index.ts +1002 -0
  29. package/examples/custom-tools/todo/index.ts +212 -0
  30. package/examples/hooks/README.md +56 -0
  31. package/examples/hooks/auto-commit-on-exit.ts +49 -0
  32. package/examples/hooks/confirm-destructive.ts +59 -0
  33. package/examples/hooks/custom-compaction.ts +116 -0
  34. package/examples/hooks/dirty-repo-guard.ts +52 -0
  35. package/examples/hooks/file-trigger.ts +41 -0
  36. package/examples/hooks/git-checkpoint.ts +53 -0
  37. package/examples/hooks/handoff.ts +150 -0
  38. package/examples/hooks/permission-gate.ts +34 -0
  39. package/examples/hooks/protected-paths.ts +30 -0
  40. package/examples/hooks/qna.ts +119 -0
  41. package/examples/hooks/snake.ts +343 -0
  42. package/examples/hooks/status-line.ts +40 -0
  43. package/examples/sdk/01-minimal.ts +22 -0
  44. package/examples/sdk/02-custom-model.ts +49 -0
  45. package/examples/sdk/03-custom-prompt.ts +44 -0
  46. package/examples/sdk/04-skills.ts +44 -0
  47. package/examples/sdk/05-tools.ts +90 -0
  48. package/examples/sdk/06-hooks.ts +61 -0
  49. package/examples/sdk/07-context-files.ts +36 -0
  50. package/examples/sdk/08-slash-commands.ts +42 -0
  51. package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
  52. package/examples/sdk/10-settings.ts +38 -0
  53. package/examples/sdk/11-sessions.ts +48 -0
  54. package/examples/sdk/12-full-control.ts +95 -0
  55. package/examples/sdk/README.md +154 -0
  56. package/package.json +81 -0
  57. package/src/cli/args.ts +246 -0
  58. package/src/cli/file-processor.ts +72 -0
  59. package/src/cli/list-models.ts +104 -0
  60. package/src/cli/plugin-cli.ts +650 -0
  61. package/src/cli/session-picker.ts +41 -0
  62. package/src/cli.ts +10 -0
  63. package/src/commands/init.md +20 -0
  64. package/src/config.ts +159 -0
  65. package/src/core/agent-session.ts +1900 -0
  66. package/src/core/auth-storage.ts +236 -0
  67. package/src/core/bash-executor.ts +196 -0
  68. package/src/core/compaction/branch-summarization.ts +343 -0
  69. package/src/core/compaction/compaction.ts +742 -0
  70. package/src/core/compaction/index.ts +7 -0
  71. package/src/core/compaction/utils.ts +154 -0
  72. package/src/core/custom-tools/index.ts +21 -0
  73. package/src/core/custom-tools/loader.ts +248 -0
  74. package/src/core/custom-tools/types.ts +169 -0
  75. package/src/core/custom-tools/wrapper.ts +28 -0
  76. package/src/core/exec.ts +129 -0
  77. package/src/core/export-html/index.ts +211 -0
  78. package/src/core/export-html/template.css +781 -0
  79. package/src/core/export-html/template.html +54 -0
  80. package/src/core/export-html/template.js +1185 -0
  81. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  82. package/src/core/export-html/vendor/marked.min.js +6 -0
  83. package/src/core/hooks/index.ts +16 -0
  84. package/src/core/hooks/loader.ts +312 -0
  85. package/src/core/hooks/runner.ts +434 -0
  86. package/src/core/hooks/tool-wrapper.ts +99 -0
  87. package/src/core/hooks/types.ts +773 -0
  88. package/src/core/index.ts +52 -0
  89. package/src/core/mcp/client.ts +158 -0
  90. package/src/core/mcp/config.ts +154 -0
  91. package/src/core/mcp/index.ts +45 -0
  92. package/src/core/mcp/loader.ts +68 -0
  93. package/src/core/mcp/manager.ts +181 -0
  94. package/src/core/mcp/tool-bridge.ts +148 -0
  95. package/src/core/mcp/transports/http.ts +316 -0
  96. package/src/core/mcp/transports/index.ts +6 -0
  97. package/src/core/mcp/transports/stdio.ts +252 -0
  98. package/src/core/mcp/types.ts +220 -0
  99. package/src/core/messages.ts +189 -0
  100. package/src/core/model-registry.ts +317 -0
  101. package/src/core/model-resolver.ts +393 -0
  102. package/src/core/plugins/doctor.ts +59 -0
  103. package/src/core/plugins/index.ts +38 -0
  104. package/src/core/plugins/installer.ts +189 -0
  105. package/src/core/plugins/loader.ts +338 -0
  106. package/src/core/plugins/manager.ts +672 -0
  107. package/src/core/plugins/parser.ts +105 -0
  108. package/src/core/plugins/paths.ts +32 -0
  109. package/src/core/plugins/types.ts +190 -0
  110. package/src/core/sdk.ts +760 -0
  111. package/src/core/session-manager.ts +1128 -0
  112. package/src/core/settings-manager.ts +443 -0
  113. package/src/core/skills.ts +437 -0
  114. package/src/core/slash-commands.ts +248 -0
  115. package/src/core/system-prompt.ts +439 -0
  116. package/src/core/timings.ts +25 -0
  117. package/src/core/tools/ask.ts +211 -0
  118. package/src/core/tools/bash-interceptor.ts +120 -0
  119. package/src/core/tools/bash.ts +250 -0
  120. package/src/core/tools/context.ts +32 -0
  121. package/src/core/tools/edit-diff.ts +475 -0
  122. package/src/core/tools/edit.ts +208 -0
  123. package/src/core/tools/exa/company.ts +59 -0
  124. package/src/core/tools/exa/index.ts +64 -0
  125. package/src/core/tools/exa/linkedin.ts +59 -0
  126. package/src/core/tools/exa/logger.ts +56 -0
  127. package/src/core/tools/exa/mcp-client.ts +368 -0
  128. package/src/core/tools/exa/render.ts +196 -0
  129. package/src/core/tools/exa/researcher.ts +90 -0
  130. package/src/core/tools/exa/search.ts +337 -0
  131. package/src/core/tools/exa/types.ts +168 -0
  132. package/src/core/tools/exa/websets.ts +248 -0
  133. package/src/core/tools/find.ts +261 -0
  134. package/src/core/tools/grep.ts +555 -0
  135. package/src/core/tools/index.ts +202 -0
  136. package/src/core/tools/ls.ts +140 -0
  137. package/src/core/tools/lsp/client.ts +605 -0
  138. package/src/core/tools/lsp/config.ts +147 -0
  139. package/src/core/tools/lsp/edits.ts +101 -0
  140. package/src/core/tools/lsp/index.ts +804 -0
  141. package/src/core/tools/lsp/render.ts +447 -0
  142. package/src/core/tools/lsp/rust-analyzer.ts +145 -0
  143. package/src/core/tools/lsp/types.ts +463 -0
  144. package/src/core/tools/lsp/utils.ts +486 -0
  145. package/src/core/tools/notebook.ts +229 -0
  146. package/src/core/tools/path-utils.ts +61 -0
  147. package/src/core/tools/read.ts +240 -0
  148. package/src/core/tools/renderers.ts +540 -0
  149. package/src/core/tools/task/agents.ts +153 -0
  150. package/src/core/tools/task/artifacts.ts +114 -0
  151. package/src/core/tools/task/bundled-agents/browser.md +71 -0
  152. package/src/core/tools/task/bundled-agents/explore.md +82 -0
  153. package/src/core/tools/task/bundled-agents/plan.md +54 -0
  154. package/src/core/tools/task/bundled-agents/reviewer.md +59 -0
  155. package/src/core/tools/task/bundled-agents/task.md +53 -0
  156. package/src/core/tools/task/bundled-commands/architect-plan.md +10 -0
  157. package/src/core/tools/task/bundled-commands/implement-with-critic.md +11 -0
  158. package/src/core/tools/task/bundled-commands/implement.md +11 -0
  159. package/src/core/tools/task/commands.ts +213 -0
  160. package/src/core/tools/task/discovery.ts +208 -0
  161. package/src/core/tools/task/executor.ts +367 -0
  162. package/src/core/tools/task/index.ts +388 -0
  163. package/src/core/tools/task/model-resolver.ts +115 -0
  164. package/src/core/tools/task/parallel.ts +38 -0
  165. package/src/core/tools/task/render.ts +232 -0
  166. package/src/core/tools/task/types.ts +99 -0
  167. package/src/core/tools/truncate.ts +265 -0
  168. package/src/core/tools/web-fetch.ts +2370 -0
  169. package/src/core/tools/web-search/auth.ts +193 -0
  170. package/src/core/tools/web-search/index.ts +537 -0
  171. package/src/core/tools/web-search/providers/anthropic.ts +198 -0
  172. package/src/core/tools/web-search/providers/exa.ts +302 -0
  173. package/src/core/tools/web-search/providers/perplexity.ts +195 -0
  174. package/src/core/tools/web-search/render.ts +182 -0
  175. package/src/core/tools/web-search/types.ts +180 -0
  176. package/src/core/tools/write.ts +99 -0
  177. package/src/index.ts +176 -0
  178. package/src/main.ts +464 -0
  179. package/src/migrations.ts +135 -0
  180. package/src/modes/index.ts +43 -0
  181. package/src/modes/interactive/components/armin.ts +382 -0
  182. package/src/modes/interactive/components/assistant-message.ts +86 -0
  183. package/src/modes/interactive/components/bash-execution.ts +196 -0
  184. package/src/modes/interactive/components/bordered-loader.ts +41 -0
  185. package/src/modes/interactive/components/branch-summary-message.ts +42 -0
  186. package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
  187. package/src/modes/interactive/components/custom-editor.ts +122 -0
  188. package/src/modes/interactive/components/diff.ts +147 -0
  189. package/src/modes/interactive/components/dynamic-border.ts +25 -0
  190. package/src/modes/interactive/components/footer.ts +381 -0
  191. package/src/modes/interactive/components/hook-editor.ts +117 -0
  192. package/src/modes/interactive/components/hook-input.ts +64 -0
  193. package/src/modes/interactive/components/hook-message.ts +96 -0
  194. package/src/modes/interactive/components/hook-selector.ts +91 -0
  195. package/src/modes/interactive/components/model-selector.ts +247 -0
  196. package/src/modes/interactive/components/oauth-selector.ts +120 -0
  197. package/src/modes/interactive/components/plugin-settings.ts +479 -0
  198. package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
  199. package/src/modes/interactive/components/session-selector.ts +204 -0
  200. package/src/modes/interactive/components/settings-selector.ts +453 -0
  201. package/src/modes/interactive/components/show-images-selector.ts +45 -0
  202. package/src/modes/interactive/components/theme-selector.ts +62 -0
  203. package/src/modes/interactive/components/thinking-selector.ts +64 -0
  204. package/src/modes/interactive/components/tool-execution.ts +675 -0
  205. package/src/modes/interactive/components/tree-selector.ts +866 -0
  206. package/src/modes/interactive/components/user-message-selector.ts +159 -0
  207. package/src/modes/interactive/components/user-message.ts +18 -0
  208. package/src/modes/interactive/components/visual-truncate.ts +50 -0
  209. package/src/modes/interactive/components/welcome.ts +183 -0
  210. package/src/modes/interactive/interactive-mode.ts +2516 -0
  211. package/src/modes/interactive/theme/dark.json +101 -0
  212. package/src/modes/interactive/theme/light.json +98 -0
  213. package/src/modes/interactive/theme/theme-schema.json +308 -0
  214. package/src/modes/interactive/theme/theme.ts +998 -0
  215. package/src/modes/print-mode.ts +128 -0
  216. package/src/modes/rpc/rpc-client.ts +527 -0
  217. package/src/modes/rpc/rpc-mode.ts +483 -0
  218. package/src/modes/rpc/rpc-types.ts +203 -0
  219. package/src/utils/changelog.ts +99 -0
  220. package/src/utils/clipboard.ts +265 -0
  221. package/src/utils/fuzzy.ts +108 -0
  222. package/src/utils/mime.ts +30 -0
  223. package/src/utils/shell.ts +276 -0
  224. package/src/utils/tools-manager.ts +274 -0
@@ -0,0 +1,1128 @@
1
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
+ import type { ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
3
+ import {
4
+ appendFileSync,
5
+ closeSync,
6
+ existsSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ readSync,
12
+ statSync,
13
+ writeFileSync,
14
+ } from "fs";
15
+ import { join, resolve } from "path";
16
+ import { getAgentDir as getDefaultAgentDir } from "../config.js";
17
+ import {
18
+ type BashExecutionMessage,
19
+ createBranchSummaryMessage,
20
+ createCompactionSummaryMessage,
21
+ createHookMessage,
22
+ type HookMessage,
23
+ } from "./messages.js";
24
+
25
+ export const CURRENT_SESSION_VERSION = 2;
26
+
27
+ export interface SessionHeader {
28
+ type: "session";
29
+ version?: number; // v1 sessions don't have this
30
+ id: string;
31
+ timestamp: string;
32
+ cwd: string;
33
+ parentSession?: string;
34
+ }
35
+
36
+ export interface NewSessionOptions {
37
+ parentSession?: string;
38
+ }
39
+
40
+ export interface SessionEntryBase {
41
+ type: string;
42
+ id: string;
43
+ parentId: string | null;
44
+ timestamp: string;
45
+ }
46
+
47
+ export interface SessionMessageEntry extends SessionEntryBase {
48
+ type: "message";
49
+ message: AgentMessage;
50
+ }
51
+
52
+ export interface ThinkingLevelChangeEntry extends SessionEntryBase {
53
+ type: "thinking_level_change";
54
+ thinkingLevel: string;
55
+ }
56
+
57
+ export interface ModelChangeEntry extends SessionEntryBase {
58
+ type: "model_change";
59
+ provider: string;
60
+ modelId: string;
61
+ }
62
+
63
+ export interface CompactionEntry<T = unknown> extends SessionEntryBase {
64
+ type: "compaction";
65
+ summary: string;
66
+ firstKeptEntryId: string;
67
+ tokensBefore: number;
68
+ /** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
69
+ details?: T;
70
+ /** True if generated by a hook, undefined/false if pi-generated (backward compatible) */
71
+ fromHook?: boolean;
72
+ }
73
+
74
+ export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
75
+ type: "branch_summary";
76
+ fromId: string;
77
+ summary: string;
78
+ /** Hook-specific data (not sent to LLM) */
79
+ details?: T;
80
+ /** True if generated by a hook, false if pi-generated */
81
+ fromHook?: boolean;
82
+ }
83
+
84
+ /**
85
+ * Custom entry for hooks to store hook-specific data in the session.
86
+ * Use customType to identify your hook's entries.
87
+ *
88
+ * Purpose: Persist hook state across session reloads. On reload, hooks can
89
+ * scan entries for their customType and reconstruct internal state.
90
+ *
91
+ * Does NOT participate in LLM context (ignored by buildSessionContext).
92
+ * For injecting content into context, see CustomMessageEntry.
93
+ */
94
+ export interface CustomEntry<T = unknown> extends SessionEntryBase {
95
+ type: "custom";
96
+ customType: string;
97
+ data?: T;
98
+ }
99
+
100
+ /** Label entry for user-defined bookmarks/markers on entries. */
101
+ export interface LabelEntry extends SessionEntryBase {
102
+ type: "label";
103
+ targetId: string;
104
+ label: string | undefined;
105
+ }
106
+
107
+ /**
108
+ * Custom message entry for hooks to inject messages into LLM context.
109
+ * Use customType to identify your hook's entries.
110
+ *
111
+ * Unlike CustomEntry, this DOES participate in LLM context.
112
+ * The content is converted to a user message in buildSessionContext().
113
+ * Use details for hook-specific metadata (not sent to LLM).
114
+ *
115
+ * display controls TUI rendering:
116
+ * - false: hidden entirely
117
+ * - true: rendered with distinct styling (different from user messages)
118
+ */
119
+ export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
120
+ type: "custom_message";
121
+ customType: string;
122
+ content: string | (TextContent | ImageContent)[];
123
+ details?: T;
124
+ display: boolean;
125
+ }
126
+
127
+ /** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */
128
+ export type SessionEntry =
129
+ | SessionMessageEntry
130
+ | ThinkingLevelChangeEntry
131
+ | ModelChangeEntry
132
+ | CompactionEntry
133
+ | BranchSummaryEntry
134
+ | CustomEntry
135
+ | CustomMessageEntry
136
+ | LabelEntry;
137
+
138
+ /** Raw file entry (includes header) */
139
+ export type FileEntry = SessionHeader | SessionEntry;
140
+
141
+ /** Tree node for getTree() - defensive copy of session structure */
142
+ export interface SessionTreeNode {
143
+ entry: SessionEntry;
144
+ children: SessionTreeNode[];
145
+ /** Resolved label for this entry, if any */
146
+ label?: string;
147
+ }
148
+
149
+ export interface SessionContext {
150
+ messages: AgentMessage[];
151
+ thinkingLevel: string;
152
+ model: { provider: string; modelId: string } | null;
153
+ }
154
+
155
+ export interface SessionInfo {
156
+ path: string;
157
+ id: string;
158
+ created: Date;
159
+ modified: Date;
160
+ messageCount: number;
161
+ firstMessage: string;
162
+ allMessagesText: string;
163
+ }
164
+
165
+ export type ReadonlySessionManager = Pick<
166
+ SessionManager,
167
+ | "getCwd"
168
+ | "getSessionDir"
169
+ | "getSessionId"
170
+ | "getSessionFile"
171
+ | "getLeafId"
172
+ | "getLeafEntry"
173
+ | "getEntry"
174
+ | "getLabel"
175
+ | "getBranch"
176
+ | "getHeader"
177
+ | "getEntries"
178
+ | "getTree"
179
+ >;
180
+
181
+ /** Generate a unique short ID (8 hex chars, collision-checked) */
182
+ function generateId(byId: { has(id: string): boolean }): string {
183
+ for (let i = 0; i < 100; i++) {
184
+ const id = crypto.randomUUID().slice(0, 8);
185
+ if (!byId.has(id)) return id;
186
+ }
187
+ // Fallback to full UUID if somehow we have collisions
188
+ return crypto.randomUUID();
189
+ }
190
+
191
+ /** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
192
+ function migrateV1ToV2(entries: FileEntry[]): void {
193
+ const ids = new Set<string>();
194
+ let prevId: string | null = null;
195
+
196
+ for (const entry of entries) {
197
+ if (entry.type === "session") {
198
+ entry.version = 2;
199
+ continue;
200
+ }
201
+
202
+ entry.id = generateId(ids);
203
+ entry.parentId = prevId;
204
+ prevId = entry.id;
205
+
206
+ // Convert firstKeptEntryIndex to firstKeptEntryId for compaction
207
+ if (entry.type === "compaction") {
208
+ const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };
209
+ if (typeof comp.firstKeptEntryIndex === "number") {
210
+ const targetEntry = entries[comp.firstKeptEntryIndex];
211
+ if (targetEntry && targetEntry.type !== "session") {
212
+ comp.firstKeptEntryId = targetEntry.id;
213
+ }
214
+ delete comp.firstKeptEntryIndex;
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ // Add future migrations here:
221
+ // function migrateV2ToV3(entries: FileEntry[]): void { ... }
222
+
223
+ /**
224
+ * Run all necessary migrations to bring entries to current version.
225
+ * Mutates entries in place. Returns true if any migration was applied.
226
+ */
227
+ function migrateToCurrentVersion(entries: FileEntry[]): boolean {
228
+ const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
229
+ const version = header?.version ?? 1;
230
+
231
+ if (version >= CURRENT_SESSION_VERSION) return false;
232
+
233
+ if (version < 2) migrateV1ToV2(entries);
234
+ // if (version < 3) migrateV2ToV3(entries);
235
+
236
+ return true;
237
+ }
238
+
239
+ /** Exported for testing */
240
+ export function migrateSessionEntries(entries: FileEntry[]): void {
241
+ migrateToCurrentVersion(entries);
242
+ }
243
+
244
+ /** Exported for compaction.test.ts */
245
+ export function parseSessionEntries(content: string): FileEntry[] {
246
+ const entries: FileEntry[] = [];
247
+ const lines = content.trim().split("\n");
248
+
249
+ for (const line of lines) {
250
+ if (!line.trim()) continue;
251
+ try {
252
+ const entry = JSON.parse(line) as FileEntry;
253
+ entries.push(entry);
254
+ } catch {
255
+ // Skip malformed lines
256
+ }
257
+ }
258
+
259
+ return entries;
260
+ }
261
+
262
+ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
263
+ for (let i = entries.length - 1; i >= 0; i--) {
264
+ if (entries[i].type === "compaction") {
265
+ return entries[i] as CompactionEntry;
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+
271
+ /**
272
+ * Build the session context from entries using tree traversal.
273
+ * If leafId is provided, walks from that entry to root.
274
+ * Handles compaction and branch summaries along the path.
275
+ */
276
+ export function buildSessionContext(
277
+ entries: SessionEntry[],
278
+ leafId?: string | null,
279
+ byId?: Map<string, SessionEntry>,
280
+ ): SessionContext {
281
+ // Build uuid index if not available
282
+ if (!byId) {
283
+ byId = new Map<string, SessionEntry>();
284
+ for (const entry of entries) {
285
+ byId.set(entry.id, entry);
286
+ }
287
+ }
288
+
289
+ // Find leaf
290
+ let leaf: SessionEntry | undefined;
291
+ if (leafId === null) {
292
+ // Explicitly null - return no messages (navigated to before first entry)
293
+ return { messages: [], thinkingLevel: "off", model: null };
294
+ }
295
+ if (leafId) {
296
+ leaf = byId.get(leafId);
297
+ }
298
+ if (!leaf) {
299
+ // Fallback to last entry (when leafId is undefined)
300
+ leaf = entries[entries.length - 1];
301
+ }
302
+
303
+ if (!leaf) {
304
+ return { messages: [], thinkingLevel: "off", model: null };
305
+ }
306
+
307
+ // Walk from leaf to root, collecting path
308
+ const path: SessionEntry[] = [];
309
+ let current: SessionEntry | undefined = leaf;
310
+ while (current) {
311
+ path.unshift(current);
312
+ current = current.parentId ? byId.get(current.parentId) : undefined;
313
+ }
314
+
315
+ // Extract settings and find compaction
316
+ let thinkingLevel = "off";
317
+ let model: { provider: string; modelId: string } | null = null;
318
+ let compaction: CompactionEntry | null = null;
319
+
320
+ for (const entry of path) {
321
+ if (entry.type === "thinking_level_change") {
322
+ thinkingLevel = entry.thinkingLevel;
323
+ } else if (entry.type === "model_change") {
324
+ model = { provider: entry.provider, modelId: entry.modelId };
325
+ } else if (entry.type === "message" && entry.message.role === "assistant") {
326
+ model = { provider: entry.message.provider, modelId: entry.message.model };
327
+ } else if (entry.type === "compaction") {
328
+ compaction = entry;
329
+ }
330
+ }
331
+
332
+ // Build messages and collect corresponding entries
333
+ // When there's a compaction, we need to:
334
+ // 1. Emit summary first (entry = compaction)
335
+ // 2. Emit kept messages (from firstKeptEntryId up to compaction)
336
+ // 3. Emit messages after compaction
337
+ const messages: AgentMessage[] = [];
338
+
339
+ const appendMessage = (entry: SessionEntry) => {
340
+ if (entry.type === "message") {
341
+ messages.push(entry.message);
342
+ } else if (entry.type === "custom_message") {
343
+ messages.push(
344
+ createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
345
+ );
346
+ } else if (entry.type === "branch_summary" && entry.summary) {
347
+ messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
348
+ }
349
+ };
350
+
351
+ if (compaction) {
352
+ // Emit summary first
353
+ messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));
354
+
355
+ // Find compaction index in path
356
+ const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id);
357
+
358
+ // Emit kept messages (before compaction, starting from firstKeptEntryId)
359
+ let foundFirstKept = false;
360
+ for (let i = 0; i < compactionIdx; i++) {
361
+ const entry = path[i];
362
+ if (entry.id === compaction.firstKeptEntryId) {
363
+ foundFirstKept = true;
364
+ }
365
+ if (foundFirstKept) {
366
+ appendMessage(entry);
367
+ }
368
+ }
369
+
370
+ // Emit messages after compaction
371
+ for (let i = compactionIdx + 1; i < path.length; i++) {
372
+ const entry = path[i];
373
+ appendMessage(entry);
374
+ }
375
+ } else {
376
+ // No compaction - emit all messages, handle branch summaries and custom messages
377
+ for (const entry of path) {
378
+ appendMessage(entry);
379
+ }
380
+ }
381
+
382
+ return { messages, thinkingLevel, model };
383
+ }
384
+
385
+ /**
386
+ * Compute the default session directory for a cwd.
387
+ * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.
388
+ */
389
+ function getDefaultSessionDir(cwd: string): string {
390
+ const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
391
+ const sessionDir = join(getDefaultAgentDir(), "sessions", safePath);
392
+ if (!existsSync(sessionDir)) {
393
+ mkdirSync(sessionDir, { recursive: true });
394
+ }
395
+ return sessionDir;
396
+ }
397
+
398
+ /** Exported for testing */
399
+ export function loadEntriesFromFile(filePath: string): FileEntry[] {
400
+ if (!existsSync(filePath)) return [];
401
+
402
+ const content = readFileSync(filePath, "utf8");
403
+ const entries: FileEntry[] = [];
404
+ const lines = content.trim().split("\n");
405
+
406
+ for (const line of lines) {
407
+ if (!line.trim()) continue;
408
+ try {
409
+ const entry = JSON.parse(line) as FileEntry;
410
+ entries.push(entry);
411
+ } catch {
412
+ // Skip malformed lines
413
+ }
414
+ }
415
+
416
+ // Validate session header
417
+ if (entries.length === 0) return entries;
418
+ const header = entries[0];
419
+ if (header.type !== "session" || typeof (header as any).id !== "string") {
420
+ return [];
421
+ }
422
+
423
+ return entries;
424
+ }
425
+
426
+ function isValidSessionFile(filePath: string): boolean {
427
+ try {
428
+ const fd = openSync(filePath, "r");
429
+ const buffer = Buffer.alloc(512);
430
+ const bytesRead = readSync(fd, buffer, 0, 512, 0);
431
+ closeSync(fd);
432
+ const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0];
433
+ if (!firstLine) return false;
434
+ const header = JSON.parse(firstLine);
435
+ return header.type === "session" && typeof header.id === "string";
436
+ } catch {
437
+ return false;
438
+ }
439
+ }
440
+
441
+ /** Exported for testing */
442
+ export function findMostRecentSession(sessionDir: string): string | null {
443
+ try {
444
+ const files = readdirSync(sessionDir)
445
+ .filter((f) => f.endsWith(".jsonl"))
446
+ .map((f) => join(sessionDir, f))
447
+ .filter(isValidSessionFile)
448
+ .map((path) => ({ path, mtime: statSync(path).mtime }))
449
+ .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
450
+
451
+ return files[0]?.path || null;
452
+ } catch {
453
+ return null;
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Manages conversation sessions as append-only trees stored in JSONL files.
459
+ *
460
+ * Each session entry has an id and parentId forming a tree structure. The "leaf"
461
+ * pointer tracks the current position. Appending creates a child of the current leaf.
462
+ * Branching moves the leaf to an earlier entry, allowing new branches without
463
+ * modifying history.
464
+ *
465
+ * Use buildSessionContext() to get the resolved message list for the LLM, which
466
+ * handles compaction summaries and follows the path from root to current leaf.
467
+ */
468
+ export class SessionManager {
469
+ private sessionId: string = "";
470
+ private sessionFile: string | undefined;
471
+ private sessionDir: string;
472
+ private cwd: string;
473
+ private persist: boolean;
474
+ private flushed: boolean = false;
475
+ private fileEntries: FileEntry[] = [];
476
+ private byId: Map<string, SessionEntry> = new Map();
477
+ private labelsById: Map<string, string> = new Map();
478
+ private leafId: string | null = null;
479
+
480
+ private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
481
+ this.cwd = cwd;
482
+ this.sessionDir = sessionDir;
483
+ this.persist = persist;
484
+ if (persist && sessionDir && !existsSync(sessionDir)) {
485
+ mkdirSync(sessionDir, { recursive: true });
486
+ }
487
+
488
+ if (sessionFile) {
489
+ this.setSessionFile(sessionFile);
490
+ } else {
491
+ this.newSession();
492
+ }
493
+ }
494
+
495
+ /** Switch to a different session file (used for resume and branching) */
496
+ setSessionFile(sessionFile: string): void {
497
+ this.sessionFile = resolve(sessionFile);
498
+ if (existsSync(this.sessionFile)) {
499
+ this.fileEntries = loadEntriesFromFile(this.sessionFile);
500
+ const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
501
+ this.sessionId = header?.id ?? crypto.randomUUID();
502
+
503
+ if (migrateToCurrentVersion(this.fileEntries)) {
504
+ this._rewriteFile();
505
+ }
506
+
507
+ this._buildIndex();
508
+ this.flushed = true;
509
+ } else {
510
+ this.newSession();
511
+ }
512
+ }
513
+
514
+ newSession(options?: NewSessionOptions): string | undefined {
515
+ this.sessionId = crypto.randomUUID();
516
+ const timestamp = new Date().toISOString();
517
+ const header: SessionHeader = {
518
+ type: "session",
519
+ version: CURRENT_SESSION_VERSION,
520
+ id: this.sessionId,
521
+ timestamp,
522
+ cwd: this.cwd,
523
+ parentSession: options?.parentSession,
524
+ };
525
+ this.fileEntries = [header];
526
+ this.byId.clear();
527
+ this.leafId = null;
528
+ this.flushed = false;
529
+
530
+ // Only generate filename if persisting and not already set (e.g., via --session flag)
531
+ if (this.persist && !this.sessionFile) {
532
+ const fileTimestamp = timestamp.replace(/[:.]/g, "-");
533
+ this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
534
+ }
535
+ return this.sessionFile;
536
+ }
537
+
538
+ private _buildIndex(): void {
539
+ this.byId.clear();
540
+ this.labelsById.clear();
541
+ this.leafId = null;
542
+ for (const entry of this.fileEntries) {
543
+ if (entry.type === "session") continue;
544
+ this.byId.set(entry.id, entry);
545
+ this.leafId = entry.id;
546
+ if (entry.type === "label") {
547
+ if (entry.label) {
548
+ this.labelsById.set(entry.targetId, entry.label);
549
+ } else {
550
+ this.labelsById.delete(entry.targetId);
551
+ }
552
+ }
553
+ }
554
+ }
555
+
556
+ private _rewriteFile(): void {
557
+ if (!this.persist || !this.sessionFile) return;
558
+ const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`;
559
+ writeFileSync(this.sessionFile, content);
560
+ }
561
+
562
+ isPersisted(): boolean {
563
+ return this.persist;
564
+ }
565
+
566
+ getCwd(): string {
567
+ return this.cwd;
568
+ }
569
+
570
+ getSessionDir(): string {
571
+ return this.sessionDir;
572
+ }
573
+
574
+ getSessionId(): string {
575
+ return this.sessionId;
576
+ }
577
+
578
+ getSessionFile(): string | undefined {
579
+ return this.sessionFile;
580
+ }
581
+
582
+ _persist(entry: SessionEntry): void {
583
+ if (!this.persist || !this.sessionFile) return;
584
+
585
+ const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
586
+ if (!hasAssistant) return;
587
+
588
+ if (!this.flushed) {
589
+ for (const e of this.fileEntries) {
590
+ appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`);
591
+ }
592
+ this.flushed = true;
593
+ } else {
594
+ appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
595
+ }
596
+ }
597
+
598
+ private _appendEntry(entry: SessionEntry): void {
599
+ this.fileEntries.push(entry);
600
+ this.byId.set(entry.id, entry);
601
+ this.leafId = entry.id;
602
+ this._persist(entry);
603
+ }
604
+
605
+ /** Append a message as child of current leaf, then advance leaf. Returns entry id.
606
+ * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
607
+ * Reason: we want these to be top-level entries in the session, not message session entries,
608
+ * so it is easier to find them.
609
+ * These need to be appended via appendCompaction() and appendBranchSummary() methods.
610
+ */
611
+ appendMessage(message: Message | HookMessage | BashExecutionMessage): string {
612
+ const entry: SessionMessageEntry = {
613
+ type: "message",
614
+ id: generateId(this.byId),
615
+ parentId: this.leafId,
616
+ timestamp: new Date().toISOString(),
617
+ message,
618
+ };
619
+ this._appendEntry(entry);
620
+ return entry.id;
621
+ }
622
+
623
+ /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
624
+ appendThinkingLevelChange(thinkingLevel: string): string {
625
+ const entry: ThinkingLevelChangeEntry = {
626
+ type: "thinking_level_change",
627
+ id: generateId(this.byId),
628
+ parentId: this.leafId,
629
+ timestamp: new Date().toISOString(),
630
+ thinkingLevel,
631
+ };
632
+ this._appendEntry(entry);
633
+ return entry.id;
634
+ }
635
+
636
+ /** Append a model change as child of current leaf, then advance leaf. Returns entry id. */
637
+ appendModelChange(provider: string, modelId: string): string {
638
+ const entry: ModelChangeEntry = {
639
+ type: "model_change",
640
+ id: generateId(this.byId),
641
+ parentId: this.leafId,
642
+ timestamp: new Date().toISOString(),
643
+ provider,
644
+ modelId,
645
+ };
646
+ this._appendEntry(entry);
647
+ return entry.id;
648
+ }
649
+
650
+ /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
651
+ appendCompaction<T = unknown>(
652
+ summary: string,
653
+ firstKeptEntryId: string,
654
+ tokensBefore: number,
655
+ details?: T,
656
+ fromHook?: boolean,
657
+ ): string {
658
+ const entry: CompactionEntry<T> = {
659
+ type: "compaction",
660
+ id: generateId(this.byId),
661
+ parentId: this.leafId,
662
+ timestamp: new Date().toISOString(),
663
+ summary,
664
+ firstKeptEntryId,
665
+ tokensBefore,
666
+ details,
667
+ fromHook,
668
+ };
669
+ this._appendEntry(entry);
670
+ return entry.id;
671
+ }
672
+
673
+ /** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */
674
+ appendCustomEntry(customType: string, data?: unknown): string {
675
+ const entry: CustomEntry = {
676
+ type: "custom",
677
+ customType,
678
+ data,
679
+ id: generateId(this.byId),
680
+ parentId: this.leafId,
681
+ timestamp: new Date().toISOString(),
682
+ };
683
+ this._appendEntry(entry);
684
+ return entry.id;
685
+ }
686
+
687
+ /**
688
+ * Append a custom message entry (for hooks) that participates in LLM context.
689
+ * @param customType Hook identifier for filtering on reload
690
+ * @param content Message content (string or TextContent/ImageContent array)
691
+ * @param display Whether to show in TUI (true = styled display, false = hidden)
692
+ * @param details Optional hook-specific metadata (not sent to LLM)
693
+ * @returns Entry id
694
+ */
695
+ appendCustomMessageEntry<T = unknown>(
696
+ customType: string,
697
+ content: string | (TextContent | ImageContent)[],
698
+ display: boolean,
699
+ details?: T,
700
+ ): string {
701
+ const entry: CustomMessageEntry<T> = {
702
+ type: "custom_message",
703
+ customType,
704
+ content,
705
+ display,
706
+ details,
707
+ id: generateId(this.byId),
708
+ parentId: this.leafId,
709
+ timestamp: new Date().toISOString(),
710
+ };
711
+ this._appendEntry(entry);
712
+ return entry.id;
713
+ }
714
+
715
+ // =========================================================================
716
+ // Tree Traversal
717
+ // =========================================================================
718
+
719
+ getLeafId(): string | null {
720
+ return this.leafId;
721
+ }
722
+
723
+ getLeafEntry(): SessionEntry | undefined {
724
+ return this.leafId ? this.byId.get(this.leafId) : undefined;
725
+ }
726
+
727
+ getEntry(id: string): SessionEntry | undefined {
728
+ return this.byId.get(id);
729
+ }
730
+
731
+ /**
732
+ * Get all direct children of an entry.
733
+ */
734
+ getChildren(parentId: string): SessionEntry[] {
735
+ const children: SessionEntry[] = [];
736
+ for (const entry of this.byId.values()) {
737
+ if (entry.parentId === parentId) {
738
+ children.push(entry);
739
+ }
740
+ }
741
+ return children;
742
+ }
743
+
744
+ /**
745
+ * Get the label for an entry, if any.
746
+ */
747
+ getLabel(id: string): string | undefined {
748
+ return this.labelsById.get(id);
749
+ }
750
+
751
+ /**
752
+ * Set or clear a label on an entry.
753
+ * Labels are user-defined markers for bookmarking/navigation.
754
+ * Pass undefined or empty string to clear the label.
755
+ */
756
+ appendLabelChange(targetId: string, label: string | undefined): string {
757
+ if (!this.byId.has(targetId)) {
758
+ throw new Error(`Entry ${targetId} not found`);
759
+ }
760
+ const entry: LabelEntry = {
761
+ type: "label",
762
+ id: generateId(this.byId),
763
+ parentId: this.leafId,
764
+ timestamp: new Date().toISOString(),
765
+ targetId,
766
+ label,
767
+ };
768
+ this._appendEntry(entry);
769
+ if (label) {
770
+ this.labelsById.set(targetId, label);
771
+ } else {
772
+ this.labelsById.delete(targetId);
773
+ }
774
+ return entry.id;
775
+ }
776
+
777
+ /**
778
+ * Walk from entry to root, returning all entries in path order.
779
+ * Includes all entry types (messages, compaction, model changes, etc.).
780
+ * Use buildSessionContext() to get the resolved messages for the LLM.
781
+ */
782
+ getBranch(fromId?: string): SessionEntry[] {
783
+ const path: SessionEntry[] = [];
784
+ const startId = fromId ?? this.leafId;
785
+ let current = startId ? this.byId.get(startId) : undefined;
786
+ while (current) {
787
+ path.unshift(current);
788
+ current = current.parentId ? this.byId.get(current.parentId) : undefined;
789
+ }
790
+ return path;
791
+ }
792
+
793
+ /**
794
+ * Build the session context (what gets sent to the LLM).
795
+ * Uses tree traversal from current leaf.
796
+ */
797
+ buildSessionContext(): SessionContext {
798
+ return buildSessionContext(this.getEntries(), this.leafId, this.byId);
799
+ }
800
+
801
+ /**
802
+ * Get session header.
803
+ */
804
+ getHeader(): SessionHeader | null {
805
+ const h = this.fileEntries.find((e) => e.type === "session");
806
+ return h ? (h as SessionHeader) : null;
807
+ }
808
+
809
+ /**
810
+ * Get all session entries (excludes header). Returns a shallow copy.
811
+ * The session is append-only: use appendXXX() to add entries, branch() to
812
+ * change the leaf pointer. Entries cannot be modified or deleted.
813
+ */
814
+ getEntries(): SessionEntry[] {
815
+ return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session");
816
+ }
817
+
818
+ /**
819
+ * Get the session as a tree structure. Returns a shallow defensive copy of all entries.
820
+ * A well-formed session has exactly one root (first entry with parentId === null).
821
+ * Orphaned entries (broken parent chain) are also returned as roots.
822
+ */
823
+ getTree(): SessionTreeNode[] {
824
+ const entries = this.getEntries();
825
+ const nodeMap = new Map<string, SessionTreeNode>();
826
+ const roots: SessionTreeNode[] = [];
827
+
828
+ // Create nodes with resolved labels
829
+ for (const entry of entries) {
830
+ const label = this.labelsById.get(entry.id);
831
+ nodeMap.set(entry.id, { entry, children: [], label });
832
+ }
833
+
834
+ // Build tree
835
+ for (const entry of entries) {
836
+ const node = nodeMap.get(entry.id)!;
837
+ if (entry.parentId === null || entry.parentId === entry.id) {
838
+ roots.push(node);
839
+ } else {
840
+ const parent = nodeMap.get(entry.parentId);
841
+ if (parent) {
842
+ parent.children.push(node);
843
+ } else {
844
+ // Orphan - treat as root
845
+ roots.push(node);
846
+ }
847
+ }
848
+ }
849
+
850
+ // Sort children by timestamp (oldest first, newest at bottom)
851
+ // Use iterative approach to avoid stack overflow on deep trees
852
+ const stack: SessionTreeNode[] = [...roots];
853
+ while (stack.length > 0) {
854
+ const node = stack.pop()!;
855
+ node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
856
+ stack.push(...node.children);
857
+ }
858
+
859
+ return roots;
860
+ }
861
+
862
+ // =========================================================================
863
+ // Branching
864
+ // =========================================================================
865
+
866
+ /**
867
+ * Start a new branch from an earlier entry.
868
+ * Moves the leaf pointer to the specified entry. The next appendXXX() call
869
+ * will create a child of that entry, forming a new branch. Existing entries
870
+ * are not modified or deleted.
871
+ */
872
+ branch(branchFromId: string): void {
873
+ if (!this.byId.has(branchFromId)) {
874
+ throw new Error(`Entry ${branchFromId} not found`);
875
+ }
876
+ this.leafId = branchFromId;
877
+ }
878
+
879
+ /**
880
+ * Reset the leaf pointer to null (before any entries).
881
+ * The next appendXXX() call will create a new root entry (parentId = null).
882
+ * Use this when navigating to re-edit the first user message.
883
+ */
884
+ resetLeaf(): void {
885
+ this.leafId = null;
886
+ }
887
+
888
+ /**
889
+ * Start a new branch with a summary of the abandoned path.
890
+ * Same as branch(), but also appends a branch_summary entry that captures
891
+ * context from the abandoned conversation path.
892
+ */
893
+ branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string {
894
+ if (branchFromId !== null && !this.byId.has(branchFromId)) {
895
+ throw new Error(`Entry ${branchFromId} not found`);
896
+ }
897
+ this.leafId = branchFromId;
898
+ const entry: BranchSummaryEntry = {
899
+ type: "branch_summary",
900
+ id: generateId(this.byId),
901
+ parentId: branchFromId,
902
+ timestamp: new Date().toISOString(),
903
+ fromId: branchFromId ?? "root",
904
+ summary,
905
+ details,
906
+ fromHook,
907
+ };
908
+ this._appendEntry(entry);
909
+ return entry.id;
910
+ }
911
+
912
+ /**
913
+ * Create a new session file containing only the path from root to the specified leaf.
914
+ * Useful for extracting a single conversation path from a branched session.
915
+ * Returns the new session file path, or undefined if not persisting.
916
+ */
917
+ createBranchedSession(leafId: string): string | undefined {
918
+ const path = this.getBranch(leafId);
919
+ if (path.length === 0) {
920
+ throw new Error(`Entry ${leafId} not found`);
921
+ }
922
+
923
+ // Filter out LabelEntry from path - we'll recreate them from the resolved map
924
+ const pathWithoutLabels = path.filter((e) => e.type !== "label");
925
+
926
+ const newSessionId = crypto.randomUUID();
927
+ const timestamp = new Date().toISOString();
928
+ const fileTimestamp = timestamp.replace(/[:.]/g, "-");
929
+ const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
930
+
931
+ const header: SessionHeader = {
932
+ type: "session",
933
+ version: CURRENT_SESSION_VERSION,
934
+ id: newSessionId,
935
+ timestamp,
936
+ cwd: this.cwd,
937
+ parentSession: this.persist ? this.sessionFile : undefined,
938
+ };
939
+
940
+ // Collect labels for entries in the path
941
+ const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));
942
+ const labelsToWrite: Array<{ targetId: string; label: string }> = [];
943
+ for (const [targetId, label] of this.labelsById) {
944
+ if (pathEntryIds.has(targetId)) {
945
+ labelsToWrite.push({ targetId, label });
946
+ }
947
+ }
948
+
949
+ if (this.persist) {
950
+ appendFileSync(newSessionFile, `${JSON.stringify(header)}\n`);
951
+ for (const entry of pathWithoutLabels) {
952
+ appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
953
+ }
954
+ // Write fresh label entries at the end
955
+ const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
956
+ let parentId = lastEntryId;
957
+ const labelEntries: LabelEntry[] = [];
958
+ for (const { targetId, label } of labelsToWrite) {
959
+ const labelEntry: LabelEntry = {
960
+ type: "label",
961
+ id: generateId(new Set(pathEntryIds)),
962
+ parentId,
963
+ timestamp: new Date().toISOString(),
964
+ targetId,
965
+ label,
966
+ };
967
+ appendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\n`);
968
+ pathEntryIds.add(labelEntry.id);
969
+ labelEntries.push(labelEntry);
970
+ parentId = labelEntry.id;
971
+ }
972
+ this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
973
+ this.sessionId = newSessionId;
974
+ this._buildIndex();
975
+ return newSessionFile;
976
+ }
977
+
978
+ // In-memory mode: replace current session with the path + labels
979
+ const labelEntries: LabelEntry[] = [];
980
+ let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
981
+ for (const { targetId, label } of labelsToWrite) {
982
+ const labelEntry: LabelEntry = {
983
+ type: "label",
984
+ id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),
985
+ parentId,
986
+ timestamp: new Date().toISOString(),
987
+ targetId,
988
+ label,
989
+ };
990
+ labelEntries.push(labelEntry);
991
+ parentId = labelEntry.id;
992
+ }
993
+ this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
994
+ this.sessionId = newSessionId;
995
+ this._buildIndex();
996
+ return undefined;
997
+ }
998
+
999
+ /**
1000
+ * Create a new session.
1001
+ * @param cwd Working directory (stored in session header)
1002
+ * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
1003
+ */
1004
+ static create(cwd: string, sessionDir?: string): SessionManager {
1005
+ const dir = sessionDir ?? getDefaultSessionDir(cwd);
1006
+ return new SessionManager(cwd, dir, undefined, true);
1007
+ }
1008
+
1009
+ /**
1010
+ * Open a specific session file.
1011
+ * @param path Path to session file
1012
+ * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
1013
+ */
1014
+ static open(path: string, sessionDir?: string): SessionManager {
1015
+ // Extract cwd from session header if possible, otherwise use process.cwd()
1016
+ const entries = loadEntriesFromFile(path);
1017
+ const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
1018
+ const cwd = header?.cwd ?? process.cwd();
1019
+ // If no sessionDir provided, derive from file's parent directory
1020
+ const dir = sessionDir ?? resolve(path, "..");
1021
+ return new SessionManager(cwd, dir, path, true);
1022
+ }
1023
+
1024
+ /**
1025
+ * Continue the most recent session, or create new if none.
1026
+ * @param cwd Working directory
1027
+ * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
1028
+ */
1029
+ static continueRecent(cwd: string, sessionDir?: string): SessionManager {
1030
+ const dir = sessionDir ?? getDefaultSessionDir(cwd);
1031
+ const mostRecent = findMostRecentSession(dir);
1032
+ if (mostRecent) {
1033
+ return new SessionManager(cwd, dir, mostRecent, true);
1034
+ }
1035
+ return new SessionManager(cwd, dir, undefined, true);
1036
+ }
1037
+
1038
+ /** Create an in-memory session (no file persistence) */
1039
+ static inMemory(cwd: string = process.cwd()): SessionManager {
1040
+ return new SessionManager(cwd, "", undefined, false);
1041
+ }
1042
+
1043
+ /**
1044
+ * List all sessions.
1045
+ * @param cwd Working directory (used to compute default session directory)
1046
+ * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
1047
+ */
1048
+ static list(cwd: string, sessionDir?: string): SessionInfo[] {
1049
+ const dir = sessionDir ?? getDefaultSessionDir(cwd);
1050
+ const sessions: SessionInfo[] = [];
1051
+
1052
+ try {
1053
+ const files = readdirSync(dir)
1054
+ .filter((f) => f.endsWith(".jsonl"))
1055
+ .map((f) => join(dir, f));
1056
+
1057
+ for (const file of files) {
1058
+ try {
1059
+ const content = readFileSync(file, "utf8");
1060
+ const lines = content.trim().split("\n");
1061
+ if (lines.length === 0) continue;
1062
+
1063
+ // Check first line for valid session header
1064
+ let header: { type: string; id: string; timestamp: string } | null = null;
1065
+ try {
1066
+ const first = JSON.parse(lines[0]);
1067
+ if (first.type === "session" && first.id) {
1068
+ header = first;
1069
+ }
1070
+ } catch {
1071
+ // Not valid JSON
1072
+ }
1073
+ if (!header) continue;
1074
+
1075
+ const stats = statSync(file);
1076
+ let messageCount = 0;
1077
+ let firstMessage = "";
1078
+ const allMessages: string[] = [];
1079
+
1080
+ for (let i = 1; i < lines.length; i++) {
1081
+ try {
1082
+ const entry = JSON.parse(lines[i]);
1083
+
1084
+ if (entry.type === "message") {
1085
+ messageCount++;
1086
+
1087
+ if (entry.message.role === "user" || entry.message.role === "assistant") {
1088
+ const textContent = entry.message.content
1089
+ .filter((c: any) => c.type === "text")
1090
+ .map((c: any) => c.text)
1091
+ .join(" ");
1092
+
1093
+ if (textContent) {
1094
+ allMessages.push(textContent);
1095
+
1096
+ if (!firstMessage && entry.message.role === "user") {
1097
+ firstMessage = textContent;
1098
+ }
1099
+ }
1100
+ }
1101
+ }
1102
+ } catch {
1103
+ // Skip malformed lines
1104
+ }
1105
+ }
1106
+
1107
+ sessions.push({
1108
+ path: file,
1109
+ id: header.id,
1110
+ created: new Date(header.timestamp),
1111
+ modified: stats.mtime,
1112
+ messageCount,
1113
+ firstMessage: firstMessage || "(no messages)",
1114
+ allMessagesText: allMessages.join(" "),
1115
+ });
1116
+ } catch {
1117
+ // Skip files that can't be read
1118
+ }
1119
+ }
1120
+
1121
+ sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1122
+ } catch {
1123
+ // Return empty list on error
1124
+ }
1125
+
1126
+ return sessions;
1127
+ }
1128
+ }