@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
@@ -1,10 +1,20 @@
1
1
  import { toError } from "@oh-my-pi/pi-utils";
2
2
  import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
3
+ import {
4
+ overlayTitleSlotContent,
5
+ overlayTitleSlotPrefix,
6
+ parseTitleSlotFromContent,
7
+ type SessionTitleUpdate,
8
+ titleUpdateFromSlot,
9
+ } from "./session-title-slot";
3
10
 
4
11
  export interface SessionStorageIndexEntry {
5
12
  path: string;
6
13
  size: number;
7
14
  mtimeMs: number;
15
+ title?: string;
16
+ titleSource?: SessionTitleUpdate["source"];
17
+ titleUpdatedAt?: string;
8
18
  }
9
19
 
10
20
  export interface SessionStorageBackend {
@@ -12,8 +22,9 @@ export interface SessionStorageBackend {
12
22
  loadIndex(): Promise<Iterable<SessionStorageIndexEntry>>;
13
23
  readFull(path: string): Promise<string | null>;
14
24
  readSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]>;
15
- writeFull(path: string, content: string, mtimeMs: number): Promise<void>;
25
+ writeFull(path: string, content: string, mtimeMs: number, title?: SessionTitleUpdate): Promise<void>;
16
26
  append(path: string, line: string, mtimeMs: number): Promise<void>;
27
+ updateSessionTitle(path: string, title: SessionTitleUpdate, mtimeMs: number): Promise<void>;
17
28
  truncate(path: string, mtimeMs: number): Promise<void>;
18
29
  remove(paths: string[]): Promise<void>;
19
30
  move(src: string, dst: string, mtimeMs: number): Promise<void>;
@@ -22,6 +33,9 @@ export interface SessionStorageBackend {
22
33
  interface IndexEntry {
23
34
  size: number;
24
35
  mtimeMs: number;
36
+ title?: string;
37
+ titleSource?: SessionTitleUpdate["source"];
38
+ titleUpdatedAt?: string;
25
39
  }
26
40
 
27
41
  interface EnqueueOptions {
@@ -64,6 +78,10 @@ function uniquePaths(paths: readonly string[]): string[] {
64
78
  }
65
79
  return out;
66
80
  }
81
+ function titleUpdateForIndex(entry: IndexEntry): SessionTitleUpdate | undefined {
82
+ if (!entry.titleUpdatedAt) return undefined;
83
+ return { title: entry.title, source: entry.titleSource, updatedAt: entry.titleUpdatedAt };
84
+ }
67
85
 
68
86
  export class IndexedSessionStorage implements SessionStorage {
69
87
  readonly #backend: SessionStorageBackend;
@@ -89,7 +107,10 @@ export class IndexedSessionStorage implements SessionStorage {
89
107
  const rows = await this.#backend.loadIndex();
90
108
  this.#index.clear();
91
109
  for (const row of rows) {
92
- this.#setIndex(row.path, row.size, row.mtimeMs);
110
+ const title = row.titleUpdatedAt
111
+ ? { title: row.title, source: row.titleSource, updatedAt: row.titleUpdatedAt }
112
+ : null;
113
+ this.#setIndex(row.path, row.size, row.mtimeMs, title);
93
114
  }
94
115
  }
95
116
 
@@ -112,8 +133,40 @@ export class IndexedSessionStorage implements SessionStorage {
112
133
 
113
134
  writeTextSync(path: string, content: string): void {
114
135
  const mtimeMs = this.#allocMtimeMs();
115
- this.#setIndex(path, byteLength(content), mtimeMs);
116
- this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs), { trackDrain: true });
136
+ const title = titleUpdateFromSlot(parseTitleSlotFromContent(content));
137
+ this.#setIndex(path, byteLength(content), mtimeMs, title ?? null);
138
+ this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs, title), { trackDrain: true });
139
+ }
140
+
141
+ async updateSessionTitle(path: string, title: SessionTitleUpdate): Promise<void> {
142
+ await this.#awaitPath(path);
143
+ const previous = this.#index.get(path);
144
+ if (!previous) throw enoent(path);
145
+ const mtimeMs = this.#allocMtimeMs();
146
+ const next = {
147
+ ...previous,
148
+ title: title.title,
149
+ titleSource: title.source,
150
+ titleUpdatedAt: title.updatedAt,
151
+ mtimeMs,
152
+ };
153
+ this.#index.set(path, next);
154
+ try {
155
+ await this.#enqueuePath(path, () => this.#backend.updateSessionTitle(path, title, mtimeMs), {
156
+ trackDrain: false,
157
+ });
158
+ } catch (err) {
159
+ const current = this.#index.get(path);
160
+ if (
161
+ current?.mtimeMs === next.mtimeMs &&
162
+ current.title === next.title &&
163
+ current.titleSource === next.titleSource &&
164
+ current.titleUpdatedAt === next.titleUpdatedAt
165
+ ) {
166
+ this.#index.set(path, previous);
167
+ }
168
+ throw toError(err);
169
+ }
117
170
  }
118
171
 
119
172
  statSync(path: string): SessionStorageStat {
@@ -144,29 +197,37 @@ export class IndexedSessionStorage implements SessionStorage {
144
197
  }
145
198
 
146
199
  async readText(path: string): Promise<string> {
147
- if (!this.#index.has(path)) throw enoent(path);
200
+ const entry = this.#index.get(path);
201
+ if (!entry) throw enoent(path);
148
202
  await this.#awaitPath(path);
149
203
  const content = await this.#backend.readFull(path);
150
204
  if (content === null) throw enoent(path);
151
- return content;
205
+ const title = titleUpdateForIndex(entry);
206
+ return title ? overlayTitleSlotContent(content, title) : content;
152
207
  }
153
208
 
154
209
  async readTextSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]> {
155
- if (!this.#index.has(path)) throw enoent(path);
210
+ const entry = this.#index.get(path);
211
+ if (!entry) throw enoent(path);
156
212
  const prefixLimit = normalizeByteLimit(prefixBytes);
157
213
  const suffixLimit = normalizeByteLimit(suffixBytes);
158
214
  if (prefixLimit === 0 && suffixLimit === 0) return ["", ""];
159
215
  await this.#awaitPath(path);
160
- return this.#backend.readSlices(path, prefixLimit, suffixLimit);
216
+ const [prefix, suffix] = await this.#backend.readSlices(path, prefixLimit, suffixLimit);
217
+ const title = titleUpdateForIndex(entry);
218
+ return [title ? overlayTitleSlotPrefix(prefix, prefixLimit, title) : prefix, suffix];
161
219
  }
162
220
 
163
221
  async writeText(path: string, content: string): Promise<void> {
164
222
  await this.#awaitPath(path);
165
223
  const previous = this.#index.get(path);
166
224
  const mtimeMs = this.#allocMtimeMs();
167
- this.#setIndex(path, byteLength(content), mtimeMs);
225
+ const title = titleUpdateFromSlot(parseTitleSlotFromContent(content));
226
+ this.#setIndex(path, byteLength(content), mtimeMs, title ?? null);
168
227
  try {
169
- await this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs), { trackDrain: false });
228
+ await this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs, title), {
229
+ trackDrain: false,
230
+ });
170
231
  } catch (err) {
171
232
  this.#restoreIndex(path, previous);
172
233
  throw toError(err);
@@ -249,7 +310,7 @@ export class IndexedSessionStorage implements SessionStorage {
249
310
 
250
311
  _truncateForWriter(path: string): number {
251
312
  const mtimeMs = this.#allocMtimeMs();
252
- this.#setIndex(path, 0, mtimeMs);
313
+ this.#setIndex(path, 0, mtimeMs, null);
253
314
  return mtimeMs;
254
315
  }
255
316
 
@@ -293,8 +354,20 @@ export class IndexedSessionStorage implements SessionStorage {
293
354
  }
294
355
  }
295
356
 
296
- #setIndex(path: string, size: number, mtimeMs: number): void {
297
- this.#index.set(path, { size, mtimeMs });
357
+ #setIndex(
358
+ path: string,
359
+ size: number,
360
+ mtimeMs: number,
361
+ title: SessionTitleUpdate | null | undefined = undefined,
362
+ ): void {
363
+ const current = title === undefined ? this.#index.get(path) : undefined;
364
+ this.#index.set(path, {
365
+ size,
366
+ mtimeMs,
367
+ title: title === undefined ? current?.title : (title?.title ?? undefined),
368
+ titleSource: title === undefined ? current?.titleSource : (title?.source ?? undefined),
369
+ titleUpdatedAt: title === undefined ? current?.titleUpdatedAt : (title?.updatedAt ?? undefined),
370
+ });
298
371
  if (mtimeMs > this.#nextMtimeMs) this.#nextMtimeMs = mtimeMs;
299
372
  }
300
373
 
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
4
+ import {
5
+ type CustomMessage,
6
+ convertToLlm,
7
+ INTERRUPTED_THINKING_MESSAGE_TYPE,
8
+ SKILL_PROMPT_MESSAGE_TYPE,
9
+ type SkillPromptDetails,
10
+ } from "./messages";
11
+
12
+ function customMessage(customType: string, attribution: "agent" | "user"): CustomMessage<SkillPromptDetails> {
13
+ return {
14
+ role: "custom",
15
+ customType,
16
+ content: "Use this skill.",
17
+ display: true,
18
+ details: { name: "atomic-commit", path: "/tmp/SKILL.md", lineCount: 1 },
19
+ attribution,
20
+ timestamp: 1,
21
+ };
22
+ }
23
+
24
+ const interruptedUsage: AssistantMessage["usage"] = {
25
+ input: 0,
26
+ output: 0,
27
+ cacheRead: 0,
28
+ cacheWrite: 0,
29
+ totalTokens: 0,
30
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
31
+ };
32
+
33
+ function abortedAssistant(content: AssistantMessage["content"]): AssistantMessage {
34
+ return {
35
+ role: "assistant",
36
+ content,
37
+ api: "anthropic-messages",
38
+ provider: "anthropic",
39
+ model: "claude-sonnet-4-5",
40
+ usage: interruptedUsage,
41
+ stopReason: "aborted",
42
+ timestamp: 1,
43
+ };
44
+ }
45
+
46
+ function interruptedThinkingContinuity(): CustomMessage {
47
+ return {
48
+ role: "custom",
49
+ customType: INTERRUPTED_THINKING_MESSAGE_TYPE,
50
+ content: "preserved reasoning",
51
+ display: false,
52
+ attribution: "agent",
53
+ timestamp: 2,
54
+ };
55
+ }
56
+
57
+ describe("convertToLlm", () => {
58
+ it("presents user-invoked skill prompts as user turns", () => {
59
+ const [message] = convertToLlm([customMessage(SKILL_PROMPT_MESSAGE_TYPE, "user")]);
60
+
61
+ expect(message?.role).toBe("user");
62
+ if (message?.role !== "user") {
63
+ throw new Error(`Expected user role, received ${message?.role ?? "none"}`);
64
+ }
65
+ expect(message.attribution).toBe("user");
66
+ });
67
+
68
+ it("keeps auto-applied skill prompts and other custom messages as developer turns", () => {
69
+ const [autoSkill, otherCustom] = convertToLlm([
70
+ customMessage(SKILL_PROMPT_MESSAGE_TYPE, "agent"),
71
+ customMessage("extension-note", "user"),
72
+ ]);
73
+
74
+ expect(autoSkill?.role).toBe("developer");
75
+ expect(otherCustom?.role).toBe("developer");
76
+ });
77
+
78
+ it("strips the demoted trailing thinking run from the assistant LLM view when its continuity message follows", () => {
79
+ const messages: AgentMessage[] = [
80
+ abortedAssistant([
81
+ { type: "text", text: "partial answer" },
82
+ { type: "thinking", thinking: "interrupted reasoning" },
83
+ ]),
84
+ interruptedThinkingContinuity(),
85
+ ];
86
+
87
+ const llm = convertToLlm(messages);
88
+ const assistant = llm.find(entry => entry.role === "assistant");
89
+ expect(Array.isArray(assistant?.content) && assistant.content.map(block => block.type)).toEqual(["text"]);
90
+ expect(llm.some(entry => entry.role === "developer")).toBe(true);
91
+ });
92
+
93
+ it("keeps trailing thinking on the assistant LLM view when no continuity message follows", () => {
94
+ const messages: AgentMessage[] = [
95
+ abortedAssistant([
96
+ { type: "text", text: "partial answer" },
97
+ { type: "thinking", thinking: "interrupted reasoning" },
98
+ ]),
99
+ ];
100
+
101
+ const llm = convertToLlm(messages);
102
+ const assistant = llm.find(entry => entry.role === "assistant");
103
+ expect(Array.isArray(assistant?.content) && assistant.content.map(block => block.type)).toEqual([
104
+ "text",
105
+ "thinking",
106
+ ]);
107
+ });
108
+
109
+ it("keeps a signed (complete) trailing thinking block in the assistant LLM view even with a continuity message", () => {
110
+ const messages: AgentMessage[] = [
111
+ abortedAssistant([
112
+ { type: "text", text: "partial answer" },
113
+ { type: "thinking", thinking: "complete reasoning", thinkingSignature: "sig" },
114
+ ]),
115
+ interruptedThinkingContinuity(),
116
+ ];
117
+
118
+ const llm = convertToLlm(messages);
119
+ const assistant = llm.find(entry => entry.role === "assistant");
120
+ expect(Array.isArray(assistant?.content) && assistant.content.map(block => block.type)).toEqual([
121
+ "text",
122
+ "thinking",
123
+ ]);
124
+ });
125
+ });
@@ -37,6 +37,97 @@ export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
37
37
  export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic";
38
38
  export const BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE = "background-tan-dispatch";
39
39
 
40
+ /** Custom message type for hidden interrupted-thinking continuity context. */
41
+ export const INTERRUPTED_THINKING_MESSAGE_TYPE = "interrupted-thinking";
42
+
43
+ /** Metadata persisted with a hidden interrupted-thinking continuity message. */
44
+ export interface InterruptedThinkingDetails {
45
+ interruptedAt: number;
46
+ provider: AssistantMessage["provider"];
47
+ model: string;
48
+ blockCount: number;
49
+ }
50
+
51
+ /** Pure helper result for persisting interrupted thinking outside the assistant turn. */
52
+ export interface DemotedInterruptedThinking {
53
+ reasoning: string;
54
+ strippedContent: AssistantMessage["content"];
55
+ blockCount: number;
56
+ }
57
+
58
+ /**
59
+ * Demote a trailing run of *incomplete* interrupted-thinking from an assistant
60
+ * message — reasoning that was still streaming when the user aborted.
61
+ *
62
+ * A block joins the run only when it is a non-empty `thinking` block with no
63
+ * `thinkingSignature`. A signed/complete thinking block (Anthropic signature,
64
+ * OpenAI reasoning item id) is safely replayable, so it ends the run and stays
65
+ * in place — as do `redactedThinking` encrypted blobs, text, tool calls,
66
+ * empty-thinking blocks, and trailing empty text placeholders.
67
+ */
68
+ export function demoteInterruptedThinking(
69
+ message: Pick<AssistantMessage, "content">,
70
+ ): DemotedInterruptedThinking | undefined {
71
+ const content = message.content;
72
+ let scanEnd = content.length;
73
+ while (scanEnd > 0) {
74
+ const block = content[scanEnd - 1]!;
75
+ if (block.type !== "text" || block.text.trim().length > 0) {
76
+ break;
77
+ }
78
+ scanEnd--;
79
+ }
80
+
81
+ let runStart = scanEnd;
82
+ while (runStart > 0) {
83
+ const block = content[runStart - 1]!;
84
+ if (block.type !== "thinking" || block.thinking.trim().length === 0 || block.thinkingSignature) {
85
+ break;
86
+ }
87
+ runStart--;
88
+ }
89
+
90
+ const blockCount = scanEnd - runStart;
91
+ if (blockCount === 0) {
92
+ return undefined;
93
+ }
94
+
95
+ const reasoningBlocks: string[] = [];
96
+ for (let index = runStart; index < scanEnd; index++) {
97
+ const block = content[index]!;
98
+ if (block.type === "thinking") {
99
+ reasoningBlocks.push(block.thinking.trim());
100
+ }
101
+ }
102
+
103
+ return {
104
+ reasoning: reasoningBlocks.join("\n\n"),
105
+ strippedContent: content.slice(0, runStart),
106
+ blockCount,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * True when the assistant turn at `messages[index]` is immediately followed by
112
+ * its hidden `interrupted-thinking` continuity message — the marker that a
113
+ * trailing thinking run was demoted on user interrupt. The run stays on the
114
+ * persisted/displayed assistant message; this flag tells the LLM path to drop it.
115
+ */
116
+ function followedByInterruptedThinking(messages: AgentMessage[], index: number): boolean {
117
+ const next = messages[index + 1];
118
+ return next !== undefined && next.role === "custom" && next.customType === INTERRUPTED_THINKING_MESSAGE_TYPE;
119
+ }
120
+
121
+ /**
122
+ * Drop the demoted trailing thinking run from an assistant message for the LLM
123
+ * view only. The run is incomplete and unsigned, so providers reject it; the
124
+ * continuity message that follows carries the reasoning instead.
125
+ */
126
+ function stripDemotedThinkingForLlm(message: AssistantMessage): AssistantMessage {
127
+ const demoted = demoteInterruptedThinking(message);
128
+ return demoted ? { ...message, content: demoted.strippedContent } : message;
129
+ }
130
+
40
131
  /** Details persisted on a `/tan` background-dispatch breadcrumb. */
41
132
  export interface BackgroundTanDispatchDetails {
42
133
  jobId: string;
@@ -459,21 +550,32 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
459
550
  if (message.providerPayload?.type !== "openaiResponsesHistory") {
460
551
  return message;
461
552
  }
553
+ // Only GitHub Copilot rejects replayed assistant-side native history on a
554
+ // warmed (resumed) session with HTTP 401 — that is the sole reason this strip
555
+ // exists. For every other Responses-family provider (OpenAI, OpenAI-Codex,
556
+ // Azure) the encrypted reasoning and native response items are self-contained
557
+ // and MUST survive rehydration: remote compaction replays them to rebuild
558
+ // faithful native history (user + assistant turns + encrypted reasoning), and
559
+ // same-model live turns reuse them for prompt-cache continuity. Stripping them
560
+ // for all providers is what left resumed sessions compacting tool-call-only
561
+ // history with no reasoning and no assistant prose.
562
+ if (message.provider !== "github-copilot") {
563
+ return message;
564
+ }
462
565
 
463
566
  let didSanitizeContent = false;
464
567
  const sanitizedContent = message.content.map(block => {
465
568
  if (block.type !== "thinking" || block.thinkingSignature === undefined) {
466
569
  return block;
467
570
  }
468
-
469
571
  didSanitizeContent = true;
470
572
  return { ...block, thinkingSignature: undefined };
471
573
  });
472
574
 
473
- // Strip the assistant-side native replay payload entirely.
474
- // After rehydration it belongs to a previous live provider connection and
475
- // replaying it on a warmed session causes 401 rejections from GitHub Copilot.
476
- // User/developer payloads are preserved separately by the caller.
575
+ // Strip the assistant-side native replay payload entirely. After rehydration
576
+ // it belongs to a previous live Copilot connection and replaying it on a
577
+ // warmed session causes 401 rejections. User/developer payloads are preserved
578
+ // separately by the caller.
477
579
  return {
478
580
  ...message,
479
581
  ...(didSanitizeContent ? { content: sanitizedContent } : {}),
@@ -481,6 +583,38 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
481
583
  };
482
584
  }
483
585
 
586
+ function customMessageContentToLlmContent(content: CustomMessage["content"]): (TextContent | ImageContent)[] {
587
+ return typeof content === "string" ? [{ type: "text", text: content }] : content;
588
+ }
589
+
590
+ function isUserInvokedSkillPrompt(message: CustomMessage): boolean {
591
+ return message.customType === SKILL_PROMPT_MESSAGE_TYPE && message.attribution === "user";
592
+ }
593
+
594
+ function convertImageBearingCustomMessage(message: CustomMessage | HookMessage): Message[] | undefined {
595
+ if (typeof message.content === "string") return undefined;
596
+ const textBlocks = message.content.filter((content): content is TextContent => content.type === "text");
597
+ const imageBlocks = message.content.filter((content): content is ImageContent => content.type === "image");
598
+ if (imageBlocks.length === 0) return undefined;
599
+
600
+ const converted: Message[] = [];
601
+ if (textBlocks.length > 0) {
602
+ converted.push({
603
+ role: "developer",
604
+ content: textBlocks,
605
+ attribution: message.attribution,
606
+ timestamp: message.timestamp,
607
+ });
608
+ }
609
+ converted.push({
610
+ role: "user",
611
+ content: [{ type: "text", text: `Images attached to ${message.customType}.` }, ...imageBlocks],
612
+ attribution: message.attribution,
613
+ timestamp: message.timestamp,
614
+ });
615
+ return converted;
616
+ }
617
+
484
618
  /**
485
619
  * Transform AgentMessages (including custom types) to LLM-compatible Messages.
486
620
  *
@@ -490,7 +624,7 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
490
624
  * - Custom extensions and tools
491
625
  */
492
626
  export function convertToLlm(messages: AgentMessage[]): Message[] {
493
- return messages.flatMap((m): Message[] => {
627
+ return messages.flatMap((m, index): Message[] => {
494
628
  switch (m.role) {
495
629
  case "bashExecution":
496
630
  if (m.excludeFromContext) {
@@ -555,13 +689,42 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
555
689
  }
556
690
  return out;
557
691
  }
558
- case "custom":
559
- case "hookMessage":
692
+ case "custom": {
693
+ if (isUserInvokedSkillPrompt(m)) {
694
+ return [
695
+ {
696
+ role: "user",
697
+ content: customMessageContentToLlmContent(m.content),
698
+ attribution: "user",
699
+ timestamp: m.timestamp,
700
+ },
701
+ ];
702
+ }
703
+ const split = convertImageBearingCustomMessage(m);
704
+ if (split) return split;
705
+ const converted = convertMessageToLlm(m);
706
+ return converted ? [converted] : [];
707
+ }
708
+ case "hookMessage": {
709
+ const split = convertImageBearingCustomMessage(m);
710
+ if (split) return split;
711
+ const converted = convertMessageToLlm(m);
712
+ return converted ? [converted] : [];
713
+ }
714
+ case "assistant": {
715
+ // A user-interrupted turn keeps its trailing thinking run on the
716
+ // persisted/displayed message so reload and Ctrl+L rebuilds still
717
+ // show it. That run is incomplete/unsigned and gets rejected on
718
+ // resend, so strip it here — LLM path only — when the hidden
719
+ // interrupted-thinking continuity message follows.
720
+ const source = followedByInterruptedThinking(messages, index) ? stripDemotedThinkingForLlm(m) : m;
721
+ const converted = convertMessageToLlm(source);
722
+ return converted ? [converted] : [];
723
+ }
560
724
  case "branchSummary":
561
725
  case "compactionSummary":
562
726
  case "user":
563
727
  case "developer":
564
- case "assistant":
565
728
  case "toolResult": {
566
729
  // Core roles share one transformer with agent-core —
567
730
  // duplicating them here is how snapcompact frames once
@@ -4,6 +4,7 @@ import {
4
4
  type SessionStorageBackend,
5
5
  type SessionStorageIndexEntry,
6
6
  } from "./indexed-session-storage";
7
+ import type { SessionTitleUpdate } from "./session-title-slot";
7
8
 
8
9
  /**
9
10
  * Minimal subset of the `bun:redis` `RedisClient` surface used by
@@ -44,6 +45,28 @@ export interface RedisSessionStorageOptions {
44
45
  const DEFAULT_PREFIX = "omp:sessions:";
45
46
  const DEFAULT_SCAN_COUNT = 500;
46
47
 
48
+ function encodeTitleMeta(title: SessionTitleUpdate): string {
49
+ return JSON.stringify(title);
50
+ }
51
+
52
+ function decodeTitleMeta(raw: string | undefined): SessionTitleUpdate | undefined {
53
+ if (!raw) return undefined;
54
+ try {
55
+ const parsed = JSON.parse(raw) as unknown;
56
+ if (typeof parsed !== "object" || parsed === null) return undefined;
57
+ const record = parsed as Record<string, unknown>;
58
+ if (typeof record.updatedAt !== "string") return undefined;
59
+ const source = record.source === "auto" || record.source === "user" ? record.source : undefined;
60
+ return {
61
+ title: typeof record.title === "string" ? record.title : undefined,
62
+ source,
63
+ updatedAt: record.updatedAt,
64
+ };
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
47
70
  /**
48
71
  * Redis-backed implementation of {@link SessionStorage}. Each session JSONL
49
72
  * file maps to a Redis STRING key, with per-key metadata (mtime) tracked in a
@@ -83,8 +106,9 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
83
106
  async loadIndex(): Promise<SessionStorageIndexEntry[]> {
84
107
  const filePrefix = this.#fileKey("");
85
108
  const metaRaw = await this.#client.hgetall(this.#metaKey());
109
+ const titleRaw = await this.#client.hgetall(this.#titleMetaKey());
86
110
  const meta: Record<string, string> = metaRaw ?? {};
87
-
111
+ const titles: Record<string, string> = titleRaw ?? {};
88
112
  const seen = new Set<string>();
89
113
  let cursor = "0";
90
114
  do {
@@ -106,10 +130,14 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
106
130
  const size = await this.#client.strlen(key);
107
131
  const rawMtime = meta[path];
108
132
  const parsedMtime = rawMtime === undefined ? Number.NaN : Number(rawMtime);
133
+ const title = decodeTitleMeta(titles[path]);
109
134
  return {
110
135
  path,
111
136
  size,
112
137
  mtimeMs: Number.isFinite(parsedMtime) ? parsedMtime : fallbackMtimeMs,
138
+ title: title?.title,
139
+ titleSource: title?.source,
140
+ titleUpdatedAt: title?.updatedAt,
113
141
  };
114
142
  }),
115
143
  );
@@ -126,9 +154,14 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
126
154
  return Promise.all([head, tail]);
127
155
  }
128
156
 
129
- async writeFull(path: string, content: string, mtimeMs: number): Promise<void> {
157
+ async writeFull(path: string, content: string, mtimeMs: number, title?: SessionTitleUpdate): Promise<void> {
130
158
  await this.#client.set(this.#fileKey(path), content);
131
159
  await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
160
+ if (title) {
161
+ await this.#client.hset(this.#titleMetaKey(), path, encodeTitleMeta(title));
162
+ } else {
163
+ await this.#client.hdel(this.#titleMetaKey(), path);
164
+ }
132
165
  }
133
166
 
134
167
  async append(path: string, line: string, mtimeMs: number): Promise<void> {
@@ -136,6 +169,11 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
136
169
  await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
137
170
  }
138
171
 
172
+ async updateSessionTitle(path: string, title: SessionTitleUpdate, mtimeMs: number): Promise<void> {
173
+ await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
174
+ await this.#client.hset(this.#titleMetaKey(), path, encodeTitleMeta(title));
175
+ }
176
+
139
177
  async truncate(path: string, mtimeMs: number): Promise<void> {
140
178
  await this.writeFull(path, "", mtimeMs);
141
179
  }
@@ -144,13 +182,18 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
144
182
  if (paths.length === 0) return;
145
183
  await this.#client.del(...paths.map(path => this.#fileKey(path)));
146
184
  await this.#client.hdel(this.#metaKey(), ...paths);
185
+ await this.#client.hdel(this.#titleMetaKey(), ...paths);
147
186
  }
148
187
 
149
188
  async move(src: string, dst: string, mtimeMs: number): Promise<void> {
150
189
  await this.#client.rename(this.#fileKey(src), this.#fileKey(dst));
151
190
  try {
191
+ const titleMeta = await this.#client.hgetall(this.#titleMetaKey());
152
192
  await this.#client.hdel(this.#metaKey(), src);
153
193
  await this.#client.hset(this.#metaKey(), dst, String(mtimeMs));
194
+ await this.#client.hdel(this.#titleMetaKey(), src);
195
+ const title = titleMeta[src];
196
+ if (title !== undefined) await this.#client.hset(this.#titleMetaKey(), dst, title);
154
197
  } catch (err) {
155
198
  logger.warn("Redis session storage meta rename failed", {
156
199
  src,
@@ -167,4 +210,8 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
167
210
  #metaKey(): string {
168
211
  return `${this.#prefix}meta`;
169
212
  }
213
+
214
+ #titleMetaKey(): string {
215
+ return `${this.#prefix}title`;
216
+ }
170
217
  }