@oh-my-pi/pi-coding-agent 16.0.1 → 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 (102) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +0 -1
  3. package/dist/cli.js +316 -371
  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 +22 -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/exec/non-interactive-env.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  12. package/dist/types/goals/runtime.d.ts +0 -1
  13. package/dist/types/mcp/tool-bridge.d.ts +3 -0
  14. package/dist/types/modes/components/custom-editor.d.ts +14 -4
  15. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  16. package/dist/types/modes/interactive-mode.d.ts +1 -1
  17. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +3 -2
  18. package/dist/types/modes/theme/mermaid-cache.d.ts +18 -1
  19. package/dist/types/modes/types.d.ts +1 -1
  20. package/dist/types/registry/agent-lifecycle.d.ts +16 -1
  21. package/dist/types/sdk.d.ts +8 -0
  22. package/dist/types/session/agent-session.d.ts +20 -8
  23. package/dist/types/session/messages.d.ts +3 -0
  24. package/dist/types/session/session-dump-format.d.ts +8 -2
  25. package/dist/types/session/session-entries.d.ts +4 -0
  26. package/dist/types/session/session-history-format.d.ts +2 -0
  27. package/dist/types/session/session-manager.d.ts +22 -0
  28. package/dist/types/stt/downloader.d.ts +5 -5
  29. package/dist/types/task/executor.d.ts +6 -0
  30. package/dist/types/task/persisted-revive.d.ts +36 -0
  31. package/dist/types/tiny/models.d.ts +8 -0
  32. package/dist/types/tools/builtin-names.d.ts +1 -1
  33. package/dist/types/tools/index.d.ts +0 -1
  34. package/dist/types/utils/markit.d.ts +8 -0
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +156 -12
  37. package/src/advisor/advise-tool.ts +48 -6
  38. package/src/advisor/runtime.ts +10 -3
  39. package/src/auto-thinking/classifier.ts +12 -3
  40. package/src/cli/args.ts +1 -0
  41. package/src/cli.ts +2 -2
  42. package/src/commands/install.ts +3 -3
  43. package/src/config/model-resolver.ts +63 -12
  44. package/src/config/settings-schema.ts +0 -11
  45. package/src/discovery/github.ts +89 -1
  46. package/src/eval/agent-bridge.ts +2 -0
  47. package/src/eval/js/context-manager.ts +2 -1
  48. package/src/eval/js/shared/runtime.ts +189 -15
  49. package/src/eval/js/worker-core.ts +19 -0
  50. package/src/exec/bash-executor.ts +2 -2
  51. package/src/exec/non-interactive-env.ts +71 -0
  52. package/src/export/html/index.ts +1 -1
  53. package/src/export/html/tool-views.generated.js +34 -35
  54. package/src/extensibility/extensions/loader.ts +21 -9
  55. package/src/extensibility/extensions/runner.ts +17 -1
  56. package/src/extensibility/plugins/loader.ts +154 -21
  57. package/src/extensibility/plugins/manager.ts +40 -33
  58. package/src/goals/runtime.ts +1 -23
  59. package/src/internal-urls/docs-index.generated.ts +9 -11
  60. package/src/main.ts +20 -0
  61. package/src/mcp/render.ts +11 -1
  62. package/src/mcp/tool-bridge.ts +3 -0
  63. package/src/modes/components/custom-editor.test.ts +63 -18
  64. package/src/modes/components/custom-editor.ts +63 -15
  65. package/src/modes/controllers/command-controller.ts +2 -2
  66. package/src/modes/controllers/input-controller.ts +15 -9
  67. package/src/modes/controllers/selector-controller.ts +13 -8
  68. package/src/modes/controllers/tan-command-controller.ts +1 -0
  69. package/src/modes/interactive-mode.ts +4 -2
  70. package/src/modes/setup-wizard/wizard-overlay.ts +26 -4
  71. package/src/modes/theme/mermaid-cache.ts +74 -11
  72. package/src/modes/theme/theme.ts +14 -1
  73. package/src/modes/types.ts +1 -1
  74. package/src/prompts/system/system-prompt.md +2 -1
  75. package/src/registry/agent-lifecycle.ts +60 -8
  76. package/src/sdk.ts +20 -26
  77. package/src/session/agent-session.ts +381 -110
  78. package/src/session/artifacts.ts +19 -1
  79. package/src/session/messages.ts +1 -1
  80. package/src/session/session-dump-format.ts +167 -23
  81. package/src/session/session-entries.ts +4 -0
  82. package/src/session/session-history-format.ts +37 -3
  83. package/src/session/session-manager.ts +94 -4
  84. package/src/slash-commands/builtin-registry.ts +4 -7
  85. package/src/stt/asr-client.ts +6 -0
  86. package/src/stt/downloader.ts +13 -6
  87. package/src/stt/stt-controller.ts +52 -11
  88. package/src/system-prompt.ts +7 -1
  89. package/src/task/executor.ts +118 -6
  90. package/src/task/index.ts +2 -2
  91. package/src/task/persisted-revive.ts +128 -0
  92. package/src/tiny/models.ts +10 -0
  93. package/src/tiny/worker.ts +4 -3
  94. package/src/tools/builtin-names.ts +0 -1
  95. package/src/tools/index.ts +0 -4
  96. package/src/tools/output-meta.ts +17 -3
  97. package/src/utils/lang-from-path.ts +5 -0
  98. package/src/utils/markit.ts +24 -1
  99. package/src/utils/title-generator.ts +4 -4
  100. package/dist/types/tools/render-mermaid.d.ts +0 -38
  101. package/src/prompts/tools/render-mermaid.md +0 -9
  102. package/src/tools/render-mermaid.ts +0 -69
@@ -7,6 +7,24 @@
7
7
  import * as fs from "node:fs/promises";
8
8
  import * as path from "node:path";
9
9
 
10
+ /**
11
+ * Sanitize a tool name for safe use as the middle segment of the artifact
12
+ * filename (`${id}.${toolType}.log`). Built-in tool names are fixed, but MCP,
13
+ * extension, and RPC-host tool names are arbitrary and may contain path
14
+ * separators (`/`, `\`) or traversal sequences (`..`) that would otherwise let
15
+ * a spilled artifact escape the artifacts directory. Collapse everything
16
+ * outside `[A-Za-z0-9_-]` to `_`, and cap the length so an arbitrarily long
17
+ * name cannot overflow the filesystem's filename limit (ENAMETOOLONG). Fall
18
+ * back to `tool` when nothing survives.
19
+ */
20
+ function sanitizeToolType(toolType: string): string {
21
+ const sanitized = toolType
22
+ .replace(/[^A-Za-z0-9_-]+/g, "_")
23
+ .slice(0, 64)
24
+ .replace(/^_+|_+$/g, "");
25
+ return sanitized.length > 0 ? sanitized : "tool";
26
+ }
27
+
10
28
  /**
11
29
  * Manages artifact storage for a session.
12
30
  *
@@ -83,7 +101,7 @@ export class ArtifactManager {
83
101
  async allocatePath(toolType: string): Promise<{ id: string; path: string }> {
84
102
  await this.#ensureDir();
85
103
  const id = String(this.allocateId());
86
- const filename = `${id}.${toolType}.log`;
104
+ const filename = `${id}.${sanitizeToolType(toolType)}.log`;
87
105
  return { id, path: path.join(this.#dir, filename) };
88
106
  }
89
107
 
@@ -94,7 +94,7 @@ export function shouldRenderAbortReason(errorMessage: string | undefined): boole
94
94
 
95
95
  /** Sentinel `errorMessage` the agent stamps on any abort that carried no custom
96
96
  * reason (bare `abort()`). Renderers treat it as "no specific reason given". */
97
- const GENERIC_ABORT_SENTINEL = "Request was aborted";
97
+ export const GENERIC_ABORT_SENTINEL = "Request was aborted";
98
98
 
99
99
  /** Resolve the operator-facing label for an aborted assistant turn. A custom
100
100
  * abort reason threaded onto `errorMessage` is returned verbatim; aborts with
@@ -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
  }