@oh-my-pi/pi-coding-agent 16.2.1 → 16.2.2

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 (59) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/cli.js +2648 -2662
  3. package/dist/types/advisor/runtime.d.ts +15 -1
  4. package/dist/types/config/model-roles.d.ts +1 -1
  5. package/dist/types/config/settings-schema.d.ts +32 -12
  6. package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
  7. package/dist/types/edit/index.d.ts +18 -0
  8. package/dist/types/edit/streaming.d.ts +30 -0
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  10. package/dist/types/extensibility/shared-events.d.ts +1 -0
  11. package/dist/types/mcp/oauth-discovery.d.ts +0 -11
  12. package/dist/types/modes/components/status-line/component.d.ts +0 -2
  13. package/dist/types/sdk.d.ts +1 -1
  14. package/dist/types/session/agent-session.d.ts +26 -2
  15. package/dist/types/session/messages.d.ts +6 -7
  16. package/dist/types/session/settings-stream-fn.d.ts +21 -0
  17. package/dist/types/session/turn-persistence.d.ts +88 -0
  18. package/package.json +12 -12
  19. package/src/advisor/__tests__/advisor.test.ts +196 -0
  20. package/src/advisor/runtime.ts +65 -2
  21. package/src/auto-thinking/classifier.ts +2 -2
  22. package/src/config/model-resolver.ts +5 -1
  23. package/src/config/model-roles.ts +3 -3
  24. package/src/config/settings-schema.ts +30 -8
  25. package/src/discovery/omp-extension-roots.ts +38 -13
  26. package/src/edit/index.ts +21 -0
  27. package/src/edit/streaming.ts +170 -0
  28. package/src/extensibility/custom-tools/types.ts +1 -0
  29. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
  30. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
  31. package/src/extensibility/plugins/manager.ts +74 -4
  32. package/src/extensibility/shared-events.ts +1 -0
  33. package/src/internal-urls/docs-index.generated.txt +1 -1
  34. package/src/mcp/oauth-discovery.ts +5 -29
  35. package/src/mcp/transports/http.ts +3 -1
  36. package/src/mnemopi/backend.ts +2 -2
  37. package/src/modes/acp/acp-agent.ts +1 -1
  38. package/src/modes/components/assistant-message.ts +5 -5
  39. package/src/modes/components/status-line/component.ts +1 -9
  40. package/src/modes/components/status-line/segments.ts +1 -1
  41. package/src/modes/controllers/event-controller.ts +8 -11
  42. package/src/modes/interactive-mode.ts +0 -5
  43. package/src/modes/print-mode.ts +1 -1
  44. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  45. package/src/modes/utils/ui-helpers.ts +5 -4
  46. package/src/sdk.ts +12 -26
  47. package/src/session/agent-session.ts +319 -219
  48. package/src/session/messages.ts +20 -12
  49. package/src/session/session-persistence.ts +1 -2
  50. package/src/session/settings-stream-fn.ts +49 -0
  51. package/src/session/turn-persistence.ts +142 -0
  52. package/src/session/unexpected-stop-classifier.ts +2 -2
  53. package/src/slash-commands/helpers/mcp.ts +2 -1
  54. package/src/tiny/models.ts +8 -6
  55. package/src/tiny/text.ts +14 -7
  56. package/src/tools/image-gen.ts +2 -1
  57. package/src/tools/tts.ts +2 -1
  58. package/src/utils/title-generator.ts +1 -1
  59. package/src/web/search/providers/tavily.ts +36 -19
@@ -1005,6 +1005,202 @@ describe("advisor", () => {
1005
1005
  expect(runtime.backlog).toBe(0);
1006
1006
  });
1007
1007
 
1008
+ it("notifies the host once when consecutive prompt failures make the advisor unavailable", async () => {
1009
+ const promptInputs: string[] = [];
1010
+ const failures: unknown[] = [];
1011
+ let shouldFail = true;
1012
+ const agent: AdvisorAgent = {
1013
+ prompt: async input => {
1014
+ promptInputs.push(input);
1015
+ if (shouldFail) {
1016
+ throw new Error("404 No endpoints available matching your guardrail restrictions and data policy.");
1017
+ }
1018
+ },
1019
+ abort: () => {},
1020
+ reset: () => {},
1021
+ state: { messages: [] },
1022
+ };
1023
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
1024
+ const host: AdvisorRuntimeHost = {
1025
+ snapshotMessages: () => messages,
1026
+ enqueueAdvice: () => {},
1027
+ notifyFailure: error => failures.push(error),
1028
+ };
1029
+ const runtime = new AdvisorRuntime(agent, host, 0);
1030
+
1031
+ runtime.onTurnEnd(messages);
1032
+ await Bun.sleep(0);
1033
+ await Bun.sleep(0);
1034
+ await Bun.sleep(0);
1035
+
1036
+ expect(promptInputs).toHaveLength(3);
1037
+ expect(failures).toHaveLength(1);
1038
+ const failure = failures[0];
1039
+ expect(failure).toBeInstanceOf(Error);
1040
+ if (!(failure instanceof Error)) throw new Error("expected advisor failure error");
1041
+ expect(failure.message).toContain("No endpoints available");
1042
+
1043
+ messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
1044
+ runtime.onTurnEnd(messages);
1045
+ await Bun.sleep(0);
1046
+ await Bun.sleep(0);
1047
+ await Bun.sleep(0);
1048
+
1049
+ expect(promptInputs).toHaveLength(6);
1050
+ expect(failures).toHaveLength(1);
1051
+
1052
+ shouldFail = false;
1053
+ messages.push({ role: "user", content: "ccc", timestamp: 3 } as AgentMessage);
1054
+ runtime.onTurnEnd(messages);
1055
+ await Bun.sleep(0);
1056
+ expect(failures).toHaveLength(1);
1057
+
1058
+ shouldFail = true;
1059
+ messages.push({ role: "user", content: "ddd", timestamp: 4 } as AgentMessage);
1060
+ runtime.onTurnEnd(messages);
1061
+ await Bun.sleep(0);
1062
+ await Bun.sleep(0);
1063
+ await Bun.sleep(0);
1064
+
1065
+ expect(failures).toHaveLength(2);
1066
+ });
1067
+
1068
+ it("treats a clean prompt resolution with state.error as a failed turn (real Agent contract)", async () => {
1069
+ // `Agent.#runLoop` catches provider/stream failures internally — it resolves
1070
+ // `prompt()` cleanly and stores the message on `state.error` (e.g. the
1071
+ // OpenRouter ZDR `404 No endpoints available` case from #3635). The runtime
1072
+ // must surface that as a failed turn even though the awaited promise did
1073
+ // not reject.
1074
+ const promptInputs: string[] = [];
1075
+ const failures: unknown[] = [];
1076
+ const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
1077
+ let shouldFail = true;
1078
+ const agent: AdvisorAgent = {
1079
+ prompt: async input => {
1080
+ promptInputs.push(input);
1081
+ state.error = shouldFail
1082
+ ? "404 No endpoints available matching your guardrail restrictions and data policy."
1083
+ : undefined;
1084
+ },
1085
+ abort: () => {},
1086
+ reset: () => {
1087
+ state.error = undefined;
1088
+ },
1089
+ state,
1090
+ };
1091
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
1092
+ const host: AdvisorRuntimeHost = {
1093
+ snapshotMessages: () => messages,
1094
+ enqueueAdvice: () => {},
1095
+ notifyFailure: error => failures.push(error),
1096
+ };
1097
+ const runtime = new AdvisorRuntime(agent, host, 0);
1098
+
1099
+ runtime.onTurnEnd(messages);
1100
+ await Bun.sleep(0);
1101
+ await Bun.sleep(0);
1102
+ await Bun.sleep(0);
1103
+
1104
+ expect(promptInputs).toHaveLength(3);
1105
+ expect(failures).toHaveLength(1);
1106
+ const failure = failures[0];
1107
+ if (!(failure instanceof Error)) throw new Error("expected advisor failure error");
1108
+ expect(failure.message).toContain("No endpoints available");
1109
+ expect(runtime.backlog).toBe(0);
1110
+
1111
+ shouldFail = false;
1112
+ messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
1113
+ runtime.onTurnEnd(messages);
1114
+ await Bun.sleep(0);
1115
+ expect(failures).toHaveLength(1);
1116
+
1117
+ shouldFail = true;
1118
+ messages.push({ role: "user", content: "ccc", timestamp: 3 } as AgentMessage);
1119
+ runtime.onTurnEnd(messages);
1120
+ await Bun.sleep(0);
1121
+ await Bun.sleep(0);
1122
+ await Bun.sleep(0);
1123
+
1124
+ expect(failures).toHaveLength(2);
1125
+ });
1126
+
1127
+ it("rolls advisor state back after each failed prompt so retries don't replay duplicate turns", async () => {
1128
+ // The real `Agent` appends the user batch + a synthetic `stopReason: "error"`
1129
+ // assistant turn before `state.error` is read. Without rollback, the runtime's
1130
+ // retry/drop path would replay the failed batch on top of those orphans,
1131
+ // duplicating session-update user turns and leaking dropped failures into the
1132
+ // next successful run's context.
1133
+ const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
1134
+ const rollbackCalls: number[] = [];
1135
+ const lengthsBeforePrompt: number[] = [];
1136
+ let shouldFail = true;
1137
+ const agent: AdvisorAgent = {
1138
+ prompt: async input => {
1139
+ lengthsBeforePrompt.push(state.messages.length);
1140
+ state.messages.push({ role: "user", content: input, timestamp: Date.now() } as AgentMessage);
1141
+ if (shouldFail) {
1142
+ state.messages.push({
1143
+ role: "assistant",
1144
+ content: [{ type: "text", text: "" }],
1145
+ stopReason: "error",
1146
+ errorMessage: "404 No endpoints available",
1147
+ timestamp: Date.now(),
1148
+ } as unknown as AgentMessage);
1149
+ state.error = "404 No endpoints available";
1150
+ } else {
1151
+ state.messages.push({
1152
+ role: "assistant",
1153
+ content: [{ type: "text", text: "ok" }],
1154
+ timestamp: Date.now(),
1155
+ } as unknown as AgentMessage);
1156
+ state.error = undefined;
1157
+ }
1158
+ },
1159
+ abort: () => {},
1160
+ reset: () => {
1161
+ state.messages.length = 0;
1162
+ state.error = undefined;
1163
+ },
1164
+ rollbackTo: count => {
1165
+ rollbackCalls.push(count);
1166
+ if (count < state.messages.length) state.messages.length = count;
1167
+ state.error = undefined;
1168
+ },
1169
+ state,
1170
+ };
1171
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
1172
+ const host: AdvisorRuntimeHost = {
1173
+ snapshotMessages: () => messages,
1174
+ enqueueAdvice: () => {},
1175
+ };
1176
+ const runtime = new AdvisorRuntime(agent, host, 0);
1177
+
1178
+ runtime.onTurnEnd(messages);
1179
+ await Bun.sleep(0);
1180
+ await Bun.sleep(0);
1181
+ await Bun.sleep(0);
1182
+
1183
+ // Three failed prompts each rolled back to the empty baseline, so every retry
1184
+ // saw a clean state.messages instead of stacked failed turns.
1185
+ expect(lengthsBeforePrompt).toEqual([0, 0, 0]);
1186
+ expect(rollbackCalls).toEqual([0, 0, 0]);
1187
+ // The drop-after-3 path also left state.messages empty — no orphan failed
1188
+ // turns leak into the next successful run's context.
1189
+ expect(state.messages).toHaveLength(0);
1190
+ expect(state.error).toBeUndefined();
1191
+
1192
+ // A subsequent successful run starts from the clean baseline and is NOT
1193
+ // rolled back.
1194
+ shouldFail = false;
1195
+ messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
1196
+ runtime.onTurnEnd(messages);
1197
+ await Bun.sleep(0);
1198
+
1199
+ expect(lengthsBeforePrompt[lengthsBeforePrompt.length - 1]).toBe(0);
1200
+ expect(rollbackCalls).toHaveLength(3);
1201
+ expect(state.messages).toHaveLength(2);
1202
+ });
1203
+
1008
1204
  it("drops the in-flight batch when a reset aborts the advisor prompt", async () => {
1009
1205
  const promptInputs: string[] = [];
1010
1206
  const { promise: firstPromptStarted, resolve: startFirstPrompt } = Promise.withResolvers<void>();
@@ -5,12 +5,23 @@ import { logger } from "@oh-my-pi/pi-utils";
5
5
  import { obfuscateToolArguments, type SecretObfuscator } from "../secrets/obfuscator";
6
6
  import { formatSessionHistoryMarkdown, PRIMARY_CONTEXT_CUSTOM_TYPES } from "../session/session-history-format";
7
7
 
8
- /** Minimal slice of `Agent` the runtime drives — satisfied by pi-agent-core `Agent`. */
8
+ /**
9
+ * Minimal slice of `Agent` the runtime drives — satisfied by pi-agent-core
10
+ * `Agent`. `state.error` mirrors `Agent.state.error`: provider/stream failures
11
+ * the loop catches internally never reject `prompt()`, so the runtime reads
12
+ * this field after every prompt to detect a failed turn.
13
+ */
9
14
  export interface AdvisorAgent {
10
15
  prompt(input: string): Promise<void>;
11
16
  abort(reason?: unknown): void;
12
17
  reset(): void;
13
- readonly state: { messages: AgentMessage[] };
18
+ /**
19
+ * Drop messages appended past `count`. Called after a failed `prompt()` so a
20
+ * retry doesn't replay the failed user batch + synthetic assistant-error
21
+ * turn `Agent.#runLoop` records on its internal state.
22
+ */
23
+ rollbackTo?(count: number): void;
24
+ readonly state: { messages: AgentMessage[]; error?: string };
14
25
  }
15
26
 
16
27
  export interface AdvisorRuntimeHost {
@@ -37,6 +48,8 @@ export interface AdvisorRuntimeHost {
37
48
  * one that routes `advise()` results back to the primary.
38
49
  */
39
50
  beginAdvisorUpdate?(): void;
51
+ /** Surface a non-recovering advisor failure to the host UI without adding model-visible context. */
52
+ notifyFailure?(error: unknown): void;
40
53
  }
41
54
 
42
55
  interface PendingDelta {
@@ -63,6 +76,7 @@ export class AdvisorRuntime {
63
76
  #busy = false;
64
77
  #backlog = 0;
65
78
  #consecutiveFailures = 0;
79
+ #failureNotified = false;
66
80
  #latestMessages?: AgentMessage[];
67
81
  #waiters: CatchupWaiter[] = [];
68
82
  /** Bumped by every external {@link reset}/{@link dispose}. A drain iteration
@@ -121,6 +135,7 @@ export class AdvisorRuntime {
121
135
  this.#pending = [];
122
136
  this.#backlog = 0;
123
137
  this.#consecutiveFailures = 0;
138
+ this.#failureNotified = false;
124
139
  this.#wakeAllWaiters();
125
140
  try {
126
141
  this.agent.abort("advisor disposed");
@@ -131,6 +146,7 @@ export class AdvisorRuntime {
131
146
  this.#lastCount = 0;
132
147
  this.#pending = [];
133
148
  this.#consecutiveFailures = 0;
149
+ this.#failureNotified = false;
134
150
  this.#seenContext.clear();
135
151
  if (clearBacklog) {
136
152
  this.#backlog = 0;
@@ -168,6 +184,7 @@ export class AdvisorRuntime {
168
184
  this.#pending = [];
169
185
  this.#backlog = 0;
170
186
  this.#consecutiveFailures = 0;
187
+ this.#failureNotified = false;
171
188
  this.#seenContext.clear();
172
189
  this.#wakeAllWaiters();
173
190
  }
@@ -233,6 +250,28 @@ export class AdvisorRuntime {
233
250
  }
234
251
  }
235
252
 
253
+ /**
254
+ * Drop the user batch + synthetic assistant-error turn `Agent.#runLoop`
255
+ * appended for a failed prompt so a retry replays a clean baseline and the
256
+ * dropped-after-3 path never leaks orphan failures into the next successful
257
+ * run. Prefers the agent's own `rollbackTo` (which also re-syncs its
258
+ * append-only context); falls back to truncating `state.messages` for tests
259
+ * that hand-roll a minimal facade.
260
+ */
261
+ #rollbackFailedTurn(snapshot: number): void {
262
+ const messages = this.agent.state.messages;
263
+ if (messages.length <= snapshot) return;
264
+ try {
265
+ if (this.agent.rollbackTo) {
266
+ this.agent.rollbackTo(snapshot);
267
+ return;
268
+ }
269
+ messages.length = snapshot;
270
+ } catch (err) {
271
+ logger.debug("advisor rollback failed", { err: String(err) });
272
+ }
273
+ }
274
+
236
275
  async #drain(): Promise<void> {
237
276
  if (this.#busy) return;
238
277
  this.#busy = true;
@@ -281,24 +320,48 @@ export class AdvisorRuntime {
281
320
  }
282
321
 
283
322
  let success = false;
323
+ // Capture the advisor's message count BEFORE the prompt so a failure can
324
+ // roll back the user batch + synthetic assistant-error turn `Agent.#runLoop`
325
+ // appends to internal state. Without this, a retry would replay the
326
+ // failed batch on top of the stale turns and the dropped-after-3 path
327
+ // would leak orphan failures into the next successful run's context.
328
+ const messageSnapshot = this.agent.state.messages.length;
284
329
  try {
285
330
  // Reset the host's per-update advisor state (one-advise-per-update
286
331
  // gate) before each model cycle, so the new batch starts with a
287
332
  // fresh budget. Dedupe history persists across cycles.
288
333
  this.host.beginAdvisorUpdate?.();
289
334
  await this.agent.prompt(batch);
335
+ // `Agent.#runLoop` catches provider/stream failures internally and
336
+ // resolves `prompt()` cleanly with the assistant turn ending in
337
+ // `stopReason: "error"` and the message recorded on `state.error`.
338
+ // Treat that as a failed turn so OpenRouter ZDR-style endpoint
339
+ // rejections trip the retry/notify path instead of looking like a
340
+ // successful empty cycle.
341
+ const promptError = this.agent.state.error;
342
+ if (promptError) throw new Error(promptError);
290
343
  success = true;
291
344
  this.#consecutiveFailures = 0;
345
+ this.#failureNotified = false;
292
346
  } catch (err) {
293
347
  // reset()/dispose() aborts the in-flight prompt; the rejection is the
294
348
  // reset itself, not a transient advisor failure. Drop the stale batch
295
349
  // (reset already cleared #pending and rewound the cursor) instead of
296
350
  // requeuing it into the post-reset conversation.
297
351
  if (this.#epoch !== epoch) continue;
352
+ this.#rollbackFailedTurn(messageSnapshot);
298
353
  logger.debug("advisor turn failed", { err: String(err) });
299
354
  this.#consecutiveFailures++;
300
355
  if (this.#consecutiveFailures >= 3) {
301
356
  logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
357
+ if (!this.#failureNotified) {
358
+ this.#failureNotified = true;
359
+ try {
360
+ this.host.notifyFailure?.(err);
361
+ } catch (notifyErr) {
362
+ logger.warn("advisor failure notification failed", { err: String(notifyErr) });
363
+ }
364
+ }
302
365
  this.#consecutiveFailures = 0;
303
366
  // The dropped batch may carry primary-context we never delivered; drop
304
367
  // the seen-state too so the next turn re-expands it instead of marking
@@ -74,10 +74,10 @@ export async function classifyDifficulty(
74
74
  }
75
75
 
76
76
  async function classifyOnline(input: string, deps: ClassifyDifficultyDeps): Promise<Effort> {
77
- const resolved = resolveRoleSelection(["smol"], deps.settings, deps.registry.getAvailable(), deps.registry);
77
+ const resolved = resolveRoleSelection(["tiny", "smol"], deps.settings, deps.registry.getAvailable(), deps.registry);
78
78
  const model = resolved?.model;
79
79
  if (!model) {
80
- throw new Error("auto-thinking: no smol model available for classification");
80
+ throw new Error("auto-thinking: no tiny/smol model available for classification");
81
81
  }
82
82
  const apiKey = await deps.registry.getApiKey(model, deps.sessionId);
83
83
  if (!apiKey) {
@@ -882,10 +882,14 @@ function shouldInheritDefaultBeforePriority(role: ModelRole): boolean {
882
882
  * list. The advisor — a second-opinion reviewer — defaults to the `slow`
883
883
  * reasoning chain, but (unlike the `slow` role, see
884
884
  * {@link shouldInheritDefaultBeforePriority}) never inherits the primary's
885
- * model, so it stays a distinct strong model out of the box.
885
+ * model, so it stays a distinct strong model out of the box. The `tiny` role —
886
+ * the override for online title/memory/classifier tasks — reuses the `smol`
887
+ * fast chain so an unset tiny role auto-resolves to the same fast model smol
888
+ * would pick.
886
889
  */
887
890
  const ROLE_PRIORITY_ALIAS: Partial<Record<ModelRole, keyof typeof MODEL_PRIO>> = {
888
891
  advisor: "slow",
892
+ tiny: "smol",
889
893
  };
890
894
 
891
895
  /** Built-in priority patterns for a role, following {@link ROLE_PRIORITY_ALIAS}. */
@@ -13,7 +13,7 @@ export type ModelRole =
13
13
  | "plan"
14
14
  | "designer"
15
15
  | "commit"
16
- | "title"
16
+ | "tiny"
17
17
  | "task"
18
18
  | "advisor";
19
19
 
@@ -33,7 +33,7 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
33
33
  plan: { tag: "PLAN", name: "Architect", color: "muted" },
34
34
  designer: { tag: "DESIGNER", name: "Designer", color: "muted" },
35
35
  commit: { tag: "COMMIT", name: "Commit", color: "dim" },
36
- title: { tag: "TITLE", name: "Title", color: "dim", hidden: true },
36
+ tiny: { tag: "TINY", name: "Tiny", color: "dim" },
37
37
  task: { tag: "TASK", name: "Subtask", color: "muted" },
38
38
  advisor: { tag: "ADVISOR", name: "Advisor", color: "accent" },
39
39
  };
@@ -46,7 +46,7 @@ export const MODEL_ROLE_IDS: ModelRole[] = [
46
46
  "plan",
47
47
  "designer",
48
48
  "commit",
49
- "title",
49
+ "tiny",
50
50
  "task",
51
51
  "advisor",
52
52
  ];
@@ -1183,6 +1183,23 @@ export const SETTINGS_SCHEMA = {
1183
1183
  },
1184
1184
  },
1185
1185
 
1186
+ textVerbosity: {
1187
+ type: "enum",
1188
+ values: ["low", "medium", "high"] as const,
1189
+ default: "high",
1190
+ ui: {
1191
+ tab: "model",
1192
+ group: "Sampling",
1193
+ label: "Text Verbosity",
1194
+ description: "OpenAI Responses and Codex response verbosity (low, medium, or high)",
1195
+ options: [
1196
+ { value: "low", label: "Low", description: "Prefer concise responses" },
1197
+ { value: "medium", label: "Medium", description: "Balance brevity and detail" },
1198
+ { value: "high", label: "High", description: "Prefer detailed responses (default)" },
1199
+ ],
1200
+ },
1201
+ },
1202
+
1186
1203
  serviceTier: {
1187
1204
  type: "enum",
1188
1205
  values: SERVICE_TIER_SETTING_VALUES,
@@ -2007,7 +2024,6 @@ export const SETTINGS_SCHEMA = {
2007
2024
  "anthropic",
2008
2025
  "deepseek",
2009
2026
  "harmony",
2010
- "pi",
2011
2027
  "qwen3",
2012
2028
  "gemini",
2013
2029
  "gemma",
@@ -2034,7 +2050,6 @@ export const SETTINGS_SCHEMA = {
2034
2050
  { value: "anthropic", label: "Anthropic", description: "Use Anthropic-style in-band tool calls." },
2035
2051
  { value: "deepseek", label: "DeepSeek", description: "Use DeepSeek-style in-band tool calls." },
2036
2052
  { value: "harmony", label: "Harmony", description: "Use Harmony-style in-band tool calls." },
2037
- { value: "pi", label: "Pi", description: "Use the Pi owned dialect (compact sigil-delimited tool calls)." },
2038
2053
  { value: "qwen3", label: "Qwen3", description: "Use the Qwen3 owned dialect." },
2039
2054
  { value: "gemini", label: "Gemini", description: "Use the Gemini owned dialect." },
2040
2055
  { value: "gemma", label: "Gemma", description: "Use the Gemma owned dialect." },
@@ -2440,11 +2455,16 @@ export const SETTINGS_SCHEMA = {
2440
2455
  tab: "memory",
2441
2456
  group: "Mnemopi",
2442
2457
  label: "Mnemopi LLM Mode",
2443
- description: "Use no LLM, the configured smol model, or a remote OpenAI-compatible endpoint",
2458
+ description:
2459
+ "Use no LLM, the online tiny model (the TINY role from /models, else pi/smol), or a remote OpenAI-compatible endpoint",
2444
2460
  condition: "mnemopiActive",
2445
2461
  options: [
2446
2462
  { value: "none", label: "None", description: "Disable Mnemopi LLM-backed extraction" },
2447
- { value: "smol", label: "Smol", description: "Use the configured pi-ai smol model" },
2463
+ {
2464
+ value: "smol",
2465
+ label: "Online (tiny)",
2466
+ description: "Use the online tiny model (the TINY role from /models, else pi/smol)",
2467
+ },
2448
2468
  { value: "remote", label: "Remote", description: "Use the Mnemopi remote LLM settings below" },
2449
2469
  ],
2450
2470
  },
@@ -4342,7 +4362,8 @@ export const SETTINGS_SCHEMA = {
4342
4362
  tab: "providers",
4343
4363
  group: "Tiny Model",
4344
4364
  label: "Tiny Model",
4345
- description: "Session-title model: online pi/smol by default, or a local on-device model",
4365
+ description:
4366
+ "Session-title model: online (the TINY role from /models, else pi/smol) by default, or a local on-device model",
4346
4367
  options: TINY_TITLE_MODEL_OPTIONS,
4347
4368
  },
4348
4369
  },
@@ -4381,7 +4402,7 @@ export const SETTINGS_SCHEMA = {
4381
4402
  group: "General",
4382
4403
  label: "Memory Model",
4383
4404
  description:
4384
- "Mnemopi LLM for fact extraction + consolidation: online (smol/remote) by default, or a local on-device model",
4405
+ "Mnemopi LLM for fact extraction + consolidation: online (the TINY role from /models, else smol/remote) by default, or a local on-device model",
4385
4406
  condition: "mnemopiActive",
4386
4407
  options: TINY_MEMORY_MODEL_OPTIONS,
4387
4408
  },
@@ -4396,7 +4417,7 @@ export const SETTINGS_SCHEMA = {
4396
4417
  group: "Thinking",
4397
4418
  label: "Auto Thinking Model",
4398
4419
  description:
4399
- "Difficulty classifier for the `auto` thinking level: online smol by default, or a local on-device model",
4420
+ "Difficulty classifier for the `auto` thinking level: online (the TINY role from /models, else smol) by default, or a local on-device model",
4400
4421
  condition: "autoThinkingActive",
4401
4422
  options: AUTO_THINKING_MODEL_OPTIONS,
4402
4423
  },
@@ -4420,7 +4441,8 @@ export const SETTINGS_SCHEMA = {
4420
4441
  tab: "providers",
4421
4442
  group: "Tiny Model",
4422
4443
  label: "Unexpected Stop Model",
4423
- description: "Classifier for unexpected-stop detection: online smol by default, or a local on-device model.",
4444
+ description:
4445
+ "Classifier for unexpected-stop detection: online (the TINY role from /models, else smol) by default, or a local on-device model.",
4424
4446
  condition: "unexpectedStopDetection",
4425
4447
  options: TINY_MEMORY_MODEL_OPTIONS,
4426
4448
  },
@@ -22,6 +22,7 @@ import { readDirEntries, readFile } from "../capability/fs";
22
22
  import type { LoadContext } from "../capability/types";
23
23
  import { getEnabledPlugins } from "../extensibility/plugins/loader";
24
24
  import { expandTilde } from "../tools/path-utils";
25
+ import { listClaudePluginRoots } from "./helpers";
25
26
 
26
27
  /** A resolved extension package directory wired into the discovery surfaces. */
27
28
  export interface OmpExtensionRoot {
@@ -123,9 +124,9 @@ async function isDirectory(p: string): Promise<boolean> {
123
124
  * 1. CLI roots injected via {@link injectOmpExtensionCliRoots}
124
125
  * 2. Project `<cwd>/.omp/settings.json#extensions`
125
126
  * 3. User `~/.omp/agent/settings.json#extensions`
126
- * 4. Enabled plugins installed under `<plugins>/node_modules/` (e.g. via
127
- * `omp install <pkg>` / `omp plugin install` / `omp plugin link`)
128
- *
127
+ * 4. Enabled npm/link plugins installed under `<plugins>/node_modules/` (for
128
+ * `omp install <pkg>` / `omp plugin install` / `omp plugin link`). Marketplace
129
+ * installs are loaded by the `claude-plugins` provider and are excluded here.
129
130
  * Only entries that resolve to a directory on disk are returned; file
130
131
  * entrypoints contribute zero sub-discovery surface and are filtered out.
131
132
  * Installed-plugin enumeration failures (missing lockfile, unreadable
@@ -167,20 +168,44 @@ export async function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtens
167
168
  }
168
169
 
169
170
  /**
170
- * Enumerate every enabled installed plugin's package directory so its
171
- * conventional `skills/`, `hooks/`, `tools/`, `commands/`, `rules/`,
172
- * `prompts/`, and `.mcp.json` are wired into discovery — mirrors how
173
- * `getAllPluginExtensionPaths` already feeds the extension factory loader.
171
+ * Enumerate every enabled npm/link plugin's package directory so its conventional
172
+ * `skills/`, `hooks/`, `tools/`, `commands/`, `rules/`, `prompts/`, and
173
+ * `.mcp.json` are wired into discovery — mirrors how `getAllPluginExtensionPaths`
174
+ * already feeds the extension factory loader.
174
175
  *
175
- * Marketplace and `omp plugin link` installs write to the plugin manager's
176
- * `node_modules` (or symlink into it) rather than to `extensions:` in
177
- * settings; without this branch the sub-discovery provider would still miss
178
- * everything those install paths produce.
176
+ * Marketplace installs also create runtime symlinks for enable-state persistence,
177
+ * but their resources are discovered through the `claude-plugins` provider.
178
+ * Filtering them here prevents `/status` from showing the same plugin under both
179
+ * "Claude Code Marketplace" and "OMP Extension Packages".
179
180
  */
181
+ async function realpathOrResolved(p: string): Promise<string> {
182
+ try {
183
+ return await fs.realpath(p);
184
+ } catch (err) {
185
+ if (isEnoent(err)) return path.resolve(p);
186
+ throw err;
187
+ }
188
+ }
189
+
180
190
  async function listInstalledPluginRoots(ctx: LoadContext): Promise<InjectedRoot[]> {
181
191
  try {
182
- const plugins = await getEnabledPlugins(ctx.cwd, { home: ctx.home });
183
- return plugins.map(({ path: p, scope }) => ({ path: p, level: scope }));
192
+ const [plugins, marketplaceRoots] = await Promise.all([
193
+ getEnabledPlugins(ctx.cwd, { home: ctx.home }),
194
+ listClaudePluginRoots(ctx.home, ctx.cwd),
195
+ ]);
196
+ const marketplaceRealpaths = new Set(
197
+ await Promise.all(marketplaceRoots.roots.map(root => realpathOrResolved(root.path))),
198
+ );
199
+ const installedRoots = await Promise.all(
200
+ plugins.map(async plugin => ({
201
+ path: plugin.path,
202
+ scope: plugin.scope,
203
+ realpath: await realpathOrResolved(plugin.path),
204
+ })),
205
+ );
206
+ return installedRoots
207
+ .filter(root => !marketplaceRealpaths.has(root.realpath))
208
+ .map(({ path: p, scope }) => ({ path: p, level: scope }));
184
209
  } catch (err) {
185
210
  logger.debug("listInstalledPluginRoots: enumeration failed", { error: String(err) });
186
211
  return [];
package/src/edit/index.ts CHANGED
@@ -397,6 +397,27 @@ export class EditTool implements AgentTool<TInput> {
397
397
  return EDIT_MODE_STRATEGIES[this.mode].matcherDigest(args);
398
398
  }
399
399
 
400
+ /**
401
+ * Project the streamed args onto their target file paths so path-scoped
402
+ * stream matchers (e.g. TTSR `tool:edit(*.ts)` globs) match hashline and
403
+ * apply_patch edits even though the path lives in the wire payload (a
404
+ * section header / envelope marker) rather than a top-level argument.
405
+ */
406
+ matcherPaths(args: unknown): readonly string[] | undefined {
407
+ return EDIT_MODE_STRATEGIES[this.mode].matcherPaths(args);
408
+ }
409
+
410
+ /**
411
+ * Per-file projection of the streamed args, splitting multi-section
412
+ * hashline / multi-hunk apply_patch payloads into one (path, digest) entry
413
+ * per touched file. Path-scoped stream matchers (TTSR) then evaluate each
414
+ * file in isolation, so a `tool:edit(*.ts)` rule never fires on text that
415
+ * actually belongs to a sibling Markdown hunk.
416
+ */
417
+ matcherEntries(args: unknown): readonly { path: string; digest: string }[] | undefined {
418
+ return EDIT_MODE_STRATEGIES[this.mode].matcherEntries(args);
419
+ }
420
+
400
421
  async execute(
401
422
  _toolCallId: string,
402
423
  params: EditParams,