@oh-my-pi/pi-coding-agent 15.13.2 → 15.13.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 (50) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/cli.js +147 -122
  3. package/dist/types/config/settings-schema.d.ts +31 -0
  4. package/dist/types/eval/js/context-manager.d.ts +15 -0
  5. package/dist/types/modes/interactive-mode.d.ts +1 -0
  6. package/dist/types/modes/types.d.ts +6 -0
  7. package/dist/types/session/unexpected-stop-classifier.d.ts +13 -0
  8. package/dist/types/stt/asr-client.d.ts +1 -1
  9. package/dist/types/tiny/title-client.d.ts +1 -1
  10. package/dist/types/tools/job.d.ts +1 -0
  11. package/dist/types/tts/tts-client.d.ts +1 -1
  12. package/dist/types/utils/thinking-display.d.ts +1 -17
  13. package/package.json +12 -12
  14. package/src/cli.ts +25 -12
  15. package/src/config/model-registry.ts +6 -2
  16. package/src/config/settings-schema.ts +25 -0
  17. package/src/eval/__tests__/agent-bridge.test.ts +106 -46
  18. package/src/eval/__tests__/js-context-manager.test.ts +12 -2
  19. package/src/eval/js/context-manager.ts +40 -3
  20. package/src/eval/js/worker-entry.ts +7 -0
  21. package/src/export/html/template.js +18 -22
  22. package/src/internal-urls/docs-index.generated.ts +5 -3
  23. package/src/main.ts +15 -5
  24. package/src/modes/acp/acp-agent.ts +2 -2
  25. package/src/modes/acp/acp-event-mapper.ts +2 -2
  26. package/src/modes/components/agent-hub.ts +31 -7
  27. package/src/modes/components/assistant-message.ts +24 -15
  28. package/src/modes/components/snapcompact-shape-preview-doc.md +2 -2
  29. package/src/modes/components/snapcompact-shape-preview.ts +2 -2
  30. package/src/modes/components/tree-selector.ts +3 -2
  31. package/src/modes/controllers/event-controller.ts +3 -3
  32. package/src/modes/controllers/input-controller.ts +7 -1
  33. package/src/modes/controllers/streaming-reveal.ts +4 -4
  34. package/src/modes/interactive-mode.ts +2 -0
  35. package/src/modes/types.ts +6 -0
  36. package/src/modes/utils/ui-helpers.ts +3 -3
  37. package/src/prompts/agents/oracle.md +0 -1
  38. package/src/prompts/agents/reviewer.md +0 -1
  39. package/src/prompts/system/unexpected-stop-classifier.md +17 -0
  40. package/src/prompts/system/unexpected-stop-retry.md +4 -0
  41. package/src/session/agent-session.ts +164 -10
  42. package/src/session/session-dump-format.ts +8 -19
  43. package/src/session/unexpected-stop-classifier.ts +129 -0
  44. package/src/stt/asr-client.ts +1 -1
  45. package/src/tiny/title-client.ts +1 -1
  46. package/src/tools/browser/tab-supervisor.ts +1 -1
  47. package/src/tools/browser/tab-worker-entry.ts +12 -4
  48. package/src/tools/job.ts +1 -0
  49. package/src/tts/tts-client.ts +1 -1
  50. package/src/utils/thinking-display.ts +8 -34
@@ -202,6 +202,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
202
202
  };
203
203
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
204
204
  import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
205
+ import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
205
206
  import {
206
207
  deobfuscateSessionContext,
207
208
  obfuscateProviderContext,
@@ -270,6 +271,7 @@ import { EPHEMERAL_MODEL_CHANGE_ROLE } from "./session-entries";
270
271
  import type { SessionManager } from "./session-manager";
271
272
  import type { ShakeMode, ShakeResult } from "./shake-types";
272
273
  import { ToolChoiceQueue } from "./tool-choice-queue";
274
+ import { classifyUnexpectedStop, isUnexpectedStopCandidate } from "./unexpected-stop-classifier";
273
275
  import { YieldQueue } from "./yield-queue";
274
276
 
275
277
  /** Session-specific events that extend the core AgentEvent */
@@ -308,14 +310,16 @@ export type AgentSessionEvent =
308
310
  resolved?: Effort;
309
311
  }
310
312
  | { type: "goal_updated"; goal: Goal | null; state?: GoalModeState };
311
-
312
313
  /** Listener function for agent session events */
313
314
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
314
- export type CommandMetadataChangedListener = () => void | Promise<void>;
315
- export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
316
315
 
316
+ const UNEXPECTED_STOP_MAX_RETRIES = 3;
317
+ const UNEXPECTED_STOP_TIMEOUT_MS = 4000;
317
318
  const EMPTY_STOP_MAX_RETRIES = 3;
318
319
  const RETRY_BACKOFF_MAX_DELAY_MS = 8_000;
320
+ export type CommandMetadataChangedListener = () => void | Promise<void>;
321
+ export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
322
+
319
323
  const RETRY_BACKOFF_JITTER_RATIO = 0.25;
320
324
  /**
321
325
  * Hysteresis band for the post-shake "did we actually create headroom?" check.
@@ -1106,15 +1110,23 @@ export class AgentSession {
1106
1110
  // `#emit(event)` that reaches external subscribers (rpc-mode stdout, ACP bridge,
1107
1111
  // Cursor exec, TUI listeners) is held back. Without this, a client that resumes
1108
1112
  // on `agent_end` can fire its next `prompt` before #promptWithMessage's finally
1109
- // has decremented #promptInFlightCount, hitting AgentBusyError. Flushed from
1110
- // both #endInFlight (normal) and #resetInFlight (abort).
1113
+ #emptyStopRetryCount = 0;
1114
+ #unexpectedStopRetryCount = 0;
1115
+ #promptGeneration = 0;
1111
1116
  #pendingAgentEndEmit: AgentSessionEvent | undefined;
1117
+ #pendingProviderRequestNonMessageTokens: number | undefined = undefined;
1118
+ #lastProviderUsageNonMessage:
1119
+ | {
1120
+ provider: AssistantMessage["provider"];
1121
+ model: AssistantMessage["model"];
1122
+ timestamp: AssistantMessage["timestamp"];
1123
+ tokens: number;
1124
+ }
1125
+ | undefined;
1112
1126
  #obfuscator: SecretObfuscator | undefined;
1113
1127
  #checkpointState: CheckpointState | undefined = undefined;
1114
1128
  #pendingRewindReport: string | undefined = undefined;
1115
1129
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1116
- #emptyStopRetryCount = 0;
1117
- #promptGeneration = 0;
1118
1130
  #providerSessionState = new Map<string, ProviderSessionState>();
1119
1131
  #hindsightSessionState: HindsightSessionState | undefined = undefined;
1120
1132
  readonly rawSseDebugBuffer: RawSseDebugBuffer;
@@ -1619,8 +1631,34 @@ export class AgentSession {
1619
1631
  // Track last assistant message for auto-compaction check
1620
1632
  #lastAssistantMessage: AssistantMessage | undefined = undefined;
1621
1633
 
1622
- /** Internal handler for agent events - shared by subscribe and reconnect */
1634
+ /** Internal handler for agent events - shared by subscribe and reconnect.
1635
+ *
1636
+ * `agent_end` handling schedules deferred post-prompt recovery work
1637
+ * (compaction/handoff, context-promotion continuations). It is invoked
1638
+ * fire-and-forget by the agent's synchronous `#emit`, and only reaches
1639
+ * `#checkCompaction` after several internal awaits. `prompt()` runs
1640
+ * `#waitForPostPromptRecovery()` the instant `agent.prompt()` resolves — which
1641
+ * can land BEFORE the handler registers its tasks, so the wait would observe an
1642
+ * empty task set and return early, letting a deferred handoff/promotion race
1643
+ * prompt completion. Tracking the `agent_end` handler as a post-prompt task
1644
+ * that is registered SYNCHRONOUSLY (before the first await) closes that window:
1645
+ * `#postPromptTasksPromise` is set the moment `#emit` invokes this handler, so
1646
+ * the recovery wait always sees the in-flight handler and blocks until it — and
1647
+ * everything it schedules — settles. */
1623
1648
  #handleAgentEvent = async (event: AgentEvent): Promise<void> => {
1649
+ if (event.type !== "agent_end") {
1650
+ return this.#processAgentEvent(event);
1651
+ }
1652
+ const { promise, resolve } = Promise.withResolvers<void>();
1653
+ this.#trackPostPromptTask(promise);
1654
+ try {
1655
+ await this.#processAgentEvent(event);
1656
+ } finally {
1657
+ resolve();
1658
+ }
1659
+ };
1660
+
1661
+ #processAgentEvent = async (event: AgentEvent): Promise<void> => {
1624
1662
  // Plan-mode → compaction transition: stamp `SILENT_ABORT_MARKER` on the
1625
1663
  // persisted message BEFORE the obfuscator's display-side copy below.
1626
1664
  // Invariant (must hold across refactors): this branch precedes the
@@ -1787,6 +1825,14 @@ export class AgentSession {
1787
1825
  if (event.message.role === "assistant") {
1788
1826
  this.#lastAssistantMessage = event.message;
1789
1827
  const assistantMsg = event.message as AssistantMessage;
1828
+ if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
1829
+ this.#lastProviderUsageNonMessage = {
1830
+ provider: assistantMsg.provider,
1831
+ model: assistantMsg.model,
1832
+ timestamp: assistantMsg.timestamp,
1833
+ tokens: this.#pendingProviderRequestNonMessageTokens ?? computeNonMessageTokens(this),
1834
+ };
1835
+ }
1790
1836
  const currentGrantsAnthropicPriority =
1791
1837
  this.serviceTier === "priority" || this.serviceTier === "claude-only";
1792
1838
  if (assistantMsg.disabledFeatures?.includes("priority") && currentGrantsAnthropicPriority) {
@@ -1933,6 +1979,9 @@ export class AgentSession {
1933
1979
  if (await this.#handleEmptyAssistantStop(msg)) {
1934
1980
  return;
1935
1981
  }
1982
+ if (await this.#handleUnexpectedAssistantStop(msg)) {
1983
+ return;
1984
+ }
1936
1985
 
1937
1986
  // A deliberate abort should settle the current turn, not trigger queued continuations.
1938
1987
  if (msg.stopReason === "aborted") {
@@ -4765,6 +4814,7 @@ export class AgentSession {
4765
4814
  this.#todoReminderCount = 0;
4766
4815
  this.#todoReminderAwaitingProgress = false;
4767
4816
  this.#emptyStopRetryCount = 0;
4817
+ this.#unexpectedStopRetryCount = 0;
4768
4818
 
4769
4819
  await this.#maybeRestoreRetryFallbackPrimary();
4770
4820
 
@@ -4905,7 +4955,12 @@ export class AgentSession {
4905
4955
  }
4906
4956
 
4907
4957
  const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
4908
- await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
4958
+ this.#pendingProviderRequestNonMessageTokens = computeNonMessageTokens(this);
4959
+ try {
4960
+ await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
4961
+ } finally {
4962
+ this.#pendingProviderRequestNonMessageTokens = undefined;
4963
+ }
4909
4964
  if (!options?.skipPostPromptRecoveryWait) {
4910
4965
  await this.#waitForPostPromptRecovery(generation);
4911
4966
  }
@@ -6771,13 +6826,34 @@ export class AgentSession {
6771
6826
  return tokens;
6772
6827
  }
6773
6828
 
6829
+ #estimatePrePromptContextTokens(messages: AgentMessage[], contextWindow: number): number {
6830
+ const currentUsage = this.getContextUsage({ contextWindow });
6831
+ if (typeof currentUsage?.tokens !== "number" || !Number.isFinite(currentUsage.tokens)) {
6832
+ return this.#estimatePendingPromptTokens(messages);
6833
+ }
6834
+
6835
+ const currentEstimate = this.#estimateContextTokens();
6836
+ if (!currentEstimate.providerAnchored) {
6837
+ return this.#estimatePendingPromptTokens(messages);
6838
+ }
6839
+
6840
+ let tokens = currentUsage.tokens;
6841
+ if (currentEstimate.providerNonMessageTokens !== undefined) {
6842
+ tokens += Math.max(0, computeNonMessageTokens(this) - currentEstimate.providerNonMessageTokens);
6843
+ }
6844
+ for (const message of messages) {
6845
+ tokens += estimateTokens(message);
6846
+ }
6847
+ return tokens;
6848
+ }
6849
+
6774
6850
  async #runPrePromptCompactionIfNeeded(messages: AgentMessage[]): Promise<void> {
6775
6851
  const model = this.model;
6776
6852
  if (!model) return;
6777
6853
  const contextWindow = model.contextWindow ?? 0;
6778
6854
  if (contextWindow <= 0) return;
6779
6855
  const compactionSettings = this.settings.getGroup("compaction");
6780
- const contextTokens = this.#estimatePendingPromptTokens(messages);
6856
+ const contextTokens = this.#estimatePrePromptContextTokens(messages, contextWindow);
6781
6857
  if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
6782
6858
 
6783
6859
  // Auto-promote first: switching to a larger-context model avoids compacting
@@ -7027,6 +7103,71 @@ export class AgentSession {
7027
7103
  maxRetries: EMPTY_STOP_MAX_RETRIES,
7028
7104
  });
7029
7105
  }
7106
+ async #handleUnexpectedAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
7107
+ if (!this.settings.get("features.unexpectedStopDetection")) {
7108
+ return false;
7109
+ }
7110
+ if (!isUnexpectedStopCandidate(assistantMessage)) {
7111
+ this.#unexpectedStopRetryCount = 0;
7112
+ return false;
7113
+ }
7114
+
7115
+ const text = assistantMessage.content
7116
+ .filter((content): content is TextContent => content.type === "text")
7117
+ .map(content => content.text)
7118
+ .join("\n");
7119
+ if (!/\S/.test(text)) {
7120
+ this.#unexpectedStopRetryCount = 0;
7121
+ return false;
7122
+ }
7123
+
7124
+ const controller = new AbortController();
7125
+ const timeout = setTimeout(() => controller.abort(), UNEXPECTED_STOP_TIMEOUT_MS);
7126
+ let classification: boolean | undefined;
7127
+ try {
7128
+ classification = await classifyUnexpectedStop(text, {
7129
+ settings: this.settings,
7130
+ registry: this.#modelRegistry,
7131
+ sessionId: this.sessionId,
7132
+ metadataResolver: (provider: string) => this.agent.metadataForProvider(provider),
7133
+ signal: controller.signal,
7134
+ });
7135
+ } finally {
7136
+ clearTimeout(timeout);
7137
+ }
7138
+
7139
+ if (classification !== true) {
7140
+ this.#unexpectedStopRetryCount = 0;
7141
+ return false;
7142
+ }
7143
+
7144
+ this.#unexpectedStopRetryCount++;
7145
+ if (this.#unexpectedStopRetryCount > UNEXPECTED_STOP_MAX_RETRIES) {
7146
+ logger.warn("Assistant returned unexpected stop after retry cap", {
7147
+ attempts: this.#unexpectedStopRetryCount - 1,
7148
+ model: assistantMessage.model,
7149
+ provider: assistantMessage.provider,
7150
+ });
7151
+ this.#unexpectedStopRetryCount = 0;
7152
+ return false;
7153
+ }
7154
+
7155
+ this.agent.appendMessage({
7156
+ role: "developer",
7157
+ content: [{ type: "text", text: this.#unexpectedStopRetryReminder() }],
7158
+ attribution: "agent",
7159
+ timestamp: Date.now(),
7160
+ });
7161
+ this.#scheduleAgentContinue({ generation: this.#promptGeneration });
7162
+ return true;
7163
+ }
7164
+
7165
+ #unexpectedStopRetryReminder(): string {
7166
+ return prompt.render(unexpectedStopRetryTemplate, {
7167
+ retryCount: this.#unexpectedStopRetryCount,
7168
+ maxRetries: UNEXPECTED_STOP_MAX_RETRIES,
7169
+ });
7170
+ }
7030
7171
 
7031
7172
  #removeEmptyStopFromActiveContext(assistantMessage: AssistantMessage): void {
7032
7173
  const messages = this.agent.state.messages;
@@ -10528,6 +10669,8 @@ export class AgentSession {
10528
10669
  */
10529
10670
  #estimateContextTokens(): {
10530
10671
  tokens: number;
10672
+ providerAnchored: boolean;
10673
+ providerNonMessageTokens?: number;
10531
10674
  } {
10532
10675
  const messages = this.messages;
10533
10676
 
@@ -10554,10 +10697,19 @@ export class AgentSession {
10554
10697
  }
10555
10698
  return {
10556
10699
  tokens: estimated,
10700
+ providerAnchored: false,
10557
10701
  };
10558
10702
  }
10559
10703
 
10560
10704
  const usageTokens = calculatePromptTokens(lastUsage);
10705
+ const providerNonMessage =
10706
+ this.#lastProviderUsageNonMessage &&
10707
+ messages[lastUsageIndex]?.role === "assistant" &&
10708
+ this.#lastProviderUsageNonMessage.provider === (messages[lastUsageIndex] as AssistantMessage).provider &&
10709
+ this.#lastProviderUsageNonMessage.model === (messages[lastUsageIndex] as AssistantMessage).model &&
10710
+ this.#lastProviderUsageNonMessage.timestamp === (messages[lastUsageIndex] as AssistantMessage).timestamp
10711
+ ? this.#lastProviderUsageNonMessage.tokens
10712
+ : undefined;
10561
10713
  let trailingTokens = 0;
10562
10714
  for (let i = lastUsageIndex + 1; i < messages.length; i++) {
10563
10715
  trailingTokens += estimateTokens(messages[i]);
@@ -10565,6 +10717,8 @@ export class AgentSession {
10565
10717
 
10566
10718
  return {
10567
10719
  tokens: usageTokens + trailingTokens,
10720
+ providerAnchored: true,
10721
+ providerNonMessageTokens: providerNonMessage,
10568
10722
  };
10569
10723
  }
10570
10724
 
@@ -4,8 +4,9 @@
4
4
  import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
5
  import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
6
6
  import type { AssistantMessage, Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai";
7
- import { renderToolInventory } from "@oh-my-pi/pi-ai/grammar";
8
- import { getVisibleThinkingText } from "../utils/thinking-display";
7
+ import { getInbandGrammar, renderToolInventory } from "@oh-my-pi/pi-ai/grammar";
8
+ import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
9
+ import { canonicalizeMessage } from "../utils/thinking-display";
9
10
  import {
10
11
  type BashExecutionMessage,
11
12
  type BranchSummaryMessage,
@@ -34,22 +35,12 @@ export interface FormatSessionDumpTextOptions {
34
35
  tools?: readonly SessionDumpToolInfo[];
35
36
  }
36
37
 
37
- /** Serialize an object as XML parameter elements, one per key. */
38
- function formatArgsAsXml(args: Record<string, unknown>, indent = "\t"): string {
39
- const parts: string[] = [];
40
- for (const [key, value] of Object.entries(args)) {
41
- if (key === INTENT_FIELD) continue;
42
- const text = typeof value === "string" ? value : JSON.stringify(value);
43
- parts.push(`${indent}<parameter name="${key}">${text}</parameter>`);
44
- }
45
- return parts.join("\n");
46
- }
47
-
48
38
  /**
49
39
  * Format messages and session metadata as markdown/plain text (same as AgentSession.formatSessionAsText / /dump).
50
40
  */
51
41
  export function formatSessionDumpText(options: FormatSessionDumpTextOptions): string {
52
42
  const lines: string[] = [];
43
+ const grammar = getInbandGrammar(preferredToolSyntax(options.model?.id ?? ""));
53
44
 
54
45
  const systemPrompt = options.systemPrompt?.filter(prompt => prompt.length > 0) ?? [];
55
46
  if (systemPrompt.length > 0) {
@@ -106,17 +97,15 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
106
97
  if (c.type === "text") {
107
98
  lines.push(c.text);
108
99
  } else if (c.type === "thinking") {
109
- const thinking = getVisibleThinkingText(c);
100
+ const thinking = canonicalizeMessage(c.thinking);
110
101
  if (thinking.length === 0) continue;
111
102
  lines.push("<thinking>");
112
103
  lines.push(thinking);
113
104
  lines.push("</thinking>\n");
114
105
  } else if (c.type === "toolCall") {
115
- lines.push(`<invoke name="${c.name}">`);
116
- if (c.arguments && typeof c.arguments === "object") {
117
- lines.push(formatArgsAsXml(c.arguments as Record<string, unknown>));
118
- }
119
- lines.push("<" + "/invoke>\n");
106
+ const args = { ...(c.arguments as Record<string, unknown>) };
107
+ delete args[INTENT_FIELD];
108
+ lines.push(grammar.renderToolCall({ ...c, arguments: args }));
120
109
  }
121
110
  }
122
111
  lines.push("");
@@ -0,0 +1,129 @@
1
+ import { type AssistantMessage, completeSimple } from "@oh-my-pi/pi-ai";
2
+ import { logger, prompt } from "@oh-my-pi/pi-utils";
3
+
4
+ import type { ModelRegistry } from "../config/model-registry";
5
+ import { resolveRoleSelection } from "../config/model-resolver";
6
+ import type { Settings } from "../config/settings";
7
+ import unexpectedStopClassifierPrompt from "../prompts/system/unexpected-stop-classifier.md" with { type: "text" };
8
+ import { isTinyMemoryLocalModelKey, ONLINE_MEMORY_MODEL_KEY } from "../tiny/models";
9
+ import { tinyModelClient } from "../tiny/title-client";
10
+
11
+ const CLASSIFIER_SYSTEM_PROMPT = prompt.render(unexpectedStopClassifierPrompt);
12
+
13
+ /**
14
+ * The answer is a single word. OpenAI-compatible endpoints reject values below
15
+ * 16, so 16 is the smallest portable budget for this classifier.
16
+ */
17
+ const ANSWER_MAX_TOKENS = 16;
18
+ /**
19
+ * Reasoning backends ignore `disableReasoning` on some providers, so reserve
20
+ * enough output room for the keyword to still land after unavoidable thinking.
21
+ */
22
+ const REASONING_SAFE_MAX_TOKENS = 1024;
23
+
24
+ export interface ClassifyUnexpectedStopDeps {
25
+ settings: Settings;
26
+ registry: ModelRegistry;
27
+ sessionId: string;
28
+ metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
29
+ signal?: AbortSignal;
30
+ }
31
+
32
+ export function isUnexpectedStopCandidate(message: AssistantMessage): boolean {
33
+ if (message.stopReason !== "stop") return false;
34
+ let hasText = false;
35
+ for (const content of message.content) {
36
+ if (content.type === "toolCall") return false;
37
+ if (content.type === "text" && /\S/.test(content.text)) {
38
+ hasText = true;
39
+ }
40
+ }
41
+ return hasText;
42
+ }
43
+
44
+ export async function classifyUnexpectedStop(
45
+ text: string,
46
+ deps: ClassifyUnexpectedStopDeps,
47
+ ): Promise<boolean | undefined> {
48
+ const backend = deps.settings.get("providers.unexpectedStopModel");
49
+ try {
50
+ if (backend === ONLINE_MEMORY_MODEL_KEY) {
51
+ return await classifyOnline(text, deps);
52
+ }
53
+ if (isTinyMemoryLocalModelKey(backend)) {
54
+ return await classifyLocal(text, backend, deps);
55
+ }
56
+ return undefined;
57
+ } catch (error) {
58
+ logger.debug("unexpected-stop: classification failed", {
59
+ error: error instanceof Error ? error.message : String(error),
60
+ backend,
61
+ });
62
+ return undefined;
63
+ }
64
+ }
65
+
66
+ async function classifyOnline(text: string, deps: ClassifyUnexpectedStopDeps): Promise<boolean | undefined> {
67
+ const resolved = resolveRoleSelection(["smol"], deps.settings, deps.registry.getAvailable(), deps.registry);
68
+ const model = resolved?.model;
69
+ if (!model) {
70
+ throw new Error("unexpected-stop: no smol model available for classification");
71
+ }
72
+ const apiKey = await deps.registry.getApiKey(model, deps.sessionId);
73
+ if (!apiKey) {
74
+ throw new Error(`unexpected-stop: no API key for ${model.provider}/${model.id}`);
75
+ }
76
+ const metadata = deps.metadataResolver?.(model.provider);
77
+ const maxTokens = model.reasoning ? Math.max(ANSWER_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : ANSWER_MAX_TOKENS;
78
+
79
+ const response = await completeSimple(
80
+ model,
81
+ {
82
+ systemPrompt: [CLASSIFIER_SYSTEM_PROMPT],
83
+ messages: [{ role: "user", content: text, timestamp: Date.now() }],
84
+ },
85
+ {
86
+ apiKey: deps.registry.resolver(model, deps.sessionId),
87
+ maxTokens,
88
+ disableReasoning: true,
89
+ metadata,
90
+ signal: deps.signal,
91
+ },
92
+ );
93
+
94
+ if (response.stopReason === "error") {
95
+ throw new Error(`unexpected-stop: online classification failed: ${response.errorMessage ?? "unknown error"}`);
96
+ }
97
+
98
+ const outputText = response.content
99
+ .filter((part): part is { type: "text"; text: string } => part.type === "text")
100
+ .map(part => part.text)
101
+ .join("\n");
102
+ return parseUnexpectedStopClassification(outputText);
103
+ }
104
+
105
+ async function classifyLocal(
106
+ text: string,
107
+ modelKey: string,
108
+ deps: ClassifyUnexpectedStopDeps,
109
+ ): Promise<boolean | undefined> {
110
+ if (!isTinyMemoryLocalModelKey(modelKey)) {
111
+ throw new Error(`unexpected-stop: unsupported local classifier model: ${modelKey}`);
112
+ }
113
+ const builtPrompt = prompt.render(unexpectedStopClassifierPrompt, { message: text });
114
+ const output = await tinyModelClient.complete(modelKey, builtPrompt, {
115
+ maxTokens: ANSWER_MAX_TOKENS,
116
+ signal: deps.signal,
117
+ });
118
+ if (!output) {
119
+ return undefined;
120
+ }
121
+ return parseUnexpectedStopClassification(output);
122
+ }
123
+
124
+ export function parseUnexpectedStopClassification(text: string): boolean | undefined {
125
+ const trimmed = text.trim().toLowerCase();
126
+ if (trimmed.startsWith("yes")) return true;
127
+ if (trimmed.startsWith("no")) return false;
128
+ return undefined;
129
+ }
@@ -72,7 +72,7 @@ const SMOKE_TEST_TIMEOUT_MS = 30_000;
72
72
  * Hidden subcommand on the main CLI that boots the speech-recognition worker in
73
73
  * the spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
74
74
  */
75
- export const STT_WORKER_ARG = "__omp_stt_worker";
75
+ export const STT_WORKER_ARG = "__omp_worker_stt";
76
76
 
77
77
  function readTinyModelSetting(key: "providers.tinyModelDevice" | "providers.tinyModelDtype"): string | undefined {
78
78
  try {
@@ -69,7 +69,7 @@ function normalizeTinyTitleGenerateOptions(
69
69
  * Hidden subcommand on the main CLI that boots the tiny-model worker in the
70
70
  * spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
71
71
  */
72
- export const TINY_WORKER_ARG = "--tiny-worker";
72
+ export const TINY_WORKER_ARG = "__omp_worker_tiny_inference";
73
73
 
74
74
  function readTinyModelSetting(path: "providers.tinyModelDevice" | "providers.tinyModelDtype"): string | undefined {
75
75
  try {
@@ -685,7 +685,7 @@ async function spawnTabWorker(): Promise<WorkerHandle> {
685
685
  try {
686
686
  const hostEntry = workerHostEntry();
687
687
  const worker = hostEntry
688
- ? new Worker(hostEntry, { type: "module", argv: ["__omp_tab_worker"] })
688
+ ? new Worker(hostEntry, { type: "module", argv: ["__omp_worker_tab"] })
689
689
  : new Worker(new URL("./tab-worker-entry.ts", import.meta.url).href, { type: "module" });
690
690
  return wrapBunWorker(worker);
691
691
  } catch (err) {
@@ -1,20 +1,28 @@
1
1
  import { parentPort } from "node:worker_threads";
2
+ import { consumeWorkerInbox } from "@oh-my-pi/pi-utils/worker-host";
2
3
  import type { Transport, WorkerInbound, WorkerOutbound } from "./tab-protocol";
3
4
  import { WorkerCore } from "./tab-worker";
4
5
 
5
6
  if (!parentPort) throw new Error("tab-worker-entry: missing parentPort");
6
7
 
8
+ const port = parentPort;
9
+ // When the CLI host pre-buffered messages (it imports this module dynamically),
10
+ // bind that inbox so the parent's already-delivered `init` is replayed. Loaded
11
+ // directly (test/SDK fallback), this module's top-level runs synchronously at
12
+ // worker start, so the direct `parentPort.on` below wins the flush on its own.
13
+ const inbox = consumeWorkerInbox();
7
14
  const transport: Transport = {
8
15
  send(msg, transferList) {
9
- parentPort!.postMessage(msg, transferList ?? []);
16
+ port.postMessage(msg, transferList ?? []);
10
17
  },
11
18
  onMessage(handler) {
19
+ if (inbox) return inbox.bind(data => handler(data as WorkerOutbound | WorkerInbound));
12
20
  const wrap = (message: unknown): void => handler(message as WorkerOutbound | WorkerInbound);
13
- parentPort!.on("message", wrap);
14
- return () => parentPort!.off("message", wrap);
21
+ port.on("message", wrap);
22
+ return () => port.off("message", wrap);
15
23
  },
16
24
  close() {
17
- parentPort!.close();
25
+ port.close();
18
26
  },
19
27
  };
20
28
 
package/src/tools/job.ts CHANGED
@@ -87,6 +87,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
87
87
  readonly description: string;
88
88
  readonly parameters = jobSchema;
89
89
  readonly strict = true;
90
+ readonly interruptible = true;
90
91
  readonly loadMode = "discoverable";
91
92
  constructor(private readonly session: ToolSession) {
92
93
  this.description = prompt.render(jobDescription);
@@ -142,7 +142,7 @@ const SMOKE_TEST_TIMEOUT_MS = 30_000;
142
142
  * Hidden subcommand on the main CLI that boots the TTS worker in the spawned
143
143
  * subprocess. Kept in sync with the dispatch in `cli.ts` (Main-owned).
144
144
  */
145
- export const TTS_WORKER_ARG = "__omp_tts_worker";
145
+ export const TTS_WORKER_ARG = "__omp_worker_tts";
146
146
 
147
147
  function readTinyModelSetting(path: "providers.tinyModelDevice" | "providers.tinyModelDtype"): string | undefined {
148
148
  try {
@@ -1,37 +1,11 @@
1
- import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
-
3
- type AssistantContentBlock = AssistantMessage["content"][number];
4
- type ThinkingBlock = Extract<AssistantContentBlock, { type: "thinking" }>;
5
-
6
- function isDotOnlyThinking(text: string): boolean {
7
- let sawDot = false;
8
- for (let i = 0; i < text.length; i++) {
9
- const code = text.charCodeAt(i);
10
- if (code === 0x2e || code === 0x2026) {
11
- sawDot = true;
12
- continue;
1
+ export function canonicalizeMessage(text: string | null | undefined): string {
2
+ if (!text) return "";
3
+ const trimmed = text.trim();
4
+ for (let i = 0; i < trimmed.length; i++) {
5
+ const code = trimmed.charCodeAt(i);
6
+ if (code !== 0x2e && code !== 0x2026 && code !== 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) {
7
+ return trimmed;
13
8
  }
14
- if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) continue;
15
- return false;
16
9
  }
17
- return sawDot;
18
- }
19
-
20
- /**
21
- * Returns the operator-visible thinking text for a block.
22
- *
23
- * Some OpenAI-compatible reasoning gateways require a non-empty
24
- * `reasoning_content` field on historical assistant tool-call turns even when
25
- * the model did not emit any reasoning. The provider adapter uses a single dot
26
- * as the wire-only placeholder those gateways accept; if that value is later
27
- * replayed or echoed as a thinking block, it should not render as model thought.
28
- */
29
- export function getVisibleThinkingText(block: ThinkingBlock): string {
30
- const text = block.thinking.trim();
31
- if (text.length === 0) return "";
32
- return isDotOnlyThinking(text) ? "" : text;
33
- }
34
-
35
- export function hasVisibleThinking(block: ThinkingBlock): boolean {
36
- return getVisibleThinkingText(block).length > 0;
10
+ return "";
37
11
  }