@oh-my-pi/pi-coding-agent 9.6.4 → 9.8.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [9.8.0] - 2026-02-01
6
+ ### Breaking Changes
7
+
8
+ - Removed persistent shell session support; bash execution now uses native bindings via brush-core for improved reliability
9
+
10
+ ### Added
11
+
12
+ - Added `sessionKey` option to bash executor to isolate shell sessions per agent instance
13
+ - Added shell snapshot support for bash execution to preserve shell state across commands
14
+ - Added `onChunk` callback support for streaming command output in real-time
15
+
16
+ ### Changed
17
+
18
+ - Refactored bash executor to queue output chunks asynchronously for improved reliability
19
+ - Updated bash executor to pass environment variables separately as `sessionEnv` to native bindings
20
+ - Migrated system information collection to use native bindings from brush-core instead of shell command execution
21
+ - Updated CPU information to report core count alongside model name
22
+ - Simplified OS version reporting to use Node.js built-in APIs
23
+ - Migrated bash command execution from ptree-based persistent sessions to native shell bindings with streaming support
24
+ - Simplified bash executor to use brush-core native API instead of managing long-lived shell processes
25
+ - Routed clipboard copy and image paste through native arboard bindings instead of shell commands
26
+ - Embedded native addon payload for compiled binaries and extract to `~/.omp/natives/<version>` on first run
27
+
28
+ ### Removed
29
+
30
+ - Removed shell configuration from environment information display
31
+ - Removed `shell-session.ts` module providing persistent shell session management
32
+ - Removed shell session test suite for persistent execution patterns
33
+
5
34
  ## [9.6.2] - 2026-02-01
6
35
  ### Changed
7
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "9.6.4",
3
+ "version": "9.8.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -74,17 +74,17 @@
74
74
  "scripts": {
75
75
  "check": "tsgo -p tsconfig.json",
76
76
  "format-prompts": "bun scripts/format-prompts.ts",
77
- "build:binary": "cd ../.. && bun build --compile --define OMP_COMPILED=true --root . ./packages/coding-agent/src/cli.ts --outfile packages/coding-agent/dist/omp",
77
+ "build:binary": "cd ../.. && bun --cwd=packages/natives run embed:native && bun build --compile --define OMP_COMPILED=true --root . ./packages/coding-agent/src/cli.ts --outfile packages/coding-agent/dist/omp && bun --cwd=packages/natives run embed:native --reset",
78
78
  "generate-template": "bun scripts/generate-template.ts",
79
79
  "test": "bun test"
80
80
  },
81
81
  "dependencies": {
82
- "@oh-my-pi/omp-stats": "9.6.4",
83
- "@oh-my-pi/pi-agent-core": "9.6.4",
84
- "@oh-my-pi/pi-ai": "9.6.4",
85
- "@oh-my-pi/pi-natives": "9.6.4",
86
- "@oh-my-pi/pi-tui": "9.6.4",
87
- "@oh-my-pi/pi-utils": "9.6.4",
82
+ "@oh-my-pi/omp-stats": "9.8.0",
83
+ "@oh-my-pi/pi-agent-core": "9.8.0",
84
+ "@oh-my-pi/pi-ai": "9.8.0",
85
+ "@oh-my-pi/pi-natives": "9.8.0",
86
+ "@oh-my-pi/pi-tui": "9.8.0",
87
+ "@oh-my-pi/pi-utils": "9.8.0",
88
88
  "@openai/agents": "^0.4.4",
89
89
  "@sinclair/typebox": "^0.34.48",
90
90
  "ajv": "^8.17.1",
@@ -1,19 +1,20 @@
1
1
  /**
2
2
  * Bash command execution with streaming support and cancellation.
3
3
  *
4
- * Provides unified bash execution for AgentSession.executeBash() and direct calls.
4
+ * Uses brush-core via native bindings for shell execution.
5
5
  */
6
- import { Exception, ptree } from "@oh-my-pi/pi-utils";
6
+ import { Shell } from "@oh-my-pi/pi-natives";
7
7
  import { Settings } from "../config/settings";
8
8
  import { OutputSink } from "../session/streaming-output";
9
- import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
10
- import { executeShellCommand } from "./shell-session";
9
+ import { getOrCreateSnapshot } from "../utils/shell-snapshot";
11
10
 
12
11
  export interface BashExecutorOptions {
13
12
  cwd?: string;
14
13
  timeout?: number;
15
14
  onChunk?: (chunk: string) => void;
16
15
  signal?: AbortSignal;
16
+ /** Session key suffix to isolate shell sessions per agent */
17
+ sessionKey?: string;
17
18
  /** Additional environment variables to inject */
18
19
  env?: Record<string, string>;
19
20
  /** Artifact path/id for full output storage */
@@ -33,117 +34,114 @@ export interface BashResult {
33
34
  artifactId?: string;
34
35
  }
35
36
 
37
+ const shellSessions = new Map<string, Shell>();
38
+
36
39
  export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
37
40
  const settings = await Settings.init();
38
- const { shell, args, env, prefix } = settings.getShellConfig();
39
- const snapshotPath = await getOrCreateSnapshot(shell, env);
40
-
41
- if (shouldUsePersistentShell(settings.get("bash.persistentShell"))) {
42
- return await executeShellCommand({ shell, env, prefix, snapshotPath }, command, {
43
- cwd: options?.cwd,
44
- timeout: options?.timeout,
45
- signal: options?.signal,
46
- onChunk: options?.onChunk,
47
- env: options?.env,
48
- artifactPath: options?.artifactPath,
49
- artifactId: options?.artifactId,
50
- });
51
- }
52
-
53
- return await executeBashOnce(command, options, { shell, args, env, prefix, snapshotPath });
54
- }
55
-
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)
62
- const flag = parseEnvFlag(process.env.OMP_SHELL_PERSIST);
63
- if (flag !== undefined) return flag;
64
- // Windows never uses persistent shell (too unreliable)
65
- if (process.platform === "win32") return false;
66
- // Use setting value (defaults to false)
67
- return settingValue;
68
- }
69
-
70
- function parseEnvFlag(value: string | undefined): boolean | undefined {
71
- if (!value) return undefined;
72
- const normalized = value.toLowerCase();
73
- if (["1", "true", "yes", "on"].includes(normalized)) return true;
74
- if (["0", "false", "no", "off"].includes(normalized)) return false;
75
- return undefined;
76
- }
41
+ const { shell, env: shellEnv, prefix } = settings.getShellConfig();
42
+ const snapshotPath = shell.includes("bash") ? await getOrCreateSnapshot(shell, shellEnv) : null;
77
43
 
78
- async function executeBashOnce(
79
- command: string,
80
- options: BashExecutorOptions | undefined,
81
- config: {
82
- shell: string;
83
- args: string[];
84
- env: Record<string, string | undefined>;
85
- prefix?: string;
86
- snapshotPath: string | null;
87
- },
88
- ): Promise<BashResult> {
89
- const { shell, args, env, prefix, snapshotPath } = config;
90
-
91
- // Merge additional env vars if provided
92
- const finalEnv = options?.env ? { ...env, ...options.env } : env;
93
- const snapshotPrefix = getSnapshotSourceCommand(snapshotPath);
44
+ // Apply command prefix if configured
94
45
  const prefixedCommand = prefix ? `${prefix} ${command}` : command;
95
- const finalCommand = `${snapshotPrefix}${prefixedCommand}`;
46
+ const finalCommand = prefixedCommand;
96
47
 
48
+ // Create output sink for truncation and artifact handling
97
49
  const sink = new OutputSink({
98
50
  onChunk: options?.onChunk,
99
51
  artifactPath: options?.artifactPath,
100
52
  artifactId: options?.artifactId,
101
53
  });
102
54
 
103
- using child = ptree.spawn([shell, ...args, finalCommand], {
104
- cwd: options?.cwd,
105
- env: finalEnv,
106
- signal: options?.signal,
107
- timeout: options?.timeout,
108
- detached: true,
109
- });
55
+ let pendingChunks = Promise.resolve();
56
+ const enqueueChunk = (chunk: string) => {
57
+ pendingChunks = pendingChunks.then(() => sink.push(chunk)).catch(() => {});
58
+ };
110
59
 
111
- // Pump streams - errors during abort/timeout are expected
112
- // Use preventClose to avoid closing the shared sink when either stream finishes
113
- await Promise.allSettled([child.stdout.pipeTo(sink.createInput()), child.stderr.pipeTo(sink.createInput())]).catch(
114
- () => {},
115
- );
60
+ if (options?.signal?.aborted) {
61
+ return {
62
+ exitCode: undefined,
63
+ cancelled: true,
64
+ ...(await sink.dump("Command cancelled")),
65
+ };
66
+ }
67
+
68
+ let abortListener: (() => void) | undefined;
116
69
 
117
- // Wait for process exit
118
70
  try {
71
+ const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey);
72
+ let shellSession = shellSessions.get(sessionKey);
73
+ if (!shellSession) {
74
+ shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined });
75
+ shellSessions.set(sessionKey, shellSession);
76
+ }
77
+
78
+ if (options?.signal) {
79
+ abortListener = () => {
80
+ shellSession?.abort();
81
+ };
82
+ options.signal.addEventListener("abort", abortListener, { once: true });
83
+ }
84
+
85
+ const result = await shellSession.run(
86
+ {
87
+ command: finalCommand,
88
+ cwd: options?.cwd,
89
+ env: options?.env,
90
+ timeoutMs: options?.timeout,
91
+ },
92
+ (err, chunk) => {
93
+ if (!err) {
94
+ enqueueChunk(chunk);
95
+ }
96
+ },
97
+ );
98
+
99
+ await pendingChunks;
100
+
101
+ // Handle timeout
102
+ if (result.timedOut) {
103
+ const annotation = options?.timeout
104
+ ? `Command timed out after ${Math.round(options.timeout / 1000)} seconds`
105
+ : "Command timed out";
106
+ return {
107
+ exitCode: undefined,
108
+ cancelled: true,
109
+ ...(await sink.dump(annotation)),
110
+ };
111
+ }
112
+
113
+ // Handle cancellation
114
+ if (result.cancelled) {
115
+ return {
116
+ exitCode: undefined,
117
+ cancelled: true,
118
+ ...(await sink.dump("Command cancelled")),
119
+ };
120
+ }
121
+
122
+ // Normal completion
119
123
  return {
120
- exitCode: await child.exited,
124
+ exitCode: result.exitCode,
121
125
  cancelled: false,
122
126
  ...(await sink.dump()),
123
127
  };
124
- } catch (err: unknown) {
125
- // Exception covers NonZeroExitError, AbortError, TimeoutError
126
- if (err instanceof Exception) {
127
- if (err.aborted) {
128
- const isTimeout = err instanceof ptree.TimeoutError || err.message.toLowerCase().includes("timed out");
129
- const annotation = isTimeout
130
- ? `Command timed out after ${Math.round((options?.timeout ?? 0) / 1000)} seconds`
131
- : undefined;
132
- return {
133
- exitCode: undefined,
134
- cancelled: true,
135
- ...(await sink.dump(annotation)),
136
- };
137
- }
138
-
139
- // NonZeroExitError
140
- return {
141
- exitCode: err.exitCode,
142
- cancelled: false,
143
- ...(await sink.dump()),
144
- };
128
+ } finally {
129
+ await pendingChunks;
130
+ if (options?.signal && abortListener) {
131
+ options.signal.removeEventListener("abort", abortListener);
145
132
  }
146
-
147
- throw err;
148
133
  }
149
134
  }
135
+
136
+ function buildSessionKey(
137
+ shell: string,
138
+ prefix: string | undefined,
139
+ snapshotPath: string | null,
140
+ env: Record<string, string>,
141
+ agentSessionKey?: string,
142
+ ): string {
143
+ const entries = Object.entries(env);
144
+ entries.sort(([a], [b]) => a.localeCompare(b));
145
+ const envSerialized = entries.map(([key, value]) => `${key}=${value}`).join("\n");
146
+ return [agentSessionKey ?? "", shell, prefix ?? "", snapshotPath ?? "", envSerialized].join("\n");
147
+ }
@@ -424,7 +424,8 @@ export async function executePython(code: string, options?: PythonExecutorOption
424
424
  await ensureKernelAvailable(cwd);
425
425
 
426
426
  const kernelMode = options?.kernelMode ?? "session";
427
- const useSharedGateway = options?.useSharedGateway;
427
+ const isTestEnv = process.env.BUN_ENV === "test" || process.env.NODE_ENV === "test";
428
+ const useSharedGateway = isTestEnv ? false : options?.useSharedGateway;
428
429
  const sessionFile = options?.sessionFile;
429
430
  const artifactsDir = options?.artifactsDir;
430
431
 
@@ -265,8 +265,8 @@ async function startGatewayProcess(
265
265
  stdin: "ignore",
266
266
  stdout: "pipe",
267
267
  stderr: "pipe",
268
- detached: true,
269
268
  windowsHide: true,
269
+ detached: true,
270
270
  env: kernelEnv,
271
271
  },
272
272
  );
@@ -296,13 +296,13 @@ async function startGatewayProcess(
296
296
  await Bun.sleep(100);
297
297
  }
298
298
 
299
- await procmgr.terminate({ target: gatewayProcess, group: true });
299
+ gatewayProcess.kill();
300
300
  throw new Error("Gateway startup timeout");
301
301
  }
302
302
 
303
303
  async function killGateway(pid: number, context: string): Promise<void> {
304
304
  try {
305
- await procmgr.terminate({ target: pid, group: true });
305
+ await procmgr.terminate({ target: pid });
306
306
  } catch (err) {
307
307
  logger.warn("Failed to kill shared gateway process", {
308
308
  error: err instanceof Error ? err.message : String(err),
package/src/ipy/kernel.ts CHANGED
@@ -490,7 +490,6 @@ export class PythonKernel {
490
490
  {
491
491
  cwd: options.cwd,
492
492
  env: kernelEnv,
493
- detached: true,
494
493
  },
495
494
  );
496
495
 
package/src/lsp/client.ts CHANGED
@@ -411,7 +411,6 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
411
411
 
412
412
  const proc = ptree.spawn([command, ...args], {
413
413
  cwd,
414
- detached: true,
415
414
  stdin: "pipe",
416
415
  env: env ? { ...process.env, ...env } : undefined,
417
416
  });
package/src/main.ts CHANGED
@@ -385,7 +385,8 @@ async function buildSessionOptions(
385
385
  process.exit(1);
386
386
  }
387
387
  options.model = model;
388
- settings.setModelRole("default", `${model.provider}/${model.id}`);
388
+ const currentRoles = settings.get("modelRoles") as Record<string, string>;
389
+ settings.override("modelRoles", { ...currentRoles, default: `${model.provider}/${model.id}` });
389
390
  } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
390
391
  const remembered = settings.getModelRole("default");
391
392
  if (remembered) {
@@ -637,9 +638,10 @@ export async function main(args: string[]) {
637
638
  const planModel = parsed.plan ?? process.env.OMP_PLAN_MODEL;
638
639
  if (smolModel || slowModel || planModel) {
639
640
  const currentRoles = settings.get("modelRoles") as Record<string, string>;
640
- if (smolModel) settings.override("modelRoles", { ...currentRoles, smol: smolModel });
641
- if (slowModel) settings.override("modelRoles", { ...currentRoles, slow: slowModel });
642
- if (planModel) settings.override("modelRoles", { ...currentRoles, plan: planModel });
641
+ if (smolModel) currentRoles.smol = smolModel;
642
+ if (slowModel) currentRoles.slow = slowModel;
643
+ if (planModel) currentRoles.plan = planModel;
644
+ settings.override("modelRoles", currentRoles);
643
645
  }
644
646
 
645
647
  await initTheme(settings.get("theme"), isInteractive, settings.get("symbolPreset"), settings.get("colorBlindMode"));
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
5
+ import { copyToClipboard } from "@oh-my-pi/pi-natives";
5
6
  import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
6
7
  import { $ } from "bun";
7
8
  import { nanoid } from "nanoid";
@@ -18,7 +19,6 @@ import type { InteractiveModeContext } from "../../modes/types";
18
19
  import { createCompactionSummaryMessage } from "../../session/messages";
19
20
  import { outputMeta } from "../../tools/output-meta";
20
21
  import { getChangelogPath, parseChangelog } from "../../utils/changelog";
21
- import { copyToClipboard } from "../../utils/clipboard";
22
22
 
23
23
  export class CommandController {
24
24
  constructor(private readonly ctx: InteractiveModeContext) {}
@@ -3,13 +3,13 @@ import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
6
+ import { readImageFromClipboard } from "@oh-my-pi/pi-natives";
6
7
  import { nanoid } from "nanoid";
7
8
  import { settings } from "../../config/settings";
8
9
  import { theme } from "../../modes/theme/theme";
9
10
  import type { InteractiveModeContext } from "../../modes/types";
10
11
  import type { AgentSessionEvent } from "../../session/agent-session";
11
12
  import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
12
- import { readImageFromClipboard } from "../../utils/clipboard";
13
13
  import { resizeImage } from "../../utils/image-resize";
14
14
  import { generateSessionTitle, setTerminalTitle } from "../../utils/title-generator";
15
15
 
@@ -563,17 +563,18 @@ export class InputController {
563
563
  try {
564
564
  const image = await readImageFromClipboard();
565
565
  if (image) {
566
- let imageData = image;
566
+ const base64Data = Buffer.from(image.data).toString("base64");
567
+ let imageData = { data: base64Data, mimeType: image.mimeType };
567
568
  if (settings.get("images.autoResize")) {
568
569
  try {
569
570
  const resized = await resizeImage({
570
571
  type: "image",
571
- data: image.data,
572
+ data: base64Data,
572
573
  mimeType: image.mimeType,
573
574
  });
574
575
  imageData = { data: resized.data, mimeType: resized.mimeType };
575
576
  } catch {
576
- imageData = image;
577
+ imageData = { data: base64Data, mimeType: image.mimeType };
577
578
  }
578
579
  }
579
580
 
@@ -290,6 +290,31 @@ These are comfort. They are not clarity.
290
290
  {{appendSystemPrompt}}
291
291
  {{/if}}
292
292
 
293
+ {{#if isCoordinator}}
294
+ {{#has tools "task"}}
295
+ <parallel_reflex>
296
+ When the work forks, you fork.
297
+
298
+ Notice the sequential habit:
299
+ - The comfort of doing one thing at a time
300
+ - The illusion that order means correctness
301
+ - The assumption that you must finish A before starting B
302
+ **Triggers requiring Task tool:**
303
+ - Editing 4+ files with no dependencies between edits
304
+ - Investigating 2+ independent subsystems or questions
305
+ - Any work that decomposes into pieces that don't need each other's results
306
+
307
+ <critical>
308
+ Sequential requires justification.
309
+ If you cannot articulate why B depends on A's result, they are parallel.
310
+ </critical>
311
+
312
+ Do not carry the whole problem in one skull.
313
+ Split the load. Bring back facts. Then cut code.
314
+ </parallel_reflex>
315
+ {{/has}}
316
+ {{/if}}
317
+
293
318
  <stakes>
294
319
  This is not practice.
295
320
 
@@ -325,23 +350,4 @@ The question is not "Does this work?"
325
350
  but "Under what conditions does this work, and what happens outside them?"
326
351
 
327
352
  Write what you can defend.
328
- </critical>
329
-
330
- {{#if isCoordinator}}
331
- {{#has tools "task"}}
332
- <critical id="coordinator">
333
- Your context window is limited—especially the output. Work in discrete steps and run each step using Task tool. Avoid putting substantial work in the main context when possible. Run multiple tasks in parallel whenever possible.
334
-
335
- ## Triggers requiring Task tool
336
- - Editing 4+ files with no dependencies → `Task`
337
- - Investigating 2+ independent questions → `Task`
338
- - Any work that decomposes into pieces that don't need each other's results → `Task`
339
-
340
- Sequential requires justification.
341
- If you cannot articulate why B depends on A's result, they are parallel.
342
-
343
- Do not carry the whole problem in one skull.
344
- Split the load. Bring back facts. Then synthesize.
345
- </critical>
346
- {{/has}}
347
- {{/if}}
353
+ </critical>
@@ -3080,6 +3080,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3080
3080
  const result = await executeBashCommand(command, {
3081
3081
  onChunk,
3082
3082
  signal: this._bashAbortController.signal,
3083
+ sessionKey: this.sessionId,
3083
3084
  });
3084
3085
 
3085
3086
  this.recordBashResult(command, result, options);