@oh-my-pi/pi-coding-agent 16.0.1 → 16.0.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.
@@ -37,7 +37,21 @@ export declare function parseModelString(modelStr: string): {
37
37
  * Format a model as "provider/modelId" string.
38
38
  */
39
39
  export declare function formatModelString(model: Model<Api>): string;
40
+ export declare function formatModelStringWithRouting(model: Model<Api>): string;
40
41
  export declare function formatModelSelectorValue(selector: string, thinkingLevel: ThinkingLevel | undefined): string;
42
+ /**
43
+ * Split a trailing `@<upstream>` provider-routing selector off a model pattern.
44
+ *
45
+ * `openrouter/z-ai/glm-4.7@cerebras` -> base `openrouter/z-ai/glm-4.7`, upstream
46
+ * `cerebras`. A `:thinking` suffix after the slug is kept on the base
47
+ * (`...@cerebras:high` -> base `...:high`). Returns undefined when there is no
48
+ * `@` or the suffix is not a bare provider slug, so model ids that legitimately
49
+ * contain `@` (`claude-opus-4-8@default`, `workers-ai/@cf/...`) are never split.
50
+ */
51
+ export declare function splitUpstreamRouting(pattern: string): {
52
+ base: string;
53
+ upstream: string;
54
+ } | undefined;
41
55
  export declare function resolveProviderModelReference(provider: string, modelId: string, availableModels: readonly Model<Api>[]): Model<Api> | undefined;
42
56
  export interface ModelMatchPreferences {
43
57
  /** Most-recently-used model keys (provider/modelId) to prefer when ambiguous. */
@@ -1 +1,3 @@
1
1
  export declare const NON_INTERACTIVE_ENV: Readonly<Record<string, string>>;
2
+ /** Builds the per-command environment for non-interactive child processes. */
3
+ export declare function buildNonInteractiveEnv(overrides?: Record<string, string>, baseEnv?: Record<string, string | undefined>, platform?: NodeJS.Platform): Record<string, string>;
@@ -55,6 +55,9 @@ export declare function isSilentAbort(errorMessage: string | undefined): boolean
55
55
  export declare const USER_INTERRUPT_LABEL = "Interrupted by user";
56
56
  export declare function isUserInterruptAbort(errorMessage: string | undefined): boolean;
57
57
  export declare function shouldRenderAbortReason(errorMessage: string | undefined): boolean;
58
+ /** Sentinel `errorMessage` the agent stamps on any abort that carried no custom
59
+ * reason (bare `abort()`). Renderers treat it as "no specific reason given". */
60
+ export declare const GENERIC_ABORT_SENTINEL = "Request was aborted";
58
61
  /** Resolve the operator-facing label for an aborted assistant turn. A custom
59
62
  * abort reason threaded onto `errorMessage` is returned verbatim; aborts with
60
63
  * no threaded reason fall back to the retry-aware generic label. Call
@@ -3,5 +3,13 @@ export interface MarkitConversionResult {
3
3
  ok: boolean;
4
4
  error?: string;
5
5
  }
6
+ interface MuPdfWasmModuleConfig {
7
+ print?: (...values: unknown[]) => void;
8
+ printErr?: (...values: unknown[]) => void;
9
+ }
10
+ declare global {
11
+ var $libmupdf_wasm_Module: MuPdfWasmModuleConfig | undefined;
12
+ }
6
13
  export declare function convertFileWithMarkit(filePath: string, signal?: AbortSignal): Promise<MarkitConversionResult>;
7
14
  export declare function convertBufferWithMarkit(buffer: Uint8Array, extension: string, signal?: AbortSignal): Promise<MarkitConversionResult>;
15
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.0.1",
4
+ "version": "16.0.2",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,17 +47,17 @@
47
47
  "@agentclientprotocol/sdk": "0.25.0",
48
48
  "@babel/parser": "^7.29.7",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/hashline": "16.0.1",
51
- "@oh-my-pi/omp-stats": "16.0.1",
52
- "@oh-my-pi/pi-agent-core": "16.0.1",
53
- "@oh-my-pi/pi-ai": "16.0.1",
54
- "@oh-my-pi/pi-catalog": "16.0.1",
55
- "@oh-my-pi/pi-mnemopi": "16.0.1",
56
- "@oh-my-pi/pi-natives": "16.0.1",
57
- "@oh-my-pi/pi-tui": "16.0.1",
58
- "@oh-my-pi/pi-utils": "16.0.1",
59
- "@oh-my-pi/pi-wire": "16.0.1",
60
- "@oh-my-pi/snapcompact": "16.0.1",
50
+ "@oh-my-pi/hashline": "16.0.2",
51
+ "@oh-my-pi/omp-stats": "16.0.2",
52
+ "@oh-my-pi/pi-agent-core": "16.0.2",
53
+ "@oh-my-pi/pi-ai": "16.0.2",
54
+ "@oh-my-pi/pi-catalog": "16.0.2",
55
+ "@oh-my-pi/pi-mnemopi": "16.0.2",
56
+ "@oh-my-pi/pi-natives": "16.0.2",
57
+ "@oh-my-pi/pi-tui": "16.0.2",
58
+ "@oh-my-pi/pi-utils": "16.0.2",
59
+ "@oh-my-pi/pi-wire": "16.0.2",
60
+ "@oh-my-pi/snapcompact": "16.0.2",
61
61
  "@opentelemetry/api": "^1.9.1",
62
62
  "@opentelemetry/context-async-hooks": "^2.7.1",
63
63
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "bun:test";
2
2
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
3
  import { createAdvisorMessageCard } from "../../modes/components/advisor-message";
4
4
  import { getThemeByName } from "../../modes/theme/theme";
5
+ import { formatSessionDumpText } from "../../session/session-dump-format";
5
6
  import { formatSessionHistoryMarkdown } from "../../session/session-history-format";
6
7
  import { YieldQueue } from "../../session/yield-queue";
7
8
  import {
@@ -583,4 +584,47 @@ describe("advisor", () => {
583
584
  expect(text).toContain("truncated.");
584
585
  });
585
586
  });
587
+ describe("formatSessionDumpText raw thinking", () => {
588
+ it("does not nest literal thinking envelopes", () => {
589
+ const md = formatSessionDumpText({
590
+ messages: [
591
+ {
592
+ role: "assistant",
593
+ content: [
594
+ {
595
+ type: "thinking",
596
+ thinking: "<thinking>\nCheck logs before accepting container health.\n</thinking>",
597
+ },
598
+ ],
599
+ timestamp: Date.now(),
600
+ } as AgentMessage,
601
+ ],
602
+ thinkingLevel: "high",
603
+ });
604
+
605
+ expect(md).toContain("Assistant: <thinking>\nCheck logs before accepting container health.\n</thinking>");
606
+ expect(md).not.toContain("<thinking>\n<thinking>");
607
+ });
608
+
609
+ it("unwraps sibling literal thinking envelopes independently", () => {
610
+ const md = formatSessionDumpText({
611
+ messages: [
612
+ {
613
+ role: "assistant",
614
+ content: [
615
+ { type: "thinking", thinking: "<thinking>\nfirst\n</thinking>" },
616
+ { type: "toolCall", id: "tc-1", name: "read", arguments: { path: "file.ts" } },
617
+ { type: "thinking", thinking: "<thinking>\nsecond\n</thinking>" },
618
+ ],
619
+ timestamp: Date.now(),
620
+ } as AgentMessage,
621
+ ],
622
+ tools: [{ name: "read", description: "Read a file", parameters: { type: "object" } }],
623
+ thinkingLevel: "high",
624
+ });
625
+
626
+ expect(md).toContain("Assistant: <thinking>\nfirst\nsecond\n</thinking>");
627
+ expect(md).not.toContain("first\n</thinking>\n<thinking>\nsecond");
628
+ });
629
+ });
586
630
  });
package/src/cli/args.ts CHANGED
@@ -280,6 +280,7 @@ export function getExtraHelpText(): string {
280
280
  MISTRAL_API_KEY - Mistral models
281
281
  ZAI_API_KEY - z.ai models (ZhipuAI/GLM)
282
282
  UMANS_AI_CODING_PLAN_API_KEY - Umans AI Coding Plan models
283
+ UMANS_WEBSEARCH_PROVIDER - Umans gateway web search backend (native or exa)
283
284
  MINIMAX_API_KEY - MiniMax models
284
285
  OPENCODE_API_KEY - OpenCode Zen/OpenCode Go models
285
286
  CURSOR_ACCESS_TOKEN - Cursor AI models
@@ -92,6 +92,33 @@ export function formatModelString(model: Model<Api>): string {
92
92
  return `${model.provider}/${model.id}`;
93
93
  }
94
94
 
95
+ function getSingleRoutingOnly(routing: unknown): string | undefined {
96
+ if (!routing || typeof routing !== "object" || !("only" in routing) || !Array.isArray(routing.only)) {
97
+ return undefined;
98
+ }
99
+ if (routing.only.length !== 1) return undefined;
100
+ const upstream = routing.only[0];
101
+ return typeof upstream === "string" && upstream ? upstream : undefined;
102
+ }
103
+
104
+ function getSingleUpstreamRoute(model: Model<Api>): string | undefined {
105
+ const compat = model.compat;
106
+ if (!compat || typeof compat !== "object") return undefined;
107
+ if (modelMatchesHost(model, "vercelAIGateway") && "vercelGatewayRouting" in compat) {
108
+ return getSingleRoutingOnly(compat.vercelGatewayRouting);
109
+ }
110
+ if (modelMatchesHost(model, "openrouter") && "openRouterRouting" in compat) {
111
+ return getSingleRoutingOnly(compat.openRouterRouting);
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ export function formatModelStringWithRouting(model: Model<Api>): string {
117
+ const selector = formatModelString(model);
118
+ const upstream = getSingleUpstreamRoute(model);
119
+ return upstream ? `${selector}@${upstream}` : selector;
120
+ }
121
+
95
122
  export function formatModelSelectorValue(selector: string, thinkingLevel: ThinkingLevel | undefined): string {
96
123
  return thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit ? `${selector}:${thinkingLevel}` : selector;
97
124
  }
@@ -161,7 +188,7 @@ const UPSTREAM_ROUTING_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
161
188
  * `@` or the suffix is not a bare provider slug, so model ids that legitimately
162
189
  * contain `@` (`claude-opus-4-8@default`, `workers-ai/@cf/...`) are never split.
163
190
  */
164
- function splitUpstreamRouting(pattern: string): { base: string; upstream: string } | undefined {
191
+ export function splitUpstreamRouting(pattern: string): { base: string; upstream: string } | undefined {
165
192
  const at = pattern.lastIndexOf("@");
166
193
  if (at <= 0) return undefined;
167
194
  const rest = pattern.slice(at + 1);
@@ -481,6 +508,13 @@ function matchModel(
481
508
  // The prefix is not a known provider in this candidate set, so treat the
482
509
  // slash as part of the raw model ID and continue with generic matching.
483
510
  } else {
511
+ // Let the routing fallback apply `@upstream` before fuzzy matching can consume the
512
+ // slug — but only for aggregator providers (OpenRouter / Vercel Gateway). Other
513
+ // providers have ids that legitimately end in `@` (Vertex `claude-opus-4-8@default`),
514
+ // and the fallback never routes them, so they must keep fuzzy matching.
515
+ if (splitUpstreamRouting(modelId) && providerModels.some(supportsUpstreamRouting)) {
516
+ return undefined;
517
+ }
484
518
  const scored = providerModels
485
519
  .map(model => ({ model, match: fuzzyMatch(modelId, model.id) }))
486
520
  .filter(entry => entry.match.matches);
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Capabilities:
13
13
  * - context-files: copilot-instructions.md in .github/ and ~/.copilot/; AGENTS.md in each COPILOT_CUSTOM_INSTRUCTIONS_DIRS
14
- * - instructions: *.instructions.md under .github/instructions/ (project) and <dir>/.github/instructions/ for each custom dir (applyTo frontmatter)
14
+ * - rules: *.instructions.md under .github/instructions/ and <dir>/.github/instructions/ for each custom dir (applyTo frontmatter)
15
15
  * - prompts: *.prompt.md in .github/prompts/ (VS Code Copilot prompt files)
16
16
  * - skills: <name>/SKILL.md in .github/skills/ (GitHub Agent Skills layout)
17
17
  */
@@ -22,10 +22,12 @@ import { type ContextFile, contextFileCapability } from "../capability/context-f
22
22
  import { readFile } from "../capability/fs";
23
23
  import { type Instruction, instructionCapability } from "../capability/instruction";
24
24
  import { type Prompt, promptCapability } from "../capability/prompt";
25
+ import { type Rule, ruleCapability } from "../capability/rule";
25
26
  import { type Skill, skillCapability } from "../capability/skill";
26
27
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
27
28
 
28
29
  import {
30
+ buildRuleFromMarkdown,
29
31
  calculateDepth,
30
32
  createSourceMeta,
31
33
  getProjectPath,
@@ -152,6 +154,85 @@ function transformInstruction(name: string, content: string, filePath: string, s
152
154
  };
153
155
  }
154
156
 
157
+ // =============================================================================
158
+ // Rules
159
+ // =============================================================================
160
+
161
+ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
162
+ const items: Rule[] = [];
163
+ const warnings: string[] = [];
164
+
165
+ const load = async (dir: string, level: "user" | "project") => {
166
+ const applyToWarnings: string[] = [];
167
+ const result = await loadFilesFromDir<Rule>(ctx, dir, PROVIDER_ID, level, {
168
+ extensions: ["md"],
169
+ transform: (name, content, filePath, source) =>
170
+ transformInstructionRule(name, content, filePath, source, applyToWarnings),
171
+ recursive: true,
172
+ });
173
+ items.push(...result.items);
174
+ if (result.warnings) warnings.push(...result.warnings);
175
+ warnings.push(...applyToWarnings);
176
+ };
177
+
178
+ const instructionsDir = getProjectPath(ctx, "github", "instructions");
179
+ if (instructionsDir) {
180
+ await load(instructionsDir, "project");
181
+ }
182
+
183
+ for (const dir of copilotCustomInstructionDirs()) {
184
+ await load(path.join(dir, ".github", "instructions"), "user");
185
+ }
186
+
187
+ return { items, warnings };
188
+ }
189
+
190
+ function transformInstructionRule(
191
+ name: string,
192
+ content: string,
193
+ filePath: string,
194
+ source: SourceMeta,
195
+ warnings: string[],
196
+ ): Rule | null {
197
+ if (!name.endsWith(".instructions.md")) {
198
+ return null;
199
+ }
200
+
201
+ const { frontmatter } = parseFrontmatter(content, { source: filePath });
202
+ const applyToGlobs = normalizeApplyToGlobs(frontmatter.applyTo);
203
+ if (!applyToGlobs) {
204
+ warnings.push(`Missing applyTo in ${filePath}; loaded without GitHub glob scoping.`);
205
+ }
206
+
207
+ const rule = buildRuleFromMarkdown(name, content, filePath, source, {
208
+ stripNamePattern: /\.instructions\.md$/,
209
+ });
210
+ if (applyToGlobs?.some(isAlwaysApplyGlob)) {
211
+ return { ...rule, alwaysApply: true, globs: undefined };
212
+ }
213
+
214
+ const description = rule.description ?? describeInstructionRule(applyToGlobs);
215
+ return { ...rule, alwaysApply: false, globs: applyToGlobs, description };
216
+ }
217
+
218
+ function normalizeApplyToGlobs(value: unknown): string[] | undefined {
219
+ // GitHub documents applyTo as a single comma-separated string (e.g.
220
+ // "**/*.ts,**/*.tsx"); also tolerate a YAML array of such strings.
221
+ const raw = Array.isArray(value) ? value : [value];
222
+ const globs = raw.flatMap(item => (typeof item === "string" ? parseCSV(item) : []));
223
+ return globs.length > 0 ? globs : undefined;
224
+ }
225
+
226
+ function isAlwaysApplyGlob(glob: string): boolean {
227
+ // GitHub treats "*", "**", and "**/*" as matching every file.
228
+ return glob === "*" || glob === "**" || glob === "**/*";
229
+ }
230
+
231
+ function describeInstructionRule(globs: string[] | undefined): string {
232
+ if (!globs) return "GitHub Copilot instructions without applyTo metadata";
233
+ return `GitHub Copilot instructions for ${globs.join(", ")}`;
234
+ }
235
+
155
236
  // =============================================================================
156
237
  // Prompts
157
238
  // =============================================================================
@@ -232,6 +313,13 @@ registerProvider(instructionCapability.id, {
232
313
  load: loadInstructions,
233
314
  });
234
315
 
316
+ registerProvider<Rule>(ruleCapability.id, {
317
+ id: PROVIDER_ID,
318
+ displayName: DISPLAY_NAME,
319
+ description: "Load *.instructions.md from .github/instructions/ as Copilot-scoped rules",
320
+ priority: PRIORITY,
321
+ load: loadRules,
322
+ });
235
323
  registerProvider<Skill>(skillCapability.id, {
236
324
  id: PROVIDER_ID,
237
325
  displayName: DISPLAY_NAME,
@@ -11,7 +11,7 @@ import { Settings, type ShellMinimizerSettings } from "../config/settings";
11
11
  import { OutputSink } from "../session/streaming-output";
12
12
  import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
13
13
  import { getOrCreateSnapshot } from "../utils/shell-snapshot";
14
- import { NON_INTERACTIVE_ENV } from "./non-interactive-env";
14
+ import { buildNonInteractiveEnv } from "./non-interactive-env";
15
15
 
16
16
  export interface BashExecutorOptions {
17
17
  cwd?: string;
@@ -184,7 +184,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
184
184
  const minimizer = buildMinimizerOptions(settings.getGroup("shellMinimizer"));
185
185
 
186
186
  const commandCwd = await resolveShellCwd(options?.cwd);
187
- const commandEnv = options?.env ? { ...NON_INTERACTIVE_ENV, ...options.env } : NON_INTERACTIVE_ENV;
187
+ const commandEnv = buildNonInteractiveEnv(options?.env);
188
188
 
189
189
  // Apply command prefix if configured
190
190
  const prefixedCommand = prefix ? `${prefix} ${command}` : command;
@@ -46,3 +46,74 @@ export const NON_INTERACTIVE_ENV: Readonly<Record<string, string>> = {
46
46
  COMPOSER_NO_INTERACTION: "1",
47
47
  CLOUDSDK_CORE_DISABLE_PROMPTS: "1",
48
48
  };
49
+
50
+ const WINDOWS_UTF8_ENV_DEFAULT_GROUPS: ReadonlyArray<ReadonlyArray<readonly [key: string, value: string]>> = [
51
+ [
52
+ ["PYTHONIOENCODING", "utf-8"],
53
+ ["PYTHONUTF8", "1"],
54
+ ],
55
+ [
56
+ ["LANG", "C.UTF-8"],
57
+ ["LC_ALL", "C.UTF-8"],
58
+ ],
59
+ ];
60
+
61
+ function hasEnvValue(
62
+ env: Record<string, string | undefined> | undefined,
63
+ key: string,
64
+ platform: NodeJS.Platform,
65
+ ): boolean {
66
+ if (!env) return false;
67
+ if (platform !== "win32") return env[key] !== undefined;
68
+
69
+ for (const [existingKey, value] of Object.entries(env)) {
70
+ if (value !== undefined && existingKey.toLowerCase() === key.toLowerCase()) {
71
+ return true;
72
+ }
73
+ }
74
+ return false;
75
+ }
76
+
77
+ function hasLocaleEnvValue(env: Record<string, string | undefined> | undefined, platform: NodeJS.Platform): boolean {
78
+ if (!env) return false;
79
+ for (const [key, value] of Object.entries(env)) {
80
+ if (value === undefined) continue;
81
+ const normalizedKey = platform === "win32" ? key.toUpperCase() : key;
82
+ if (normalizedKey === "LANG" || normalizedKey.startsWith("LC_")) return true;
83
+ }
84
+ return false;
85
+ }
86
+
87
+ function hasEnvGroupValue(
88
+ env: Record<string, string | undefined> | undefined,
89
+ group: ReadonlyArray<readonly [key: string, value: string]>,
90
+ platform: NodeJS.Platform,
91
+ ): boolean {
92
+ if (group.some(([key]) => key === "LC_ALL") && hasLocaleEnvValue(env, platform)) return true;
93
+ for (const [key] of group) {
94
+ if (hasEnvValue(env, key, platform)) return true;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ /** Builds the per-command environment for non-interactive child processes. */
100
+ export function buildNonInteractiveEnv(
101
+ overrides?: Record<string, string>,
102
+ baseEnv: Record<string, string | undefined> = Bun.env,
103
+ platform: NodeJS.Platform = process.platform,
104
+ ): Record<string, string> {
105
+ if (platform !== "win32") {
106
+ return overrides ? { ...NON_INTERACTIVE_ENV, ...overrides } : NON_INTERACTIVE_ENV;
107
+ }
108
+
109
+ const env: Record<string, string> = { ...NON_INTERACTIVE_ENV };
110
+ for (const group of WINDOWS_UTF8_ENV_DEFAULT_GROUPS) {
111
+ if (hasEnvGroupValue(baseEnv, group, platform) || hasEnvGroupValue(overrides, group, platform)) {
112
+ continue;
113
+ }
114
+ for (const [key, value] of group) {
115
+ env[key] = value;
116
+ }
117
+ }
118
+ return overrides ? { ...env, ...overrides } : env;
119
+ }
@@ -543,7 +543,9 @@ export class ExtensionRunner {
543
543
  event.type === "session_before_tree"
544
544
  );
545
545
  }
546
-
546
+ #isSessionShutdownEvent(event: RunnerEmitEvent): event is Extract<RunnerEmitEvent, { type: "session_shutdown" }> {
547
+ return event.type === "session_shutdown";
548
+ }
547
549
  async #runHandlerWithTimeout<TEvent extends { type: string }, TResult>(
548
550
  handler: (event: TEvent, ctx: ExtensionContext) => Promise<TResult | undefined> | TResult | undefined,
549
551
  event: TEvent,
@@ -588,6 +590,20 @@ export class ExtensionRunner {
588
590
  const ctx = this.createContext();
589
591
  let result: SessionBeforeEventResult | SessionCompactingResult | undefined;
590
592
 
593
+ if (this.#isSessionShutdownEvent(event)) {
594
+ const timeoutMs = handlerTimeoutForEvent(event.type);
595
+ const promises: Promise<unknown>[] = [];
596
+ for (const ext of this.extensions) {
597
+ const handlers = ext.handlers.get(event.type);
598
+ if (!handlers || handlers.length === 0) continue;
599
+ for (const handler of handlers) {
600
+ promises.push(this.#runHandlerWithTimeout(handler, event, ctx, ext, timeoutMs));
601
+ }
602
+ }
603
+ await Promise.all(promises);
604
+ return result as RunnerEmitResult<TEvent>;
605
+ }
606
+
591
607
  for (const ext of this.extensions) {
592
608
  const handlers = ext.handlers.get(event.type);
593
609
  if (!handlers || handlers.length === 0) continue;