@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.
- package/CHANGELOG.md +25 -0
- package/dist/cli.js +135 -131
- package/dist/types/config/model-resolver.d.ts +14 -0
- package/dist/types/exec/non-interactive-env.d.ts +2 -0
- package/dist/types/session/messages.d.ts +3 -0
- package/dist/types/utils/markit.d.ts +8 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +44 -0
- package/src/cli/args.ts +1 -0
- package/src/config/model-resolver.ts +35 -1
- package/src/discovery/github.ts +89 -1
- package/src/exec/bash-executor.ts +2 -2
- package/src/exec/non-interactive-env.ts +71 -0
- package/src/extensibility/extensions/runner.ts +17 -1
- package/src/extensibility/plugins/loader.ts +154 -21
- package/src/extensibility/plugins/manager.ts +40 -33
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/session/agent-session.ts +135 -32
- package/src/session/messages.ts +1 -1
- package/src/system-prompt.ts +7 -1
- package/src/task/executor.ts +100 -4
- package/src/utils/lang-from-path.ts +5 -0
- package/src/utils/markit.ts +24 -1
|
@@ -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.
|
|
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.
|
|
51
|
-
"@oh-my-pi/omp-stats": "16.0.
|
|
52
|
-
"@oh-my-pi/pi-agent-core": "16.0.
|
|
53
|
-
"@oh-my-pi/pi-ai": "16.0.
|
|
54
|
-
"@oh-my-pi/pi-catalog": "16.0.
|
|
55
|
-
"@oh-my-pi/pi-mnemopi": "16.0.
|
|
56
|
-
"@oh-my-pi/pi-natives": "16.0.
|
|
57
|
-
"@oh-my-pi/pi-tui": "16.0.
|
|
58
|
-
"@oh-my-pi/pi-utils": "16.0.
|
|
59
|
-
"@oh-my-pi/pi-wire": "16.0.
|
|
60
|
-
"@oh-my-pi/snapcompact": "16.0.
|
|
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);
|
package/src/discovery/github.ts
CHANGED
|
@@ -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
|
-
* -
|
|
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 {
|
|
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
|
|
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;
|