@oh-my-pi/pi-coding-agent 16.2.2 → 16.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cli.js +3682 -3615
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  8. package/dist/types/advisor/watchdog.d.ts +20 -0
  9. package/dist/types/cli/update-cli.d.ts +15 -0
  10. package/dist/types/collab/guest.d.ts +29 -0
  11. package/dist/types/collab/replication-shrink.d.ts +39 -0
  12. package/dist/types/config/provider-globals.d.ts +7 -0
  13. package/dist/types/config/settings-schema.d.ts +81 -0
  14. package/dist/types/debug/log-viewer.d.ts +1 -0
  15. package/dist/types/debug/raw-sse.d.ts +1 -0
  16. package/dist/types/edit/hashline/diff.d.ts +0 -11
  17. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  18. package/dist/types/extensibility/utils.d.ts +12 -0
  19. package/dist/types/mcp/transports/index.d.ts +1 -0
  20. package/dist/types/mcp/transports/sse.d.ts +20 -0
  21. package/dist/types/memories/index.d.ts +20 -1
  22. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  23. package/dist/types/modes/components/index.d.ts +1 -0
  24. package/dist/types/modes/components/model-selector.d.ts +9 -1
  25. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  26. package/dist/types/modes/components/status-line/component.d.ts +35 -1
  27. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  28. package/dist/types/modes/controllers/command-controller.d.ts +3 -3
  29. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  30. package/dist/types/modes/interactive-mode.d.ts +11 -4
  31. package/dist/types/modes/skill-command.d.ts +32 -0
  32. package/dist/types/modes/types.d.ts +9 -3
  33. package/dist/types/session/agent-session.d.ts +58 -10
  34. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  35. package/dist/types/session/messages.d.ts +26 -0
  36. package/dist/types/session/messages.test.d.ts +1 -0
  37. package/dist/types/session/session-entries.d.ts +31 -3
  38. package/dist/types/session/session-history-format.d.ts +6 -0
  39. package/dist/types/session/session-loader.d.ts +9 -1
  40. package/dist/types/session/session-manager.d.ts +8 -7
  41. package/dist/types/session/session-storage.d.ts +11 -0
  42. package/dist/types/session/session-title-slot.d.ts +19 -0
  43. package/dist/types/ssh/connection-manager.d.ts +47 -0
  44. package/dist/types/ssh/utils.d.ts +16 -0
  45. package/dist/types/task/executor.d.ts +3 -16
  46. package/dist/types/task/render.d.ts +0 -5
  47. package/dist/types/task/renderer.d.ts +13 -0
  48. package/dist/types/task/types.d.ts +16 -0
  49. package/dist/types/task/yield-assembly.d.ts +28 -0
  50. package/dist/types/tiny/text.d.ts +8 -0
  51. package/dist/types/tools/render-utils.d.ts +2 -0
  52. package/dist/types/tools/review.d.ts +6 -4
  53. package/dist/types/tools/ssh.d.ts +1 -1
  54. package/dist/types/tools/todo.d.ts +6 -0
  55. package/dist/types/tools/yield.d.ts +8 -3
  56. package/dist/types/utils/thinking-display.d.ts +4 -0
  57. package/package.json +12 -12
  58. package/src/advisor/__tests__/advisor.test.ts +242 -10
  59. package/src/advisor/__tests__/config.test.ts +173 -0
  60. package/src/advisor/advise-tool.ts +11 -6
  61. package/src/advisor/config.ts +256 -0
  62. package/src/advisor/index.ts +1 -0
  63. package/src/advisor/runtime.ts +12 -2
  64. package/src/advisor/transcript-recorder.ts +25 -2
  65. package/src/advisor/watchdog.ts +57 -31
  66. package/src/autolearn/controller.ts +13 -22
  67. package/src/autoresearch/index.ts +7 -2
  68. package/src/cli/gc-cli.ts +17 -10
  69. package/src/cli/update-cli.ts +254 -0
  70. package/src/collab/guest.ts +43 -7
  71. package/src/collab/host.ts +13 -8
  72. package/src/collab/replication-shrink.ts +111 -0
  73. package/src/config/model-registry.ts +80 -18
  74. package/src/config/provider-globals.ts +25 -0
  75. package/src/config/settings-schema.ts +77 -0
  76. package/src/debug/index.ts +32 -7
  77. package/src/debug/log-viewer.ts +111 -53
  78. package/src/debug/raw-sse.ts +68 -48
  79. package/src/discovery/codex.ts +13 -5
  80. package/src/edit/hashline/diff.ts +57 -4
  81. package/src/eval/__tests__/julia-prelude.test.ts +2 -2
  82. package/src/eval/js/shared/local-module-loader.ts +23 -1
  83. package/src/export/html/template.js +13 -7
  84. package/src/extensibility/extensions/loader.ts +5 -3
  85. package/src/extensibility/extensions/wrapper.ts +9 -3
  86. package/src/extensibility/hooks/loader.ts +3 -3
  87. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  88. package/src/extensibility/plugins/manager.ts +2 -1
  89. package/src/extensibility/tool-event-input.ts +23 -0
  90. package/src/extensibility/utils.ts +74 -0
  91. package/src/internal-urls/docs-index.generated.txt +1 -1
  92. package/src/mcp/client.ts +3 -1
  93. package/src/mcp/manager.ts +12 -5
  94. package/src/mcp/transports/index.ts +1 -0
  95. package/src/mcp/transports/sse.ts +377 -0
  96. package/src/memories/index.ts +130 -15
  97. package/src/memory-backend/local-backend.ts +5 -3
  98. package/src/modes/components/advisor-config.ts +555 -0
  99. package/src/modes/components/advisor-message.ts +9 -2
  100. package/src/modes/components/agent-hub.ts +9 -4
  101. package/src/modes/components/index.ts +2 -0
  102. package/src/modes/components/model-selector.ts +79 -48
  103. package/src/modes/components/settings-selector.ts +1 -0
  104. package/src/modes/components/status-line/component.ts +150 -5
  105. package/src/modes/components/status-line/segments.ts +46 -21
  106. package/src/modes/components/status-line/types.ts +13 -1
  107. package/src/modes/components/tool-execution.ts +47 -6
  108. package/src/modes/controllers/command-controller.ts +27 -36
  109. package/src/modes/controllers/event-controller.ts +113 -1
  110. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  111. package/src/modes/controllers/input-controller.ts +61 -61
  112. package/src/modes/controllers/selector-controller.ts +100 -9
  113. package/src/modes/interactive-mode.ts +74 -25
  114. package/src/modes/skill-command.ts +116 -0
  115. package/src/modes/types.ts +9 -3
  116. package/src/modes/utils/ui-helpers.ts +41 -23
  117. package/src/prompts/agents/reviewer.md +11 -10
  118. package/src/prompts/goals/goal-todo-context.md +12 -0
  119. package/src/prompts/review-custom-request.md +1 -2
  120. package/src/prompts/review-request.md +1 -2
  121. package/src/prompts/system/interrupted-thinking.md +7 -0
  122. package/src/prompts/system/recap-user.md +9 -0
  123. package/src/prompts/system/subagent-system-prompt.md +8 -5
  124. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  125. package/src/prompts/system/system-prompt.md +0 -1
  126. package/src/prompts/tools/irc.md +2 -2
  127. package/src/prompts/tools/read.md +2 -2
  128. package/src/sdk.ts +28 -24
  129. package/src/session/agent-session.ts +899 -429
  130. package/src/session/indexed-session-storage.ts +86 -13
  131. package/src/session/messages.test.ts +125 -0
  132. package/src/session/messages.ts +172 -9
  133. package/src/session/redis-session-storage.ts +49 -2
  134. package/src/session/session-entries.ts +39 -2
  135. package/src/session/session-history-format.ts +29 -2
  136. package/src/session/session-listing.ts +54 -24
  137. package/src/session/session-loader.ts +66 -3
  138. package/src/session/session-manager.ts +115 -22
  139. package/src/session/session-persistence.ts +95 -1
  140. package/src/session/session-storage.ts +36 -0
  141. package/src/session/session-title-slot.ts +141 -0
  142. package/src/session/sql-session-storage.ts +71 -11
  143. package/src/slash-commands/builtin-registry.ts +23 -24
  144. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  145. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  146. package/src/ssh/connection-manager.ts +139 -12
  147. package/src/ssh/file-transfer.ts +23 -18
  148. package/src/ssh/ssh-executor.ts +2 -13
  149. package/src/ssh/utils.ts +19 -0
  150. package/src/task/executor.ts +21 -23
  151. package/src/task/render.ts +162 -20
  152. package/src/task/renderer.ts +14 -0
  153. package/src/task/types.ts +17 -0
  154. package/src/task/yield-assembly.ts +207 -0
  155. package/src/tiny/text.ts +23 -0
  156. package/src/tools/ask.ts +55 -4
  157. package/src/tools/render-utils.ts +2 -0
  158. package/src/tools/renderers.ts +8 -2
  159. package/src/tools/review.ts +17 -7
  160. package/src/tools/ssh.ts +8 -4
  161. package/src/tools/todo.ts +17 -1
  162. package/src/tools/yield.ts +140 -31
  163. package/src/utils/thinking-display.ts +15 -0
  164. package/src/prompts/system/autolearn-nudge.md +0 -5
@@ -3,6 +3,24 @@ import type { ImageContent, MessageAttribution, ServiceTier, TextContent } from
3
3
 
4
4
  export const CURRENT_SESSION_VERSION = 3;
5
5
 
6
+ export const SESSION_TITLE_SLOT_BYTES = 256;
7
+
8
+ export const SESSION_TITLE_SLOT_ENTRY_TYPE = "title";
9
+
10
+ export const TITLE_CHANGE_ENTRY_TYPE = "title_change";
11
+
12
+ export type SessionTitleSource = "auto" | "user";
13
+
14
+ /** Fixed-width first-line slot carrying the mutable current session title. */
15
+ export interface SessionTitleSlotEntry {
16
+ type: typeof SESSION_TITLE_SLOT_ENTRY_TYPE;
17
+ v: 1;
18
+ title: string;
19
+ source?: SessionTitleSource;
20
+ updatedAt: string;
21
+ pad: string;
22
+ }
23
+
6
24
  export const EPHEMERAL_MODEL_CHANGE_ROLE = "fallback";
7
25
 
8
26
  export interface SessionHeader {
@@ -10,7 +28,7 @@ export interface SessionHeader {
10
28
  version?: number; // v1 sessions don't have this
11
29
  id: string;
12
30
  title?: string; // Auto-generated title from first message
13
- titleSource?: "auto" | "user";
31
+ titleSource?: SessionTitleSource;
14
32
  timestamp: string;
15
33
  cwd: string;
16
34
  parentSession?: string;
@@ -105,6 +123,21 @@ export interface LabelEntry extends SessionEntryBase {
105
123
  label: string | undefined;
106
124
  }
107
125
 
126
+ /** Append-only audit entry recording a session title change. */
127
+ export interface TitleChangeEntry extends SessionEntryBase {
128
+ type: typeof TITLE_CHANGE_ENTRY_TYPE;
129
+ title: string;
130
+ previousTitle?: string;
131
+ source: SessionTitleSource;
132
+ trigger?: string;
133
+ }
134
+
135
+ declare module "@oh-my-pi/pi-agent-core/compaction/entries" {
136
+ interface CustomCompactionSessionEntries {
137
+ titleChange: TitleChangeEntry;
138
+ }
139
+ }
140
+
108
141
  /** TTSR injection entry - tracks which time-traveling rules have been injected this session. */
109
142
  export interface TtsrInjectionEntry extends SessionEntryBase {
110
143
  type: "ttsr_injection";
@@ -178,14 +211,18 @@ export type SessionEntry =
178
211
  | CustomEntry
179
212
  | CustomMessageEntry
180
213
  | LabelEntry
214
+ | TitleChangeEntry
181
215
  | TtsrInjectionEntry
182
216
  | MCPToolSelectionEntry
183
217
  | SessionInitEntry
184
218
  | ModeChangeEntry;
185
219
 
186
- /** Raw file entry (includes header) */
220
+ /** Raw logical file entry after loaders strip any fixed-width title slot. */
187
221
  export type FileEntry = SessionHeader | SessionEntry;
188
222
 
223
+ /** Physical JSONL entry before slot-aware loaders fold the title slot. */
224
+ export type RawFileEntry = SessionTitleSlotEntry | FileEntry;
225
+
189
226
  /** Tree node for getTree() - defensive copy of session structure */
190
227
  export interface SessionTreeNode {
191
228
  entry: SessionEntry;
@@ -40,6 +40,12 @@ export interface HistoryFormatOptions {
40
40
  * still collapse to a one-liner.
41
41
  */
42
42
  expandPrimaryContext?: boolean;
43
+ /**
44
+ * Append the full unified diff (from a tool result's `details.diff`) below
45
+ * edit/apply_patch tool lines, instead of just the path. The advisor sets
46
+ * this so it sees what changed without re-reading the file.
47
+ */
48
+ expandEditDiffs?: boolean;
43
49
  }
44
50
 
45
51
  /** Max length of the primary-arg summary inside `→ tool(...)` lines. */
@@ -139,12 +145,24 @@ function primaryArg(name: string, args: Record<string, unknown> | undefined): st
139
145
  }
140
146
  }
141
147
 
148
+ /**
149
+ * Wrap a diff body in a backtick fence sized to outlast the longest backtick
150
+ * run inside it, so a diff that touches markdown (triple backticks) can't break
151
+ * out of the fence. Info string `diff` for syntax highlighting.
152
+ */
153
+ function fenceDiff(diff: string): string {
154
+ const longest = diff.match(/`+/g)?.reduce((m, run) => Math.max(m, run.length), 0) ?? 0;
155
+ const fence = "`".repeat(Math.max(3, longest + 1));
156
+ return `${fence}diff\n${diff}\n${fence}`;
157
+ }
158
+
142
159
  /** One line per tool call: `→ read(src/foo.ts:50-80) ⇒ ok · 31 lines`. */
143
160
  function toolCallLine(
144
161
  name: string,
145
162
  args: Record<string, unknown> | undefined,
146
163
  result: ToolResultMessage | undefined,
147
164
  includeToolIntent?: boolean,
165
+ expandEditDiffs?: boolean,
148
166
  ): string {
149
167
  const head = `→ ${name}(${primaryArg(name, args)})`;
150
168
  let base: string;
@@ -162,6 +180,13 @@ function toolCallLine(
162
180
  }
163
181
  }
164
182
 
183
+ if (expandEditDiffs) {
184
+ const diff = (result?.details as { diff?: unknown } | undefined)?.diff;
185
+ if (typeof diff === "string" && diff.trim()) {
186
+ base = `${base}\n${fenceDiff(diff)}`;
187
+ }
188
+ }
189
+
165
190
  const intent = includeToolIntent ? args?.[INTENT_FIELD] : undefined;
166
191
  if (typeof intent === "string" && intent.trim()) {
167
192
  const formattedIntent = oneLine(intent, 80);
@@ -280,7 +305,9 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
280
305
  } else if (block.type === "toolCall") {
281
306
  const result = resultsByCallId.get(block.id);
282
307
  if (result) consumed.add(block.id);
283
- body.push(toolCallLine(block.name, block.arguments, result, opts?.includeToolIntent));
308
+ body.push(
309
+ toolCallLine(block.name, block.arguments, result, opts?.includeToolIntent, opts?.expandEditDiffs),
310
+ );
284
311
  } else if (opts?.includeThinking && block.type === "thinking" && block.thinking.trim()) {
285
312
  body.push(`_thinking:_ ${block.thinking}`);
286
313
  }
@@ -303,7 +330,7 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
303
330
  case "toolResult": {
304
331
  // Normally consumed by its toolCall; orphans (e.g. truncated history) get their own line.
305
332
  if (consumed.has(msg.toolCallId)) break;
306
- lines.push(toolCallLine(msg.toolName, undefined, msg, opts?.includeToolIntent), "");
333
+ lines.push(toolCallLine(msg.toolName, undefined, msg, opts?.includeToolIntent, opts?.expandEditDiffs), "");
307
334
  lastWatchedLabel = undefined;
308
335
  break;
309
336
  }
@@ -270,39 +270,69 @@ interface SessionListHeader {
270
270
  timestamp?: string;
271
271
  }
272
272
 
273
- function parseSessionListHeader(
274
- content: string,
275
- entries: Array<Record<string, unknown>>,
276
- ): SessionListHeader | undefined {
277
- const parsedHeader = entries[0];
278
- if (parsedHeader?.type === "session" && typeof parsedHeader.id === "string") {
279
- return {
280
- type: "session",
281
- id: parsedHeader.id,
282
- cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined,
283
- title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined,
284
- parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined,
285
- timestamp: typeof parsedHeader.timestamp === "string" ? parsedHeader.timestamp : undefined,
286
- };
287
- }
273
+ function normalizeTitleOverride(title: string | undefined): string | null | undefined {
274
+ if (title === undefined) return undefined;
275
+ return title.trim() ? title : null;
276
+ }
288
277
 
289
- const firstLineEnd = content.indexOf("\n");
290
- const firstLine = firstLineEnd === -1 ? content : content.slice(0, firstLineEnd);
291
- if (extractStringProperty(firstLine, "type") !== "session") return undefined;
278
+ function sessionListHeaderFromRecord(
279
+ record: Record<string, unknown> | undefined,
280
+ titleOverride?: string | null,
281
+ ): SessionListHeader | undefined {
282
+ if (record?.type !== "session" || typeof record.id !== "string") return undefined;
283
+ return {
284
+ type: "session",
285
+ id: record.id,
286
+ cwd: typeof record.cwd === "string" ? record.cwd : undefined,
287
+ title:
288
+ titleOverride === null
289
+ ? undefined
290
+ : (titleOverride ?? (typeof record.title === "string" ? record.title : undefined)),
291
+ parentSession: typeof record.parentSession === "string" ? record.parentSession : undefined,
292
+ timestamp: typeof record.timestamp === "string" ? record.timestamp : undefined,
293
+ };
294
+ }
292
295
 
293
- const id = extractStringProperty(firstLine, "id");
296
+ function parseSessionListHeaderLine(line: string, titleOverride?: string | null): SessionListHeader | undefined {
297
+ if (extractStringProperty(line, "type") !== "session") return undefined;
298
+ const id = extractStringProperty(line, "id");
294
299
  if (!id) return undefined;
295
-
296
300
  return {
297
301
  type: "session",
298
302
  id,
299
- cwd: extractStringProperty(firstLine, "cwd"),
300
- title: extractStringProperty(firstLine, "title"),
301
- parentSession: extractStringProperty(firstLine, "parentSession"),
302
- timestamp: extractStringProperty(firstLine, "timestamp"),
303
+ cwd: extractStringProperty(line, "cwd"),
304
+ title: titleOverride === null ? undefined : (titleOverride ?? extractStringProperty(line, "title")),
305
+ parentSession: extractStringProperty(line, "parentSession"),
306
+ timestamp: extractStringProperty(line, "timestamp"),
303
307
  };
304
308
  }
305
309
 
310
+ function parseSessionListHeader(
311
+ content: string,
312
+ entries: Array<Record<string, unknown>>,
313
+ ): SessionListHeader | undefined {
314
+ const firstEntry = entries[0];
315
+ const parsedSlotTitle = normalizeTitleOverride(
316
+ firstEntry?.type === "title" && typeof firstEntry.title === "string" ? firstEntry.title : undefined,
317
+ );
318
+ const parsedHeader = sessionListHeaderFromRecord(entries[firstEntry?.type === "title" ? 1 : 0], parsedSlotTitle);
319
+ if (parsedHeader) return parsedHeader;
320
+
321
+ let slotTitle: string | null | undefined;
322
+ let firstNonEmpty = true;
323
+ for (const rawLine of content.split(/\r?\n/)) {
324
+ const line = rawLine.trim();
325
+ if (!line) continue;
326
+ if (firstNonEmpty && extractStringProperty(line, "type") === "title") {
327
+ slotTitle = normalizeTitleOverride(extractStringProperty(line, "title"));
328
+ firstNonEmpty = false;
329
+ continue;
330
+ }
331
+ return parseSessionListHeaderLine(line, slotTitle);
332
+ }
333
+ return undefined;
334
+ }
335
+
306
336
  function getSessionListWorkerCount(fileCount: number): number {
307
337
  if (fileCount <= SESSION_LIST_PARALLEL_THRESHOLD) return 1;
308
338
  return Math.min(
@@ -2,14 +2,77 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import { getBlobsDir, isEnoent, parseJsonlLenient } from "@oh-my-pi/pi-utils";
3
3
  import { BlobStore, isBlobRef, resolveImageData, resolveImageDataUrl } from "./blob-store";
4
4
  import { buildSessionContext } from "./session-context";
5
- import type { FileEntry, SessionEntry, SessionHeader } from "./session-entries";
5
+ import {
6
+ type FileEntry,
7
+ type RawFileEntry,
8
+ SESSION_TITLE_SLOT_BYTES,
9
+ type SessionEntry,
10
+ type SessionHeader,
11
+ type SessionTitleSlotEntry,
12
+ } from "./session-entries";
6
13
  import { migrateToCurrentVersion } from "./session-migrations";
7
14
  import { isImageBlock, isImageDataPayload } from "./session-persistence";
8
15
  import { FileSessionStorage, type SessionStorage } from "./session-storage";
16
+ import {
17
+ parseTitleSlotFromContent,
18
+ parseTitleSlotLine,
19
+ type SessionTitleUpdate,
20
+ titleUpdateFromSlot,
21
+ } from "./session-title-slot";
9
22
 
23
+ function splitTitleSlot(content: string): { body: string; slot: SessionTitleUpdate | undefined } {
24
+ const slot = titleUpdateFromSlot(parseTitleSlotFromContent(content));
25
+ if (!slot) return { body: content, slot: undefined };
26
+ const newlineIndex = content.indexOf("\n");
27
+ return { body: content.slice(newlineIndex + 1), slot };
28
+ }
29
+
30
+ function foldTitleSlot(entries: FileEntry[], slot: SessionTitleUpdate | undefined): FileEntry[] {
31
+ if (!slot || entries.length === 0) return entries;
32
+ const header = entries[0] as SessionHeader;
33
+ if (header.type !== "session" || typeof header.id !== "string") return entries;
34
+ if (slot.title && slot.title.length > 0) {
35
+ header.title = slot.title;
36
+ } else {
37
+ delete header.title;
38
+ }
39
+ if (slot.source) {
40
+ header.titleSource = slot.source;
41
+ } else {
42
+ delete header.titleSource;
43
+ }
44
+ return entries;
45
+ }
46
+
47
+ /** Parse session JSONL while stripping and folding the optional fixed title slot. */
48
+ export function parseSessionContent(content: string): {
49
+ entries: FileEntry[];
50
+ titleSlot: SessionTitleUpdate | undefined;
51
+ } {
52
+ const { body, slot } = splitTitleSlot(content);
53
+ const entries = parseJsonlLenient<RawFileEntry>(body) as FileEntry[];
54
+ return { entries: foldTitleSlot(entries, slot), titleSlot: slot };
55
+ }
56
+
57
+ /** Read only the fixed-size head window to detect a physical title slot. */
58
+ export async function readTitleSlotFromFile(
59
+ filePath: string,
60
+ storage: SessionStorage = new FileSessionStorage(),
61
+ ): Promise<SessionTitleSlotEntry | undefined> {
62
+ let head: string;
63
+ try {
64
+ [head] = await storage.readTextSlices(filePath, SESSION_TITLE_SLOT_BYTES, 0);
65
+ } catch (err) {
66
+ if (isEnoent(err)) return undefined;
67
+ throw err;
68
+ }
69
+ const newlineIndex = head.indexOf("\n");
70
+ if (newlineIndex < 0) return undefined;
71
+ return parseTitleSlotLine(head.slice(0, newlineIndex));
72
+ }
10
73
  /** Exported for compaction.test.ts */
11
74
  export function parseSessionEntries(content: string): FileEntry[] {
12
- return parseJsonlLenient<FileEntry>(content);
75
+ return parseSessionContent(content).entries;
13
76
  }
14
77
 
15
78
  /** Exported for testing */
@@ -24,7 +87,7 @@ export async function loadEntriesFromFile(
24
87
  if (isEnoent(err)) return [];
25
88
  throw err;
26
89
  }
27
- const entries = parseJsonlLenient<FileEntry>(content);
90
+ const { entries } = parseSessionContent(content);
28
91
 
29
92
  // Validate session header
30
93
  if (entries.length === 0) return entries;
@@ -39,13 +39,16 @@ import {
39
39
  type SessionHeader,
40
40
  type SessionInitEntry,
41
41
  type SessionMessageEntry,
42
+ type SessionTitleSource,
42
43
  type SessionTreeNode,
43
44
  type ThinkingLevelChangeEntry,
45
+ TITLE_CHANGE_ENTRY_TYPE,
46
+ type TitleChangeEntry,
44
47
  type TtsrInjectionEntry,
45
48
  type UsageStatistics,
46
49
  } from "./session-entries";
47
50
  import { findMostRecentSession, listAllSessions, listSessions, type SessionInfo } from "./session-listing";
48
- import { loadEntriesFromFile, resolveBlobRefsInEntries } from "./session-loader";
51
+ import { loadEntriesFromFile, readTitleSlotFromFile, resolveBlobRefsInEntries } from "./session-loader";
49
52
  import { generateId, migrateToCurrentVersion } from "./session-migrations";
50
53
  import {
51
54
  computeDefaultSessionDir,
@@ -60,6 +63,7 @@ import {
60
63
  type SessionStorage,
61
64
  type SessionStorageWriter,
62
65
  } from "./session-storage";
66
+ import { type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot";
63
67
 
64
68
  const JSONL_SUFFIX_LENGTH = ".jsonl".length;
65
69
 
@@ -298,8 +302,10 @@ interface SessionManagerStateSnapshot {
298
302
  sessionDir: string;
299
303
  sessionId: string;
300
304
  sessionName: string | undefined;
301
- titleSource: "auto" | "user" | undefined;
305
+ titleSource: SessionTitleSource | undefined;
302
306
  sessionFile: string | undefined;
307
+ titleUpdatedAt: string;
308
+ hasTitleSlot: boolean;
303
309
  onDisk: boolean;
304
310
  needsRewrite: boolean;
305
311
  header: SessionHeader;
@@ -334,9 +340,11 @@ export class SessionManager {
334
340
 
335
341
  #sessionId = "";
336
342
  #sessionName: string | undefined;
337
- #titleSource: "auto" | "user" | undefined;
343
+ #titleSource: SessionTitleSource | undefined;
338
344
  #sessionFile: string | undefined;
339
345
  #header!: SessionHeader;
346
+ #titleUpdatedAt = "";
347
+ #hasTitleSlot = true;
340
348
  #entries: SessionEntry[] = [];
341
349
  #index = new SessionEntryIndex();
342
350
 
@@ -471,8 +479,17 @@ export class SessionManager {
471
479
  return `${JSON.stringify(prepareEntryForPersistence(entry, this.#blobs))}\n`;
472
480
  }
473
481
 
482
+ #titleSlotLine(): string {
483
+ return serializeTitleSlot({
484
+ title: this.#sessionName,
485
+ source: this.#titleSource,
486
+ updatedAt: this.#titleUpdatedAt || this.#header.timestamp,
487
+ });
488
+ }
489
+
474
490
  #fileBody(): string {
475
- let body = this.#lineFor(this.#header);
491
+ let body = this.#titleSlotLine();
492
+ body += this.#lineFor(this.#header);
476
493
  for (const entry of this.#entries) body += this.#lineFor(entry);
477
494
  return body;
478
495
  }
@@ -501,6 +518,7 @@ export class SessionManager {
501
518
  this.#storage.writeTextSync(this.#sessionFile, body);
502
519
  this.#fileIsCurrent = true;
503
520
  this.#rewriteRequired = false;
521
+ this.#hasTitleSlot = true;
504
522
  } catch (err) {
505
523
  this.#noteDiskFailure(err);
506
524
  }
@@ -523,6 +541,7 @@ export class SessionManager {
523
541
  await this.#storage.writeTextAtomic(sessionFile, this.#fileBody());
524
542
  this.#fileIsCurrent = true;
525
543
  this.#rewriteRequired = false;
544
+ this.#hasTitleSlot = true;
526
545
  },
527
546
  { epoch },
528
547
  );
@@ -561,12 +580,67 @@ export class SessionManager {
561
580
  }
562
581
  }
563
582
 
583
+ async #persistTitleChangeEntry(entry: TitleChangeEntry, update: SessionTitleUpdate): Promise<void> {
584
+ if (!this.#persist || !this.#sessionFile) return;
585
+ if (this.#diskFailure) throw this.#diskFailure;
586
+
587
+ if (!this.#shouldHaveSessionFile()) {
588
+ this.#fileIsCurrent = false;
589
+ return;
590
+ }
591
+
592
+ if (
593
+ !this.#fileIsCurrent ||
594
+ this.#rewriteRequired ||
595
+ !this.#hasTitleSlot ||
596
+ !this.#storage.existsSync(this.#sessionFile)
597
+ ) {
598
+ await this.#rewriteAtomically();
599
+ return;
600
+ }
601
+
602
+ const epoch = this.#diskEpoch;
603
+ const line = this.#lineFor(entry);
604
+ await this.#scheduleDiskWork(
605
+ async () => {
606
+ const sessionFile = this.#sessionFile;
607
+ if (!sessionFile) return;
608
+ try {
609
+ await this.#appendWriter().append(line);
610
+ await this.#storage.updateSessionTitle(sessionFile, update);
611
+ this.#fileIsCurrent = true;
612
+ } catch {
613
+ await this.#closeWriterHandle();
614
+ await this.#storage.writeTextAtomic(sessionFile, this.#fileBody());
615
+ this.#clearDiskError();
616
+ this.#fileIsCurrent = true;
617
+ this.#rewriteRequired = false;
618
+ this.#hasTitleSlot = true;
619
+ }
620
+ },
621
+ { epoch },
622
+ );
623
+ }
624
+
625
+ #notifyEntryAppended(entry: SessionEntry): void {
626
+ const callback = this.onEntryAppended;
627
+ if (callback) {
628
+ try {
629
+ callback(entry);
630
+ } catch (err) {
631
+ logger.warn("collab entry hook failed", { error: String(err) });
632
+ }
633
+ }
634
+ }
635
+
564
636
  #resetToNewSession(options?: NewSessionOptions, forcedSessionFile?: string): string | undefined {
565
637
  this.#diskTail = Promise.resolve();
566
638
  this.#clearDiskError();
567
639
  this.#sessionId = mintSessionId();
568
640
  this.#sessionName = undefined;
569
641
  this.#titleSource = undefined;
642
+ this.#titleUpdatedAt = "";
643
+ this.#hasTitleSlot = true;
570
644
 
571
645
  const timestamp = nowIso();
572
646
  this.#header = {
@@ -577,6 +651,7 @@ export class SessionManager {
577
651
  cwd: this.#cwd,
578
652
  parentSession: options?.parentSession,
579
653
  };
654
+ this.#titleUpdatedAt = timestamp;
580
655
 
581
656
  this.#entries = [];
582
657
  this.#index.clear();
@@ -611,6 +686,7 @@ export class SessionManager {
611
686
  this.#sessionId = header.id;
612
687
  this.#sessionName = header.title;
613
688
  this.#titleSource = header.titleSource;
689
+ this.#titleUpdatedAt = header.timestamp;
614
690
  this.#index.rebuild(entries);
615
691
  }
616
692
 
@@ -626,15 +702,7 @@ export class SessionManager {
626
702
  this.#entries.push(entry);
627
703
  this.#index.insert(entry);
628
704
  this.#appendToSessionFile(entry);
629
-
630
- const callback = this.onEntryAppended;
631
- if (callback) {
632
- try {
633
- callback(entry);
634
- } catch (err) {
635
- logger.warn("collab entry hook failed", { error: String(err) });
636
- }
637
- }
705
+ this.#notifyEntryAppended(entry);
638
706
  }
639
707
 
640
708
  #draftPath(): string | null {
@@ -693,6 +761,8 @@ export class SessionManager {
693
761
  sessionId: this.#sessionId,
694
762
  sessionName: this.#sessionName,
695
763
  titleSource: this.#titleSource,
764
+ titleUpdatedAt: this.#titleUpdatedAt,
765
+ hasTitleSlot: this.#hasTitleSlot,
696
766
  sessionFile: this.#sessionFile,
697
767
  onDisk: this.#fileIsCurrent,
698
768
  needsRewrite: this.#rewriteRequired,
@@ -717,6 +787,8 @@ export class SessionManager {
717
787
  this.#applyEntries(snapshot.header, [...snapshot.entries]);
718
788
  this.#sessionName = snapshot.sessionName;
719
789
  this.#titleSource = snapshot.titleSource;
790
+ this.#titleUpdatedAt = snapshot.titleUpdatedAt;
791
+ this.#hasTitleSlot = snapshot.hasTitleSlot;
720
792
  this.#artifactManager = null;
721
793
  this.#artifactManagerSessionFile = null;
722
794
  this.#adoptedArtifactManager = null;
@@ -733,6 +805,7 @@ export class SessionManager {
733
805
  this.#sessionFile = resolvedSessionFile;
734
806
  this.#rememberBreadcrumb(this.#cwd, resolvedSessionFile);
735
807
 
808
+ const titleSlot = await readTitleSlotFromFile(resolvedSessionFile, this.#storage);
736
809
  const fileEntries = await loadEntriesFromFile(resolvedSessionFile, this.#storage);
737
810
  if (fileEntries.length === 0) {
738
811
  // Explicit but empty/missing path (e.g. --session flag): start fresh but
@@ -763,6 +836,8 @@ export class SessionManager {
763
836
  }
764
837
 
765
838
  this.#applyEntries(header, fileEntries.slice(1) as SessionEntry[]);
839
+ this.#titleUpdatedAt = titleSlot?.updatedAt ?? header.timestamp;
840
+ this.#hasTitleSlot = titleSlot !== undefined;
766
841
  this.#fileIsCurrent = true;
767
842
  this.#rewriteRequired = migrated;
768
843
  this.#forceFileCreation = true;
@@ -815,6 +890,8 @@ export class SessionManager {
815
890
  };
816
891
  this.#sessionName = this.#header.title;
817
892
  this.#titleSource = this.#header.titleSource;
893
+ this.#titleUpdatedAt = timestamp;
894
+ this.#hasTitleSlot = true;
818
895
  this.#fileIsCurrent = false;
819
896
  this.#rewriteRequired = false;
820
897
  this.#forceFileCreation = true;
@@ -1083,7 +1160,7 @@ export class SessionManager {
1083
1160
  }
1084
1161
 
1085
1162
  /** The source that set the session name: "user" (manual/RPC) or "auto" (generated title). */
1086
- get titleSource(): "auto" | "user" | undefined {
1163
+ get titleSource(): SessionTitleSource | undefined {
1087
1164
  return this.#titleSource;
1088
1165
  }
1089
1166
 
@@ -1103,20 +1180,33 @@ export class SessionManager {
1103
1180
  * @param source "user" for explicit renames; "auto" for generated titles.
1104
1181
  * Auto titles are ignored once the user has set a name.
1105
1182
  */
1106
- async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
1183
+ async setSessionName(name: string, source: SessionTitleSource = "auto", trigger?: string): Promise<boolean> {
1107
1184
  if (this.#titleSource === "user" && source === "auto") return false;
1108
1185
 
1109
1186
  const title = SessionManager.#cleanTitle(name);
1110
1187
  if (!title) return false;
1111
1188
 
1189
+ const previousTitle = this.#sessionName;
1190
+ const timestamp = nowIso();
1112
1191
  this.#sessionName = title;
1113
1192
  this.#titleSource = source;
1193
+ this.#titleUpdatedAt = timestamp;
1114
1194
  this.#header.title = title;
1115
1195
  this.#header.titleSource = source;
1116
1196
 
1117
- if (this.#persist && this.#sessionFile && this.#storage.existsSync(this.#sessionFile)) {
1118
- await this.#rewriteAtomically();
1119
- }
1197
+ const entry: TitleChangeEntry = {
1198
+ type: TITLE_CHANGE_ENTRY_TYPE,
1199
+ ...this.#freshEntryFields(),
1200
+ timestamp,
1201
+ title,
1202
+ source,
1203
+ };
1204
+ if (previousTitle) entry.previousTitle = previousTitle;
1205
+ if (trigger) entry.trigger = trigger;
1206
+ this.#entries.push(entry);
1207
+ this.#index.insert(entry);
1208
+ this.#notifyEntryAppended(entry);
1209
+ await this.#persistTitleChangeEntry(entry, { title, source, updatedAt: timestamp });
1120
1210
 
1121
1211
  this.#notifySessionNameListeners();
1122
1212
  return true;
@@ -1484,6 +1574,8 @@ export class SessionManager {
1484
1574
  this.#sessionId = newSessionId;
1485
1575
  this.#sessionName = header.title;
1486
1576
  this.#titleSource = header.titleSource;
1577
+ this.#titleUpdatedAt = timestamp;
1578
+ this.#hasTitleSlot = true;
1487
1579
  this.#index.rebuild(this.#entries);
1488
1580
  this.#artifactManager = null;
1489
1581
  this.#artifactManagerSessionFile = null;
@@ -1526,9 +1618,8 @@ export class SessionManager {
1526
1618
  /**
1527
1619
  * Create a fresh empty session file in the default session directory for
1528
1620
  * `cwd`, writing only the session header. The returned path can be passed to
1529
- * `setSessionFile` / `AgentSession.switchSession` to start a new empty
1530
- * session in that directory. Used by `/move` to switch projects without
1531
- * dragging the current conversation along.
1621
+ * `setSessionFile` / `AgentSession.switchSession` when a caller explicitly
1622
+ * needs a brand-new persisted session at a cwd-derived path.
1532
1623
  */
1533
1624
  static createEmptySessionFile(cwd: string, storage: SessionStorage = new FileSessionStorage()): string {
1534
1625
  const sessionDir = SessionManager.getDefaultSessionDir(cwd, undefined, storage);
@@ -1542,7 +1633,7 @@ export class SessionManager {
1542
1633
  cwd: path.resolve(cwd),
1543
1634
  };
1544
1635
  const file = path.join(sessionDir, `${fileSafeTimestamp(timestamp)}_${id}.jsonl`);
1545
- storage.writeTextSync(file, `${JSON.stringify(header)}\n`);
1636
+ storage.writeTextSync(file, `${serializeTitleSlot({ updatedAt: timestamp })}${JSON.stringify(header)}\n`);
1546
1637
  return file;
1547
1638
  }
1548
1639
 
@@ -1577,6 +1668,8 @@ export class SessionManager {
1577
1668
  manager.#header.titleSource = sourceHeader?.titleSource;
1578
1669
  manager.#sessionName = manager.#header.title;
1579
1670
  manager.#titleSource = manager.#header.titleSource;
1671
+ manager.#titleUpdatedAt = nowIso();
1672
+ manager.#hasTitleSlot = true;
1580
1673
  manager.#entries = history;
1581
1674
  manager.#index.rebuild(history);
1582
1675
  manager.sanitizeLoadedOpenAIResponsesReplayMetadata();