@oh-my-pi/pi-coding-agent 8.12.8 → 8.12.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "8.12.8",
3
+ "version": "8.12.10",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -79,22 +79,20 @@
79
79
  "test": "bun test"
80
80
  },
81
81
  "dependencies": {
82
- "@oh-my-pi/omp-stats": "8.12.8",
83
- "@oh-my-pi/pi-agent-core": "8.12.8",
84
- "@oh-my-pi/pi-ai": "8.12.8",
85
- "@oh-my-pi/pi-natives": "8.12.8",
86
- "@oh-my-pi/pi-tui": "8.12.8",
87
- "@oh-my-pi/pi-utils": "8.12.8",
82
+ "@oh-my-pi/omp-stats": "8.12.10",
83
+ "@oh-my-pi/pi-agent-core": "8.12.10",
84
+ "@oh-my-pi/pi-ai": "8.12.10",
85
+ "@oh-my-pi/pi-natives": "8.12.10",
86
+ "@oh-my-pi/pi-tui": "8.12.10",
87
+ "@oh-my-pi/pi-utils": "8.12.10",
88
88
  "@openai/agents": "^0.4.4",
89
89
  "@sinclair/typebox": "^0.34.48",
90
90
  "ajv": "^8.17.1",
91
91
  "chalk": "^5.6.2",
92
- "cli-highlight": "^2.1.11",
93
92
  "diff": "^8.0.3",
94
93
  "file-type": "^21.3.0",
95
94
  "glob": "^13.0.0",
96
95
  "handlebars": "^4.7.8",
97
- "highlight.js": "^11.11.1",
98
96
  "marked": "^17.0.1",
99
97
  "nanoid": "^5.1.6",
100
98
  "node-html-parser": "^7.0.2",
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { isEnoent, logger } from "@oh-my-pi/pi-utils";
3
+ import { isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
4
4
  import { YAML } from "bun";
5
5
  import { type Settings as SettingsItem, settingsCapability } from "../capability/settings";
6
6
  import { getAgentDbPath, getAgentDir } from "../config";
@@ -461,6 +461,8 @@ export class SettingsManager {
461
461
  private settings!: Settings;
462
462
  private persist: boolean;
463
463
 
464
+ static #lastInstance: SettingsManager | null = null;
465
+
464
466
  /**
465
467
  * Private constructor - use static factory methods instead.
466
468
  * @param storage - SQLite storage instance for auth/cache, or null for in-memory mode
@@ -477,6 +479,7 @@ export class SettingsManager {
477
479
  initialSettings: Settings,
478
480
  persist: boolean,
479
481
  projectSettings: Settings,
482
+ private agentDir: string | null,
480
483
  ) {
481
484
  this.storage = storage;
482
485
  this.configPath = configPath;
@@ -517,6 +520,9 @@ export class SettingsManager {
517
520
  * @returns Configured SettingsManager with merged global and user settings
518
521
  */
519
522
  static async create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): Promise<SettingsManager> {
523
+ cwd = path.normalize(cwd);
524
+ agentDir = path.normalize(agentDir);
525
+
520
526
  const configPath = path.join(agentDir, "config.yml");
521
527
  const storage = await AgentStorage.open(getAgentDbPath(agentDir));
522
528
 
@@ -541,7 +547,9 @@ export class SettingsManager {
541
547
  // Load project settings before construction (constructor is sync)
542
548
  const projectSettings = await SettingsManager.loadProjectSettingsStatic(cwd);
543
549
 
544
- return new SettingsManager(storage, configPath, cwd, globalSettings, true, projectSettings);
550
+ const instance = new SettingsManager(storage, configPath, cwd, globalSettings, true, projectSettings, agentDir);
551
+ SettingsManager.#lastInstance = instance;
552
+ return instance;
545
553
  }
546
554
 
547
555
  /**
@@ -550,7 +558,7 @@ export class SettingsManager {
550
558
  * @returns SettingsManager that won't persist changes to disk
551
559
  */
552
560
  static inMemory(settings: Partial<Settings> = {}): SettingsManager {
553
- return new SettingsManager(null, null, null, settings, false, {});
561
+ return new SettingsManager(null, null, null, settings, false, {}, null);
554
562
  }
555
563
 
556
564
  /**
@@ -1784,4 +1792,54 @@ export class SettingsManager {
1784
1792
  await this.save();
1785
1793
  }
1786
1794
  }
1795
+
1796
+ _compareUniqueCtorKeys(cwd: string, agentDir: string): boolean {
1797
+ if (this.cwd !== cwd) {
1798
+ cwd = path.normalize(cwd);
1799
+ if (this.cwd !== cwd) {
1800
+ return false;
1801
+ }
1802
+ }
1803
+ if (this.agentDir !== agentDir) {
1804
+ agentDir = path.normalize(agentDir);
1805
+ if (this.agentDir !== agentDir) {
1806
+ return false;
1807
+ }
1808
+ }
1809
+ return true;
1810
+ }
1811
+
1812
+ /**
1813
+ * Acquire the last created SettingsManager instance.
1814
+ * If no instance exists, create a new one.
1815
+ * @returns The SettingsManager instance
1816
+ */
1817
+ static acquire(
1818
+ cwd: string = process.cwd(),
1819
+ agentDir: string = getAgentDir(),
1820
+ ): SettingsManager | Promise<SettingsManager> {
1821
+ const prev = SettingsManager.#lastInstance;
1822
+ if (prev?._compareUniqueCtorKeys(cwd, agentDir)) {
1823
+ return prev;
1824
+ }
1825
+ return SettingsManager.create(cwd, agentDir);
1826
+ }
1827
+
1828
+ /**
1829
+ * Gets the shell configuration
1830
+ * @returns The shell configuration
1831
+ */
1832
+ async getShellConfig() {
1833
+ const shell = this.getShellPath();
1834
+ return procmgr.getShellConfig(shell);
1835
+ }
1836
+
1837
+ /**
1838
+ * Gets the shell configuration from the last created SettingsManager instance.
1839
+ * @returns The shell configuration
1840
+ */
1841
+ static async getGlobalShellConfig() {
1842
+ const settings = await SettingsManager.acquire();
1843
+ return settings.getShellConfig();
1844
+ }
1787
1845
  }
@@ -4,8 +4,8 @@
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
8
  import { OutputSink } from "../session/streaming-output";
8
- import { getShellConfig } from "../utils/shell";
9
9
  import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
10
10
 
11
11
  export interface BashExecutorOptions {
@@ -33,7 +33,7 @@ export interface BashResult {
33
33
  }
34
34
 
35
35
  export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
36
- const { shell, args, env, prefix } = await getShellConfig();
36
+ const { shell, args, env, prefix } = await SettingsManager.getGlobalShellConfig();
37
37
 
38
38
  // Merge additional env vars if provided
39
39
  const finalEnv = options?.env ? { ...env, ...options.env } : env;
@@ -45,7 +45,7 @@ export async function installPlugin(packageName: string): Promise<InstalledPlugi
45
45
  }
46
46
 
47
47
  // Run npm install in plugins directory
48
- const proc = Bun.spawn(["npm", "install", packageName], {
48
+ const proc = Bun.spawn(["bun", "install", packageName], {
49
49
  cwd: PLUGINS_DIR,
50
50
  stdin: "ignore",
51
51
  stdout: "pipe",
@@ -86,7 +86,7 @@ export async function uninstallPlugin(name: string): Promise<void> {
86
86
 
87
87
  await ensurePluginsDir();
88
88
 
89
- const proc = Bun.spawn(["npm", "uninstall", name], {
89
+ const proc = Bun.spawn(["bun", "uninstall", name], {
90
90
  cwd: PLUGINS_DIR,
91
91
  stdin: "ignore",
92
92
  stdout: "pipe",
@@ -154,7 +154,7 @@ export class PluginManager {
154
154
  }
155
155
 
156
156
  // Run npm install
157
- const proc = Bun.spawn(["npm", "install", spec.packageName], {
157
+ const proc = Bun.spawn(["bun", "install", spec.packageName], {
158
158
  cwd: getPluginsDir(),
159
159
  stdin: "ignore",
160
160
  stdout: "pipe",
@@ -234,7 +234,7 @@ export class PluginManager {
234
234
  validatePackageName(name);
235
235
  await this.ensurePackageJson();
236
236
 
237
- const proc = Bun.spawn(["npm", "uninstall", name], {
237
+ const proc = Bun.spawn(["bun", "uninstall", name], {
238
238
  cwd: getPluginsDir(),
239
239
  stdin: "ignore",
240
240
  stdout: "pipe",
@@ -616,7 +616,7 @@ export class PluginManager {
616
616
 
617
617
  private async fixMissingPlugin(): Promise<boolean> {
618
618
  try {
619
- const proc = Bun.spawn(["npm", "install"], {
619
+ const proc = Bun.spawn(["bun", "install"], {
620
620
  cwd: getPluginsDir(),
621
621
  stdin: "ignore",
622
622
  stdout: "pipe",
package/src/index.ts CHANGED
@@ -276,4 +276,3 @@ export {
276
276
  truncateTail,
277
277
  type WriteToolDetails,
278
278
  } from "./tools";
279
- export { getShellConfig } from "./utils/shell";
@@ -1,10 +1,10 @@
1
1
  import * as fs from "node:fs";
2
2
  import { createServer } from "node:net";
3
3
  import * as path from "node:path";
4
- import { isEnoent, logger } from "@oh-my-pi/pi-utils";
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 { getShellConfig, killProcessTree } from "../utils/shell";
7
+ import { SettingsManager } from "../config/settings-manager";
8
8
  import { getOrCreateSnapshot } from "../utils/shell-snapshot";
9
9
  import { time } from "../utils/timings";
10
10
 
@@ -304,7 +304,7 @@ async function withGatewayLock<T>(handler: () => Promise<T>): Promise<T> {
304
304
  const lockPid = lockInfo?.pid;
305
305
  const lockAgeMs = lockInfo?.startedAt ? Date.now() - lockInfo.startedAt : Date.now() - lockStat.mtimeMs;
306
306
  const staleByTime = lockAgeMs > GATEWAY_LOCK_STALE_MS;
307
- const staleByPid = lockPid !== undefined && !isPidRunning(lockPid);
307
+ const staleByPid = lockPid !== undefined && !procmgr.isPidRunning(lockPid);
308
308
  const staleByMissingPid = lockPid === undefined && staleByTime;
309
309
  if (staleByPid || staleByMissingPid) {
310
310
  await fs.promises.unlink(lockPath);
@@ -365,15 +365,6 @@ async function clearGatewayInfo(): Promise<void> {
365
365
  }
366
366
  }
367
367
 
368
- function isPidRunning(pid: number): boolean {
369
- try {
370
- process.kill(pid, 0);
371
- return true;
372
- } catch {
373
- return false;
374
- }
375
- }
376
-
377
368
  async function isGatewayHealthy(url: string): Promise<boolean> {
378
369
  try {
379
370
  const response = await fetch(`${url}/api/kernelspecs`, {
@@ -386,14 +377,14 @@ async function isGatewayHealthy(url: string): Promise<boolean> {
386
377
  }
387
378
 
388
379
  async function isGatewayAlive(info: GatewayInfo): Promise<boolean> {
389
- if (!isPidRunning(info.pid)) return false;
380
+ if (!procmgr.isPidRunning(info.pid)) return false;
390
381
  return await isGatewayHealthy(info.url);
391
382
  }
392
383
 
393
384
  async function startGatewayProcess(
394
385
  cwd: string,
395
386
  ): Promise<{ url: string; pid: number; pythonPath: string; venvPath: string | null }> {
396
- const { shell, env } = await getShellConfig();
387
+ const { shell, env } = await SettingsManager.getGlobalShellConfig();
397
388
  const filteredEnv = filterEnv(env);
398
389
  const runtime = await resolvePythonRuntime(cwd, filteredEnv);
399
390
  const snapshotPath = await getOrCreateSnapshot(shell, env).catch((err: unknown) => {
@@ -428,17 +419,16 @@ async function startGatewayProcess(
428
419
  stdin: "ignore",
429
420
  stdout: "pipe",
430
421
  stderr: "pipe",
422
+ detached: true,
431
423
  env: kernelEnv,
432
424
  },
433
425
  );
434
426
 
435
427
  let exited = false;
436
428
  gatewayProcess.exited
429
+ .catch(() => {})
437
430
  .then(() => {
438
431
  exited = true;
439
- })
440
- .catch(() => {
441
- exited = true;
442
432
  });
443
433
 
444
434
  const startTime = Date.now();
@@ -459,13 +449,13 @@ async function startGatewayProcess(
459
449
  await Bun.sleep(100);
460
450
  }
461
451
 
462
- await killProcessTree(gatewayProcess.pid);
452
+ await procmgr.terminate({ target: gatewayProcess, group: true });
463
453
  throw new Error("Gateway startup timeout");
464
454
  }
465
455
 
466
456
  async function killGateway(pid: number, context: string): Promise<void> {
467
457
  try {
468
- await killProcessTree(pid);
458
+ await procmgr.terminate({ target: pid, group: true });
469
459
  } catch (err) {
470
460
  logger.warn("Failed to kill shared gateway process", {
471
461
  error: err instanceof Error ? err.message : String(err),
@@ -495,7 +485,7 @@ export async function acquireSharedGateway(cwd: string): Promise<AcquireResult |
495
485
  }
496
486
 
497
487
  logger.debug("Cleaning up stale gateway info", { pid: existingInfo.pid });
498
- if (isPidRunning(existingInfo.pid)) {
488
+ if (procmgr.isPidRunning(existingInfo.pid)) {
499
489
  await killGateway(existingInfo.pid, "stale");
500
490
  }
501
491
  await clearGatewayInfo();
@@ -557,7 +547,7 @@ export async function getGatewayStatus(): Promise<GatewayStatus> {
557
547
  venvPath: null,
558
548
  };
559
549
  }
560
- const active = isPidRunning(info.pid);
550
+ const active = procmgr.isPidRunning(info.pid);
561
551
  return {
562
552
  active,
563
553
  url: info.url,
@@ -573,7 +563,7 @@ export async function shutdownSharedGateway(): Promise<void> {
573
563
  await withGatewayLock(async () => {
574
564
  const info = await readGatewayInfo();
575
565
  if (!info) return;
576
- if (isPidRunning(info.pid)) {
566
+ if (procmgr.isPidRunning(info.pid)) {
577
567
  await killGateway(info.pid, "shutdown");
578
568
  }
579
569
  await clearGatewayInfo();
package/src/ipy/kernel.ts CHANGED
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import { logger, ptree } from "@oh-my-pi/pi-utils";
4
4
  import { $ } from "bun";
5
5
  import { nanoid } from "nanoid";
6
- import { getShellConfig } from "../utils/shell";
6
+ import { SettingsManager } from "../config/settings-manager";
7
7
  import { getOrCreateSnapshot } from "../utils/shell-snapshot";
8
8
  import { time } from "../utils/timings";
9
9
  import { htmlToBasicMarkdown } from "../web/scrapers/types";
@@ -279,7 +279,7 @@ export async function checkPythonKernelAvailability(cwd: string): Promise<Python
279
279
  }
280
280
 
281
281
  try {
282
- const { env } = await getShellConfig();
282
+ const { env } = await SettingsManager.getGlobalShellConfig();
283
283
  const baseEnv = filterEnv(env);
284
284
  const runtime = await resolvePythonRuntime(cwd, baseEnv);
285
285
  const checkScript =
@@ -611,7 +611,7 @@ export class PythonKernel {
611
611
  }
612
612
 
613
613
  private static async startWithLocalGateway(options: KernelStartOptions): Promise<PythonKernel> {
614
- const { shell, env } = await getShellConfig();
614
+ const { shell, env } = await SettingsManager.getGlobalShellConfig();
615
615
  const filteredEnv = filterEnv(env);
616
616
  const runtime = await resolvePythonRuntime(options.cwd, filteredEnv);
617
617
  const snapshotPath = await getOrCreateSnapshot(shell, env).catch((err: unknown) => {
package/src/lsp/client.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { isEnoent, logger } from "@oh-my-pi/pi-utils";
1
+ import { isEnoent, logger, ptree } from "@oh-my-pi/pi-utils";
2
2
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
3
3
  import { applyWorkspaceEdit } from "./edits";
4
4
  import { getLspmuxCommand, isLspmuxSupported } from "./lspmux";
@@ -206,7 +206,7 @@ function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array {
206
206
  }
207
207
 
208
208
  async function writeMessage(
209
- sink: import("bun").FileSink,
209
+ sink: Bun.FileSink,
210
210
  message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse,
211
211
  ): Promise<void> {
212
212
  const content = JSON.stringify(message);
@@ -230,7 +230,7 @@ async function startMessageReader(client: LspClient): Promise<void> {
230
230
  if (client.isReading) return;
231
231
  client.isReading = true;
232
232
 
233
- const reader = (client.process.stdout as ReadableStream<Uint8Array>).getReader();
233
+ const reader = (client.proc.stdout as ReadableStream<Uint8Array>).getReader();
234
234
 
235
235
  try {
236
236
  while (true) {
@@ -364,7 +364,7 @@ async function sendResponse(
364
364
  };
365
365
 
366
366
  try {
367
- await writeMessage(client.process.stdin as import("bun").FileSink, response);
367
+ await writeMessage(client.proc.stdin, response);
368
368
  } catch (err) {
369
369
  logger.error("LSP failed to respond.", { method, error: String(err) });
370
370
  }
@@ -409,18 +409,17 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
409
409
  ? await getLspmuxCommand(baseCommand, baseArgs)
410
410
  : { command: baseCommand, args: baseArgs };
411
411
 
412
- const proc = Bun.spawn([command, ...args], {
412
+ const proc = ptree.spawn([command, ...args], {
413
413
  cwd,
414
+ detached: true,
414
415
  stdin: "pipe",
415
- stdout: "pipe",
416
- stderr: "pipe",
417
416
  env: env ? { ...process.env, ...env } : undefined,
418
417
  });
419
418
 
420
419
  const client: LspClient = {
421
420
  name: key,
422
421
  cwd,
423
- process: proc,
422
+ proc,
424
423
  config,
425
424
  requestId: 0,
426
425
  diagnostics: new Map(),
@@ -686,7 +685,7 @@ export function shutdownClient(key: string): void {
686
685
  sendRequest(client, "shutdown", null).catch(() => {});
687
686
 
688
687
  // Kill process
689
- client.process.kill();
688
+ client.proc.kill();
690
689
  clients.delete(key);
691
690
  }
692
691
 
@@ -773,7 +772,7 @@ export async function sendRequest(
773
772
  });
774
773
 
775
774
  // Write request
776
- writeMessage(client.process.stdin as import("bun").FileSink, request).catch(err => {
775
+ writeMessage(client.proc.stdin, request).catch(err => {
777
776
  if (timeout) clearTimeout(timeout);
778
777
  client.pendingRequests.delete(id);
779
778
  cleanup();
@@ -793,26 +792,33 @@ export async function sendNotification(client: LspClient, method: string, params
793
792
  };
794
793
 
795
794
  client.lastActivity = Date.now();
796
- await writeMessage(client.process.stdin as import("bun").FileSink, notification);
795
+ await writeMessage(client.proc.stdin, notification);
797
796
  }
798
797
 
799
798
  /**
800
799
  * Shutdown all LSP clients.
801
800
  */
802
801
  export function shutdownAll(): void {
803
- for (const client of Array.from(clients.values())) {
804
- // Reject all pending requests
805
- for (const pending of Array.from(client.pendingRequests.values())) {
806
- pending.reject(new Error("LSP client shutdown"));
807
- }
808
- client.pendingRequests.clear();
802
+ const clientsToShutdown = Array.from(clients.values());
803
+ clients.clear();
809
804
 
810
- // Send shutdown request (best effort, don't wait)
811
- sendRequest(client, "shutdown", null).catch(() => {});
805
+ const err = new Error("LSP client shutdown");
806
+ for (const client of clientsToShutdown) {
807
+ /// Reject all pending requests
808
+ const reqs = Array.from(client.pendingRequests.values());
809
+ client.pendingRequests.clear();
810
+ for (const pending of reqs) {
811
+ pending.reject(err);
812
+ }
812
813
 
813
- client.process.kill();
814
+ void (async () => {
815
+ // Send shutdown request (best effort, don't wait)
816
+ const timeout = Bun.sleep(5_000);
817
+ const result = sendRequest(client, "shutdown", null).catch(() => {});
818
+ await Promise.race([result, timeout]);
819
+ client.proc.kill();
820
+ })().catch(() => {});
814
821
  }
815
- clients.clear();
816
822
  }
817
823
 
818
824
  /** Status of an LSP server */
package/src/lsp/render.ts CHANGED
@@ -8,8 +8,8 @@
8
8
  * - Collapsible/expandable views
9
9
  */
10
10
  import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core";
11
+ import { type HighlightColors, highlightCode as nativeHighlightCode, supportsLanguage } from "@oh-my-pi/pi-natives";
11
12
  import { type Component, Text } from "@oh-my-pi/pi-tui";
12
- import { highlight, supportsLanguage } from "cli-highlight";
13
13
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
14
14
  import {
15
15
  formatExpandHint,
@@ -291,29 +291,23 @@ function renderHover(
291
291
  }
292
292
 
293
293
  /**
294
- * Syntax highlight code using highlight.ts.
294
+ * Syntax highlight code using native WASM highlighter.
295
295
  */
296
296
  function highlightCode(codeText: string, language: string, theme: Theme): string[] {
297
297
  const validLang = language && supportsLanguage(language) ? language : undefined;
298
298
  try {
299
- const cliTheme = {
300
- keyword: (s: string) => theme.fg("syntaxKeyword", s),
301
- built_in: (s: string) => theme.fg("syntaxType", s),
302
- literal: (s: string) => theme.fg("syntaxNumber", s),
303
- number: (s: string) => theme.fg("syntaxNumber", s),
304
- string: (s: string) => theme.fg("syntaxString", s),
305
- comment: (s: string) => theme.fg("syntaxComment", s),
306
- function: (s: string) => theme.fg("syntaxFunction", s),
307
- title: (s: string) => theme.fg("syntaxFunction", s),
308
- class: (s: string) => theme.fg("syntaxType", s),
309
- type: (s: string) => theme.fg("syntaxType", s),
310
- attr: (s: string) => theme.fg("syntaxVariable", s),
311
- variable: (s: string) => theme.fg("syntaxVariable", s),
312
- params: (s: string) => theme.fg("syntaxVariable", s),
313
- operator: (s: string) => theme.fg("syntaxOperator", s),
314
- punctuation: (s: string) => theme.fg("syntaxPunctuation", s),
299
+ const colors: HighlightColors = {
300
+ comment: theme.getFgAnsi("syntaxComment"),
301
+ keyword: theme.getFgAnsi("syntaxKeyword"),
302
+ function: theme.getFgAnsi("syntaxFunction"),
303
+ variable: theme.getFgAnsi("syntaxVariable"),
304
+ string: theme.getFgAnsi("syntaxString"),
305
+ number: theme.getFgAnsi("syntaxNumber"),
306
+ type: theme.getFgAnsi("syntaxType"),
307
+ operator: theme.getFgAnsi("syntaxOperator"),
308
+ punctuation: theme.getFgAnsi("syntaxPunctuation"),
315
309
  };
316
- return highlight(codeText, { language: validLang, ignoreIllegals: true, theme: cliTheme }).split("\n");
310
+ return nativeHighlightCode(codeText, validLang, colors).split("\n");
317
311
  } catch {
318
312
  return codeText.split("\n");
319
313
  }
package/src/lsp/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { StringEnum } from "@oh-my-pi/pi-ai";
2
+ import type { ptree } from "@oh-my-pi/pi-utils";
2
3
  import { type Static, Type } from "@sinclair/typebox";
3
- import type { Subprocess } from "bun";
4
4
 
5
5
  // =============================================================================
6
6
  // Tool Schema
@@ -400,7 +400,7 @@ export interface LspClient {
400
400
  name: string;
401
401
  cwd: string;
402
402
  config: ServerConfig;
403
- process: Subprocess;
403
+ proc: ptree.ChildProcess<"pipe">;
404
404
  requestId: number;
405
405
  diagnostics: Map<string, Diagnostic[]>;
406
406
  diagnosticsVersion: number;
@@ -524,7 +524,7 @@ export class RpcClient {
524
524
 
525
525
  // Write to stdin after registering the handler
526
526
  const stdin = this.process!.stdin as import("bun").FileSink;
527
- stdin.write(new TextEncoder().encode(`${JSON.stringify(fullCommand)}\n`));
527
+ stdin.write(`${JSON.stringify(fullCommand)}\n`);
528
528
  // flush() returns number | Promise<number> - handle both cases
529
529
  const flushResult = stdin.flush();
530
530
  if (flushResult instanceof Promise) {
@@ -1,11 +1,15 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import {
4
+ type HighlightColors as NativeHighlightColors,
5
+ highlightCode as nativeHighlightCode,
6
+ supportsLanguage as nativeSupportsLanguage,
7
+ } from "@oh-my-pi/pi-natives";
3
8
  import type { EditorTheme, MarkdownTheme, SelectListTheme, SymbolTheme } from "@oh-my-pi/pi-tui";
4
9
  import { adjustHsv, isEnoent, logger } from "@oh-my-pi/pi-utils";
5
10
  import { type Static, Type } from "@sinclair/typebox";
6
11
  import { TypeCompiler } from "@sinclair/typebox/compiler";
7
12
  import chalk from "chalk";
8
- import { highlight, supportsLanguage } from "cli-highlight";
9
13
  import { getCustomThemesDir } from "../../config";
10
14
  // Embed theme JSON files at build time
11
15
  import darkThemeJson from "./dark.json" with { type: "json" };
@@ -2029,37 +2033,25 @@ export async function getThemeExportColors(themeName?: string): Promise<{
2029
2033
  // TUI Helpers
2030
2034
  // ============================================================================
2031
2035
 
2032
- type CliHighlightTheme = Record<string, (s: string) => string>;
2033
-
2034
- let cachedHighlightThemeFor: Theme | undefined;
2035
- let cachedCliHighlightTheme: CliHighlightTheme | undefined;
2036
-
2037
- function buildCliHighlightTheme(t: Theme): CliHighlightTheme {
2038
- return {
2039
- keyword: (s: string) => t.fg("syntaxKeyword", s),
2040
- built_in: (s: string) => t.fg("syntaxType", s),
2041
- literal: (s: string) => t.fg("syntaxNumber", s),
2042
- number: (s: string) => t.fg("syntaxNumber", s),
2043
- string: (s: string) => t.fg("syntaxString", s),
2044
- comment: (s: string) => t.fg("syntaxComment", s),
2045
- function: (s: string) => t.fg("syntaxFunction", s),
2046
- title: (s: string) => t.fg("syntaxFunction", s),
2047
- class: (s: string) => t.fg("syntaxType", s),
2048
- type: (s: string) => t.fg("syntaxType", s),
2049
- attr: (s: string) => t.fg("syntaxVariable", s),
2050
- variable: (s: string) => t.fg("syntaxVariable", s),
2051
- params: (s: string) => t.fg("syntaxVariable", s),
2052
- operator: (s: string) => t.fg("syntaxOperator", s),
2053
- punctuation: (s: string) => t.fg("syntaxPunctuation", s),
2054
- };
2055
- }
2056
-
2057
- function getCliHighlightTheme(t: Theme): CliHighlightTheme {
2058
- if (cachedHighlightThemeFor !== t || !cachedCliHighlightTheme) {
2059
- cachedHighlightThemeFor = t;
2060
- cachedCliHighlightTheme = buildCliHighlightTheme(t);
2036
+ let cachedHighlightColorsFor: Theme | undefined;
2037
+ let cachedHighlightColors: NativeHighlightColors | undefined;
2038
+
2039
+ function getHighlightColors(t: Theme): NativeHighlightColors {
2040
+ if (cachedHighlightColorsFor !== t || !cachedHighlightColors) {
2041
+ cachedHighlightColorsFor = t;
2042
+ cachedHighlightColors = {
2043
+ comment: t.getFgAnsi("syntaxComment"),
2044
+ keyword: t.getFgAnsi("syntaxKeyword"),
2045
+ function: t.getFgAnsi("syntaxFunction"),
2046
+ variable: t.getFgAnsi("syntaxVariable"),
2047
+ string: t.getFgAnsi("syntaxString"),
2048
+ number: t.getFgAnsi("syntaxNumber"),
2049
+ type: t.getFgAnsi("syntaxType"),
2050
+ operator: t.getFgAnsi("syntaxOperator"),
2051
+ punctuation: t.getFgAnsi("syntaxPunctuation"),
2052
+ };
2061
2053
  }
2062
- return cachedCliHighlightTheme;
2054
+ return cachedHighlightColors;
2063
2055
  }
2064
2056
 
2065
2057
  /**
@@ -2067,15 +2059,9 @@ function getCliHighlightTheme(t: Theme): CliHighlightTheme {
2067
2059
  * Returns array of highlighted lines.
2068
2060
  */
2069
2061
  export function highlightCode(code: string, lang?: string): string[] {
2070
- // Validate language before highlighting to avoid stderr spam from cli-highlight
2071
- const validLang = lang && supportsLanguage(lang) ? lang : undefined;
2072
- const opts = {
2073
- language: validLang,
2074
- ignoreIllegals: true,
2075
- theme: getCliHighlightTheme(theme),
2076
- };
2062
+ const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2077
2063
  try {
2078
- return highlight(code, opts).split("\n");
2064
+ return nativeHighlightCode(code, validLang, getHighlightColors(theme)).split("\n");
2079
2065
  } catch {
2080
2066
  return code.split("\n");
2081
2067
  }
@@ -2212,15 +2198,9 @@ export function getMarkdownTheme(): MarkdownTheme {
2212
2198
  symbols: getSymbolTheme(),
2213
2199
  getMermaidImage,
2214
2200
  highlightCode: (code: string, lang?: string): string[] => {
2215
- // Validate language before highlighting to avoid stderr spam from cli-highlight
2216
- const validLang = lang && supportsLanguage(lang) ? lang : undefined;
2217
- const opts = {
2218
- language: validLang,
2219
- ignoreIllegals: true,
2220
- theme: getCliHighlightTheme(theme),
2221
- };
2201
+ const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2222
2202
  try {
2223
- return highlight(code, opts).split("\n");
2203
+ return nativeHighlightCode(code, validLang, getHighlightColors(theme)).split("\n");
2224
2204
  } catch {
2225
2205
  return code.split("\n").map(line => theme.fg("mdCodeBlock", line));
2226
2206
  }
package/src/tools/read.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as os from "node:os";
2
3
  import path from "node:path";
3
4
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
@@ -43,77 +44,183 @@ function isRemoteMountPath(absolutePath: string): boolean {
43
44
  return absolutePath.startsWith(REMOTE_MOUNT_PREFIX);
44
45
  }
45
46
 
46
- /**
47
- * Stream lines from a file, collecting only the requested range.
48
- * Avoids loading the entire file into memory for large files.
49
- *
50
- * @param filePath - Path to the file
51
- * @param startLine - 0-indexed start line
52
- * @param maxLinesToCollect - Maximum lines to collect (from startLine)
53
- * @param maxBytes - Maximum bytes to collect
54
- * @returns Collected lines, total line count, and truncation info
55
- */
47
+ const READ_CHUNK_SIZE = 64 * 1024;
48
+
56
49
  async function streamLinesFromFile(
57
50
  filePath: string,
58
51
  startLine: number,
59
52
  maxLinesToCollect: number,
60
53
  maxBytes: number,
54
+ selectedLineLimit: number | null,
55
+ signal?: AbortSignal,
61
56
  ): Promise<{
62
57
  lines: string[];
63
58
  totalFileLines: number;
64
59
  collectedBytes: number;
65
60
  stoppedByByteLimit: boolean;
61
+ firstLinePreview?: { text: string; bytes: number };
62
+ firstLineByteLength?: number;
63
+ selectedBytesTotal: number;
66
64
  }> {
67
- const stream = Bun.file(filePath).stream();
68
- const decoder = new TextDecoder();
69
-
65
+ const bufferChunk = Buffer.allocUnsafe(READ_CHUNK_SIZE);
70
66
  const collectedLines: string[] = [];
71
67
  let lineIndex = 0;
72
68
  let collectedBytes = 0;
73
69
  let stoppedByByteLimit = false;
74
- let buffer = "";
75
70
  let doneCollecting = false;
71
+ let fileHandle: fs.FileHandle | null = null;
72
+ let currentLineLength = 0;
73
+ let currentLineChunks: Buffer[] = [];
74
+ let sawAnyByte = false;
75
+ let endedWithNewline = false;
76
+ let firstLinePreviewBytes = 0;
77
+ const firstLinePreviewChunks: Buffer[] = [];
78
+ let firstLineByteLength: number | undefined;
79
+ let selectedBytesTotal = 0;
80
+ let selectedLinesSeen = 0;
81
+ let captureLine = false;
82
+ let discardLineChunks = false;
83
+ let lineCaptureLimit = 0;
84
+
85
+ const setupLineState = () => {
86
+ captureLine = !doneCollecting && lineIndex >= startLine;
87
+ discardLineChunks = !captureLine;
88
+ if (captureLine) {
89
+ const separatorBytes = collectedLines.length > 0 ? 1 : 0;
90
+ lineCaptureLimit = maxBytes - collectedBytes - separatorBytes;
91
+ if (lineCaptureLimit <= 0) {
92
+ discardLineChunks = true;
93
+ }
94
+ } else {
95
+ lineCaptureLimit = 0;
96
+ }
97
+ };
98
+
99
+ const decodeLine = (): string => {
100
+ if (currentLineLength === 0) return "";
101
+ if (currentLineChunks.length === 1 && currentLineChunks[0]?.length === currentLineLength) {
102
+ return currentLineChunks[0].toString("utf-8");
103
+ }
104
+ return Buffer.concat(currentLineChunks, currentLineLength).toString("utf-8");
105
+ };
76
106
 
77
- for await (const chunk of stream) {
78
- buffer += decoder.decode(chunk, { stream: true });
107
+ const maybeCapturePreview = (segment: Uint8Array) => {
108
+ if (doneCollecting || lineIndex < startLine || collectedLines.length !== 0) return;
109
+ if (firstLinePreviewBytes >= maxBytes || segment.length === 0) return;
110
+ const remaining = maxBytes - firstLinePreviewBytes;
111
+ const slice = segment.length > remaining ? segment.subarray(0, remaining) : segment;
112
+ if (slice.length === 0) return;
113
+ firstLinePreviewChunks.push(Buffer.from(slice));
114
+ firstLinePreviewBytes += slice.length;
115
+ };
79
116
 
80
- for (let newlinePos = buffer.indexOf("\n"); newlinePos !== -1; newlinePos = buffer.indexOf("\n")) {
81
- const line = buffer.slice(0, newlinePos);
82
- buffer = buffer.slice(newlinePos + 1);
117
+ const appendSegment = (segment: Uint8Array) => {
118
+ currentLineLength += segment.length;
119
+ maybeCapturePreview(segment);
120
+ if (!captureLine || discardLineChunks || segment.length === 0) return;
121
+ if (currentLineLength <= lineCaptureLimit) {
122
+ currentLineChunks.push(Buffer.from(segment));
123
+ } else {
124
+ discardLineChunks = true;
125
+ }
126
+ };
83
127
 
84
- if (!doneCollecting && lineIndex >= startLine) {
85
- const lineBytes = Buffer.byteLength(line, "utf-8") + (collectedLines.length > 0 ? 1 : 0);
128
+ const finalizeLine = () => {
129
+ if (lineIndex >= startLine && (selectedLineLimit === null || selectedLinesSeen < selectedLineLimit)) {
130
+ selectedBytesTotal += currentLineLength + (selectedLinesSeen > 0 ? 1 : 0);
131
+ selectedLinesSeen++;
132
+ }
86
133
 
87
- if (collectedBytes + lineBytes > maxBytes && collectedLines.length > 0) {
134
+ if (!doneCollecting && lineIndex >= startLine) {
135
+ const separatorBytes = collectedLines.length > 0 ? 1 : 0;
136
+ if (collectedLines.length >= maxLinesToCollect) {
137
+ doneCollecting = true;
138
+ } else if (collectedLines.length === 0 && currentLineLength > maxBytes) {
139
+ stoppedByByteLimit = true;
140
+ doneCollecting = true;
141
+ if (firstLineByteLength === undefined) {
142
+ firstLineByteLength = currentLineLength;
143
+ }
144
+ } else if (collectedLines.length > 0 && collectedBytes + separatorBytes + currentLineLength > maxBytes) {
145
+ stoppedByByteLimit = true;
146
+ doneCollecting = true;
147
+ } else {
148
+ const lineText = decodeLine();
149
+ collectedLines.push(lineText);
150
+ collectedBytes += separatorBytes + currentLineLength;
151
+ if (firstLineByteLength === undefined) {
152
+ firstLineByteLength = currentLineLength;
153
+ }
154
+ if (collectedBytes > maxBytes) {
88
155
  stoppedByByteLimit = true;
89
156
  doneCollecting = true;
90
- } else if (collectedLines.length < maxLinesToCollect) {
91
- collectedLines.push(line);
92
- collectedBytes += lineBytes;
93
- if (collectedLines.length >= maxLinesToCollect) {
94
- doneCollecting = true;
95
- }
96
- } else {
157
+ } else if (collectedLines.length >= maxLinesToCollect) {
97
158
  doneCollecting = true;
98
159
  }
99
160
  }
161
+ } else if (lineIndex >= startLine && firstLineByteLength === undefined) {
162
+ firstLineByteLength = currentLineLength;
163
+ }
164
+
165
+ lineIndex++;
166
+ currentLineLength = 0;
167
+ currentLineChunks = [];
168
+ setupLineState();
169
+ };
170
+
171
+ setupLineState();
172
+
173
+ try {
174
+ fileHandle = await fs.open(filePath, "r");
175
+
176
+ while (true) {
177
+ throwIfAborted(signal);
178
+ const { bytesRead } = await fileHandle.read(bufferChunk, 0, bufferChunk.length, null);
179
+ if (bytesRead === 0) break;
180
+
181
+ sawAnyByte = true;
182
+ const chunk = bufferChunk.subarray(0, bytesRead);
183
+ endedWithNewline = chunk[bytesRead - 1] === 0x0a;
184
+
185
+ let start = 0;
186
+ for (let i = 0; i < chunk.length; i++) {
187
+ if (chunk[i] === 0x0a) {
188
+ const segment = chunk.subarray(start, i);
189
+ if (segment.length > 0) {
190
+ appendSegment(segment);
191
+ }
192
+ finalizeLine();
193
+ start = i + 1;
194
+ }
195
+ }
100
196
 
101
- lineIndex++;
197
+ if (start < chunk.length) {
198
+ appendSegment(chunk.subarray(start));
199
+ }
200
+ }
201
+ } finally {
202
+ if (fileHandle) {
203
+ await fileHandle.close();
102
204
  }
103
205
  }
104
206
 
105
- // Handle remaining buffer (last line without trailing newline)
106
- if (buffer.length > 0) {
107
- if (!doneCollecting && lineIndex >= startLine && collectedLines.length < maxLinesToCollect) {
108
- const lineBytes = Buffer.byteLength(buffer, "utf-8") + (collectedLines.length > 0 ? 1 : 0);
109
- if (collectedBytes + lineBytes <= maxBytes || collectedLines.length === 0) {
110
- collectedLines.push(buffer);
111
- collectedBytes += lineBytes;
112
- } else {
113
- stoppedByByteLimit = true;
114
- }
207
+ if (endedWithNewline || currentLineLength > 0 || !sawAnyByte) {
208
+ finalizeLine();
209
+ }
210
+
211
+ let firstLinePreview: { text: string; bytes: number } | undefined;
212
+ if (firstLinePreviewBytes > 0) {
213
+ const buf = Buffer.concat(firstLinePreviewChunks, firstLinePreviewBytes);
214
+ let end = Math.min(buf.length, maxBytes);
215
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) {
216
+ end--;
217
+ }
218
+ if (end > 0) {
219
+ const text = buf.slice(0, end).toString("utf-8");
220
+ firstLinePreview = { text, bytes: Buffer.byteLength(text, "utf-8") };
221
+ } else {
222
+ firstLinePreview = { text: "", bytes: 0 };
115
223
  }
116
- lineIndex++;
117
224
  }
118
225
 
119
226
  return {
@@ -121,6 +228,9 @@ async function streamLinesFromFile(
121
228
  totalFileLines: lineIndex,
122
229
  collectedBytes,
123
230
  stoppedByByteLimit,
231
+ firstLinePreview,
232
+ firstLineByteLength,
233
+ selectedBytesTotal,
124
234
  };
125
235
  }
126
236
 
@@ -599,13 +709,25 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
599
709
  const startLine = offset ? Math.max(0, offset - 1) : 0;
600
710
  const startLineDisplay = startLine + 1; // For display (1-indexed)
601
711
 
602
- // Calculate how many lines to collect: user limit or default truncation limit
603
- const maxLinesToCollect = limit !== undefined ? limit : DEFAULT_MAX_LINES;
604
-
605
- // Stream the file, collecting only the needed lines
606
- const streamResult = await streamLinesFromFile(absolutePath, startLine, maxLinesToCollect, DEFAULT_MAX_BYTES);
712
+ const maxLinesToCollect = limit !== undefined ? Math.min(limit, DEFAULT_MAX_LINES) : DEFAULT_MAX_LINES;
713
+ const selectedLineLimit = limit ?? null;
714
+ const streamResult = await streamLinesFromFile(
715
+ absolutePath,
716
+ startLine,
717
+ maxLinesToCollect,
718
+ DEFAULT_MAX_BYTES,
719
+ selectedLineLimit,
720
+ signal,
721
+ );
607
722
 
608
- const { lines: collectedLines, totalFileLines, collectedBytes, stoppedByByteLimit } = streamResult;
723
+ const {
724
+ lines: collectedLines,
725
+ totalFileLines,
726
+ collectedBytes,
727
+ stoppedByByteLimit,
728
+ firstLinePreview,
729
+ firstLineByteLength,
730
+ } = streamResult;
609
731
 
610
732
  // Check if offset is out of bounds - return graceful message instead of throwing
611
733
  if (startLine >= totalFileLines) {
@@ -618,14 +740,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
618
740
  .done();
619
741
  }
620
742
 
621
- // Build the selected content from collected lines
622
743
  const selectedContent = collectedLines.join("\n");
623
744
  const userLimitedLines = limit !== undefined ? collectedLines.length : undefined;
624
745
 
625
- // Build truncation result from streaming data
626
746
  const totalSelectedLines = totalFileLines - startLine;
627
- const totalSelectedBytes = collectedBytes; // We don't know exact total bytes without reading all
747
+ const totalSelectedBytes = collectedBytes;
628
748
  const wasTruncated = collectedLines.length < totalSelectedLines || stoppedByByteLimit;
749
+ const firstLineExceedsLimit = firstLineByteLength !== undefined && firstLineByteLength > DEFAULT_MAX_BYTES;
629
750
 
630
751
  const truncation: TruncationResult = {
631
752
  content: selectedContent,
@@ -636,7 +757,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
636
757
  outputLines: collectedLines.length,
637
758
  outputBytes: collectedBytes,
638
759
  lastLinePartial: false,
639
- firstLineExceedsLimit: collectedLines.length === 0 && totalFileLines > startLine,
760
+ firstLineExceedsLimit,
640
761
  maxLines: DEFAULT_MAX_LINES,
641
762
  maxBytes: DEFAULT_MAX_BYTES,
642
763
  };
@@ -658,9 +779,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
658
779
  let outputText: string;
659
780
 
660
781
  if (truncation.firstLineExceedsLimit) {
661
- const firstLine = collectedLines[0] ?? "";
662
- const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
663
- const snippet = truncateStringToBytesFromStart(firstLine, DEFAULT_MAX_BYTES);
782
+ const firstLineBytes = firstLineByteLength ?? 0;
783
+ const snippet = firstLinePreview ?? { text: "", bytes: 0 };
664
784
 
665
785
  outputText = shouldAddLineNumbers ? prependLineNumbers(snippet.text, startLineDisplay) : snippet.text;
666
786
  if (snippet.text.length === 0) {
@@ -1,302 +0,0 @@
1
- import * as fs from "node:fs";
2
- import { $ } from "bun";
3
- import { SettingsManager } from "../config/settings-manager";
4
-
5
- export interface ShellConfig {
6
- shell: string;
7
- args: string[];
8
- env: Record<string, string | undefined>;
9
- prefix: string | undefined;
10
- }
11
-
12
- let cachedShellConfig: ShellConfig | null = null;
13
-
14
- /**
15
- * Check if a shell binary is executable.
16
- */
17
- async function isExecutable(path: string): Promise<boolean> {
18
- try {
19
- await fs.promises.access(path, fs.constants.X_OK);
20
- return true;
21
- } catch {
22
- return false;
23
- }
24
- }
25
-
26
- /**
27
- * Build the spawn environment (cached).
28
- */
29
- function buildSpawnEnv(shell: string): Record<string, string | undefined> {
30
- const noCI = process.env.OMP_BASH_NO_CI || process.env.CLAUDE_BASH_NO_CI;
31
- return {
32
- ...process.env,
33
- SHELL: shell,
34
- GIT_EDITOR: "true",
35
- GPG_TTY: "not a tty",
36
- OMPCODE: "1",
37
- CLAUDECODE: "1",
38
- ...(noCI ? {} : { CI: "true" }),
39
- };
40
- }
41
-
42
- /**
43
- * Get shell args, optionally including login shell flag.
44
- * Supports OMP_BASH_NO_LOGIN and CLAUDE_BASH_NO_LOGIN to skip -l.
45
- */
46
- function getShellArgs(): string[] {
47
- const noLogin = process.env.OMP_BASH_NO_LOGIN || process.env.CLAUDE_BASH_NO_LOGIN;
48
- return noLogin ? ["-c"] : ["-l", "-c"];
49
- }
50
-
51
- /**
52
- * Get shell prefix for wrapping commands (profilers, strace, etc.).
53
- */
54
- function getShellPrefix(): string | undefined {
55
- return process.env.OMP_SHELL_PREFIX || process.env.CLAUDE_CODE_SHELL_PREFIX;
56
- }
57
-
58
- /**
59
- * Find bash executable on PATH (Windows)
60
- */
61
- function findBashOnPath(): string | null {
62
- try {
63
- return Bun.which("bash.exe");
64
- } catch {
65
- // Ignore errors
66
- }
67
- return null;
68
- }
69
-
70
- /**
71
- * Build full shell config from a shell path.
72
- */
73
- function buildConfig(shell: string): ShellConfig {
74
- return {
75
- shell,
76
- args: getShellArgs(),
77
- env: buildSpawnEnv(shell),
78
- prefix: getShellPrefix(),
79
- };
80
- }
81
-
82
- /**
83
- * Get shell configuration based on platform.
84
- * Resolution order:
85
- * 1. User-specified shellPath in settings.json
86
- * 2. On Windows: Git Bash in known locations, then bash on PATH
87
- * 3. On Unix: $SHELL if bash/zsh, then fallback paths
88
- * 4. Fallback: sh
89
- */
90
- export async function getShellConfig(): Promise<ShellConfig> {
91
- if (cachedShellConfig) {
92
- return cachedShellConfig;
93
- }
94
-
95
- const settings = await SettingsManager.create();
96
- const customShellPath = settings.getShellPath();
97
-
98
- // 1. Check user-specified shell path
99
- if (customShellPath) {
100
- if (await Bun.file(customShellPath).exists()) {
101
- cachedShellConfig = buildConfig(customShellPath);
102
- return cachedShellConfig;
103
- }
104
- throw new Error(
105
- `Custom shell path not found: ${customShellPath}\nPlease update shellPath in ~/.omp/agent/settings.json`,
106
- );
107
- }
108
-
109
- if (process.platform === "win32") {
110
- // 2. Try Git Bash in known locations
111
- const paths: string[] = [];
112
- const programFiles = process.env.ProgramFiles;
113
- if (programFiles) {
114
- paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
115
- }
116
- const programFilesX86 = process.env["ProgramFiles(x86)"];
117
- if (programFilesX86) {
118
- paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
119
- }
120
-
121
- for (const path of paths) {
122
- if (await Bun.file(path).exists()) {
123
- cachedShellConfig = buildConfig(path);
124
- return cachedShellConfig;
125
- }
126
- }
127
-
128
- // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
129
- const bashOnPath = findBashOnPath();
130
- if (bashOnPath) {
131
- cachedShellConfig = buildConfig(bashOnPath);
132
- return cachedShellConfig;
133
- }
134
-
135
- throw new Error(
136
- `No bash shell found. Options:\n` +
137
- ` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
138
- ` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
139
- ` 3. Set shellPath in ~/.omp/agent/settings.json\n\n` +
140
- `Searched Git Bash in:\n${paths.map(p => ` ${p}`).join("\n")}`,
141
- );
142
- }
143
-
144
- // Unix: prefer user's shell from $SHELL if it's bash/zsh and executable
145
- const userShell = process.env.SHELL;
146
- const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh"));
147
- if (isValidShell && (await isExecutable(userShell))) {
148
- cachedShellConfig = buildConfig(userShell);
149
- return cachedShellConfig;
150
- }
151
-
152
- // Fallback paths (Claude's approach: check known locations)
153
- const fallbackPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
154
- const preferZsh = !userShell?.includes("bash");
155
- const shellOrder = preferZsh ? ["zsh", "bash"] : ["bash", "zsh"];
156
-
157
- for (const shellName of shellOrder) {
158
- for (const dir of fallbackPaths) {
159
- const shellPath = `${dir}/${shellName}`;
160
- if (await isExecutable(shellPath)) {
161
- cachedShellConfig = buildConfig(shellPath);
162
- return cachedShellConfig;
163
- }
164
- }
165
- }
166
-
167
- // Last resort: use Bun.which
168
- const bashPath = Bun.which("bash");
169
- if (bashPath) {
170
- cachedShellConfig = buildConfig(bashPath);
171
- return cachedShellConfig;
172
- }
173
-
174
- const shPath = Bun.which("sh");
175
- cachedShellConfig = buildConfig(shPath || "sh");
176
- return cachedShellConfig;
177
- }
178
-
179
- let pgrepAvailable: string | null | undefined;
180
-
181
- /**
182
- * Check if pgrep is available on this system (cached).
183
- */
184
- function hasPgrep(): string | null {
185
- if (pgrepAvailable === undefined) {
186
- try {
187
- pgrepAvailable = Bun.which("pgrep") ?? null;
188
- } catch {
189
- pgrepAvailable = null;
190
- }
191
- }
192
- return pgrepAvailable;
193
- }
194
-
195
- /**
196
- * Get direct children of a PID using pgrep.
197
- */
198
- async function getChildrenViaPgrep(pid: number): Promise<number[]> {
199
- const result = await $`pgrep -P ${pid}`.quiet().nothrow();
200
- if (result.exitCode !== 0) return [];
201
- const output = result.stdout.toString().trim();
202
- if (!output) return [];
203
-
204
- const children: number[] = [];
205
- for (const line of output.split("\n")) {
206
- const childPid = parseInt(line, 10);
207
- if (!Number.isNaN(childPid)) children.push(childPid);
208
- }
209
- return children;
210
- }
211
-
212
- /**
213
- * Get direct children of a PID using /proc (Linux only).
214
- */
215
- async function getChildrenViaProc(pid: number): Promise<number[]> {
216
- try {
217
- const script = `for p in /proc/[0-9]*/stat; do cat "$p" 2>/dev/null; done | awk -v ppid=${pid} '$4 == ppid { print $1 }'`;
218
- const result = await $`sh -c ${script}`.quiet().nothrow();
219
- if (result.exitCode !== 0) return [];
220
- const output = result.stdout.toString().trim();
221
- if (!output) return [];
222
-
223
- const children: number[] = [];
224
- for (const line of output.split("\n")) {
225
- const childPid = parseInt(line, 10);
226
- if (!Number.isNaN(childPid)) children.push(childPid);
227
- }
228
- return children;
229
- } catch {
230
- return [];
231
- }
232
- }
233
-
234
- /**
235
- * Collect all descendant PIDs breadth-first.
236
- * Returns deepest descendants first (reverse BFS order) for proper kill ordering.
237
- */
238
- async function getDescendantPids(pid: number): Promise<number[]> {
239
- const getChildren = hasPgrep() ? getChildrenViaPgrep : getChildrenViaProc;
240
- const descendants: number[] = [];
241
- const queue = [pid];
242
-
243
- while (queue.length > 0) {
244
- const current = queue.shift()!;
245
- const children = await getChildren(current);
246
- for (const child of children) {
247
- descendants.push(child);
248
- queue.push(child);
249
- }
250
- }
251
-
252
- // Reverse so deepest children are killed first
253
- return descendants.reverse();
254
- }
255
-
256
- function tryKill(pid: number, signal: NodeJS.Signals): boolean {
257
- try {
258
- process.kill(pid, signal);
259
- return true;
260
- } catch {
261
- return false;
262
- }
263
- }
264
-
265
- /**
266
- * Kill a process and all its descendants.
267
- * @param gracePeriodMs - Time to wait after SIGTERM before SIGKILL (0 = immediate SIGKILL)
268
- */
269
- export async function killProcessTree(pid: number, gracePeriodMs = 0): Promise<void> {
270
- if (process.platform === "win32") {
271
- await $`taskkill /F /T /PID ${pid}`.quiet().nothrow();
272
- return;
273
- }
274
-
275
- const signal = gracePeriodMs > 0 ? "SIGTERM" : "SIGKILL";
276
-
277
- // Fast path: process group kill (works if pid is group leader)
278
- try {
279
- process.kill(-pid, signal);
280
- if (gracePeriodMs > 0) {
281
- await Bun.sleep(gracePeriodMs);
282
- try {
283
- process.kill(-pid, "SIGKILL");
284
- } catch {
285
- // Already dead
286
- }
287
- }
288
- return;
289
- } catch {
290
- // Not a process group leader, fall through
291
- }
292
-
293
- // Collect descendants BEFORE killing to minimize race window
294
- const allPids = [...(await getDescendantPids(pid)), pid];
295
-
296
- if (gracePeriodMs > 0) {
297
- for (const p of allPids) tryKill(p, "SIGTERM");
298
- await Bun.sleep(gracePeriodMs);
299
- }
300
-
301
- for (const p of allPids) tryKill(p, "SIGKILL");
302
- }