@oh-my-pi/pi-coding-agent 16.0.2 → 16.0.3

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 (88) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +0 -1
  3. package/dist/cli.js +217 -276
  4. package/dist/types/advisor/advise-tool.d.ts +30 -1
  5. package/dist/types/commands/install.d.ts +1 -1
  6. package/dist/types/config/model-resolver.d.ts +8 -0
  7. package/dist/types/config/settings-schema.d.ts +0 -10
  8. package/dist/types/eval/js/shared/runtime.d.ts +1 -0
  9. package/dist/types/eval/js/worker-core.d.ts +1 -0
  10. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  11. package/dist/types/goals/runtime.d.ts +0 -1
  12. package/dist/types/mcp/tool-bridge.d.ts +3 -0
  13. package/dist/types/modes/components/custom-editor.d.ts +14 -4
  14. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  15. package/dist/types/modes/interactive-mode.d.ts +1 -1
  16. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +3 -2
  17. package/dist/types/modes/theme/mermaid-cache.d.ts +18 -1
  18. package/dist/types/modes/types.d.ts +1 -1
  19. package/dist/types/registry/agent-lifecycle.d.ts +16 -1
  20. package/dist/types/sdk.d.ts +8 -0
  21. package/dist/types/session/agent-session.d.ts +20 -8
  22. package/dist/types/session/session-dump-format.d.ts +8 -2
  23. package/dist/types/session/session-entries.d.ts +4 -0
  24. package/dist/types/session/session-history-format.d.ts +2 -0
  25. package/dist/types/session/session-manager.d.ts +22 -0
  26. package/dist/types/stt/downloader.d.ts +5 -5
  27. package/dist/types/task/executor.d.ts +6 -0
  28. package/dist/types/task/persisted-revive.d.ts +36 -0
  29. package/dist/types/tiny/models.d.ts +8 -0
  30. package/dist/types/tools/builtin-names.d.ts +1 -1
  31. package/dist/types/tools/index.d.ts +0 -1
  32. package/package.json +12 -12
  33. package/src/advisor/__tests__/advisor.test.ts +150 -50
  34. package/src/advisor/advise-tool.ts +48 -6
  35. package/src/advisor/runtime.ts +10 -3
  36. package/src/auto-thinking/classifier.ts +12 -3
  37. package/src/cli.ts +2 -2
  38. package/src/commands/install.ts +3 -3
  39. package/src/config/model-resolver.ts +28 -11
  40. package/src/config/settings-schema.ts +0 -11
  41. package/src/eval/agent-bridge.ts +2 -0
  42. package/src/eval/js/context-manager.ts +2 -1
  43. package/src/eval/js/shared/runtime.ts +189 -15
  44. package/src/eval/js/worker-core.ts +19 -0
  45. package/src/export/html/index.ts +1 -1
  46. package/src/export/html/tool-views.generated.js +34 -35
  47. package/src/extensibility/extensions/loader.ts +21 -9
  48. package/src/goals/runtime.ts +1 -23
  49. package/src/internal-urls/docs-index.generated.ts +4 -6
  50. package/src/main.ts +20 -0
  51. package/src/mcp/render.ts +11 -1
  52. package/src/mcp/tool-bridge.ts +3 -0
  53. package/src/modes/components/custom-editor.test.ts +63 -18
  54. package/src/modes/components/custom-editor.ts +63 -15
  55. package/src/modes/controllers/command-controller.ts +2 -2
  56. package/src/modes/controllers/input-controller.ts +15 -9
  57. package/src/modes/controllers/selector-controller.ts +13 -8
  58. package/src/modes/controllers/tan-command-controller.ts +1 -0
  59. package/src/modes/interactive-mode.ts +4 -2
  60. package/src/modes/setup-wizard/wizard-overlay.ts +26 -4
  61. package/src/modes/theme/mermaid-cache.ts +74 -11
  62. package/src/modes/theme/theme.ts +14 -1
  63. package/src/modes/types.ts +1 -1
  64. package/src/prompts/system/system-prompt.md +2 -1
  65. package/src/registry/agent-lifecycle.ts +60 -8
  66. package/src/sdk.ts +20 -26
  67. package/src/session/agent-session.ts +246 -78
  68. package/src/session/artifacts.ts +19 -1
  69. package/src/session/session-dump-format.ts +167 -23
  70. package/src/session/session-entries.ts +4 -0
  71. package/src/session/session-history-format.ts +37 -3
  72. package/src/session/session-manager.ts +94 -4
  73. package/src/slash-commands/builtin-registry.ts +4 -7
  74. package/src/stt/asr-client.ts +6 -0
  75. package/src/stt/downloader.ts +13 -6
  76. package/src/stt/stt-controller.ts +52 -11
  77. package/src/task/executor.ts +18 -2
  78. package/src/task/index.ts +2 -2
  79. package/src/task/persisted-revive.ts +128 -0
  80. package/src/tiny/models.ts +10 -0
  81. package/src/tiny/worker.ts +4 -3
  82. package/src/tools/builtin-names.ts +0 -1
  83. package/src/tools/index.ts +0 -4
  84. package/src/tools/output-meta.ts +17 -3
  85. package/src/utils/title-generator.ts +4 -4
  86. package/dist/types/tools/render-mermaid.d.ts +0 -38
  87. package/src/prompts/tools/render-mermaid.md +0 -9
  88. package/src/tools/render-mermaid.ts +0 -69
@@ -1,11 +1,28 @@
1
1
  /**
2
- * Plain-text / markdown session formatting (same shape as /dump clipboard export).
2
+ * Plain-text / markdown session formatting for `/dump` and `/advisor dump raw`.
3
+ *
4
+ * Renders a prelude (system prompt, model/thinking config, tool inventory)
5
+ * followed by the message history as per-message markdown headings: `## User`,
6
+ * `## Assistant` (with `<thinking>` blocks and `### Tool Call: <name>` + YAML
7
+ * args), `### Tool Result: <name>`, and the execution/summary sections.
3
8
  */
4
9
  import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
- import type { Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai";
6
- import { getDialectDefinition, renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
7
- import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
8
- import { convertToLlm } from "./messages";
10
+ import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
11
+ import type { AssistantMessage, Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai";
12
+ import { renderDelimitedThinking, renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
13
+ import { YAML } from "bun";
14
+ import { canonicalizeMessage } from "../utils/thinking-display";
15
+ import {
16
+ type BashExecutionMessage,
17
+ type BranchSummaryMessage,
18
+ bashExecutionToText,
19
+ type CompactionSummaryMessage,
20
+ type CustomMessage,
21
+ type FileMentionMessage,
22
+ type HookMessage,
23
+ type PythonExecutionMessage,
24
+ pythonExecutionToText,
25
+ } from "./messages";
9
26
 
10
27
  /** Minimal tool shape for dump output (matches AgentTool fields used by formatSessionDumpText). */
11
28
  export interface SessionDumpToolInfo {
@@ -23,12 +40,25 @@ export interface FormatSessionDumpTextOptions {
23
40
  tools?: readonly SessionDumpToolInfo[];
24
41
  }
25
42
 
26
- /**
27
- * Format messages and session metadata as markdown/plain text (same as AgentSession.formatSessionAsText / /dump).
28
- */
29
- export function formatSessionDumpText(options: FormatSessionDumpTextOptions): string {
43
+ interface InventoryTool {
44
+ name: string;
45
+ description: string;
46
+ parameters: TSchema;
47
+ examples?: readonly ToolExample[];
48
+ }
49
+
50
+ function toInventoryTools(tools: readonly SessionDumpToolInfo[]): InventoryTool[] {
51
+ return tools.map(tool => ({
52
+ name: tool.name,
53
+ description: tool.description,
54
+ parameters: tool.parameters as TSchema,
55
+ examples: tool.examples,
56
+ }));
57
+ }
58
+
59
+ /** System prompt + model/thinking config + tool inventory — shared by both transcript styles. */
60
+ function renderDumpHeader(options: FormatSessionDumpTextOptions, inventoryTools: readonly InventoryTool[]): string[] {
30
61
  const lines: string[] = [];
31
- const definition = getDialectDefinition(preferredDialect(options.model?.id ?? ""));
32
62
 
33
63
  const systemPrompt = options.systemPrompt?.filter(prompt => prompt.length > 0) ?? [];
34
64
  if (systemPrompt.length > 0) {
@@ -43,28 +73,142 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
43
73
  }
44
74
 
45
75
  const model = options.model;
46
- const thinkingLevel = options.thinkingLevel;
47
76
  lines.push("## Configuration\n");
48
77
  lines.push(`Model: ${model ? `${model.provider}/${model.id}` : "(not selected)"}`);
49
- lines.push(`Thinking Level: ${thinkingLevel ?? ""}`);
78
+ lines.push(`Thinking Level: ${options.thinkingLevel ?? ""}`);
50
79
  lines.push("\n");
51
80
 
52
- const tools = options.tools ?? [];
53
- const inventoryTools = tools.map(tool => ({
54
- name: tool.name,
55
- description: tool.description,
56
- parameters: tool.parameters as TSchema,
57
- examples: tool.examples,
58
- }));
59
81
  if (inventoryTools.length > 0) {
60
82
  lines.push("## Available Tools\n");
61
- lines.push(renderToolInventory(inventoryTools, options.model?.id ?? ""));
83
+ lines.push(renderToolInventory(inventoryTools, model?.id ?? ""));
62
84
  lines.push("\n");
63
85
  }
64
86
 
65
- lines.push("## Transcript\n");
66
- lines.push(definition.renderTranscript(convertToLlm([...options.messages]), { tools: inventoryTools }));
67
- lines.push("\n");
87
+ return lines;
88
+ }
89
+
90
+ /** Append the legacy per-message markdown-heading transcript (the pre-16.x `/dump` body). */
91
+ function appendMarkdownTranscript(lines: string[], messages: readonly AgentMessage[]): void {
92
+ for (const msg of messages) {
93
+ if (msg.role === "user" || msg.role === "developer") {
94
+ lines.push(msg.role === "developer" ? "## Developer\n" : "## User\n");
95
+ if (typeof msg.content === "string") {
96
+ lines.push(msg.content);
97
+ } else {
98
+ for (const c of msg.content) {
99
+ if (c.type === "text") lines.push(c.text);
100
+ else if (c.type === "image") lines.push("[Image]");
101
+ }
102
+ }
103
+ lines.push("\n");
104
+ } else if (msg.role === "assistant") {
105
+ const assistantMsg = msg as AssistantMessage;
106
+ lines.push("## Assistant\n");
107
+ for (const c of assistantMsg.content) {
108
+ if (c.type === "text") {
109
+ lines.push(c.text);
110
+ } else if (c.type === "thinking") {
111
+ const thinking = canonicalizeMessage(c.thinking);
112
+ if (thinking.length === 0) continue;
113
+ // Unwrap any literal `<thinking>` envelope already present in the
114
+ // block (e.g. Opus 4.5 — issue #2700) so the dump never nests tags.
115
+ lines.push(`${renderDelimitedThinking("<thinking>", "</thinking>", thinking)}\n`);
116
+ } else if (c.type === "toolCall") {
117
+ lines.push(`### Tool Call: ${c.name}`);
118
+ const rawArgs = c.arguments as Record<string, unknown> | undefined;
119
+ if (rawArgs && typeof rawArgs === "object") {
120
+ const intent = rawArgs[INTENT_FIELD];
121
+ if (typeof intent === "string" && intent.trim().length > 0) {
122
+ for (const line of intent.split("\n")) lines.push(`// ${line}`);
123
+ }
124
+ const args: Record<string, unknown> = {};
125
+ let hasArgs = false;
126
+ for (const key in rawArgs) {
127
+ if (key === INTENT_FIELD) continue;
128
+ args[key] = rawArgs[key];
129
+ hasArgs = true;
130
+ }
131
+ if (hasArgs) {
132
+ lines.push("```yaml");
133
+ lines.push(YAML.stringify(args, null, 2).trimEnd());
134
+ lines.push("```\n");
135
+ }
136
+ }
137
+ }
138
+ }
139
+ lines.push("");
140
+ } else if (msg.role === "toolResult") {
141
+ lines.push(`### Tool Result: ${msg.toolName}`);
142
+ if (msg.isError) lines.push("(error)");
143
+ for (const c of msg.content) {
144
+ if (c.type === "text") {
145
+ lines.push("```");
146
+ lines.push(c.text);
147
+ lines.push("```");
148
+ } else if (c.type === "image") {
149
+ lines.push("[Image output]");
150
+ }
151
+ }
152
+ lines.push("");
153
+ } else if (msg.role === "bashExecution") {
154
+ const bashMsg = msg as BashExecutionMessage;
155
+ if (!bashMsg.excludeFromContext) {
156
+ lines.push("## Bash Execution\n");
157
+ lines.push(bashExecutionToText(bashMsg));
158
+ lines.push("\n");
159
+ }
160
+ } else if (msg.role === "pythonExecution") {
161
+ const pythonMsg = msg as PythonExecutionMessage;
162
+ if (!pythonMsg.excludeFromContext) {
163
+ lines.push("## Python Execution\n");
164
+ lines.push(pythonExecutionToText(pythonMsg));
165
+ lines.push("\n");
166
+ }
167
+ } else if (msg.role === "custom" || msg.role === "hookMessage") {
168
+ const customMsg = msg as CustomMessage | HookMessage;
169
+ lines.push(`## ${customMsg.customType}\n`);
170
+ if (typeof customMsg.content === "string") {
171
+ lines.push(customMsg.content);
172
+ } else {
173
+ for (const c of customMsg.content) {
174
+ if (c.type === "text") lines.push(c.text);
175
+ else if (c.type === "image") lines.push("[Image]");
176
+ }
177
+ }
178
+ lines.push("\n");
179
+ } else if (msg.role === "branchSummary") {
180
+ const branchMsg = msg as BranchSummaryMessage;
181
+ lines.push("## Branch Summary\n");
182
+ lines.push(`(from branch: ${branchMsg.fromId})\n`);
183
+ lines.push(branchMsg.summary);
184
+ lines.push("\n");
185
+ } else if (msg.role === "compactionSummary") {
186
+ const compactMsg = msg as CompactionSummaryMessage;
187
+ lines.push("## Compaction Summary\n");
188
+ lines.push(`(${compactMsg.tokensBefore} tokens before compaction)\n`);
189
+ lines.push(compactMsg.summary);
190
+ lines.push("\n");
191
+ } else if (msg.role === "fileMention") {
192
+ const fileMsg = msg as FileMentionMessage;
193
+ lines.push("## File Mention\n");
194
+ for (const file of fileMsg.files) {
195
+ lines.push(`<file path="${file.path}">`);
196
+ if (file.content) lines.push(file.content);
197
+ if (file.image) lines.push("[Image attached]");
198
+ lines.push("</file>\n");
199
+ }
200
+ lines.push("\n");
201
+ }
202
+ }
203
+ }
68
204
 
205
+ /**
206
+ * Format messages and session metadata as markdown/plain text (same as
207
+ * AgentSession.formatSessionAsText / /dump).
208
+ */
209
+ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): string {
210
+ const inventoryTools = toInventoryTools(options.tools ?? []);
211
+ const lines = renderDumpHeader(options, inventoryTools);
212
+ appendMarkdownTranscript(lines, options.messages);
69
213
  return lines.join("\n").trim();
70
214
  }
@@ -124,6 +124,10 @@ export interface SessionInitEntry extends SessionEntryBase {
124
124
  tools: string[];
125
125
  /** Output schema if structured output was requested */
126
126
  outputSchema?: unknown;
127
+ /** Spawn allowlist the subagent ran with ("" = none, "*" = any, else CSV); absent on pre-spawns files. */
128
+ spawns?: string;
129
+ /** The agent's `readSummarize` setting (`false` = read summarization disabled); absent uses the session default. */
130
+ readSummarize?: boolean;
127
131
  }
128
132
 
129
133
  /** Mode change entry - tracks agent mode transitions (e.g. plan mode). */
@@ -26,6 +26,8 @@ export interface HistoryFormatOptions {
26
26
  includeThinking?: boolean;
27
27
  /** Render tool intent comment before tool call lines. */
28
28
  includeToolIntent?: boolean;
29
+ /** Render watched-session roles as inline `**agent**:` / `**user**:` labels (collapsing consecutive same-role messages) instead of `## ` headings, so a primary transcript embedded inside an advisor turn stays visually distinct. */
30
+ watchedRoles?: boolean;
29
31
  }
30
32
 
31
33
  /** Max length of the primary-arg summary inside `→ tool(...)` lines. */
@@ -125,7 +127,7 @@ function toolCallLine(
125
127
  const intent = includeToolIntent ? args?.[INTENT_FIELD] : undefined;
126
128
  if (typeof intent === "string" && intent.trim()) {
127
129
  const formattedIntent = oneLine(intent, 80);
128
- return `# ${formattedIntent}\n${base}`;
130
+ return `// ${formattedIntent}\n${base}`;
129
131
  }
130
132
  return base;
131
133
  }
@@ -191,6 +193,11 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
191
193
  }
192
194
  }
193
195
  const consumed = new Set<string>();
196
+ // In watched mode, consecutive same-role messages collapse under one label
197
+ // (the watched agent emits one assistant message per tool call, so otherwise
198
+ // every call repeats `**agent**:`). Cleared whenever a
199
+ // non-role-labeled line is emitted so the next turn re-labels.
200
+ let lastWatchedLabel: string | undefined;
194
201
 
195
202
  for (const msg of typed) {
196
203
  switch (msg.role) {
@@ -198,7 +205,17 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
198
205
  case "developer": {
199
206
  const text = contentToText(msg.content);
200
207
  if (!text.trim()) break;
201
- lines.push(`## ${msg.role}`, "", text, "");
208
+ if (opts?.watchedRoles) {
209
+ const label = `**${msg.role}**:`;
210
+ if (lastWatchedLabel === label) {
211
+ lines.push(text, "");
212
+ } else {
213
+ lines.push(label, text, "");
214
+ lastWatchedLabel = label;
215
+ }
216
+ } else {
217
+ lines.push(`## ${msg.role}`, "", text, "");
218
+ }
202
219
  break;
203
220
  }
204
221
  case "assistant": {
@@ -217,45 +234,62 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
217
234
  // redactedThinking elided entirely (no readable text)
218
235
  }
219
236
  if (body.length === 0) break;
220
- lines.push("## assistant", "", ...body, "");
237
+ if (opts?.watchedRoles) {
238
+ const label = "**agent**:";
239
+ if (lastWatchedLabel === label) {
240
+ lines.push(...body, "");
241
+ } else {
242
+ lines.push(label, ...body, "");
243
+ lastWatchedLabel = label;
244
+ }
245
+ } else {
246
+ lines.push("## assistant", "", ...body, "");
247
+ }
221
248
  break;
222
249
  }
223
250
  case "toolResult": {
224
251
  // Normally consumed by its toolCall; orphans (e.g. truncated history) get their own line.
225
252
  if (consumed.has(msg.toolCallId)) break;
226
253
  lines.push(toolCallLine(msg.toolName, undefined, msg, opts?.includeToolIntent), "");
254
+ lastWatchedLabel = undefined;
227
255
  break;
228
256
  }
229
257
  case "bashExecution": {
230
258
  const bashMsg = msg as BashExecutionMessage;
231
259
  if (bashMsg.excludeFromContext) break;
232
260
  lines.push(executionLine("bash", bashMsg.command, bashMsg), "");
261
+ lastWatchedLabel = undefined;
233
262
  break;
234
263
  }
235
264
  case "pythonExecution": {
236
265
  const pythonMsg = msg as PythonExecutionMessage;
237
266
  if (pythonMsg.excludeFromContext) break;
238
267
  lines.push(executionLine("python", pythonMsg.code, pythonMsg), "");
268
+ lastWatchedLabel = undefined;
239
269
  break;
240
270
  }
241
271
  case "custom":
242
272
  case "hookMessage": {
243
273
  lines.push(customOneLiner(msg as CustomMessage | HookMessage), "");
274
+ lastWatchedLabel = undefined;
244
275
  break;
245
276
  }
246
277
  case "branchSummary": {
247
278
  const branchMsg = msg as BranchSummaryMessage;
248
279
  lines.push(`[branch] from ${branchMsg.fromId}: ${oneLine(branchMsg.summary)}`, "");
280
+ lastWatchedLabel = undefined;
249
281
  break;
250
282
  }
251
283
  case "compactionSummary": {
252
284
  const compactMsg = msg as CompactionSummaryMessage;
253
285
  lines.push(`[compaction] ${oneLine(compactMsg.summary)}`, "");
286
+ lastWatchedLabel = undefined;
254
287
  break;
255
288
  }
256
289
  case "fileMention": {
257
290
  const fileMsg = msg as FileMentionMessage;
258
291
  lines.push(`[file-mention] ${oneLine(fileMsg.files.map(f => f.path).join(", "))}`, "");
292
+ lastWatchedLabel = undefined;
259
293
  break;
260
294
  }
261
295
  }
@@ -71,6 +71,27 @@ function artifactsDirectoryFor(sessionFile: string | undefined): string | null {
71
71
  return sessionFile ? sessionFile.slice(0, -JSONL_SUFFIX_LENGTH) : null;
72
72
  }
73
73
 
74
+ /**
75
+ * Resolve a breadcrumb's recorded session file to its interactive root. Subagent
76
+ * (and other artifact) sessions live inside a parent session's artifacts dir —
77
+ * `<parent>.jsonl` strips its suffix to `<parent>/`, and a child writes
78
+ * `<parent>/<agentId>.jsonl`. A breadcrumb that points at such a child — a
79
+ * pre-fix poisoned crumb left by a subagent that opened in the parent's TTY, or
80
+ * any nested artifact — must resolve back up to the top-level session so
81
+ * `--continue` resumes the real conversation instead of a subagent transcript.
82
+ */
83
+ function resolveBreadcrumbToInteractiveRoot(sessionFile: string): string {
84
+ let current = path.resolve(sessionFile);
85
+ // Walk up while the containing dir is itself a session's artifacts dir
86
+ // (`<dir>.jsonl` exists). Capped to defend against pathological layouts.
87
+ for (let depth = 0; depth < 8; depth++) {
88
+ const parentSessionFile = `${path.dirname(current)}.jsonl`;
89
+ if (!fs.existsSync(parentSessionFile)) return current;
90
+ current = parentSessionFile;
91
+ }
92
+ return current;
93
+ }
94
+
74
95
  function emptyUsageStatistics(): UsageStatistics {
75
96
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
76
97
  }
@@ -462,7 +483,7 @@ export class SessionManager {
462
483
  * bytes in the kernel page cache, so the file is software-crash durable.
463
484
  */
464
485
  #rewriteSynchronously(): void {
465
- if (!this.#persist || !this.#sessionFile) return;
486
+ if (!this.#persist || !this.#sessionFile || !this.#shouldHaveSessionFile()) return;
466
487
 
467
488
  try {
468
489
  const body = this.#fileBody();
@@ -931,8 +952,10 @@ export class SessionManager {
931
952
  async close(): Promise<void> {
932
953
  if (!this.#persist) return;
933
954
  await this.#scheduleDiskWork(async () => {
955
+ const hadWriter = this.#writer !== undefined;
934
956
  await this.#closeWriterHandle();
935
- this.#fileIsCurrent = true;
957
+ if (hadWriter || (this.#sessionFile && this.#storage.existsSync(this.#sessionFile)))
958
+ this.#fileIsCurrent = true;
936
959
  });
937
960
  if (this.#diskFailure) throw this.#diskFailure;
938
961
  }
@@ -1157,7 +1180,14 @@ export class SessionManager {
1157
1180
  return entry.id;
1158
1181
  }
1159
1182
 
1160
- appendSessionInit(init: { systemPrompt: string; task: string; tools: string[]; outputSchema?: unknown }): string {
1183
+ appendSessionInit(init: {
1184
+ systemPrompt: string;
1185
+ task: string;
1186
+ tools: string[];
1187
+ outputSchema?: unknown;
1188
+ spawns?: string;
1189
+ readSummarize?: boolean;
1190
+ }): string {
1161
1191
  const entry: SessionInitEntry = { type: "session_init", ...this.#freshEntryFields(), ...init };
1162
1192
  this.#recordEntry(entry);
1163
1193
  return entry.id;
@@ -1528,17 +1558,74 @@ export class SessionManager {
1528
1558
  filePath: string,
1529
1559
  sessionDir?: string,
1530
1560
  storage: SessionStorage = new FileSessionStorage(),
1531
- options?: { initialCwd?: string },
1561
+ options?: { initialCwd?: string; suppressBreadcrumb?: boolean },
1532
1562
  ): Promise<SessionManager> {
1533
1563
  const loaded = await loadEntriesFromFile(filePath, storage);
1534
1564
  const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined;
1535
1565
  const cwd = header?.cwd ?? options?.initialCwd ?? getProjectDir();
1536
1566
  const dir = sessionDir ?? path.dirname(path.resolve(filePath));
1537
1567
  const manager = new SessionManager(cwd, dir, true, storage);
1568
+ manager.#suppressBreadcrumb = options?.suppressBreadcrumb === true;
1538
1569
  await manager.setSessionFile(filePath);
1539
1570
  return manager;
1540
1571
  }
1541
1572
 
1573
+ /**
1574
+ * Lock-free peek for cold subagent revival: returns the recorded working
1575
+ * directory (session header) and the latest `session_init` contract (system
1576
+ * prompt / tools / output schema) WITHOUT taking the single-writer lock that
1577
+ * {@link open} acquires — the caller re-opens for the actual revive. Returns
1578
+ * null when the file can't be read; `init` is null for files written before
1579
+ * `session_init` was recorded (no faithful contract to rebuild from).
1580
+ */
1581
+ static async peekSessionInit(
1582
+ filePath: string,
1583
+ storage: SessionStorage = new FileSessionStorage(),
1584
+ ): Promise<{
1585
+ cwd: string;
1586
+ init: {
1587
+ systemPrompt: string;
1588
+ task: string;
1589
+ tools: string[];
1590
+ outputSchema?: unknown;
1591
+ spawns?: string;
1592
+ readSummarize?: boolean;
1593
+ } | null;
1594
+ } | null> {
1595
+ let loaded: FileEntry[];
1596
+ try {
1597
+ loaded = await loadEntriesFromFile(filePath, storage);
1598
+ } catch {
1599
+ return null;
1600
+ }
1601
+ // A missing/empty file has no usable session — nothing to revive from.
1602
+ if (loaded.length === 0) return null;
1603
+ const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined;
1604
+ let init: {
1605
+ systemPrompt: string;
1606
+ task: string;
1607
+ tools: string[];
1608
+ outputSchema?: unknown;
1609
+ spawns?: string;
1610
+ readSummarize?: boolean;
1611
+ } | null = null;
1612
+ for (let index = loaded.length - 1; index >= 0; index--) {
1613
+ const entry = loaded[index];
1614
+ if (entry.type === "session_init") {
1615
+ init = {
1616
+ systemPrompt: entry.systemPrompt,
1617
+ task: entry.task,
1618
+ tools: entry.tools,
1619
+ outputSchema: entry.outputSchema,
1620
+ readSummarize: entry.readSummarize,
1621
+ spawns: entry.spawns,
1622
+ };
1623
+ break;
1624
+ }
1625
+ }
1626
+ return { cwd: header?.cwd ?? getProjectDir(), init };
1627
+ }
1628
+
1542
1629
  /** Continue the most recent session, or create a new one if none exists. */
1543
1630
  static async continueRecent(
1544
1631
  cwd: string,
@@ -1551,6 +1638,9 @@ export class SessionManager {
1551
1638
  let chosenSession: string | null | undefined;
1552
1639
 
1553
1640
  if (breadcrumb) {
1641
+ // Recover stale crumbs: a subagent open (pre-fix) may have pointed this
1642
+ // terminal's breadcrumb at an artifact child; resume the parent instead.
1643
+ breadcrumb.sessionFile = resolveBreadcrumbToInteractiveRoot(breadcrumb.sessionFile);
1554
1644
  const breadcrumbCwd = path.resolve(breadcrumb.cwd);
1555
1645
  if (breadcrumbCwd === resolvedCwd) {
1556
1646
  chosenSession = breadcrumb.sessionFile;
@@ -547,17 +547,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
547
547
  name: "dump",
548
548
  description: "Copy session transcript to clipboard",
549
549
  acpDescription: "Return full transcript as plain text",
550
- inlineHint: "[raw]",
551
550
  allowArgs: true,
552
- handle: async (command, runtime) => {
553
- const isRaw = command.args.trim().toLowerCase() === "raw";
554
- const text = runtime.session.formatSessionAsText({ compact: !isRaw });
551
+ handle: async (_command, runtime) => {
552
+ const text = runtime.session.formatSessionAsText();
555
553
  await runtime.output(text || "No messages to dump yet.");
556
554
  return commandConsumed();
557
555
  },
558
- handleTui: (command, runtime) => {
559
- const isRaw = command.args.trim().toLowerCase() === "raw";
560
- runtime.ctx.handleDumpCommand(isRaw);
556
+ handleTui: (_command, runtime) => {
557
+ runtime.ctx.handleDumpCommand();
561
558
  runtime.ctx.editor.setText("");
562
559
  },
563
560
  },
@@ -314,6 +314,12 @@ export class SttClient {
314
314
  const worker = this.#ensureWorker();
315
315
  const id = String(++this.#nextRequestId);
316
316
  const { promise, resolve, reject } = Promise.withResolvers<string>();
317
+ // `stop()` is normally the only awaiter of `promise`, but with model loading
318
+ // now deferred to the stream, a load failure (or early worker error) can
319
+ // reject it before the caller stops — attach a benign handler so that never
320
+ // surfaces as an unhandled rejection. stop()/await still observes the
321
+ // rejection through the original promise.
322
+ void promise.catch(() => {});
317
323
  const signal = options.signal;
318
324
  let settled = false;
319
325
  const onAbort = (): void => handle.cancel();
@@ -39,12 +39,12 @@ export interface SttDownloadProgress {
39
39
  }
40
40
 
41
41
  /**
42
- * Whether the selected model is already present in the local cache. For
42
+ * Whether the selected model is fully present in the local cache. For
43
43
  * transformers.js Whisper tiers a complete download leaves `config.json` plus
44
- * the `onnx/` weight files (a bare `config.json` from an interrupted fetch reads
45
- * as not-cached); for sherpa-onnx tiers every model file (encoder/decoder/joiner
46
- * + tokens) must be present (`.part` sidecars from an interrupted fetch are
47
- * ignored).
44
+ * matching `encoder*.onnx` and `decoder*.onnx` shards under `onnx/` (a partial
45
+ * fetch with only one shard, or a bare `config.json`, reads as not-cached); for
46
+ * sherpa-onnx tiers every model file (encoder/decoder/joiner + tokens) must be
47
+ * present (`.part` sidecars from an interrupted fetch are ignored).
48
48
  */
49
49
  export async function isSttModelCached(key: string): Promise<boolean> {
50
50
  const spec = resolveSttModelSpec(key);
@@ -63,8 +63,15 @@ export async function isSttModelCached(key: string): Promise<boolean> {
63
63
  try {
64
64
  const root = await fs.readdir(repoDir);
65
65
  if (!root.includes("config.json")) return false;
66
+ // Whisper tiers are encoder-decoder: a complete download leaves both an
67
+ // `encoder*.onnx` and a `decoder*.onnx` (the dtype suffix varies). Require
68
+ // both rather than any single `.onnx`, so an interrupted fetch that landed
69
+ // only one shard reads as not-cached and the caller takes the foreground
70
+ // download path with progress instead of silently fetching mid-recording.
66
71
  const onnxFiles = await fs.readdir(path.join(repoDir, "onnx")).catch(() => [] as string[]);
67
- return onnxFiles.some(file => file.endsWith(".onnx"));
72
+ const hasEncoder = onnxFiles.some(file => file.startsWith("encoder") && file.endsWith(".onnx"));
73
+ const hasDecoder = onnxFiles.some(file => file.startsWith("decoder") && file.endsWith(".onnx"));
74
+ return hasEncoder && hasDecoder;
68
75
  } catch {
69
76
  return false;
70
77
  }
@@ -4,10 +4,11 @@ import * as path from "node:path";
4
4
  import { logger, Snowflake } from "@oh-my-pi/pi-utils";
5
5
  import { settings } from "../config/settings";
6
6
  import { type SttStreamHandle, sttClient } from "./asr-client";
7
- import { ensureSTTDependencies } from "./downloader";
7
+ import { downloadSttModel, isSttModelCached } from "./downloader";
8
8
  import { resolveSttModelSpec } from "./models";
9
9
  import {
10
10
  detectRecorder,
11
+ ensureRecorder,
11
12
  type RecordingHandle,
12
13
  type StreamingRecordingHandle,
13
14
  startRecording,
@@ -36,7 +37,7 @@ interface Editor {
36
37
 
37
38
  export class STTController {
38
39
  #state: SttState = "idle";
39
- #depsResolved = false;
40
+ #resolvedModelKey: string | null = null;
40
41
  #toggling = false;
41
42
  #stopAfterStart = false;
42
43
  #disposed = false;
@@ -92,15 +93,39 @@ export class STTController {
92
93
  }
93
94
 
94
95
  async #ensureDeps(options: ToggleOptions): Promise<boolean> {
95
- if (this.#depsResolved) return true;
96
+ const modelKey = resolveSttModelSpec(settings.get("stt.modelName") as string | undefined).key;
97
+ // Keyed on the model rather than a one-shot flag: switching stt.modelName
98
+ // mid-session must re-run preflight so an uncached new tier downloads here
99
+ // (with progress) instead of blocking silently at stop.
100
+ if (this.#resolvedModelKey === modelKey) return true;
96
101
  try {
97
- options.showStatus("Checking STT dependencies...");
98
- await ensureSTTDependencies({
99
- modelName: settings.get("stt.modelName") as string | undefined,
100
- onProgress: p => options.showStatus(p.stage + (p.percent != null ? ` (${p.percent}%)` : "")),
101
- });
102
- options.showStatus("");
103
- this.#depsResolved = true;
102
+ // Only clear the status line if we actually wrote to it: the cached
103
+ // fast path (recorder on PATH, model present) emits nothing, so an
104
+ // unconditional clear would be a stray write.
105
+ let wroteStatus = false;
106
+ const status = (msg: string): void => {
107
+ wroteStatus = true;
108
+ options.showStatus(msg);
109
+ };
110
+ // A recorder is required to capture audio; startRecording /
111
+ // startStreamingRecording only *detect* a recorder and throw when none
112
+ // exists, so provision one here. Instant when sox/ffmpeg/arecord is on
113
+ // PATH — only a first-run static-ffmpeg download actually blocks.
114
+ await ensureRecorder(p => status(p.stage + (p.percent != null ? ` (${p.percent}%)` : "")));
115
+ // Loading the multi-hundred-MB speech model into the worker is what made
116
+ // the old "Checking STT dependencies…" step slow. Don't pay it before
117
+ // recording: when the weights are already cached, start now and warm the
118
+ // model in the background — the stream/transcribe paths load it on demand
119
+ // (memoized in the worker) and it is hot by the time recording stops.
120
+ // Only a genuine first-use download blocks, with explicit progress, so we
121
+ // never record silently against missing weights.
122
+ if (await isSttModelCached(modelKey)) {
123
+ this.#warmModel(modelKey);
124
+ } else {
125
+ await downloadSttModel(modelKey, p => status(`Downloading speech model ${p.label} (${p.percent}%)`));
126
+ }
127
+ if (wroteStatus) options.showStatus("");
128
+ this.#resolvedModelKey = modelKey;
104
129
  return true;
105
130
  } catch (err) {
106
131
  const msg = err instanceof Error ? err.message : "Failed to setup STT dependencies";
@@ -110,6 +135,22 @@ export class STTController {
110
135
  }
111
136
  }
112
137
 
138
+ /** Warm the speech model in the worker without blocking recording. The worker
139
+ * memoizes the load, so the stream/transcribe path reuses it and the model is
140
+ * hot by the time recording stops. Only called when the weights are already
141
+ * cached, so no network fetch happens. On load failure (corrupt cache, OOM,
142
+ * runtime install) invalidate the resolved key so the next toggle re-runs
143
+ * preflight and retries instead of skipping it forever. */
144
+ #warmModel(modelKey: string): void {
145
+ void downloadSttModel(modelKey).catch(err => {
146
+ // Guard against a concurrent model switch clobbering a newer resolution.
147
+ if (!this.#disposed && this.#resolvedModelKey === modelKey) this.#resolvedModelKey = null;
148
+ logger.debug("stt: background model warmup failed", {
149
+ error: err instanceof Error ? err.message : String(err),
150
+ });
151
+ });
152
+ }
153
+
113
154
  async #start(editor: Editor, options: ToggleOptions): Promise<void> {
114
155
  if (!(await this.#ensureDeps(options))) return;
115
156
  // Live transcription needs a recorder that can pipe PCM; the Windows
@@ -334,6 +375,6 @@ export class STTController {
334
375
  this.#tempFile = null;
335
376
  }
336
377
  this.#state = "idle";
337
- this.#depsResolved = false;
378
+ this.#resolvedModelKey = null;
338
379
  }
339
380
  }