@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
@@ -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
  }
@@ -615,7 +615,13 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
615
615
  resolvedCustomPrompt,
616
616
  resolvedAppendPrompt,
617
617
  ]);
618
- const promptSources = [effectiveSystemPromptCustomization, resolvedCustomPrompt, resolvedAppendPrompt];
618
+ const contextPromptSources = contextFiles.map(file => file.content);
619
+ const promptSources = [
620
+ effectiveSystemPromptCustomization,
621
+ resolvedCustomPrompt,
622
+ resolvedAppendPrompt,
623
+ ...contextPromptSources,
624
+ ];
619
625
  const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources);
620
626
 
621
627
  const environment = await logger.time("getEnvironmentInfo", getEnvironmentInfo);
@@ -7,11 +7,16 @@
7
7
  import path from "node:path";
8
8
  import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
9
9
  import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
10
- import type { Usage } from "@oh-my-pi/pi-ai";
10
+ import type { Api, Model, Usage } from "@oh-my-pi/pi-ai";
11
11
  import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-my-pi/pi-utils";
12
12
  import type { Rule } from "../capability/rule";
13
13
  import { ModelRegistry } from "../config/model-registry";
14
- import { resolveModelOverrideWithAuthFallback } from "../config/model-resolver";
14
+ import {
15
+ formatModelSelectorValue,
16
+ formatModelStringWithRouting,
17
+ resolveModelOverride,
18
+ resolveModelOverrideWithAuthFallback,
19
+ } from "../config/model-resolver";
15
20
  import type { PromptTemplate } from "../config/prompt-templates";
16
21
  import { Settings } from "../config/settings";
17
22
  import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
@@ -120,6 +125,74 @@ function normalizeModelPatterns(value: string | string[] | undefined): string[]
120
125
  .filter(Boolean);
121
126
  }
122
127
 
128
+ const SUBAGENT_RETRY_FALLBACK_ROLE_PREFIX = "subagent:";
129
+
130
+ interface SubagentRetryFallbackCandidate {
131
+ model: Model<Api>;
132
+ selector: string;
133
+ }
134
+
135
+ function resolveSubagentRetryFallbackCandidates(
136
+ modelPatterns: string[],
137
+ modelRegistry: ModelRegistry,
138
+ settings: Settings,
139
+ ): SubagentRetryFallbackCandidate[] {
140
+ const candidates: SubagentRetryFallbackCandidate[] = [];
141
+ const seen = new Set<string>();
142
+ for (const pattern of modelPatterns) {
143
+ const resolved = resolveModelOverride([pattern], modelRegistry, settings);
144
+ if (!resolved.model) continue;
145
+ const selector = resolved.explicitThinkingLevel
146
+ ? formatModelSelectorValue(formatModelStringWithRouting(resolved.model), resolved.thinkingLevel)
147
+ : formatModelStringWithRouting(resolved.model);
148
+ if (seen.has(selector)) continue;
149
+ seen.add(selector);
150
+ candidates.push({ model: resolved.model, selector });
151
+ }
152
+ return candidates;
153
+ }
154
+
155
+ function installSubagentRetryFallbackChain(args: {
156
+ settings: Settings;
157
+ id: string;
158
+ candidates: SubagentRetryFallbackCandidate[];
159
+ model: Model<Api> | undefined;
160
+ authFallbackUsed: boolean;
161
+ }): string | undefined {
162
+ const { settings, id, candidates, model, authFallbackUsed } = args;
163
+ if (!model || authFallbackUsed || candidates.length <= 1) return undefined;
164
+
165
+ const selectedIndex = candidates.findIndex(
166
+ candidate => candidate.model.provider === model.provider && candidate.model.id === model.id,
167
+ );
168
+ if (selectedIndex < 0) return undefined;
169
+ const fallbackSelectors = candidates.slice(selectedIndex + 1).map(candidate => candidate.selector);
170
+ if (fallbackSelectors.length === 0) return undefined;
171
+
172
+ const role = `${SUBAGENT_RETRY_FALLBACK_ROLE_PREFIX}${id}`;
173
+ const modelRoles: Record<string, string> = {};
174
+ const existingRoles = settings.getModelRoles();
175
+ for (const existingRole in existingRoles) {
176
+ const selector = existingRoles[existingRole];
177
+ if (selector) {
178
+ modelRoles[existingRole] = selector;
179
+ }
180
+ }
181
+ modelRoles[role] = candidates[selectedIndex].selector;
182
+ settings.override("modelRoles", modelRoles);
183
+ const fallbackChains: Record<string, string[]> = {
184
+ [role]: fallbackSelectors,
185
+ };
186
+ const existingFallbackChains = settings.get("retry.fallbackChains");
187
+ for (const existingRole in existingFallbackChains) {
188
+ if (existingRole !== role) {
189
+ fallbackChains[existingRole] = existingFallbackChains[existingRole];
190
+ }
191
+ }
192
+ settings.override("retry.fallbackChains", fallbackChains);
193
+ return role;
194
+ }
195
+
123
196
  function renderIrcPeerRoster(selfId: string): string {
124
197
  const peers = AgentRegistry.global()
125
198
  .list()
@@ -278,6 +351,12 @@ export interface ExecutorOptions {
278
351
  parentTelemetry?: AgentTelemetryConfig;
279
352
  /** Skills to autoload via sendCustomMessage before the first prompt */
280
353
  autoloadSkills?: Skill[];
354
+ /**
355
+ * Registry id of the spawning agent, recorded as this subagent's parent.
356
+ * Forwarded verbatim to the SDK; the executor never derives it (the spawner
357
+ * passes its own `getAgentId()`).
358
+ */
359
+ parentAgentId?: string;
281
360
  }
282
361
 
283
362
  function parseStringifiedJson(value: unknown): unknown {
@@ -1222,6 +1301,16 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1222
1301
  popLoopPhase();
1223
1302
  }
1224
1303
  }
1304
+ if (event.type === "retry_fallback_applied") {
1305
+ progress.resolvedModel = event.to;
1306
+ scheduleProgress(true);
1307
+ return;
1308
+ }
1309
+ if (event.type === "retry_fallback_succeeded") {
1310
+ progress.resolvedModel = event.model;
1311
+ scheduleProgress(true);
1312
+ return;
1313
+ }
1225
1314
  });
1226
1315
 
1227
1316
  const captureSalvage = (session: AgentSession): void => {
@@ -1817,13 +1906,26 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1817
1906
  resolvedModel: model.id,
1818
1907
  });
1819
1908
  }
1909
+ const retryFallbackRole = installSubagentRetryFallbackChain({
1910
+ settings: subagentSettings,
1911
+ id,
1912
+ candidates: resolveSubagentRetryFallbackCandidates(modelPatterns, modelRegistry, settings),
1913
+ model,
1914
+ authFallbackUsed,
1915
+ });
1916
+ if (retryFallbackRole) {
1917
+ logger.debug("Configured subagent runtime model fallback chain", {
1918
+ role: retryFallbackRole,
1919
+ requested: modelPatterns,
1920
+ });
1921
+ }
1820
1922
  if (model?.contextWindow && model.contextWindow > 0) {
1821
1923
  progress.contextWindow = model.contextWindow;
1822
1924
  }
1823
1925
  if (model) {
1824
1926
  progress.resolvedModel = explicitThinkingLevel
1825
- ? `${model.provider}/${model.id}:${resolvedThinkingLevel}`
1826
- : `${model.provider}/${model.id}`;
1927
+ ? formatModelSelectorValue(formatModelStringWithRouting(model), resolvedThinkingLevel)
1928
+ : formatModelStringWithRouting(model);
1827
1929
  }
1828
1930
  const effectiveThinkingLevel = explicitThinkingLevel
1829
1931
  ? resolvedThinkingLevel
@@ -1831,7 +1933,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1831
1933
 
1832
1934
  const effectiveCwd = worktree ?? cwd;
1833
1935
  const sessionManager = sessionFile
1834
- ? await awaitAbortable(SessionManager.open(sessionFile, undefined, undefined, { initialCwd: effectiveCwd }))
1936
+ ? await awaitAbortable(
1937
+ SessionManager.open(sessionFile, undefined, undefined, {
1938
+ initialCwd: effectiveCwd,
1939
+ suppressBreadcrumb: true,
1940
+ }),
1941
+ )
1835
1942
  : SessionManager.inMemory(effectiveCwd);
1836
1943
  if (options.parentArtifactManager) {
1837
1944
  sessionManager.adoptArtifactManager(options.parentArtifactManager);
@@ -1920,6 +2027,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1920
2027
  parentHindsightSessionState: options.parentHindsightSessionState,
1921
2028
  parentMnemopiSessionState: options.parentMnemopiSessionState,
1922
2029
  parentTaskPrefix: id,
2030
+ parentAgentId: options.parentAgentId,
1923
2031
  agentId: id,
1924
2032
  agentDisplayName: subagentDisplayName,
1925
2033
  enableLsp: lspEnabled,
@@ -1952,7 +2060,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1952
2060
  // (createAgentSession → agent.replaceMessages). Isolated runs are not
1953
2061
  // resumable (worktree is merged + cleaned) and never get a reviver.
1954
2062
  reviveSession = async () => {
1955
- const reopened = await SessionManager.open(sessionFile);
2063
+ const reopened = await SessionManager.open(sessionFile, undefined, undefined, {
2064
+ suppressBreadcrumb: true,
2065
+ });
1956
2066
  if (options.parentArtifactManager) {
1957
2067
  reopened.adoptArtifactManager(options.parentArtifactManager);
1958
2068
  }
@@ -1988,6 +2098,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1988
2098
  systemPrompt: session.agent.state.systemPrompt.join("\n\n"),
1989
2099
  task,
1990
2100
  tools: session.getActiveToolNames(),
2101
+ spawns: spawnsEnv,
2102
+ readSummarize: agent.readSummarize,
1991
2103
  outputSchema,
1992
2104
  });
1993
2105
 
package/src/task/index.ts CHANGED
@@ -46,7 +46,7 @@ import "../tools/review";
46
46
  import type { AsyncJobManager } from "../async";
47
47
  import type { LocalProtocolOptions } from "../internal-urls";
48
48
  import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
49
- import { AgentRegistry } from "../registry/agent-registry";
49
+ import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
50
50
  import { generateCommitMessage } from "../utils/commit-message-generator";
51
51
  import * as git from "../utils/git";
52
52
  import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
@@ -156,7 +156,6 @@ export const READ_ONLY_TOOL_NAMES: ReadonlySet<string> = new Set([
156
156
  "reflect",
157
157
  "retain",
158
158
  "memory_edit",
159
- "render_mermaid",
160
159
  "inspect_image",
161
160
  "checkpoint",
162
161
  "rewind",
@@ -1301,6 +1300,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1301
1300
  parentMnemopiSessionState: this.session.getMnemopiSessionState?.(),
1302
1301
  parentTelemetry: this.session.getTelemetry?.(),
1303
1302
  parentEvalSessionId,
1303
+ parentAgentId: this.session.getAgentId?.() ?? MAIN_AGENT_ID,
1304
1304
  };
1305
1305
 
1306
1306
  const runTask = async (): Promise<SingleResult> => {
@@ -0,0 +1,128 @@
1
+ import * as fs from "node:fs/promises";
2
+
3
+ import type { ModelRegistry } from "../config/model-registry";
4
+ import type { Settings } from "../config/settings";
5
+ import { MCPManager } from "../mcp/manager";
6
+ import type { PersistedSubagentReviverFactory } from "../registry/agent-lifecycle";
7
+ import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
8
+ import { createAgentSession } from "../sdk";
9
+ import type { AgentSession } from "../session/agent-session";
10
+ import type { AuthStorage } from "../session/auth-storage";
11
+ import { SessionManager } from "../session/session-manager";
12
+ import { createMCPProxyTools, createSubagentSettings } from "./executor";
13
+
14
+ /**
15
+ * Ambient context the reviver needs at revive time. The top-level session is
16
+ * kept LIVE (cwd / artifact manager read on demand) so a later `/new` or cwd
17
+ * move is followed rather than snapshotted; auth/models/settings are
18
+ * process-stable and captured by reference.
19
+ */
20
+ export interface PersistedSubagentReviveContext {
21
+ session: AgentSession;
22
+ authStorage: AuthStorage;
23
+ modelRegistry: ModelRegistry;
24
+ settings: Settings;
25
+ /** LSP policy of the top-level session; revived subagents inherit it rather than defaulting on. */
26
+ enableLsp: boolean;
27
+ }
28
+
29
+ /**
30
+ * Build the factory the {@link AgentLifecycleManager} uses to cold-revive a
31
+ * `parked` subagent ref restored from disk (Agent Hub scan, collab mirror, or a
32
+ * resumed process). Such a ref carries a sessionFile but no in-memory adoption —
33
+ * the executor's live reviver closure died with the process/turn that spawned
34
+ * it — so `ensureLive` (IRC sends, hub focus) would otherwise refuse it.
35
+ *
36
+ * This rebuilds the subagent the same way `--resume` rebuilds a session: reopen
37
+ * the JSONL and replay it through {@link createAgentSession}. The catch is that
38
+ * resume restores only conversation/model from the file — the runtime contract
39
+ * (tools / system prompt / output schema / kind) is built from options, so a
40
+ * bare reopen would resurrect a wrong (top-level) session. We source that
41
+ * contract from the persisted `session_init` entry instead, and mirror the
42
+ * executor's subagent wiring (MCP proxy tools, depth-derived gating,
43
+ * yield-required, active-tool clamp, registry status sync).
44
+ */
45
+ export function createPersistedSubagentReviverFactory(
46
+ ctx: PersistedSubagentReviveContext,
47
+ ): PersistedSubagentReviverFactory {
48
+ const registry = AgentRegistry.global();
49
+ return async ref => {
50
+ const sessionFile = ref.sessionFile;
51
+ if (!sessionFile) return undefined;
52
+ const peek = await SessionManager.peekSessionInit(sessionFile);
53
+ // No persisted contract (pre-session_init file) or the recorded workspace
54
+ // is gone (isolated/merged worktree, moved dir): leave it transcript-only
55
+ // (history://) rather than resurrect a wrong or broken session.
56
+ if (!peek?.init) return undefined;
57
+ try {
58
+ await fs.stat(peek.cwd);
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ const init = peek.init;
63
+ // taskDepth drives real capability gating (task-spawn allowance, memory
64
+ // startup, …); derive it from the persisted parent chain rather than
65
+ // assuming a fixed level.
66
+ let taskDepth = 1;
67
+ let parentId = ref.parentId;
68
+ const seen = new Set<string>();
69
+ while (parentId && parentId !== MAIN_AGENT_ID && !seen.has(parentId)) {
70
+ seen.add(parentId);
71
+ taskDepth++;
72
+ parentId = registry.get(parentId)?.parentId;
73
+ }
74
+ return async () => {
75
+ // Re-open fresh on every revive: park closes the writer, so this takes
76
+ // the single-writer lock cleanly and restores the full message history.
77
+ const reopened = await SessionManager.open(sessionFile, undefined, undefined, {
78
+ suppressBreadcrumb: true,
79
+ });
80
+ const artifactManager = ctx.session.sessionManager.getArtifactManager();
81
+ if (artifactManager) reopened.adoptArtifactManager(artifactManager);
82
+ // Reuse the parent's live MCP connections via proxy tools (no
83
+ // re-discovery), exactly as the executor does for live subagents.
84
+ const mcpManager = MCPManager.instance();
85
+ const mcpProxyTools = mcpManager ? createMCPProxyTools(mcpManager) : [];
86
+ const { session } = await createAgentSession({
87
+ cwd: ctx.session.sessionManager.getCwd(),
88
+ authStorage: ctx.authStorage,
89
+ modelRegistry: ctx.modelRegistry,
90
+ settings: createSubagentSettings(
91
+ ctx.settings,
92
+ init.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
93
+ ),
94
+ sessionManager: reopened,
95
+ agentId: ref.id,
96
+ agentDisplayName: ref.displayName,
97
+ parentTaskPrefix: ref.id,
98
+ parentAgentId: ref.parentId,
99
+ taskDepth,
100
+ toolNames: init.tools,
101
+ outputSchema: init.outputSchema,
102
+ requireYieldTool: true,
103
+ systemPrompt: () => [init.systemPrompt],
104
+ // Old files predate persisted spawns: deny re-spawning rather than let
105
+ // createAgentSession default to wildcard ("*").
106
+ spawns: init.spawns ?? "",
107
+ hasUI: false,
108
+ enableLsp: ctx.enableLsp,
109
+ enableMCP: !mcpManager,
110
+ mcpManager,
111
+ customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
112
+ });
113
+ // Clamp the active set to the persisted list: createAgentSession's
114
+ // `alwaysInclude` can re-add non-defaultInactive extension/custom tools
115
+ // the original run didn't carry. Unknown/missing names are ignored.
116
+ await session.setActiveToolsByName(init.tools);
117
+ // Cold revives must drive registry status themselves — createAgentSession
118
+ // doesn't wire this generically (the live path does it in the executor).
119
+ // Without it the idle-TTL timer never clears on a turn and the lifecycle
120
+ // could park the agent mid-run.
121
+ session.subscribe(event => {
122
+ if (event.type === "agent_start") registry.setStatus(ref.id, "running");
123
+ else if (event.type === "agent_end") registry.setStatus(ref.id, "idle");
124
+ });
125
+ return session;
126
+ };
127
+ };
128
+ }
@@ -10,6 +10,8 @@ export interface TinyTitleLocalModelSpec {
10
10
  label: string;
11
11
  description: string;
12
12
  contextNote: string;
13
+ /** Model family emits hidden reasoning unless the chat template disables it. */
14
+ reasoning?: boolean;
13
15
  }
14
16
 
15
17
  export const TINY_TITLE_LOCAL_MODELS = [
@@ -28,6 +30,7 @@ export const TINY_TITLE_LOCAL_MODELS = [
28
30
  label: "Qwen3 0.6B",
29
31
  description: "Most robust local option; slower first load, about 500 MB cached.",
30
32
  contextNote: "Use when title quality matters more than local startup cost.",
33
+ reasoning: true,
31
34
  },
32
35
  {
33
36
  key: "gemma-270m",
@@ -122,6 +125,7 @@ export const TINY_MEMORY_LOCAL_MODELS = [
122
125
  description:
123
126
  "Recommended; most disciplined extraction (ignores chit-chat), good consolidation, about 1.1 GB cached.",
124
127
  contextNote: "Best single-model pick for memory from the local experiment.",
128
+ reasoning: true,
125
129
  },
126
130
  {
127
131
  key: "gemma-3-1b",
@@ -196,6 +200,12 @@ export function getTinyMemoryModelSpec(key: TinyMemoryLocalModelKey): (typeof TI
196
200
  return spec;
197
201
  }
198
202
 
203
+ /** Return whether a memory local model may emit reasoning tokens before answers. */
204
+ export function isTinyMemoryReasoningModelKey(key: TinyMemoryLocalModelKey): boolean {
205
+ const spec = getTinyMemoryModelSpec(key);
206
+ return "reasoning" in spec && spec.reasoning === true;
207
+ }
208
+
199
209
  /** Any local model key (title or memory), used by the shared inference worker. */
200
210
  export type TinyLocalModelKey = TinyTitleLocalModelKey | TinyMemoryLocalModelKey;
201
211
 
@@ -31,7 +31,8 @@ const TITLE_PREFILL = "<title>";
31
31
  const TITLE_CLOSE = "</title>";
32
32
  const TITLE_MAX_NEW_TOKENS = 20;
33
33
  const STOP_DECODE_WINDOW_TOKENS = 32;
34
- const MEMORY_COMPLETION_MAX_NEW_TOKENS = 256;
34
+ const MEMORY_COMPLETION_DEFAULT_MAX_NEW_TOKENS = 256;
35
+ const COMPLETION_MAX_NEW_TOKENS = 1024;
35
36
  const TINY_TITLE_SYSTEM_PROMPT = prompt.render(tinyTitleSystemPrompt);
36
37
  const TRANSFORMERS_PACKAGE = "@huggingface/transformers";
37
38
  const COMPILED_TRANSFORMERS_VERSION = process.env.PI_TINY_TRANSFORMERS_VERSION;
@@ -426,8 +427,8 @@ async function generateCompletion(
426
427
  ): Promise<string | null> {
427
428
  const generator = await loadPipeline(modelKey, transport, requestId);
428
429
  const text = buildCompletionPrompt(generator, promptText);
429
- const requested = maxTokens ?? MEMORY_COMPLETION_MAX_NEW_TOKENS;
430
- const maxNewTokens = Math.min(Math.max(1, requested), MEMORY_COMPLETION_MAX_NEW_TOKENS);
430
+ const requested = maxTokens ?? MEMORY_COMPLETION_DEFAULT_MAX_NEW_TOKENS;
431
+ const maxNewTokens = Math.min(Math.max(1, requested), COMPLETION_MAX_NEW_TOKENS);
431
432
  const output = (await generator(text, {
432
433
  max_new_tokens: maxNewTokens,
433
434
  do_sample: false,
@@ -4,7 +4,6 @@ export const BUILTIN_TOOL_NAMES = [
4
4
  "edit",
5
5
  "ast_grep",
6
6
  "ast_edit",
7
- "render_mermaid",
8
7
  "ask",
9
8
  "debug",
10
9
  "eval",
@@ -55,7 +55,6 @@ import { MemoryReflectTool } from "./memory-reflect";
55
55
  import { MemoryRetainTool } from "./memory-retain";
56
56
  import { wrapToolWithMetaNotice } from "./output-meta";
57
57
  import { ReadTool } from "./read";
58
- import { RenderMermaidTool } from "./render-mermaid";
59
58
  import { createReportToolIssueTool, isAutoQaEnabled } from "./report-tool-issue";
60
59
  import { ResolveTool } from "./resolve";
61
60
  import { reportFindingTool } from "./review";
@@ -94,7 +93,6 @@ export * from "./memory-recall";
94
93
  export * from "./memory-reflect";
95
94
  export * from "./memory-retain";
96
95
  export * from "./read";
97
- export * from "./render-mermaid";
98
96
  export * from "./report-tool-issue";
99
97
  export * from "./resolve";
100
98
  export * from "./review";
@@ -420,7 +418,6 @@ export const BUILTIN_TOOLS: Record<BuiltinToolName, ToolFactory> = {
420
418
  edit: s => new EditTool(s),
421
419
  ast_grep: s => new AstGrepTool(s),
422
420
  ast_edit: s => new AstEditTool(s),
423
- render_mermaid: s => new RenderMermaidTool(s),
424
421
  ask: AskTool.createIf,
425
422
  debug: DebugTool.createIf,
426
423
  eval: s => new EvalTool(s),
@@ -563,7 +560,6 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
563
560
  if (name === "github") return session.settings.get("github.enabled");
564
561
  if (name === "ast_grep") return session.settings.get("astGrep.enabled");
565
562
  if (name === "ast_edit") return session.settings.get("astEdit.enabled");
566
- if (name === "render_mermaid") return session.settings.get("renderMermaid.enabled");
567
563
  if (name === "inspect_image") return session.settings.get("inspect_image.enabled");
568
564
  if (name === "web_search") return session.settings.get("web_search.enabled");
569
565
  // search_tool_bm25 is allowed when either legacy mcp.discoveryMode or new tools.discoveryMode is active.
@@ -12,6 +12,7 @@ import type {
12
12
  AgentToolUpdateCallback,
13
13
  } from "@oh-my-pi/pi-agent-core";
14
14
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
15
+ import { logger } from "@oh-my-pi/pi-utils";
15
16
  import { getDefault, type Settings } from "../config/settings";
16
17
  import { formatGroupedDiagnosticMessages } from "../lsp/utils";
17
18
  import type { Theme } from "../modes/theme/theme";
@@ -616,9 +617,22 @@ async function spillLargeResultToArtifact(
616
617
  const totalBytes = Buffer.byteLength(fullText, "utf-8");
617
618
  if (totalBytes <= threshold) return result;
618
619
 
619
- // Save full output as artifact
620
- const artifactId = await sessionManager.saveArtifact(fullText, toolName);
621
- if (!artifactId) return result;
620
+ // Save the full output as an artifact so the elided bytes stay recoverable.
621
+ // In a persistent session this hits `Bun.write`, which can throw (disk full,
622
+ // permissions). The spill wraps arbitrary tools (built-in, MCP, extension,
623
+ // RPC-host); a save failure must never convert a successful call into an
624
+ // error, nor re-expose the full (possibly context-blowing) output. Mirror
625
+ // `enforceInlineByteCap`: always truncate past the threshold, and only
626
+ // attach the `artifact://` recovery link when the save actually succeeded.
627
+ let artifactId: string | undefined;
628
+ try {
629
+ artifactId = await sessionManager.saveArtifact(fullText, toolName);
630
+ } catch (error) {
631
+ logger.warn("Failed to spill large tool result to artifact", {
632
+ tool: toolName,
633
+ error: error instanceof Error ? error.message : String(error),
634
+ });
635
+ }
622
636
 
623
637
  // Truncate: middle elision when a head budget is configured, otherwise tail-only.
624
638
  const useMiddle = headBytes > 0;
@@ -66,6 +66,7 @@ const EXTENSION_LANG: Record<string, readonly [string, string]> = {
66
66
  cljc: ["clojure", "clojure"],
67
67
  cljs: ["clojure", "clojure"],
68
68
  edn: ["clojure", "clojure"],
69
+ el: ["emacs-lisp", "emacs-lisp"],
69
70
 
70
71
  // .NET
71
72
  cs: ["csharp", "csharp"],
@@ -209,6 +210,7 @@ export function getLanguageFromPath(filePath: string): string | undefined {
209
210
  if (baseName === "dockerfile" || baseName.startsWith("dockerfile.") || baseName === "containerfile") {
210
211
  return "dockerfile";
211
212
  }
213
+ if (baseName === ".emacs") return "emacs-lisp";
212
214
  if (baseName === "justfile") return "just";
213
215
  if (baseName === "cmakelists.txt") return "cmake";
214
216
 
@@ -223,6 +225,9 @@ export function detectLanguageId(filePath: string): string {
223
225
  if (baseName === "dockerfile" || baseName.startsWith("dockerfile.") || baseName === "containerfile") {
224
226
  return "dockerfile";
225
227
  }
228
+ if (baseName === ".emacs") {
229
+ return "emacs-lisp";
230
+ }
226
231
  if (baseName === "makefile" || baseName === "gnumakefile") {
227
232
  return "makefile";
228
233
  }
@@ -1,4 +1,4 @@
1
- import { untilAborted } from "@oh-my-pi/pi-utils";
1
+ import { logger, untilAborted } from "@oh-my-pi/pi-utils";
2
2
  import type { Markit, StreamInfo } from "markit-ai";
3
3
  import { ToolAbortError } from "../tools/tool-errors";
4
4
 
@@ -8,6 +8,29 @@ export interface MarkitConversionResult {
8
8
  error?: string;
9
9
  }
10
10
 
11
+ interface MuPdfWasmModuleConfig {
12
+ print?: (...values: unknown[]) => void;
13
+ printErr?: (...values: unknown[]) => void;
14
+ }
15
+
16
+ declare global {
17
+ var $libmupdf_wasm_Module: MuPdfWasmModuleConfig | undefined;
18
+ }
19
+
20
+ function logMuPdfWasmOutput(stream: "stdout" | "stderr", values: unknown[]): void {
21
+ const message = values.length === 1 && typeof values[0] === "string" ? values[0] : values.map(String).join(" ");
22
+ logger.debug("mupdf wasm output", { stream, message });
23
+ }
24
+
25
+ function installMuPdfWasmLogger(): void {
26
+ const moduleConfig = globalThis.$libmupdf_wasm_Module ?? {};
27
+ moduleConfig.print = (...values) => logMuPdfWasmOutput("stdout", values);
28
+ moduleConfig.printErr = (...values) => logMuPdfWasmOutput("stderr", values);
29
+ globalThis.$libmupdf_wasm_Module = moduleConfig;
30
+ }
31
+
32
+ installMuPdfWasmLogger();
33
+
11
34
  let markit: () => Markit | Promise<Markit> = async () => {
12
35
  const promise = import("markit-ai").then(({ Markit }) => {
13
36
  const instance = new Markit();