@oh-my-pi/pi-coding-agent 16.1.19 → 16.1.20

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.
@@ -7,7 +7,7 @@
7
7
  import path from "node:path";
8
8
  import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
9
9
  import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
10
- import type { Api, Model, Usage } from "@oh-my-pi/pi-ai";
10
+ import type { Api, Model, ServiceTier, Usage } from "@oh-my-pi/pi-ai";
11
11
  import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-my-pi/pi-utils";
12
12
  import type { Rule } from "../capability/rule";
13
13
  import { ModelRegistry } from "../config/model-registry";
@@ -18,6 +18,7 @@ import {
18
18
  resolveModelOverrideWithAuthFallback,
19
19
  } from "../config/model-resolver";
20
20
  import type { PromptTemplate } from "../config/prompt-templates";
21
+ import { resolveSubagentServiceTier } from "../config/service-tier";
21
22
  import { Settings } from "../config/settings";
22
23
  import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
23
24
  import type { ToolPathWithSource } from "../extensibility/custom-tools";
@@ -50,12 +51,12 @@ import {
50
51
  type OutputValidator,
51
52
  summarizeValidationFailure,
52
53
  } from "../tools/output-schema-validator";
53
-
54
54
  import { type ReportFindingDetails, toReviewFinding } from "../tools/review";
55
55
  import { ToolAbortError } from "../tools/tool-errors";
56
56
  import type { EventBus } from "../utils/event-bus";
57
57
  import { buildNamedToolChoice } from "../utils/tool-choice";
58
58
  import type { WorkspaceTree } from "../workspace-tree";
59
+ import { Semaphore } from "./parallel";
59
60
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
60
61
  import {
61
62
  type AgentDefinition,
@@ -194,6 +195,51 @@ function installSubagentRetryFallbackChain(args: {
194
195
  return role;
195
196
  }
196
197
 
198
+ const PROVIDER_MAX_CONCURRENCY_SETTINGS: Record<string, SettingPath> = {
199
+ "ollama-cloud": "providers.ollama-cloud.maxConcurrency",
200
+ };
201
+
202
+ interface ProviderSemaphoreEntry {
203
+ limit: number;
204
+ semaphore: Semaphore;
205
+ }
206
+
207
+ const providerSemaphores = new Map<string, ProviderSemaphoreEntry>();
208
+
209
+ /**
210
+ * Resolve the configured concurrency ceiling for a provider, or `undefined`
211
+ * when the provider has no cap concept at all. A configured value `<= 0` means
212
+ * "unlimited" and maps to `Infinity` — still a tracked ceiling, so every run
213
+ * holds a slot and a later finite resize counts work started while unlimited.
214
+ */
215
+ function getProviderConcurrencyLimit(settings: Settings, provider: string): number | undefined {
216
+ const settingPath = PROVIDER_MAX_CONCURRENCY_SETTINGS[provider];
217
+ if (!settingPath) return undefined;
218
+ const raw = settings.get(settingPath);
219
+ const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
220
+ return limit > 0 ? limit : Number.POSITIVE_INFINITY;
221
+ }
222
+
223
+ function getProviderSemaphore(settings: Settings, provider: string): Semaphore | undefined {
224
+ const limit = getProviderConcurrencyLimit(settings, provider);
225
+ if (limit === undefined) return undefined;
226
+ // Always hand out (and acquire on) the single shared limiter, even when
227
+ // unlimited (Infinity). Resizing it in place — rather than replacing it —
228
+ // keeps every in-flight slot counted, so a runtime or mixed limit change can
229
+ // never push concurrency past the cap (issue #3464 review feedback).
230
+ const existing = providerSemaphores.get(provider);
231
+ if (existing) {
232
+ if (existing.limit !== limit) {
233
+ existing.limit = limit;
234
+ existing.semaphore.resize(limit);
235
+ }
236
+ return existing.semaphore;
237
+ }
238
+ const semaphore = new Semaphore(limit);
239
+ providerSemaphores.set(provider, { limit, semaphore });
240
+ return semaphore;
241
+ }
242
+
197
243
  function renderIrcPeerRoster(selfId: string): string {
198
244
  const peers = AgentRegistry.global()
199
245
  .list()
@@ -339,6 +385,13 @@ export interface ExecutorOptions {
339
385
  authStorage?: AuthStorage;
340
386
  modelRegistry?: ModelRegistry;
341
387
  settings?: Settings;
388
+ /**
389
+ * Parent session's live effective service tier, the source of truth for a
390
+ * subagent whose `serviceTierSubagent` is `"inherit"`. `null` = the parent
391
+ * explicitly has no tier (e.g. `/fast off`); omitted = no live session, so
392
+ * inherit falls back to the configured `serviceTier` setting.
393
+ */
394
+ parentServiceTier?: ServiceTier | null;
342
395
  /** Override local:// protocol options so subagent shares parent's local:// root */
343
396
  localProtocolOptions?: LocalProtocolOptions;
344
397
  /**
@@ -732,11 +785,21 @@ export function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
732
785
  export function createSubagentSettings(
733
786
  baseSettings: Settings,
734
787
  overrides?: Partial<Record<SettingPath, unknown>>,
788
+ inheritedServiceTier?: ServiceTier | null,
735
789
  ): Settings {
736
790
  const snapshot: Partial<Record<SettingPath, unknown>> = {};
737
791
  for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
738
792
  snapshot[key] = baseSettings.get(key);
739
793
  }
794
+ // Resolve the subagent's service tier from `serviceTierSubagent` ("inherit" =
795
+ // match the parent's live tier when a live session supplied one, else the
796
+ // configured `serviceTier`). The result is stamped back onto the snapshot so
797
+ // createAgentSession's `settings.get("serviceTier")` read picks it up.
798
+ snapshot.serviceTier = resolveSubagentServiceTier(
799
+ baseSettings.get("serviceTierSubagent"),
800
+ baseSettings.get("serviceTier"),
801
+ inheritedServiceTier,
802
+ );
740
803
  return Settings.isolated({
741
804
  ...snapshot,
742
805
  "async.enabled": false,
@@ -1747,6 +1810,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1747
1810
  const subagentSettings = createSubagentSettings(
1748
1811
  settings,
1749
1812
  agent.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
1813
+ options.parentServiceTier,
1750
1814
  );
1751
1815
  const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
1752
1816
  // Tailored specialist identity for this spawn. `subagentRole` is the full
@@ -1889,6 +1953,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1889
1953
  let sessionOpenedAt: number | undefined;
1890
1954
  let sessionCreatedAt: number | undefined;
1891
1955
  let readyAt: number | undefined;
1956
+ let providerSemaphore: Semaphore | undefined;
1957
+ let providerSemaphoreAcquired = false;
1892
1958
 
1893
1959
  try {
1894
1960
  checkAbort();
@@ -1957,6 +2023,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1957
2023
  ? resolvedThinkingLevel
1958
2024
  : (thinkingLevel ?? resolvedThinkingLevel);
1959
2025
  resolvedAt = performance.now();
2026
+ if (model) {
2027
+ providerSemaphore = getProviderSemaphore(settings, model.provider);
2028
+ if (providerSemaphore) {
2029
+ await providerSemaphore.acquire(abortSignal);
2030
+ providerSemaphoreAcquired = true;
2031
+ }
2032
+ }
1960
2033
 
1961
2034
  const effectiveCwd = worktree ?? cwd;
1962
2035
  const sessionManager = sessionFile
@@ -2247,6 +2320,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2247
2320
  }
2248
2321
  if (exitCode === 0) exitCode = 1;
2249
2322
  }
2323
+ if (providerSemaphoreAcquired) {
2324
+ providerSemaphore?.release();
2325
+ providerSemaphoreAcquired = false;
2326
+ }
2250
2327
  sessionAbortController.abort();
2251
2328
  if (unsubscribe) {
2252
2329
  try {
package/src/task/index.ts CHANGED
@@ -798,13 +798,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
798
798
  onSettled?.(true);
799
799
  throw new Error("Aborted before execution");
800
800
  }
801
- markRunning();
802
- progress.status = "running";
803
- await reportProgress(
804
- `Running background task ${agentId}...`,
805
- buildDetails("running", ownJobId) as unknown as Record<string, unknown>,
806
- );
807
801
  try {
802
+ markRunning();
803
+ progress.status = "running";
804
+ await reportProgress(
805
+ `Running background task ${agentId}...`,
806
+ buildDetails("running", ownJobId) as unknown as Record<string, unknown>,
807
+ );
808
808
  const result = await this.#executeSync(
809
809
  toolCallId,
810
810
  spawnParams,
@@ -1296,6 +1296,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1296
1296
  parentTelemetry: this.session.getTelemetry?.(),
1297
1297
  parentEvalSessionId,
1298
1298
  parentAgentId: this.session.getAgentId?.() ?? MAIN_AGENT_ID,
1299
+ // Live source of truth for `serviceTierSubagent: inherit`. When the
1300
+ // session exposes a tier accessor, pass tier-or-null (null = explicit
1301
+ // none, e.g. /fast off); otherwise leave undefined so inherit falls
1302
+ // back to the configured serviceTier setting.
1303
+ parentServiceTier: this.session.getServiceTier ? (this.session.getServiceTier() ?? null) : undefined,
1299
1304
  };
1300
1305
 
1301
1306
  const runTask = async (): Promise<SingleResult> => {
@@ -100,22 +100,74 @@ export class Semaphore {
100
100
  this.#max = normalizedMax > 0 ? normalizedMax : Number.POSITIVE_INFINITY;
101
101
  }
102
102
 
103
- async acquire(): Promise<void> {
103
+ /**
104
+ * Resolves when a slot is available. Pass an `AbortSignal` so callers that
105
+ * stop waiting (parent task cancelled, wall-clock budget elapsed) also stop
106
+ * occupying a queue slot — otherwise a later `release()` would resolve the
107
+ * abandoned waiter, permanently shrinking effective concurrency for the
108
+ * remaining lifetime of the process (issue #3464 review feedback).
109
+ */
110
+ async acquire(signal?: AbortSignal): Promise<void> {
111
+ if (signal?.aborted) {
112
+ throw semaphoreAbortReason(signal);
113
+ }
104
114
  if (this.#current < this.#max) {
105
115
  this.#current++;
106
116
  return;
107
117
  }
108
- const { promise, resolve } = Promise.withResolvers<void>();
109
- this.#queue.push(resolve);
118
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
119
+ const queue = this.#queue;
120
+ let waiter: () => void = resolve;
121
+ if (signal) {
122
+ const onAbort = () => {
123
+ const index = queue.indexOf(waiter);
124
+ if (index >= 0) queue.splice(index, 1);
125
+ reject(semaphoreAbortReason(signal));
126
+ };
127
+ waiter = () => {
128
+ signal.removeEventListener("abort", onAbort);
129
+ resolve();
130
+ };
131
+ signal.addEventListener("abort", onAbort, { once: true });
132
+ }
133
+ queue.push(waiter);
110
134
  return promise;
111
135
  }
112
136
 
113
137
  release(): void {
114
- const next = this.#queue.shift();
115
- if (next) {
138
+ if (this.#current > 0) this.#current--;
139
+ // Admit the next waiter only if we are under the (possibly just-lowered) ceiling.
140
+ if (this.#current < this.#max) {
141
+ const next = this.#queue.shift();
142
+ if (next) {
143
+ this.#current++;
144
+ next();
145
+ }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Adjust the maximum concurrency in place. Raising the ceiling immediately
151
+ * admits queued waiters that now fit; lowering it lets in-flight holders
152
+ * drain naturally (new acquires keep blocking until `#current` falls below
153
+ * the new max). Resizing the single shared instance — instead of replacing
154
+ * it — keeps in-flight slots counted, so a runtime or mixed limit change can
155
+ * never push concurrency past the cap (issue #3464 review feedback).
156
+ */
157
+ resize(max: number): void {
158
+ const normalizedMax = Number.isFinite(max) ? Math.trunc(max) : 0;
159
+ this.#max = normalizedMax > 0 ? normalizedMax : Number.POSITIVE_INFINITY;
160
+ while (this.#current < this.#max) {
161
+ const next = this.#queue.shift();
162
+ if (!next) break;
163
+ this.#current++;
116
164
  next();
117
- } else {
118
- this.#current--;
119
165
  }
120
166
  }
121
167
  }
168
+
169
+ function semaphoreAbortReason(signal: AbortSignal): unknown {
170
+ const reason = signal.reason;
171
+ if (reason !== undefined) return reason;
172
+ return new Error("Semaphore acquire aborted");
173
+ }
@@ -1,6 +1,6 @@
1
1
  import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
- import type { FetchImpl, ImageContent, Model, ToolChoice } from "@oh-my-pi/pi-ai";
3
+ import type { FetchImpl, ImageContent, Model, ServiceTier, ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import { logger } from "@oh-my-pi/pi-utils";
5
5
  import type { AsyncJobManager } from "../async/job-manager";
6
6
  import type { Rule } from "../capability/rule";
@@ -240,6 +240,8 @@ export interface ToolSession {
240
240
  getActiveModelString?: () => string | undefined;
241
241
  /** Get the current session model object (provider/api capabilities), regardless of how it was chosen. */
242
242
  getActiveModel?: () => Model | undefined;
243
+ /** Get the session's live effective service tier (undefined = none). Source of truth for subagent `serviceTierSubagent: inherit`. */
244
+ getServiceTier?: () => ServiceTier | undefined;
243
245
  /** Auth storage for passing to subagents (avoids re-discovery) */
244
246
  authStorage?: import("../session/auth-storage").AuthStorage;
245
247
  /** Model registry for passing to subagents (avoids re-discovery) */
package/src/tools/irc.ts CHANGED
@@ -514,6 +514,18 @@ function callMeta(args: IrcRenderArgs | undefined): string[] {
514
514
  return meta;
515
515
  }
516
516
 
517
+ function renderErrorResult(
518
+ result: { content: Array<{ type: string; text?: string }> },
519
+ args: IrcRenderArgs | undefined,
520
+ theme: Theme,
521
+ ): string[] {
522
+ const text = textContent(result) || "IRC call failed.";
523
+ return [
524
+ renderStatusLine({ icon: "error", title: callTitle(args, theme), meta: callMeta(args) }, theme),
525
+ formatErrorDetail(text, theme),
526
+ ];
527
+ }
528
+
517
529
  /**
518
530
  * Display-only transcript card for live IRC traffic: `irc:incoming` DMs
519
531
  * delivered to this session, `irc:autoreply` side-channel replies sent on
@@ -743,9 +755,11 @@ function buildResultLines(
743
755
  case "wait":
744
756
  return renderWaitResult(result, details, args, expanded, theme);
745
757
  case "inbox":
746
- return renderInboxResult(details, args, expanded, theme);
758
+ return result.isError
759
+ ? renderErrorResult(result, args, theme)
760
+ : renderInboxResult(details, args, expanded, theme);
747
761
  case "list":
748
- return renderListResult(details, expanded, theme);
762
+ return result.isError ? renderErrorResult(result, args, theme) : renderListResult(details, expanded, theme);
749
763
  default: {
750
764
  const text = textContent(result) || (result.isError ? "IRC call failed." : "Done.");
751
765
  return [
@@ -0,0 +1,60 @@
1
+ # Helpers inlined into `generateSnapshotScript` (shell-snapshot.ts).
2
+ #
3
+ # Activation idioms like `mise activate` install a shell function whose body
4
+ # expands a sidecar env var (e.g. `mise() { command "$__MISE_EXE" "$@"; }`).
5
+ # The function survives `declare -f`/`typeset -f` capture, but the helper var
6
+ # is set on the rc-sourced shell and lost when only PATH gets re-exported.
7
+ # The replay shell then calls `command "" …` and dies with
8
+ # `command: command not found:` (issue #3470).
9
+ #
10
+ # `__omp_emit_referenced_exports` reads captured function bodies from stdin,
11
+ # extracts every `$VAR` / `${VAR…}` reference, and emits a single
12
+ # `export VAR='value'` line to stdout for each referenced var that
13
+ # (a) is currently set in this shell and (b) is not a shell-internal name.
14
+ # The script bodies are scanned, not interpreted — over-inclusion (refs inside
15
+ # comments / heredocs) is harmless because we only emit names that are set.
16
+ #
17
+ # Pure POSIX so the same helper works under bash and zsh.
18
+
19
+ # Wrap $1 in single quotes, escaping every embedded `'` as `'\''`.
20
+ __omp_sq_quote() {
21
+ __omp_qbuf=$1
22
+ __omp_qout=
23
+ __omp_sq=\'
24
+ while case "$__omp_qbuf" in *$__omp_sq*) true ;; *) false ;; esac; do
25
+ __omp_qout=$__omp_qout${__omp_qbuf%%$__omp_sq*}"'\\''"
26
+ __omp_qbuf=${__omp_qbuf#*$__omp_sq}
27
+ done
28
+ __omp_qout=$__omp_qout$__omp_qbuf
29
+ printf "'%s'" "$__omp_qout"
30
+ }
31
+
32
+ # Emit `export NAME='value'` for $1 unless the name is a shell-internal we
33
+ # must never overwrite, a likely secret (token / key / password / credential
34
+ # patterns — kept conservative, since `__MISE_EXE` and `FOO_DIR` style helper
35
+ # vars never carry secrets), or the var is unset. POSIX `case` patterns are
36
+ # byte-exact so we list common uppercase variants; lowercase secret vars are
37
+ # rare and out of scope.
38
+ __omp_emit_export_for() {
39
+ case "$1" in
40
+ _|PATH|HOME|USER|LOGNAME|PWD|OLDPWD|SHELL|SHLVL|TERM|TERMINFO|TERMCAP|IFS|TMPDIR|TMOUT|LANG|RANDOM|LINENO|SECONDS|FUNCNAME|HISTFILE|HISTSIZE|HISTFILESIZE|HISTCMD|PS1|PS2|PS3|PS4|UID|EUID|GROUPS|HOSTNAME|HOSTTYPE|OSTYPE|MACHTYPE|PIPESTATUS|BASH|ZSH|argv|PROMPT|RPROMPT|RPS1|RPS2|status|pipestatus|COLUMNS|LINES|COLORTERM|FUNCNEST) return ;;
41
+ LC_*|BASH_*|ZSH_*) return ;;
42
+ # Common secret-name patterns — never materialise these into the
43
+ # snapshot file even though it's now created 0600 (defence in depth
44
+ # against the file ending up in a backup, tarball, or NFS share).
45
+ *TOKEN*|*SECRET*|*PASSWORD*|*PASSWD*|*API_KEY*|*PRIVATE_KEY*|*ACCESS_KEY*|*CREDENTIAL*|*SESSION_KEY*) return ;;
46
+ esac
47
+ eval "[ \"\${$1+x}\" = x ]" 2>/dev/null || return
48
+ eval "__omp_xv=\"\${$1}\"" 2>/dev/null || return
49
+ printf 'export %s=%s\n' "$1" "$(__omp_sq_quote "$__omp_xv")"
50
+ }
51
+
52
+ # Read function bodies on stdin, emit dedup'd export lines on stdout.
53
+ __omp_emit_referenced_exports() {
54
+ grep -oE '\$\{?[A-Za-z_][A-Za-z0-9_]*' \
55
+ | sed -E 's/^\$\{?//' \
56
+ | sort -u \
57
+ | while IFS= read -r __omp_name; do
58
+ __omp_emit_export_for "$__omp_name"
59
+ done
60
+ }
@@ -9,6 +9,7 @@ import * as fs from "node:fs";
9
9
  import * as os from "node:os";
10
10
  import * as path from "node:path";
11
11
  import { logger, postmortem } from "@oh-my-pi/pi-utils";
12
+ import fnEnvHelper from "./shell-snapshot-fn-env.sh" with { type: "text" };
12
13
 
13
14
  const cachedSnapshotPaths = new Map<string, string>();
14
15
  const SNAPSHOT_TIMEOUT_MS = 2_000;
@@ -83,9 +84,13 @@ function sanitizeSnapshotEnv(env: Record<string, string | undefined>): Record<st
83
84
 
84
85
  /**
85
86
  * Get the user's shell config file path.
87
+ *
88
+ * Honours `env.HOME` when present so a caller can target a sandboxed/test
89
+ * home, falling back to `os.homedir()` (which Bun resolves once at startup
90
+ * and caches — `process.env.HOME` overrides don't affect it).
86
91
  */
87
- function getShellConfigFile(shell: string): string {
88
- const home = os.homedir();
92
+ function getShellConfigFile(shell: string, env: Record<string, string | undefined>): string {
93
+ const home = env.HOME || os.homedir();
89
94
  if (shell.includes("zsh")) return path.join(home, ".zshrc");
90
95
  if (shell.includes("bash")) return path.join(home, ".bashrc");
91
96
  return path.join(home, ".profile");
@@ -105,26 +110,22 @@ function generateSnapshotScript(shell: string, snapshotPath: string, rcFile: str
105
110
  // Escape the snapshot path for shell
106
111
  const escapedPath = snapshotPath.replace(/'/g, "'\\''");
107
112
 
108
- // Function extraction differs between bash and zsh
109
- const functionScript = isZsh
110
- ? `
111
- echo "# Functions" >> "$SNAPSHOT_FILE"
112
- # Force autoload all functions first
113
+ // Function extraction differs between bash and zsh. Each form prints function
114
+ // bodies on stdout so we can both persist them AND scan their bodies for
115
+ // referenced env vars (issue #3470).
116
+ const functionExtractor = isZsh
117
+ ? `# Force autoload all functions first
113
118
  typeset -f > /dev/null 2>&1
114
119
  # Get user function names - filter system/private ones
115
120
  typeset +f 2>/dev/null | grep -vE '^(_|__)' | grep -vE '${commonToolsRegex}' | while read func; do
116
- typeset -f "$func" >> "$SNAPSHOT_FILE" 2>/dev/null
117
- done
118
- `
119
- : `
120
- echo "# Functions" >> "$SNAPSHOT_FILE"
121
- # Force autoload all functions first
121
+ typeset -f "$func" 2>/dev/null
122
+ done`
123
+ : `# Force autoload all functions first
122
124
  declare -f > /dev/null 2>&1
123
125
  # Get user function names - filter system/private ones
124
126
  declare -F 2>/dev/null | cut -d' ' -f3 | grep -vE '^(_|__)' | grep -vE '${commonToolsRegex}' | while read func; do
125
- declare -f "$func" >> "$SNAPSHOT_FILE" 2>/dev/null
126
- done
127
- `;
127
+ declare -f "$func" 2>/dev/null
128
+ done`;
128
129
 
129
130
  // Shell options extraction
130
131
  const optionsScript = isZsh
@@ -142,16 +143,45 @@ echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"
142
143
  return `
143
144
  SNAPSHOT_FILE='${escapedPath}'
144
145
 
146
+ # Snapshot may inline env-var values referenced by captured functions (#3470).
147
+ # Defence in depth: (a) JS caller pre-creates the file at 0600 so the shell's
148
+ # \`>|\`/\`>>\` redirections truncate/append without changing the inode mode,
149
+ # (b) we \`umask 077\` before AND after sourcing the rc so any other file the
150
+ # script creates is private even when the rc resets umask to 022, (c) JS
151
+ # caller chmods file + dir after the script exits.
152
+ umask 077
153
+
145
154
  # Source user's rc file if it exists
146
155
  ${hasRcFile ? `source "${rcFile}" < /dev/null 2>/dev/null` : "# No user config file to source"}
147
156
 
157
+ # Re-tighten umask in case the rc reset it (common \`.bashrc\`/\`.zshrc\` set
158
+ # \`umask 022\` for the interactive shell).
159
+ umask 077
160
+
148
161
  # Create/clear the snapshot file
149
162
  echo "# Shell snapshot - generated by omp agent" >| "$SNAPSHOT_FILE"
150
163
 
151
164
  # Unalias everything first to avoid conflicts when sourced
152
165
  echo "unalias -a 2>/dev/null || true" >> "$SNAPSHOT_FILE"
153
166
 
154
- ${functionScript}
167
+ # Capture function definitions into a variable so we can both persist them
168
+ # and scan their bodies for env-var references (issue #3470).
169
+ __omp_funcs=$(
170
+ ${functionExtractor}
171
+ )
172
+ echo "# Functions" >> "$SNAPSHOT_FILE"
173
+ printf '%s\\n' "$__omp_funcs" >> "$SNAPSHOT_FILE"
174
+
175
+ # Re-export uppercase identifiers referenced by snapshotted functions whose
176
+ # value is set in the rc-sourced shell. Without this, activation idioms like
177
+ # mise's \`mise()\` -> \`command "$__MISE_EXE" "$@"\` blow up because the helper
178
+ # var is lost (issue #3470). Helper definitions are POSIX-shell so the same
179
+ # block works for both bash and zsh.
180
+ echo "# Captured function environment" >> "$SNAPSHOT_FILE"
181
+ ${fnEnvHelper}
182
+ printf '%s\\n' "$__omp_funcs" | __omp_emit_referenced_exports >> "$SNAPSHOT_FILE"
183
+ unset -f __omp_sq_quote __omp_emit_export_for __omp_emit_referenced_exports 2>/dev/null
184
+ unset __omp_funcs __omp_qbuf __omp_qout __omp_sq __omp_xv __omp_name 2>/dev/null
155
185
 
156
186
  ${optionsScript}
157
187
 
@@ -198,16 +228,35 @@ export async function getOrCreateSnapshot(
198
228
  return null;
199
229
  }
200
230
 
201
- const rcFile = getShellConfigFile(shell);
231
+ const rcFile = getShellConfigFile(shell, env);
202
232
 
203
- // Create snapshot directory
233
+ // Create snapshot directory with owner-only perms — the script may inline
234
+ // env vars referenced by captured functions (#3470) and `os.tmpdir()` is
235
+ // shared on Linux. `mode: 0o700` applies to a fresh mkdir; an existing dir
236
+ // keeps its mode, so chmod it defensively. Ignore EPERM (dir owned by
237
+ // another user on a shared box).
204
238
  const snapshotDir = path.join(os.tmpdir(), "omp-shell-snapshots");
205
- fs.mkdirSync(snapshotDir, { recursive: true });
239
+ fs.mkdirSync(snapshotDir, { recursive: true, mode: 0o700 });
240
+ try {
241
+ fs.chmodSync(snapshotDir, 0o700);
242
+ } catch {
243
+ // best-effort
244
+ }
206
245
 
207
246
  // Generate unique snapshot path
208
247
  const shellName = shell.includes("zsh") ? "zsh" : shell.includes("bash") ? "bash" : "sh";
209
248
  const snapshotPath = path.join(snapshotDir, `snapshot-${shellName}-${crypto.randomUUID()}.sh`);
210
249
 
250
+ // Pre-create the snapshot file at 0600 so the shell's `>|` (truncate) and
251
+ // `>>` (append) redirections inside `generateSnapshotScript` operate on an
252
+ // existing inode and preserve the private mode, regardless of the umask
253
+ // state inside the spawned shell. Without this, a `.bashrc` that sets
254
+ // `umask 022` (the typical interactive default) before the script's first
255
+ // redirection would create the file world-readable; the JS-side post-spawn
256
+ // chmod would tighten it, but only after the shell finished writing every
257
+ // captured env value to disk.
258
+ fs.writeFileSync(snapshotPath, "", { mode: 0o600 });
259
+
211
260
  // Generate and execute snapshot script
212
261
  const script = generateSnapshotScript(shell, snapshotPath, rcFile);
213
262
 
@@ -230,6 +279,14 @@ export async function getOrCreateSnapshot(
230
279
 
231
280
  await child.exited;
232
281
  if (child.exitCode === 0 && fs.existsSync(snapshotPath)) {
282
+ // Defence-in-depth: the script's `umask 077` already locks the file at
283
+ // first write, but chmod again in case the umask didn't take (exotic
284
+ // shells) or a postmortem-restored file ended up looser.
285
+ try {
286
+ fs.chmodSync(snapshotPath, 0o600);
287
+ } catch {
288
+ // best-effort
289
+ }
233
290
  scrubSnapshotInPlace(snapshotPath);
234
291
  cachedSnapshotPaths.set(cacheKey, snapshotPath);
235
292
  return snapshotPath;