@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/cli.js +2449 -2455
  3. package/dist/types/advisor/runtime.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +20 -0
  5. package/dist/types/export/share.d.ts +8 -1
  6. package/dist/types/mcp/transports/stdio.d.ts +12 -1
  7. package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
  8. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  9. package/dist/types/secrets/obfuscator.d.ts +3 -3
  10. package/dist/types/utils/shell-snapshot.d.ts +10 -0
  11. package/dist/types/web/search/providers/perplexity.d.ts +17 -3
  12. package/package.json +12 -12
  13. package/src/advisor/__tests__/advisor.test.ts +114 -0
  14. package/src/advisor/runtime.ts +129 -1
  15. package/src/config/model-registry.ts +12 -4
  16. package/src/config/settings-schema.ts +24 -0
  17. package/src/exec/bash-executor.ts +44 -0
  18. package/src/export/share.ts +51 -28
  19. package/src/internal-urls/docs-index.generated.txt +1 -1
  20. package/src/mcp/transports/stdio.ts +20 -4
  21. package/src/modes/components/custom-editor.test.ts +22 -0
  22. package/src/modes/components/custom-editor.ts +10 -1
  23. package/src/modes/components/footer.ts +4 -3
  24. package/src/modes/components/status-line/component.ts +5 -1
  25. package/src/modes/components/status-line/context-thresholds.ts +11 -3
  26. package/src/modes/components/status-line/segments.ts +1 -1
  27. package/src/modes/components/status-line/types.ts +1 -0
  28. package/src/modes/controllers/command-controller.ts +1 -0
  29. package/src/prompts/system/system-prompt.md +5 -5
  30. package/src/prompts/tools/bash.md +2 -2
  31. package/src/prompts/tools/search.md +1 -0
  32. package/src/secrets/obfuscator.ts +3 -9
  33. package/src/session/agent-session.ts +33 -2
  34. package/src/slash-commands/builtin-registry.ts +2 -1
  35. package/src/utils/shell-snapshot.ts +63 -1
  36. 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
- /** Resolve the subprocess argv used to launch an MCP stdio server. */
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 resolvedCommand =
224
- (await resolveWindowsCommandPath(config.command, options.cwd, options.env)) ?? config.command;
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
- if (!isWindowsBatchCommand(resolvedCommand)) return { cmd: [resolvedCommand, ...args] };
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 contextPercentValue = contextUsage?.percent ?? 0;
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 : 0;
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>` (e.g. `5.1%/1M`), matching the
60
- * status line's context gauge so subagent and footer renderers stay in sync.
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(contextPercent: number | null | undefined, contextWindow: number): string {
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
- Dedicated tools add gitignore semantics, structured output, and line anchors a raw shell command lacks — reach for them first when they fit:
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. Otherwise a dedicated tool usually fits.{{/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. 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 trim or silence output: no `| head -n N`, `| tail -n N`, `| less`, `2>&1`, `2>/dev/null`. stderr already merged; long output auto-truncated, FULL capture kept at `artifact://<id>`.
18
- - Pipelines that COMPUTE a new fact are correct bash: `wc -l`, `sort | uniq -c`, `comm`, `cut`, `diff a b`, `shasum`. Litmus: produces a count, frequency table, set difference, or checksum no tool returns → bash. Merely moves or trims bytes a tool can fetch → use the tool.
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, thinking text, and
263
- * tool-call arguments/intent/rawBlock. Signatures and redacted-thinking bytes
264
- * are opaque provider-replay data and pass through byte-identical.
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 contextWindow = options?.contextWindow ?? model?.contextWindow ?? 0;
12172
- if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined;
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 (secret gist or share server)",
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, but keep auto
844
- * provider selection credential-gated so a configured provider keeps priority
845
- * over the anonymous fallback.
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;