@oh-my-pi/pi-coding-agent 9.4.0 → 9.6.0

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 (70) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/package.json +9 -8
  3. package/src/capability/index.ts +7 -9
  4. package/src/cli/config-cli.ts +86 -73
  5. package/src/cli/update-cli.ts +45 -3
  6. package/src/commit/agentic/agent.ts +4 -4
  7. package/src/commit/agentic/index.ts +6 -5
  8. package/src/commit/agentic/tools/analyze-file.ts +5 -7
  9. package/src/commit/agentic/tools/index.ts +3 -3
  10. package/src/commit/model-selection.ts +13 -17
  11. package/src/commit/pipeline.ts +5 -5
  12. package/src/config/model-registry.ts +7 -0
  13. package/src/config/settings-schema.ts +836 -0
  14. package/src/config/settings.ts +702 -0
  15. package/src/discovery/helpers.ts +55 -11
  16. package/src/exa/index.ts +1 -1
  17. package/src/exec/bash-executor.ts +13 -13
  18. package/src/exec/shell-session.ts +15 -3
  19. package/src/export/ttsr.ts +1 -1
  20. package/src/extensibility/skills.ts +40 -9
  21. package/src/index.ts +2 -10
  22. package/src/ipy/gateway-coordinator.ts +5 -143
  23. package/src/ipy/kernel.ts +6 -171
  24. package/src/ipy/runtime.ts +198 -0
  25. package/src/lsp/client.ts +14 -1
  26. package/src/lsp/defaults.json +0 -6
  27. package/src/lsp/index.ts +1 -1
  28. package/src/lsp/types.ts +2 -0
  29. package/src/main.ts +26 -48
  30. package/src/modes/components/extensions/extension-dashboard.ts +22 -11
  31. package/src/modes/components/index.ts +1 -1
  32. package/src/modes/components/model-selector.ts +7 -7
  33. package/src/modes/components/settings-defs.ts +210 -915
  34. package/src/modes/components/settings-selector.ts +80 -106
  35. package/src/modes/components/status-line/types.ts +2 -8
  36. package/src/modes/components/status-line-segment-editor.ts +1 -1
  37. package/src/modes/components/status-line.ts +26 -3
  38. package/src/modes/controllers/event-controller.ts +9 -8
  39. package/src/modes/controllers/input-controller.ts +19 -15
  40. package/src/modes/controllers/selector-controller.ts +30 -14
  41. package/src/modes/interactive-mode.ts +10 -10
  42. package/src/modes/rpc/rpc-mode.ts +10 -0
  43. package/src/modes/rpc/rpc-types.ts +3 -0
  44. package/src/modes/types.ts +2 -2
  45. package/src/modes/utils/ui-helpers.ts +4 -3
  46. package/src/patch/index.ts +7 -7
  47. package/src/prompts/system/system-prompt.md +0 -1
  48. package/src/prompts/tools/bash.md +12 -2
  49. package/src/prompts/tools/task.md +180 -73
  50. package/src/sdk.ts +38 -61
  51. package/src/session/agent-session.ts +66 -55
  52. package/src/session/agent-storage.ts +1 -1
  53. package/src/session/session-manager.ts +10 -10
  54. package/src/system-prompt.ts +2 -2
  55. package/src/task/executor.ts +9 -9
  56. package/src/task/index.ts +2 -2
  57. package/src/tools/ask.ts +5 -6
  58. package/src/tools/bash-interceptor.ts +39 -1
  59. package/src/tools/bash-normalize.ts +126 -0
  60. package/src/tools/bash.ts +31 -5
  61. package/src/tools/find.ts +51 -33
  62. package/src/tools/index.ts +5 -23
  63. package/src/tools/plan-mode-guard.ts +1 -6
  64. package/src/tools/python.ts +2 -2
  65. package/src/tools/read.ts +2 -2
  66. package/src/tools/write.ts +2 -2
  67. package/src/utils/ignore-files.ts +119 -0
  68. package/src/web/search/providers/perplexity.ts +1 -1
  69. package/examples/sdk/10-settings.ts +0 -37
  70. package/src/config/settings-manager.ts +0 -2015
@@ -8,6 +8,7 @@ import { readDirEntries, readFile } from "../capability/fs";
8
8
  import type { Skill, SkillFrontmatter } from "../capability/skill";
9
9
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
10
10
  import { parseFrontmatter } from "../utils/frontmatter";
11
+ import { addIgnoreRules, createIgnoreMatcher, type IgnoreMatcher, shouldIgnore } from "../utils/ignore-files";
11
12
 
12
13
  const VALID_THINKING_LEVELS: readonly string[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
13
14
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
@@ -236,6 +237,10 @@ export async function loadSkillsFromDir(
236
237
  const warnings: string[] = [];
237
238
  const { dir, level, providerId, requireDescription = false } = options;
238
239
 
240
+ // Initialize ignore matcher and read ignore rules from root
241
+ const ig = createIgnoreMatcher();
242
+ await addIgnoreRules(ig, dir, dir, readFile);
243
+
239
244
  const entries = await readDirEntries(dir);
240
245
  const skillDirs = entries.filter(
241
246
  entry => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules",
@@ -243,7 +248,14 @@ export async function loadSkillsFromDir(
243
248
 
244
249
  const results = await Promise.all(
245
250
  skillDirs.map(async entry => {
246
- const skillFile = path.join(dir, entry.name, "SKILL.md");
251
+ const entryPath = path.join(dir, entry.name);
252
+
253
+ // Check if this directory should be ignored
254
+ if (shouldIgnore(ig, dir, entryPath, true)) {
255
+ return { item: null as Skill | null, warning: null as string | null };
256
+ }
257
+
258
+ const skillFile = path.join(entryPath, "SKILL.md");
247
259
  const content = await readFile(skillFile);
248
260
  if (!content) {
249
261
  return { item: null as Skill | null, warning: null as string | null };
@@ -311,6 +323,7 @@ export function expandEnvVarsDeep<T>(obj: T, extraEnv?: Record<string, string>):
311
323
 
312
324
  /**
313
325
  * Load files from a directory matching a pattern.
326
+ * Respects .gitignore, .ignore, and .fdignore files.
314
327
  */
315
328
  export async function loadFilesFromDir<T>(
316
329
  _ctx: LoadContext,
@@ -324,24 +337,47 @@ export async function loadFilesFromDir<T>(
324
337
  transform: (name: string, content: string, path: string, source: SourceMeta) => T | null;
325
338
  /** Whether to recurse into subdirectories */
326
339
  recursive?: boolean;
340
+ /** Root directory for ignore file handling (defaults to dir) */
341
+ rootDir?: string;
342
+ /** Ignore matcher (used internally for recursion) */
343
+ ignoreMatcher?: IgnoreMatcher;
327
344
  },
328
345
  ): Promise<LoadResult<T>> {
346
+ const rootDir = options.rootDir ?? dir;
347
+ const ig = options.ignoreMatcher ?? createIgnoreMatcher();
348
+
349
+ // Read ignore rules from this directory
350
+ await addIgnoreRules(ig, dir, rootDir, readFile);
351
+
329
352
  const entries = await readDirEntries(dir);
330
353
 
331
354
  const visibleEntries = entries.filter(entry => !entry.name.startsWith("."));
332
355
 
333
- const directories = options.recursive ? visibleEntries.filter(entry => entry.isDirectory()) : [];
356
+ const directories = options.recursive
357
+ ? visibleEntries.filter(entry => {
358
+ if (!entry.isDirectory()) return false;
359
+ const entryPath = path.join(dir, entry.name);
360
+ return !shouldIgnore(ig, rootDir, entryPath, true);
361
+ })
362
+ : [];
334
363
 
335
- const files = visibleEntries
336
- .filter(entry => entry.isFile())
337
- .filter(entry => {
338
- if (!options.extensions) return true;
339
- return options.extensions.some(ext => entry.name.endsWith(`.${ext}`));
340
- });
364
+ const files = visibleEntries.filter(entry => {
365
+ if (!entry.isFile()) return false;
366
+ const entryPath = path.join(dir, entry.name);
367
+ if (shouldIgnore(ig, rootDir, entryPath, false)) return false;
368
+ if (!options.extensions) return true;
369
+ return options.extensions.some(ext => entry.name.endsWith(`.${ext}`));
370
+ });
341
371
 
342
372
  const [subResults, fileResults] = await Promise.all([
343
373
  Promise.all(
344
- directories.map(entry => loadFilesFromDir(_ctx, path.join(dir, entry.name), provider, level, options)),
374
+ directories.map(entry =>
375
+ loadFilesFromDir(_ctx, path.join(dir, entry.name), provider, level, {
376
+ ...options,
377
+ rootDir,
378
+ ignoreMatcher: ig,
379
+ }),
380
+ ),
345
381
  ),
346
382
  Promise.all(
347
383
  files.map(async entry => {
@@ -435,24 +471,32 @@ function isExtensionModuleFile(name: string): boolean {
435
471
  * 3. Subdirectory with package.json: `extensions/<ext>/package.json` with "omp"/"pi" field → load declared paths
436
472
  *
437
473
  * No recursion beyond one level. Complex packages must use package.json manifest.
474
+ * Respects .gitignore, .ignore, and .fdignore files.
438
475
  */
439
476
  export async function discoverExtensionModulePaths(ctx: LoadContext, dir: string): Promise<string[]> {
440
477
  const discovered: string[] = [];
441
478
  const entries = await readDirEntries(dir);
442
479
 
480
+ // Initialize ignore matcher and read ignore rules from root
481
+ const ig = createIgnoreMatcher();
482
+ await addIgnoreRules(ig, dir, dir, readFile);
483
+
443
484
  for (const entry of entries) {
444
485
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
445
486
 
446
487
  const entryPath = path.join(dir, entry.name);
447
488
 
448
489
  // 1. Direct files: *.ts or *.js
449
- if (entry.isFile() && isExtensionModuleFile(entry.name)) {
490
+ if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionModuleFile(entry.name)) {
491
+ if (shouldIgnore(ig, dir, entryPath, false)) continue;
450
492
  discovered.push(entryPath);
451
493
  continue;
452
494
  }
453
495
 
454
496
  // 2 & 3. Subdirectories
455
- if (entry.isDirectory()) {
497
+ if (entry.isDirectory() || entry.isSymbolicLink()) {
498
+ if (shouldIgnore(ig, dir, entryPath, true)) continue;
499
+
456
500
  const subEntries = await readDirEntries(entryPath);
457
501
  const subFileNames = new Set(subEntries.filter(e => e.isFile()).map(e => e.name));
458
502
 
package/src/exa/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * - 2 researcher tools (start, poll)
9
9
  * - 14 websets tools (CRUD, items, search, enrichment, monitor)
10
10
  */
11
- import type { ExaSettings } from "../config/settings-manager";
11
+ import type { ExaSettings } from "../config/settings";
12
12
  import type { CustomTool } from "../extensibility/custom-tools/types";
13
13
  import { companyTool } from "./company";
14
14
  import { linkedinTool } from "./linkedin";
@@ -4,7 +4,7 @@
4
4
  * Provides unified bash execution for AgentSession.executeBash() and direct calls.
5
5
  */
6
6
  import { Exception, ptree } from "@oh-my-pi/pi-utils";
7
- import { SettingsManager } from "../config/settings-manager";
7
+ import { Settings } from "../config/settings";
8
8
  import { OutputSink } from "../session/streaming-output";
9
9
  import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
10
10
  import { executeShellCommand } from "./shell-session";
@@ -34,10 +34,11 @@ export interface BashResult {
34
34
  }
35
35
 
36
36
  export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
37
- const { shell, args, env, prefix } = await SettingsManager.getGlobalShellConfig();
37
+ const settings = await Settings.init();
38
+ const { shell, args, env, prefix } = settings.getShellConfig();
38
39
  const snapshotPath = await getOrCreateSnapshot(shell, env);
39
40
 
40
- if (shouldUsePersistentShell(shell)) {
41
+ if (shouldUsePersistentShell(settings.get("bash.persistentShell"))) {
41
42
  return await executeShellCommand({ shell, env, prefix, snapshotPath }, command, {
42
43
  cwd: options?.cwd,
43
44
  timeout: options?.timeout,
@@ -52,19 +53,18 @@ export async function executeBash(command: string, options?: BashExecutorOptions
52
53
  return await executeBashOnce(command, options, { shell, args, env, prefix, snapshotPath });
53
54
  }
54
55
 
55
- function shouldUsePersistentShell(shell: string): boolean {
56
+ /**
57
+ * Determine whether to use persistent shell sessions.
58
+ * Priority: OMP_SHELL_PERSIST env var > settings > default (false)
59
+ */
60
+ function shouldUsePersistentShell(settingValue: boolean): boolean {
61
+ // Env var takes precedence (for debugging/override)
56
62
  const flag = parseEnvFlag(process.env.OMP_SHELL_PERSIST);
57
63
  if (flag !== undefined) return flag;
64
+ // Windows never uses persistent shell (too unreliable)
58
65
  if (process.platform === "win32") return false;
59
- const normalized = shell.toLowerCase();
60
- return (
61
- normalized.includes("bash") ||
62
- normalized.includes("zsh") ||
63
- normalized.includes("fish") ||
64
- normalized.endsWith("/sh") ||
65
- normalized.endsWith("\\\\sh") ||
66
- normalized.endsWith("sh")
67
- );
66
+ // Use setting value (defaults to false)
67
+ return settingValue;
68
68
  }
69
69
 
70
70
  function parseEnvFlag(value: string | undefined): boolean | undefined {
@@ -194,6 +194,7 @@ class ShellSession {
194
194
  #buffer = "";
195
195
  #queue: Promise<void> = Promise.resolve();
196
196
  #chunkQueue: Promise<void> = Promise.resolve();
197
+ #streamsDone: Promise<unknown> = Promise.resolve();
197
198
  #current: RunningCommand | null = null;
198
199
  #startPromise: Promise<void> | null = null;
199
200
  #closed = false;
@@ -313,8 +314,7 @@ class ShellSession {
313
314
  }
314
315
  };
315
316
 
316
- void readStream(child.stdout);
317
- void readStream(child.stderr);
317
+ this.#streamsDone = Promise.allSettled([readStream(child.stdout), readStream(child.stderr)]);
318
318
  }
319
319
 
320
320
  async #enqueueChunk(text: string): Promise<void> {
@@ -477,6 +477,11 @@ class ShellSession {
477
477
  if (completed) return;
478
478
 
479
479
  await this.#terminateSession();
480
+
481
+ // Drain streams and chunk queue - marker might have arrived but not yet processed
482
+ await this.#streamsDone;
483
+ await this.#chunkQueue;
484
+
480
485
  if (running.completed) return;
481
486
  running.completed = true;
482
487
  running.abortListener?.();
@@ -522,14 +527,21 @@ class ShellSession {
522
527
  this.#child = null;
523
528
  this.#stdinWriter = null;
524
529
  this.#startPromise = null;
525
- this.#buffer = "";
526
530
 
527
531
  if (!running || running.completed) return;
532
+
533
+ // Wait for any pending chunks to be processed - marker might be in the queue
534
+ await this.#streamsDone;
535
+ await this.#chunkQueue;
536
+
537
+ if (running.completed) return;
538
+
528
539
  running.cancelled = true;
529
540
  running.abortReason = "signal";
530
541
  running.completed = true;
531
542
  running.abortListener?.();
532
543
  this.#current = null;
544
+ this.#buffer = "";
533
545
  const summary = await running.sink.dump(running.abortNotice ?? "Shell session terminated");
534
546
  running.resolve({
535
547
  exitCode: undefined,
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { logger } from "@oh-my-pi/pi-utils";
9
9
  import type { Rule } from "../capability/rule";
10
- import type { TtsrSettings } from "../config/settings-manager";
10
+ import type { TtsrSettings } from "../config/settings";
11
11
 
12
12
  interface TtsrEntry {
13
13
  rule: Rule;
@@ -3,10 +3,11 @@ import * as path from "node:path";
3
3
  import { logger } from "@oh-my-pi/pi-utils";
4
4
  import { skillCapability } from "../capability/skill";
5
5
  import type { SourceMeta } from "../capability/types";
6
- import type { SkillsSettings } from "../config/settings-manager";
6
+ import type { SkillsSettings } from "../config/settings";
7
7
  import type { Skill as CapabilitySkill, SkillFrontmatter as ImportedSkillFrontmatter } from "../discovery";
8
8
  import { loadCapability } from "../discovery";
9
9
  import { parseFrontmatter } from "../utils/frontmatter";
10
+ import { addIgnoreRules, createIgnoreMatcher, type IgnoreMatcher, shouldIgnore } from "../utils/ignore-files";
10
11
 
11
12
  // Re-export SkillFrontmatter for backward compatibility
12
13
  export type { ImportedSkillFrontmatter as SkillFrontmatter };
@@ -38,14 +39,24 @@ export interface LoadSkillsFromDirOptions {
38
39
  source: string;
39
40
  }
40
41
 
42
+ async function readFileContent(filePath: string): Promise<string | null> {
43
+ try {
44
+ return await fs.readFile(filePath, "utf-8");
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
41
50
  /**
42
51
  * Load skills from a directory recursively.
43
52
  * Skills are directories containing a SKILL.md file with frontmatter including a description.
53
+ * Respects .gitignore, .ignore, and .fdignore files.
44
54
  */
45
55
  export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Promise<LoadSkillsResult> {
46
56
  const skills: Skill[] = [];
47
57
  const warnings: SkillWarning[] = [];
48
58
  const seenPaths = new Set<string>();
59
+ const rootDir = options.dir;
49
60
 
50
61
  async function addSkill(skillFile: string, skillDir: string, dirName: string): Promise<void> {
51
62
  if (seenPaths.has(skillFile)) return;
@@ -70,8 +81,11 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
70
81
  }
71
82
  }
72
83
 
73
- async function scanDir(dir: string): Promise<void> {
84
+ async function scanDir(dir: string, ig: IgnoreMatcher): Promise<void> {
74
85
  try {
86
+ // Add ignore rules from this directory
87
+ await addIgnoreRules(ig, dir, rootDir, readFileContent);
88
+
75
89
  // First check if this directory itself is a skill
76
90
  const selfSkillFile = path.join(dir, "SKILL.md");
77
91
  try {
@@ -92,8 +106,13 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
92
106
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
93
107
 
94
108
  const fullPath = path.join(dir, entry.name);
95
- if (entry.isDirectory()) {
96
- await scanDir(fullPath);
109
+ const isDir = entry.isDirectory();
110
+
111
+ // Check if this entry should be ignored
112
+ if (shouldIgnore(ig, rootDir, fullPath, isDir)) continue;
113
+
114
+ if (isDir) {
115
+ await scanDir(fullPath, ig);
97
116
  }
98
117
  }
99
118
  } catch (err) {
@@ -101,7 +120,8 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
101
120
  }
102
121
  }
103
122
 
104
- await scanDir(options.dir);
123
+ const ig = createIgnoreMatcher();
124
+ await scanDir(options.dir, ig);
105
125
 
106
126
  return { skills, warnings };
107
127
  }
@@ -109,11 +129,13 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
109
129
  /**
110
130
  * Scan a directory for SKILL.md files recursively.
111
131
  * Used internally by loadSkills for custom directories.
132
+ * Respects .gitignore, .ignore, and .fdignore files.
112
133
  */
113
134
  async function scanDirectoryForSkills(dir: string): Promise<LoadSkillsResult> {
114
135
  const skills: Skill[] = [];
115
136
  const warnings: SkillWarning[] = [];
116
137
  const seenPaths = new Set<string>();
138
+ const rootDir = dir;
117
139
 
118
140
  async function addSkill(skillFile: string, skillDir: string, dirName: string): Promise<void> {
119
141
  if (seenPaths.has(skillFile)) return;
@@ -138,8 +160,11 @@ async function scanDirectoryForSkills(dir: string): Promise<LoadSkillsResult> {
138
160
  }
139
161
  }
140
162
 
141
- async function scanDir(currentDir: string): Promise<void> {
163
+ async function scanDir(currentDir: string, ig: IgnoreMatcher): Promise<void> {
142
164
  try {
165
+ // Add ignore rules from this directory
166
+ await addIgnoreRules(ig, currentDir, rootDir, readFileContent);
167
+
143
168
  // First check if this directory itself is a skill
144
169
  const selfSkillFile = path.join(currentDir, "SKILL.md");
145
170
  try {
@@ -160,8 +185,13 @@ async function scanDirectoryForSkills(dir: string): Promise<LoadSkillsResult> {
160
185
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
161
186
 
162
187
  const fullPath = path.join(currentDir, entry.name);
163
- if (entry.isDirectory()) {
164
- await scanDir(fullPath);
188
+ const isDir = entry.isDirectory();
189
+
190
+ // Check if this entry should be ignored
191
+ if (shouldIgnore(ig, rootDir, fullPath, isDir)) continue;
192
+
193
+ if (isDir) {
194
+ await scanDir(fullPath, ig);
165
195
  }
166
196
  }
167
197
  } catch (err) {
@@ -169,7 +199,8 @@ async function scanDirectoryForSkills(dir: string): Promise<LoadSkillsResult> {
169
199
  }
170
200
  }
171
201
 
172
- await scanDir(dir);
202
+ const ig = createIgnoreMatcher();
203
+ await scanDir(dir, ig);
173
204
 
174
205
  return { skills, warnings };
175
206
  }
package/src/index.ts CHANGED
@@ -12,15 +12,8 @@ export { formatKeyHint, formatKeyHints } from "./config/keybindings";
12
12
  export { ModelRegistry } from "./config/model-registry";
13
13
  // Prompt templates
14
14
  export type { PromptTemplate } from "./config/prompt-templates";
15
- export {
16
- type CompactionSettings,
17
- type ImageSettings,
18
- type LspSettings,
19
- type RetrySettings,
20
- type Settings,
21
- SettingsManager,
22
- type SkillsSettings,
23
- } from "./config/settings-manager";
15
+ export type { CompactionSettings, RetrySettings, SkillsSettings } from "./config/settings";
16
+ export { Settings, settings } from "./config/settings";
24
17
  // Custom commands
25
18
  export type {
26
19
  CustomCommand,
@@ -188,7 +181,6 @@ export {
188
181
  FindTool,
189
182
  GrepTool,
190
183
  LsTool,
191
- loadSettings,
192
184
  loadSshTool,
193
185
  PythonTool,
194
186
  ReadTool,
@@ -4,9 +4,10 @@ import * as path from "node:path";
4
4
  import { isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
5
5
  import type { Subprocess } from "bun";
6
6
  import { getAgentDir } from "../config";
7
- import { SettingsManager } from "../config/settings-manager";
7
+ import { Settings } from "../config/settings";
8
8
  import { getOrCreateSnapshot } from "../utils/shell-snapshot";
9
9
  import { time } from "../utils/timings";
10
+ import { filterEnv, resolvePythonRuntime } from "./runtime";
10
11
 
11
12
  const GATEWAY_DIR_NAME = "python-gateway";
12
13
  const GATEWAY_INFO_FILE = "gateway.json";
@@ -18,96 +19,6 @@ const GATEWAY_LOCK_STALE_MS = GATEWAY_STARTUP_TIMEOUT_MS * 2;
18
19
  const GATEWAY_LOCK_HEARTBEAT_MS = 5000;
19
20
  const HEALTH_CHECK_TIMEOUT_MS = 3000;
20
21
 
21
- const DEFAULT_ENV_ALLOWLIST = new Set([
22
- "PATH",
23
- "HOME",
24
- "USER",
25
- "LOGNAME",
26
- "SHELL",
27
- "LANG",
28
- "LC_ALL",
29
- "LC_CTYPE",
30
- "LC_MESSAGES",
31
- "TERM",
32
- "TERM_PROGRAM",
33
- "TERM_PROGRAM_VERSION",
34
- "TMPDIR",
35
- "TEMP",
36
- "TMP",
37
- "XDG_CACHE_HOME",
38
- "XDG_CONFIG_HOME",
39
- "XDG_DATA_HOME",
40
- "XDG_RUNTIME_DIR",
41
- "SSH_AUTH_SOCK",
42
- "SSH_AGENT_PID",
43
- "CONDA_PREFIX",
44
- "CONDA_DEFAULT_ENV",
45
- "VIRTUAL_ENV",
46
- "PYTHONPATH",
47
- "SYSTEMROOT",
48
- "COMSPEC",
49
- "WINDIR",
50
- "USERPROFILE",
51
- "LOCALAPPDATA",
52
- "APPDATA",
53
- "PROGRAMDATA",
54
- "PATHEXT",
55
- "USERNAME",
56
- "HOMEDRIVE",
57
- "HOMEPATH",
58
- ]);
59
-
60
- const WINDOWS_ENV_ALLOWLIST = new Set([
61
- "APPDATA",
62
- "COMPUTERNAME",
63
- "COMSPEC",
64
- "HOMEDRIVE",
65
- "HOMEPATH",
66
- "LOCALAPPDATA",
67
- "NUMBER_OF_PROCESSORS",
68
- "OS",
69
- "PATH",
70
- "PATHEXT",
71
- "PROCESSOR_ARCHITECTURE",
72
- "PROCESSOR_IDENTIFIER",
73
- "PROGRAMDATA",
74
- "PROGRAMFILES",
75
- "PROGRAMFILES(X86)",
76
- "PROGRAMW6432",
77
- "SESSIONNAME",
78
- "SYSTEMDRIVE",
79
- "SYSTEMROOT",
80
- "TEMP",
81
- "TMP",
82
- "USERDOMAIN",
83
- "USERDOMAIN_ROAMINGPROFILE",
84
- "USERPROFILE",
85
- "USERNAME",
86
- "WINDIR",
87
- ]);
88
-
89
- const DEFAULT_ENV_ALLOW_PREFIXES = ["LC_", "XDG_", "OMP_"];
90
-
91
- const CASE_INSENSITIVE_ENV = process.platform === "win32";
92
- const ACTIVE_ENV_ALLOWLIST = CASE_INSENSITIVE_ENV ? WINDOWS_ENV_ALLOWLIST : DEFAULT_ENV_ALLOWLIST;
93
-
94
- const NORMALIZED_ALLOWLIST = new Map(
95
- Array.from(ACTIVE_ENV_ALLOWLIST, key => [CASE_INSENSITIVE_ENV ? key.toUpperCase() : key, key] as const),
96
- );
97
- const NORMALIZED_ALLOW_PREFIXES = CASE_INSENSITIVE_ENV
98
- ? DEFAULT_ENV_ALLOW_PREFIXES.map(prefix => prefix.toUpperCase())
99
- : DEFAULT_ENV_ALLOW_PREFIXES;
100
-
101
- function normalizeEnvKey(key: string): string {
102
- return CASE_INSENSITIVE_ENV ? key.toUpperCase() : key;
103
- }
104
-
105
- function resolvePathKey(env: Record<string, string | undefined>): string {
106
- if (!CASE_INSENSITIVE_ENV) return "PATH";
107
- const match = Object.keys(env).find(candidate => candidate.toLowerCase() === "path");
108
- return match ?? "PATH";
109
- }
110
-
111
22
  export interface GatewayInfo {
112
23
  url: string;
113
24
  pid: number;
@@ -130,56 +41,6 @@ let localGatewayProcess: Subprocess | null = null;
130
41
  let localGatewayUrl: string | null = null;
131
42
  let isCoordinatorInitialized = false;
132
43
 
133
- function filterEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
134
- const filtered: Record<string, string | undefined> = {};
135
- for (const [key, value] of Object.entries(env)) {
136
- if (value === undefined) continue;
137
- const normalizedKey = normalizeEnvKey(key);
138
- const canonicalKey = NORMALIZED_ALLOWLIST.get(normalizedKey);
139
- if (canonicalKey !== undefined) {
140
- filtered[canonicalKey] = value;
141
- continue;
142
- }
143
- if (NORMALIZED_ALLOW_PREFIXES.some(prefix => normalizedKey.startsWith(prefix))) {
144
- filtered[key] = value;
145
- }
146
- }
147
- return filtered;
148
- }
149
-
150
- function resolveVenvPath(cwd: string): string | null {
151
- if (process.env.VIRTUAL_ENV) return process.env.VIRTUAL_ENV;
152
- const candidates = [path.join(cwd, ".venv"), path.join(cwd, "venv")];
153
- for (const candidate of candidates) {
154
- if (fs.existsSync(candidate)) {
155
- return candidate;
156
- }
157
- }
158
- return null;
159
- }
160
-
161
- function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>) {
162
- const env = { ...baseEnv };
163
- const venvPath = env.VIRTUAL_ENV ?? resolveVenvPath(cwd);
164
- if (venvPath) {
165
- env.VIRTUAL_ENV = venvPath;
166
- const binDir = process.platform === "win32" ? path.join(venvPath, "Scripts") : path.join(venvPath, "bin");
167
- const pythonCandidate = path.join(binDir, process.platform === "win32" ? "python.exe" : "python");
168
- if (fs.existsSync(pythonCandidate)) {
169
- const pathKey = resolvePathKey(env);
170
- const currentPath = env[pathKey];
171
- env[pathKey] = currentPath ? `${binDir}${path.delimiter}${currentPath}` : binDir;
172
- return { pythonPath: pythonCandidate, env, venvPath };
173
- }
174
- }
175
-
176
- const pythonPath = Bun.which("python") ?? Bun.which("python3");
177
- if (!pythonPath) {
178
- throw new Error("Python executable not found on PATH");
179
- }
180
- return { pythonPath, env, venvPath: null };
181
- }
182
-
183
44
  async function allocatePort(): Promise<number> {
184
45
  const { promise, resolve, reject } = Promise.withResolvers<number>();
185
46
  const server = createServer();
@@ -368,7 +229,8 @@ async function isGatewayAlive(info: GatewayInfo): Promise<boolean> {
368
229
  async function startGatewayProcess(
369
230
  cwd: string,
370
231
  ): Promise<{ url: string; pid: number; pythonPath: string; venvPath: string | null }> {
371
- const { shell, env } = await SettingsManager.getGlobalShellConfig();
232
+ const settings = await Settings.init();
233
+ const { shell, env } = settings.getShellConfig();
372
234
  const filteredEnv = filterEnv(env);
373
235
  const runtime = await resolvePythonRuntime(cwd, filteredEnv);
374
236
  const snapshotPath = await getOrCreateSnapshot(shell, env).catch((err: unknown) => {
@@ -389,7 +251,7 @@ async function startGatewayProcess(
389
251
 
390
252
  const gatewayProcess = Bun.spawn(
391
253
  [
392
- runtime.pythonPath,
254
+ runtime.pythonwPath,
393
255
  "-m",
394
256
  "kernel_gateway",
395
257
  "--KernelGatewayApp.ip=127.0.0.1",