@oh-my-pi/pi-coding-agent 16.1.14 → 16.1.15
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 +21 -0
- package/dist/cli.js +2449 -2455
- package/dist/types/advisor/runtime.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +20 -0
- package/dist/types/export/share.d.ts +8 -1
- package/dist/types/mcp/transports/stdio.d.ts +12 -1
- package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
- package/dist/types/modes/components/status-line/types.d.ts +1 -0
- package/dist/types/secrets/obfuscator.d.ts +3 -3
- package/dist/types/utils/shell-snapshot.d.ts +10 -0
- package/dist/types/web/search/providers/perplexity.d.ts +17 -3
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +114 -0
- package/src/advisor/runtime.ts +129 -1
- package/src/config/model-registry.ts +12 -4
- package/src/config/settings-schema.ts +24 -0
- package/src/exec/bash-executor.ts +44 -0
- package/src/export/share.ts +51 -28
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/transports/stdio.ts +20 -4
- package/src/modes/components/custom-editor.test.ts +22 -0
- package/src/modes/components/custom-editor.ts +10 -1
- package/src/modes/components/footer.ts +4 -3
- package/src/modes/components/status-line/component.ts +5 -1
- package/src/modes/components/status-line/context-thresholds.ts +11 -3
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/controllers/command-controller.ts +1 -0
- package/src/prompts/system/system-prompt.md +5 -5
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/search.md +1 -0
- package/src/secrets/obfuscator.ts +3 -9
- package/src/session/agent-session.ts +33 -2
- package/src/slash-commands/builtin-registry.ts +2 -1
- package/src/utils/shell-snapshot.ts +63 -1
- package/src/web/search/providers/perplexity.ts +18 -6
|
@@ -212,7 +212,18 @@ function buildCmdExeCommand(command: string, args: readonly string[]): string {
|
|
|
212
212
|
return `"${quotedCommand}"`;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* Resolve the subprocess argv used to launch an MCP stdio server.
|
|
217
|
+
*
|
|
218
|
+
* On Windows, our PATH/PATHEXT walk may return `null` for a bare command
|
|
219
|
+
* (e.g. `npx`) — `Bun.env.PATH` empty under a restricted parent process,
|
|
220
|
+
* UNC/network mounts that reject `fs.access`, locked-down shells. The
|
|
221
|
+
* legacy fallback handed `Bun.spawn` the bare name, but `CreateProcess`
|
|
222
|
+
* only appends `.exe` for extensionless names — `.cmd`/`.bat` are never
|
|
223
|
+
* tried, so `npx` (which exists only as `npx.cmd` on Windows) crashes the
|
|
224
|
+
* subprocess immediately. When the resolver can't pin the command down,
|
|
225
|
+
* route through `cmd.exe /d /s /c` so Windows's own PATHEXT lookup runs.
|
|
226
|
+
*/
|
|
216
227
|
export async function resolveStdioSpawnCommand(
|
|
217
228
|
config: MCPStdioServerConfig,
|
|
218
229
|
options: ResolveStdioSpawnOptions,
|
|
@@ -220,11 +231,16 @@ export async function resolveStdioSpawnCommand(
|
|
|
220
231
|
const args = config.args ?? [];
|
|
221
232
|
if (options.platform !== "win32") return { cmd: [config.command, ...args] };
|
|
222
233
|
|
|
223
|
-
const
|
|
224
|
-
|
|
234
|
+
const resolved = await resolveWindowsCommandPath(config.command, options.cwd, options.env);
|
|
235
|
+
const resolvedCommand = resolved ?? config.command;
|
|
225
236
|
const npmShimCommand = await resolveWindowsNpmShimCommand(resolvedCommand, args, options.cwd);
|
|
226
237
|
if (npmShimCommand) return npmShimCommand;
|
|
227
|
-
|
|
238
|
+
|
|
239
|
+
// Direct-spawn only when we resolved to a concrete file AND its extension
|
|
240
|
+
// is not a batch script. Everything else (resolved .cmd/.bat, or an
|
|
241
|
+
// unresolved extensionless command) goes through cmd.exe so PATHEXT runs.
|
|
242
|
+
const needsCmdExe = resolved === null || isWindowsBatchCommand(resolvedCommand);
|
|
243
|
+
if (!needsCmdExe) return { cmd: [resolvedCommand, ...args] };
|
|
228
244
|
|
|
229
245
|
return {
|
|
230
246
|
cmd: [resolveComSpec(options.env), "/d", "/s", "/c", buildCmdExeCommand(resolvedCommand, args)],
|
|
@@ -3,6 +3,7 @@ import { $ } from "bun";
|
|
|
3
3
|
import { getEditorTheme, initTheme } from "../theme/theme";
|
|
4
4
|
import {
|
|
5
5
|
CustomEditor,
|
|
6
|
+
extractBracketedImagePastePaths,
|
|
6
7
|
SPACE_HOLD_MECHANICAL_RUN,
|
|
7
8
|
SPACE_HOLD_RELEASE_MS,
|
|
8
9
|
SPACE_REPEAT_MAX_GAP_MS,
|
|
@@ -21,6 +22,12 @@ function makeEditor() {
|
|
|
21
22
|
const REPEAT_GAP_MS = 30;
|
|
22
23
|
/** A gap above the threshold — looks like a deliberate keypress. */
|
|
23
24
|
const TAP_GAP_MS = SPACE_REPEAT_MAX_GAP_MS + 80;
|
|
25
|
+
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
26
|
+
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
27
|
+
|
|
28
|
+
function bracketedPaste(text: string): string {
|
|
29
|
+
return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`;
|
|
30
|
+
}
|
|
24
31
|
|
|
25
32
|
/** Feed `count` spaces `gapMs` apart on the fake clock. The first space of a run has no prior
|
|
26
33
|
* space, so its gap is effectively infinite and it always reads as a deliberate tap. */
|
|
@@ -66,6 +73,21 @@ describe("CustomEditor placeholder decoration", () => {
|
|
|
66
73
|
});
|
|
67
74
|
});
|
|
68
75
|
|
|
76
|
+
describe("CustomEditor bracketed image-path paste", () => {
|
|
77
|
+
it("leaves a pasted bare .png filename on the normal text path", () => {
|
|
78
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("extracts explicit local image paths for attachment", () => {
|
|
82
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/icon-photo-default.png"))).toEqual([
|
|
83
|
+
"/tmp/icon-photo-default.png",
|
|
84
|
+
]);
|
|
85
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("C:\\Users\\me\\icon-photo-default.png"))).toEqual([
|
|
86
|
+
"C:\\Users\\me\\icon-photo-default.png",
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
69
91
|
describe("CustomEditor space-hold push-to-talk", () => {
|
|
70
92
|
beforeAll(async () => {
|
|
71
93
|
await initTheme();
|
|
@@ -64,6 +64,9 @@ const BRACKETED_PASTE_END = "\x1b[201~";
|
|
|
64
64
|
const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
|
|
65
65
|
const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi;
|
|
66
66
|
const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
|
|
67
|
+
const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i;
|
|
68
|
+
const FILE_URI_REGEX = /^file:\/\//i;
|
|
69
|
+
const WINDOWS_DRIVE_PATH_REGEX = /^[a-z]:[\\/]/i;
|
|
67
70
|
|
|
68
71
|
/** Max gap (ms) between two spaces for the later one to count as OS key auto-repeat rather than a
|
|
69
72
|
* deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
|
|
@@ -118,6 +121,12 @@ function normalizePastedImagePath(path: string): string {
|
|
|
118
121
|
return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
|
|
119
122
|
}
|
|
120
123
|
|
|
124
|
+
function isExplicitPastedImagePath(path: string): boolean {
|
|
125
|
+
if (WINDOWS_DRIVE_PATH_REGEX.test(path) || FILE_URI_REGEX.test(path)) return true;
|
|
126
|
+
if (URI_SCHEME_REGEX.test(path)) return false;
|
|
127
|
+
return path.includes("/") || path.includes("\\");
|
|
128
|
+
}
|
|
129
|
+
|
|
121
130
|
export function extractBracketedImagePastePaths(data: string): string[] | undefined {
|
|
122
131
|
if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
|
|
123
132
|
const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
|
|
@@ -139,7 +148,7 @@ export function extractBracketedImagePastePaths(data: string): string[] | undefi
|
|
|
139
148
|
if (boundaryEnd === undefined) continue;
|
|
140
149
|
|
|
141
150
|
const path = normalizePastedImagePath(pasted.slice(segmentStart, boundaryEnd));
|
|
142
|
-
if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path)) return undefined;
|
|
151
|
+
if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path) || !isExplicitPastedImagePath(path)) return undefined;
|
|
143
152
|
paths.push(path);
|
|
144
153
|
|
|
145
154
|
segmentStart = boundaryEnd;
|
|
@@ -142,7 +142,8 @@ export class FooterComponent implements Component {
|
|
|
142
142
|
// After compaction, tokens are unknown until the next LLM response.
|
|
143
143
|
const contextUsage = this.session.getContextUsage();
|
|
144
144
|
const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
|
|
145
|
-
const
|
|
145
|
+
const contextTokens = contextUsage?.tokens ?? 0;
|
|
146
|
+
const contextPercentValue = contextWindow > 0 ? (contextUsage?.percent ?? 0) : null;
|
|
146
147
|
|
|
147
148
|
// Replace home directory with ~
|
|
148
149
|
let pwd = shortenPath(getProjectDir());
|
|
@@ -186,8 +187,8 @@ export class FooterComponent implements Component {
|
|
|
186
187
|
// Colorize context percentage based on usage
|
|
187
188
|
let contextPercentStr: string;
|
|
188
189
|
const autoIndicator = this.#autoCompactEnabled ? " (auto)" : "";
|
|
189
|
-
const contextPercentDisplay = `${formatContextUsage(contextPercentValue, contextWindow)}${autoIndicator}`;
|
|
190
|
-
if (contextUsage) {
|
|
190
|
+
const contextPercentDisplay = `${formatContextUsage(contextPercentValue, contextWindow, contextTokens)}${autoIndicator}`;
|
|
191
|
+
if (contextUsage && contextPercentValue !== null) {
|
|
191
192
|
const color = getContextUsageThemeColor(getContextUsageLevel(contextPercentValue, contextWindow));
|
|
192
193
|
contextPercentStr =
|
|
193
194
|
color === "statusLineContext" ? contextPercentDisplay : theme.fg(color, contextPercentDisplay);
|
|
@@ -727,10 +727,12 @@ export class StatusLineComponent implements Component {
|
|
|
727
727
|
|
|
728
728
|
let contextWindow = state.model?.contextWindow ?? this.session.model?.contextWindow ?? 0;
|
|
729
729
|
let contextPercent: number | null = 0;
|
|
730
|
+
let contextTokens = 0;
|
|
730
731
|
if (includeContext) {
|
|
731
732
|
const breakdown = this.getCachedContextBreakdown();
|
|
733
|
+
contextTokens = breakdown.usedTokens;
|
|
732
734
|
contextWindow = breakdown.contextWindow || contextWindow;
|
|
733
|
-
contextPercent = contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 :
|
|
735
|
+
contextPercent = contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : null;
|
|
734
736
|
}
|
|
735
737
|
|
|
736
738
|
// Collab guest: context comes from the host's state frames — the local
|
|
@@ -738,6 +740,7 @@ export class StatusLineComponent implements Component {
|
|
|
738
740
|
const collabState = this.#collabStatus?.stateOverride;
|
|
739
741
|
if (collabState?.contextUsage) {
|
|
740
742
|
contextWindow = collabState.contextUsage.contextWindow || contextWindow;
|
|
743
|
+
contextTokens = collabState.contextUsage.tokens ?? contextTokens;
|
|
741
744
|
contextPercent = collabState.contextUsage.percent ?? contextPercent;
|
|
742
745
|
}
|
|
743
746
|
|
|
@@ -756,6 +759,7 @@ export class StatusLineComponent implements Component {
|
|
|
756
759
|
collab: this.#collabStatus,
|
|
757
760
|
usageStats,
|
|
758
761
|
contextPercent,
|
|
762
|
+
contextTokens,
|
|
759
763
|
contextWindow,
|
|
760
764
|
autoCompactEnabled: this.#autoCompactEnabled,
|
|
761
765
|
subagentCount: this.#subagentCount,
|
|
@@ -56,10 +56,18 @@ export function getContextUsageLevel(contextPercent: number, contextWindow: numb
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* Format context usage as `<percent>%/<window>`
|
|
60
|
-
*
|
|
59
|
+
* Format context usage as `<percent>%/<window>` when the model window is known.
|
|
60
|
+
* Unknown windows render as `<tokens>/?`, because `0.0%/0` suggests a real
|
|
61
|
+
* empty context instead of missing provider metadata.
|
|
61
62
|
*/
|
|
62
|
-
export function formatContextUsage(
|
|
63
|
+
export function formatContextUsage(
|
|
64
|
+
contextPercent: number | null | undefined,
|
|
65
|
+
contextWindow: number,
|
|
66
|
+
usedTokens?: number,
|
|
67
|
+
): string {
|
|
68
|
+
if (!Number.isFinite(contextWindow) || contextWindow <= 0) {
|
|
69
|
+
return `${formatNumber(usedTokens ?? 0)}/?`;
|
|
70
|
+
}
|
|
63
71
|
const pct = contextPercent === null || contextPercent === undefined ? "?" : `${contextPercent.toFixed(1)}%`;
|
|
64
72
|
return `${pct}/${formatNumber(contextWindow)}`;
|
|
65
73
|
}
|
|
@@ -375,7 +375,7 @@ const contextPctSegment: StatusLineSegment = {
|
|
|
375
375
|
const window = ctx.contextWindow;
|
|
376
376
|
|
|
377
377
|
const autoIcon = ctx.autoCompactEnabled && theme.icon.auto ? ` ${theme.icon.auto}` : "";
|
|
378
|
-
const text = `${formatContextUsage(pct, window)}${autoIcon}`;
|
|
378
|
+
const text = `${formatContextUsage(pct, window, ctx.contextTokens)}${autoIcon}`;
|
|
379
379
|
|
|
380
380
|
const color = getContextUsageThemeColor(getContextUsageLevel(pct ?? 0, window));
|
|
381
381
|
const content = withIcon(theme.icon.context, theme.fg(color, text));
|
|
@@ -73,6 +73,7 @@ export interface SegmentContext {
|
|
|
73
73
|
};
|
|
74
74
|
/** Context usage percent, or null when unknown (e.g. right after compaction). */
|
|
75
75
|
contextPercent: number | null;
|
|
76
|
+
contextTokens: number;
|
|
76
77
|
contextWindow: number;
|
|
77
78
|
autoCompactEnabled: boolean;
|
|
78
79
|
subagentCount: number;
|
|
@@ -217,6 +217,7 @@ export class CommandController {
|
|
|
217
217
|
try {
|
|
218
218
|
const result = await shareSession(this.ctx.session.sessionManager, {
|
|
219
219
|
serverUrl: this.ctx.settings.get("share.serverUrl"),
|
|
220
|
+
store: this.ctx.settings.get("share.store"),
|
|
220
221
|
state: this.ctx.session.state,
|
|
221
222
|
obfuscator: this.ctx.settings.get("share.redactSecrets") ? this.ctx.session.obfuscator : undefined,
|
|
222
223
|
});
|
|
@@ -102,16 +102,16 @@ Use tools whenever they improve correctness, completeness, or grounding.
|
|
|
102
102
|
{{#has tools "inspect_image"}}- Image tasks: prefer `{{toolRefs.inspect_image}}` over `{{toolRefs.read}}` to spare session context.{{/has}}
|
|
103
103
|
|
|
104
104
|
# Specialized Tools
|
|
105
|
-
|
|
105
|
+
You MUST use the specialized tool over its shell equivalent:
|
|
106
106
|
{{#has tools "read"}}- File or directory reads → `{{toolRefs.read}}` (a directory path lists entries).{{/has}}
|
|
107
107
|
{{#has tools "edit"}}- Surgical edits → `{{toolRefs.edit}}`.{{/has}}
|
|
108
108
|
{{#has tools "write"}}- Create or overwrite → `{{toolRefs.write}}`.{{/has}}
|
|
109
109
|
{{#has tools "lsp"}}- Code intelligence → `{{toolRefs.lsp}}`.{{/has}}
|
|
110
|
-
{{#has tools "search"}}- Regex search → `{{toolRefs.search}}`.{{/has}}
|
|
111
|
-
{{#has tools "find"}}- Globbing → `{{toolRefs.find}}`.{{/has}}
|
|
110
|
+
{{#has tools "search"}}- Regex search → `{{toolRefs.search}}`, not `grep`, `rg`, or `awk`.{{/has}}
|
|
111
|
+
{{#has tools "find"}}- Globbing → `{{toolRefs.find}}`, not `ls **/*.ext` or `fd`.{{/has}}
|
|
112
112
|
{{#has tools "eval"}}- Quick compute → `{{toolRefs.eval}}`; you SHOULD go step by step.{{/has}}
|
|
113
|
-
{{#has tools "bash"}}- Use `{{toolRefs.bash}}` for terminal work—builds, tests, git, package managers—and pipelines that COMPUTE a fact: `wc -l`, `sort | uniq -c`, `comm`, `diff a b`, checksums.
|
|
114
|
-
- Litmus: produces a count, frequency, set difference, or checksum no tool returns → bash.
|
|
113
|
+
{{#has tools "bash"}}- Use `{{toolRefs.bash}}` for terminal work—builds, tests, git, package managers—and pipelines that COMPUTE a fact: `wc -l`, `sort | uniq -c`, `comm`, `diff a b`, checksums. Commands shadowing the tools above are blocked.
|
|
114
|
+
- Litmus: produces a count, frequency, set difference, or checksum no tool returns → bash. Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}}
|
|
115
115
|
|
|
116
116
|
{{#has tools "report_tool_issue"}}
|
|
117
117
|
<critical>
|
|
@@ -14,8 +14,8 @@ Runs bash in a shell session — terminal ops: git, bun, cargo, python.
|
|
|
14
14
|
</instruction>
|
|
15
15
|
|
|
16
16
|
<critical>
|
|
17
|
-
- NEVER
|
|
18
|
-
-
|
|
17
|
+
- NEVER shell out to search content or files: `grep/rg` → `search`.
|
|
18
|
+
- Avoid head/tail/redirections: stderr already merged; long output auto-truncated, FULL capture kept at `artifact://<id>`.
|
|
19
19
|
</critical>
|
|
20
20
|
|
|
21
21
|
<output>
|
|
@@ -17,5 +17,6 @@ Searches files using regex.
|
|
|
17
17
|
</output>
|
|
18
18
|
|
|
19
19
|
<critical>
|
|
20
|
+
- MUST use built-in `search` for any content search. NEVER shell out to `grep`, `rg`, `ripgrep`, `ag`, `ack`, `git grep`, `awk`, `sed`-for-search, or any CLI search via Bash — not even for one match or a quick check.
|
|
20
21
|
- Open-ended search needing multiple rounds? MUST use the Task tool with the explore subagent, NOT chained `search` calls.
|
|
21
22
|
</critical>
|
|
@@ -259,9 +259,9 @@ export function deobfuscateAgentMessages(obfuscator: SecretObfuscator, messages:
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
/**
|
|
262
|
-
* Restore placeholders in assistant content: visible text
|
|
263
|
-
*
|
|
264
|
-
*
|
|
262
|
+
* Restore placeholders in assistant content: visible text and tool-call
|
|
263
|
+
* arguments/intent/rawBlock. Thinking and signatures are opaque
|
|
264
|
+
* provider-replay/hidden-reasoning data and pass through byte-identical.
|
|
265
265
|
*/
|
|
266
266
|
export function deobfuscateAssistantContent(
|
|
267
267
|
obfuscator: SecretObfuscator,
|
|
@@ -276,12 +276,6 @@ export function deobfuscateAssistantContent(
|
|
|
276
276
|
changed = true;
|
|
277
277
|
return { ...block, text };
|
|
278
278
|
}
|
|
279
|
-
if (block.type === "thinking") {
|
|
280
|
-
const thinking = obfuscator.deobfuscate(block.thinking);
|
|
281
|
-
if (thinking === block.thinking) return block;
|
|
282
|
-
changed = true;
|
|
283
|
-
return { ...block, thinking };
|
|
284
|
-
}
|
|
285
279
|
if (block.type === "toolCall") {
|
|
286
280
|
const args = deobfuscateToolArguments(obfuscator, block.arguments);
|
|
287
281
|
const intent = block.intent === undefined ? undefined : obfuscator.deobfuscate(block.intent);
|
|
@@ -1936,6 +1936,7 @@ export class AgentSession {
|
|
|
1936
1936
|
snapshotMessages: () => this.agent.state.messages,
|
|
1937
1937
|
enqueueAdvice,
|
|
1938
1938
|
maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
|
|
1939
|
+
obfuscator: this.#obfuscator,
|
|
1939
1940
|
});
|
|
1940
1941
|
if (seedToCurrent) {
|
|
1941
1942
|
this.#advisorRuntime.seedTo(this.agent.state.messages.length);
|
|
@@ -9026,6 +9027,21 @@ export class AgentSession {
|
|
|
9026
9027
|
providerKeys.add(`openai-responses:${nextModel.provider}`);
|
|
9027
9028
|
}
|
|
9028
9029
|
|
|
9030
|
+
// `openai-completions` sessions are keyed `openai-completions:<provider>:<resolvedBaseUrl>:<modelId>`
|
|
9031
|
+
// and cache backend-specific decisions (strict-tools disable scopes, reasoning-effort
|
|
9032
|
+
// fallbacks). The resolved request base URL can differ from the catalog `model.baseUrl`
|
|
9033
|
+
// (Moonshot env override, Alibaba Coding Plan enterprise URL, Azure deployment URL),
|
|
9034
|
+
// so evict by provider prefix when the user moves away from that completions backend.
|
|
9035
|
+
let completionsPrefixToEvict: string | undefined;
|
|
9036
|
+
if (currentModel.api === "openai-completions") {
|
|
9037
|
+
const currentScope = `${currentModel.provider}:${currentModel.baseUrl ?? ""}`;
|
|
9038
|
+
const nextScope =
|
|
9039
|
+
nextModel.api === "openai-completions" ? `${nextModel.provider}:${nextModel.baseUrl ?? ""}` : undefined;
|
|
9040
|
+
if (currentScope !== nextScope) {
|
|
9041
|
+
completionsPrefixToEvict = `openai-completions:${currentModel.provider}:`;
|
|
9042
|
+
}
|
|
9043
|
+
}
|
|
9044
|
+
|
|
9029
9045
|
for (const providerKey of providerKeys) {
|
|
9030
9046
|
const state = this.#providerSessionState.get(providerKey);
|
|
9031
9047
|
if (!state) continue;
|
|
@@ -9041,6 +9057,21 @@ export class AgentSession {
|
|
|
9041
9057
|
|
|
9042
9058
|
this.#providerSessionState.delete(providerKey);
|
|
9043
9059
|
}
|
|
9060
|
+
|
|
9061
|
+
if (completionsPrefixToEvict !== undefined) {
|
|
9062
|
+
for (const [key, state] of this.#providerSessionState) {
|
|
9063
|
+
if (!key.startsWith(completionsPrefixToEvict)) continue;
|
|
9064
|
+
try {
|
|
9065
|
+
state.close();
|
|
9066
|
+
} catch (error) {
|
|
9067
|
+
logger.warn("Failed to close provider session state during model switch", {
|
|
9068
|
+
providerKey: key,
|
|
9069
|
+
error: String(error),
|
|
9070
|
+
});
|
|
9071
|
+
}
|
|
9072
|
+
this.#providerSessionState.delete(key);
|
|
9073
|
+
}
|
|
9074
|
+
}
|
|
9044
9075
|
}
|
|
9045
9076
|
|
|
9046
9077
|
#normalizeProviderReplayValue(value: unknown): unknown {
|
|
@@ -12168,8 +12199,8 @@ export class AgentSession {
|
|
|
12168
12199
|
pendingMessages?: AgentMessage[];
|
|
12169
12200
|
}): ContextUsageBreakdown | undefined {
|
|
12170
12201
|
const model = this.model;
|
|
12171
|
-
const
|
|
12172
|
-
|
|
12202
|
+
const rawContextWindow = options?.contextWindow ?? model?.contextWindow ?? 0;
|
|
12203
|
+
const contextWindow = Number.isFinite(rawContextWindow) && rawContextWindow > 0 ? rawContextWindow : 0;
|
|
12173
12204
|
|
|
12174
12205
|
const { skillsTokens, toolsTokens, systemContextTokens, systemPromptTokens } = computeNonMessageBreakdown(this);
|
|
12175
12206
|
const categoryNonMessageTokens = skillsTokens + toolsTokens + systemContextTokens + systemPromptTokens;
|
|
@@ -602,11 +602,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
602
602
|
},
|
|
603
603
|
{
|
|
604
604
|
name: "share",
|
|
605
|
-
description: "Share session via an encrypted link (
|
|
605
|
+
description: "Share session via an encrypted link (share server or secret gist)",
|
|
606
606
|
handle: async (_command, runtime) => {
|
|
607
607
|
try {
|
|
608
608
|
const result = await shareSession(runtime.sessionManager, {
|
|
609
609
|
serverUrl: runtime.settings.get("share.serverUrl"),
|
|
610
|
+
store: runtime.settings.get("share.store"),
|
|
610
611
|
state: runtime.session.state,
|
|
611
612
|
obfuscator: runtime.settings.get("share.redactSecrets") ? runtime.session.obfuscator : undefined,
|
|
612
613
|
});
|
|
@@ -8,11 +8,72 @@
|
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
11
|
-
import { postmortem } from "@oh-my-pi/pi-utils";
|
|
11
|
+
import { logger, postmortem } from "@oh-my-pi/pi-utils";
|
|
12
12
|
|
|
13
13
|
const cachedSnapshotPaths = new Map<string, string>();
|
|
14
14
|
const SNAPSHOT_TIMEOUT_MS = 2_000;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Characters that force brush's primitive alias expander down a path it does
|
|
18
|
+
* not implement. brush-core resolves aliases via `value.split_ascii_whitespace()`
|
|
19
|
+
* (`crates/brush-core-vendored/src/interp.rs:1500`, tracking
|
|
20
|
+
* https://github.com/reubeno/brush/issues/57): the resulting pieces are dropped
|
|
21
|
+
* into argv verbatim instead of going through the shell parser. Any alias body
|
|
22
|
+
* containing subshells `(...)`, pipes `|`, redirections `<` `>`, separators
|
|
23
|
+
* `;` `&`, or command substitutions `` ` `` turns the first whitespace-split
|
|
24
|
+
* piece into the command name and produces `command not found: (alias;` style
|
|
25
|
+
* failures (issue #3234, Fedora's default `which` alias is the canonical case).
|
|
26
|
+
*
|
|
27
|
+
* Until brush implements proper alias parsing we drop these from the snapshot;
|
|
28
|
+
* brush then falls through to whatever lives on `PATH`, which is what the user
|
|
29
|
+
* actually expected when they invoked `which` / `ls` / etc.
|
|
30
|
+
*/
|
|
31
|
+
const BRUSH_INCOMPATIBLE_ALIAS_BODY = /[()|&;<>`]/;
|
|
32
|
+
|
|
33
|
+
/** Matches `alias -- NAME='VALUE'` lines emitted by `generateSnapshotScript`. */
|
|
34
|
+
const SNAPSHOT_ALIAS_LINE = /^alias -- ([^\s=]+)='(.*)'\s*$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Strip alias definitions brush's whitespace-only expander cannot execute.
|
|
38
|
+
*
|
|
39
|
+
* Returns the rewritten snapshot plus the list of dropped alias names so the
|
|
40
|
+
* caller can surface them in the debug log.
|
|
41
|
+
*/
|
|
42
|
+
export function sanitizeSnapshotForBrush(content: string): { content: string; dropped: string[] } {
|
|
43
|
+
const dropped: string[] = [];
|
|
44
|
+
const lines = content.split("\n");
|
|
45
|
+
const out: string[] = [];
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
const m = line.match(SNAPSHOT_ALIAS_LINE);
|
|
48
|
+
if (m) {
|
|
49
|
+
// Decode the bash-quoting escape `'\''` → `'` so we test the real value.
|
|
50
|
+
const value = m[2].replace(/'\\''/g, "'");
|
|
51
|
+
if (BRUSH_INCOMPATIBLE_ALIAS_BODY.test(value)) {
|
|
52
|
+
dropped.push(m[1]);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
out.push(line);
|
|
57
|
+
}
|
|
58
|
+
return { content: out.join("\n"), dropped };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Apply {@link sanitizeSnapshotForBrush} to the freshly generated snapshot
|
|
63
|
+
* file. Best-effort: I/O failures here must not poison `getOrCreateSnapshot`.
|
|
64
|
+
*/
|
|
65
|
+
function scrubSnapshotInPlace(snapshotPath: string): void {
|
|
66
|
+
try {
|
|
67
|
+
const raw = fs.readFileSync(snapshotPath, "utf8");
|
|
68
|
+
const { content, dropped } = sanitizeSnapshotForBrush(raw);
|
|
69
|
+
if (dropped.length === 0) return;
|
|
70
|
+
fs.writeFileSync(snapshotPath, content);
|
|
71
|
+
logger.debug("shell-snapshot: dropped brush-incompatible aliases", { dropped });
|
|
72
|
+
} catch (err) {
|
|
73
|
+
logger.debug("shell-snapshot: scrub failed", { err: String(err) });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
16
77
|
function sanitizeSnapshotEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
|
17
78
|
const sanitized = { ...env };
|
|
18
79
|
delete sanitized.BASH_ENV;
|
|
@@ -169,6 +230,7 @@ export async function getOrCreateSnapshot(
|
|
|
169
230
|
|
|
170
231
|
await child.exited;
|
|
171
232
|
if (child.exitCode === 0 && fs.existsSync(snapshotPath)) {
|
|
233
|
+
scrubSnapshotInPlace(snapshotPath);
|
|
172
234
|
cachedSnapshotPaths.set(cacheKey, snapshotPath);
|
|
173
235
|
return snapshotPath;
|
|
174
236
|
}
|
|
@@ -833,16 +833,28 @@ export class PerplexityProvider extends SearchProvider {
|
|
|
833
833
|
readonly id = "perplexity";
|
|
834
834
|
readonly label = "Perplexity";
|
|
835
835
|
|
|
836
|
+
/**
|
|
837
|
+
* Auto-chain admission. Requires a direct Perplexity credential
|
|
838
|
+
* (`PERPLEXITY_COOKIES`, OAuth session, or `PERPLEXITY_API_KEY`).
|
|
839
|
+
*
|
|
840
|
+
* OpenRouter auth is intentionally NOT accepted here: silently using
|
|
841
|
+
* OpenRouter's `perplexity/sonar-pro` whenever any OpenRouter key is
|
|
842
|
+
* configured surprises users (and bills them) for a path they never
|
|
843
|
+
* asked for. The auto chain skips Perplexity in that case and falls
|
|
844
|
+
* through to the next configured provider. Users who DO want the
|
|
845
|
+
* OpenRouter-backed Perplexity path can still opt in by setting
|
|
846
|
+
* `webSearch: perplexity` explicitly — see {@link isExplicitlyAvailable}.
|
|
847
|
+
*/
|
|
836
848
|
isAvailable(authStorage: AuthStorage): boolean {
|
|
837
|
-
return (
|
|
838
|
-
!!$env.PERPLEXITY_COOKIES?.trim() || authStorage.hasAuth("perplexity") || authStorage.hasAuth("openrouter")
|
|
839
|
-
);
|
|
849
|
+
return !!$env.PERPLEXITY_COOKIES?.trim() || authStorage.hasAuth("perplexity");
|
|
840
850
|
}
|
|
841
851
|
|
|
842
852
|
/**
|
|
843
|
-
* Perplexity accepts anonymous browser-style ask requests,
|
|
844
|
-
*
|
|
845
|
-
*
|
|
853
|
+
* Perplexity accepts anonymous browser-style ask requests, and the
|
|
854
|
+
* OpenRouter-backed `perplexity/sonar-pro` path is opt-in through
|
|
855
|
+
* explicit selection. Keep auto-chain admission credential-gated so a
|
|
856
|
+
* configured provider keeps priority over the anonymous/OpenRouter
|
|
857
|
+
* fallbacks.
|
|
846
858
|
*/
|
|
847
859
|
isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
|
|
848
860
|
return true;
|