@ocis/myagent-cli 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -36,15 +36,15 @@ local repo (cwd) remote myagent
36
36
  ## Install
37
37
 
38
38
  ```bash
39
- npm install -g @ocis/myagent-cli # global (Node ≥22.19)
40
- bunx @ocis/myagent-cli # one-off, no install (Bun)
39
+ bunx @ocis/myagent-cli # one-off, no install
40
+ npm install -g @ocis/myagent-cli # global
41
41
  ```
42
42
 
43
- The CLI runs on **Node.js ≥22.19** or **Bun ≥1.0** — it detects the runtime it was
44
- launched with. The published bin uses a Node shebang, so `bun install -g` also runs
45
- it under Node; use `bunx` (or `bun <path-to-bin>`) to run it on Bun. Configure
46
- credentials with `myagent config`, or set `MYAGENT_BASE_URL` /
47
- `MYAGENT_INTEGRATION_ID` / `MYAGENT_API_KEY`.
43
+ The CLI runs on **Bun ≥1.0** or **Node.js ≥22.19**. The published bin is a small
44
+ launcher that prefers Bun and falls back to Node, so `bunx` and `npm install -g`
45
+ both work with whichever runtime you have. Configure credentials with
46
+ `myagent config`, or set `MYAGENT_BASE_URL` / `MYAGENT_INTEGRATION_ID` /
47
+ `MYAGENT_API_KEY`.
48
48
 
49
49
  ## Plan / Coding / Cowork modes
50
50
 
package/bin/myagent.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // Runtime launcher for the myagent CLI.
3
+ //
4
+ // dist/index.js runs on Bun or Node >= 22.19, but a bin shebang names exactly
5
+ // one interpreter — and `bunx` honors it, so a plain Node shebang would run
6
+ // the CLI under whatever Node happens to be on PATH (often too old for
7
+ // pi-tui). This wrapper prefers Bun and falls back to Node.
8
+ import { spawn } from "node:child_process";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const entry = fileURLToPath(new URL("../dist/index.js", import.meta.url));
12
+ const args = process.argv.slice(2);
13
+
14
+ // Ctrl+C reaches the whole foreground process group, so the CLI handles it
15
+ // itself; the launcher must not exit before that shutdown finishes. SIGTERM /
16
+ // SIGHUP sent to the launcher PID alone are forwarded.
17
+ process.on("SIGINT", () => {});
18
+
19
+ let child;
20
+ for (const sig of ["SIGTERM", "SIGHUP"]) {
21
+ process.on(sig, () => { try { child?.kill(sig); } catch { /* already gone */ } });
22
+ }
23
+
24
+ /** pi-tui's regex needs the `v` flag, which Node only has from 22.19. */
25
+ function nodeTooOld() {
26
+ const [major, minor] = process.versions.node.split(".").map(Number);
27
+ return major < 22 || (major === 22 && minor < 19);
28
+ }
29
+
30
+ function launch(runtime) {
31
+ child = spawn(runtime, [entry, ...args], { stdio: "inherit" });
32
+
33
+ child.once("error", (err) => {
34
+ if (err.code === "ENOENT" && runtime !== process.execPath) {
35
+ if (nodeTooOld()) {
36
+ console.error(
37
+ `myagent requires Bun or Node.js >= 22.19 (found Node ${process.versions.node}).\n` +
38
+ "Install Bun: https://bun.sh",
39
+ );
40
+ process.exit(1);
41
+ }
42
+ launch(process.execPath);
43
+ return;
44
+ }
45
+ console.error(`myagent: failed to start ${runtime}: ${err.message}`);
46
+ process.exit(1);
47
+ });
48
+
49
+ child.once("exit", (code) => process.exit(code ?? 1));
50
+ }
51
+
52
+ launch("bun");
@@ -5,9 +5,8 @@
5
5
  // remembers which thread belongs to which working directory so `-c`/`-r`
6
6
  // resume the right conversation. Never contains credentials.
7
7
  // ---------------------------------------------------------------------------
8
- import { mkdir, rename } from "node:fs/promises";
8
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
9
9
  import { dirname } from "node:path";
10
- import { fileExists, readJsonFile, writeFile } from "../runtime.js";
11
10
  export class SessionIndex {
12
11
  path;
13
12
  data;
@@ -17,9 +16,7 @@ export class SessionIndex {
17
16
  }
18
17
  static async load(path) {
19
18
  try {
20
- if (!(await fileExists(path)))
21
- return new SessionIndex(path);
22
- const parsed = await readJsonFile(path);
19
+ const parsed = JSON.parse(await readFile(path, "utf8"));
23
20
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
24
21
  return new SessionIndex(path);
25
22
  // Normalize instead of trusting the persisted shape: a truncated write
@@ -850,6 +850,11 @@ export class AgentRunner {
850
850
  if (adopted) {
851
851
  live.push({ ...output, tool_call_id: adopted.id });
852
852
  unmatched.delete(adopted.id);
853
+ // The original id is never submitted (its result was re-attached to
854
+ // the retry), so it must leave the pending set — otherwise the run-end
855
+ // sweep announces a release for a call that was answered.
856
+ if (call)
857
+ this.pendingSubmission.delete(call.id);
853
858
  cb.onNotice(`Re-attached the result of ${call?.name ?? "a tool call"} to the agent's retry.`, "info");
854
859
  continue;
855
860
  }
@@ -142,7 +142,7 @@ export class ApprovalPolicy {
142
142
  try {
143
143
  // A cancel/timeout while queued settles immediately — no card, no wait.
144
144
  if (wait)
145
- await Promise.race([previous.catch(() => { }), deadline.promise]);
145
+ await Promise.race([previous, deadline.promise]);
146
146
  if (settled)
147
147
  return await deadline.promise;
148
148
  return await Promise.race([this.prompt(request, controller.signal), deadline.promise]);
package/dist/config.js CHANGED
@@ -7,8 +7,7 @@
7
7
  // -------------------------------------------------------------------
8
8
  import { homedir } from "node:os";
9
9
  import { join } from "node:path";
10
- import { mkdir, chmod, rename, stat } from "node:fs/promises";
11
- import { fileExists, readJsonFile, writeFile } from "./runtime.js";
10
+ import { mkdir, chmod, readFile, rename, stat, writeFile } from "node:fs/promises";
12
11
  export const EMPTY_CONFIG = {
13
12
  baseUrl: "",
14
13
  integrationId: "",
@@ -54,10 +53,7 @@ export async function migrateLegacyConfigDir() {
54
53
  }
55
54
  export async function loadConfig() {
56
55
  try {
57
- const path = configPath();
58
- if (!(await fileExists(path)))
59
- return { ...EMPTY_CONFIG };
60
- const data = await readJsonFile(path);
56
+ const data = JSON.parse(await readFile(configPath(), "utf8"));
61
57
  const merged = { ...EMPTY_CONFIG, ...data };
62
58
  // Legacy field: autoApprove:false used to mean "ask" — honour it once.
63
59
  if (data.approvalDefault === undefined && data.autoApprove === false) {
@@ -118,7 +118,7 @@ export function mergeGitStatus(porcelain, unstaged, staged) {
118
118
  const MAX_GIT_OUTPUT_CHARS = 512 * 1024;
119
119
  async function gitOutput(workspace, args, signal, okExitCodes) {
120
120
  try {
121
- const proc = spawnProcess(["git", "-C", workspace, ...args], { stdout: "pipe", stderr: "ignore", stdin: "ignore" });
121
+ const proc = spawnProcess(["git", "-C", workspace, ...args], { stdout: "pipe", stderr: "ignore" });
122
122
  const onAbort = () => { try {
123
123
  proc.kill();
124
124
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  import { type StoredConfig } from "./config.js";
3
2
  import { type AgentMode } from "./agent/modes.js";
4
3
  import { type OutputFormat } from "./headless.js";
package/dist/index.js CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  // ---------------------------------------------------------------------------
3
2
  // myagent CLI — entry point.
4
3
  //
@@ -10,7 +9,7 @@ import { createInterface } from "node:readline/promises";
10
9
  import { stdin as input, stdout as output } from "node:process";
11
10
  import { homedir } from "node:os";
12
11
  import { resolve } from "node:path";
13
- import { fileURLToPath } from "node:url";
12
+ import pkg from "../package.json" with { type: "json" };
14
13
  import { IntegrationClient } from "./protocol/client.js";
15
14
  import { ToolRegistry } from "./tools/registry.js";
16
15
  import { combinedIntegrationUrl, configDir, isConfigured, loadConfig, migrateLegacyConfigDir, resolveConfig, saveConfig, sessionsPath, splitIntegrationUrl, } from "./config.js";
@@ -20,20 +19,13 @@ import { runTui } from "./ui/app.js";
20
19
  import { failureDoneEvent, failureResult, runHeadless } from "./headless.js";
21
20
  import { discoverSkills, formatSkillList, resolveSkillDirs } from "./skills/discovery.js";
22
21
  import { sanitizeForTerminal } from "./sanitize.js";
23
- import { isMainModule, readJsonFile, readStdinText } from "./runtime.js";
22
+ import { isMainModule, readStdinText } from "./runtime.js";
24
23
  /**
25
- * Package version, read from package.json so npm releases never drift from
26
- * `--version`. Resolves to the package root from both src/ (dev) and dist/.
24
+ * Package version, bundled from package.json so npm releases and the compiled
25
+ * binary never drift from `--version` (a runtime read cannot resolve inside
26
+ * `bun build --compile`'s virtual filesystem).
27
27
  */
28
- async function readVersion() {
29
- try {
30
- const pkg = await readJsonFile(fileURLToPath(new URL("../package.json", import.meta.url)));
31
- return pkg.version ?? "0.0.0";
32
- }
33
- catch {
34
- return "0.0.0";
35
- }
36
- }
28
+ const VERSION = pkg.version;
37
29
  const HELP = `myagent — a coding agent for your local repository, powered by MyAgent
38
30
 
39
31
  Usage:
@@ -305,7 +297,7 @@ async function main() {
305
297
  return 0;
306
298
  }
307
299
  if (args.command === "version") {
308
- process.stdout.write(`${await readVersion()}\n`);
300
+ process.stdout.write(`${VERSION}\n`);
309
301
  return 0;
310
302
  }
311
303
  const stored = await loadConfig();
package/dist/runtime.d.ts CHANGED
@@ -1,10 +1,3 @@
1
- export declare const isBun: boolean;
2
- export declare function fileExists(path: string): Promise<boolean>;
3
- export declare function readTextFile(path: string): Promise<string>;
4
- export declare function readJsonFile<T>(path: string): Promise<T>;
5
- export declare function writeFile(path: string, data: string): Promise<void>;
6
- /** Resolve an executable on PATH (Bun.which on Bun, a PATH scan on Node). */
7
- export declare function which(cmd: string): string | null;
8
1
  /** Read all of stdin as UTF-8 text. Callers check `process.stdin.isTTY` first. */
9
2
  export declare function readStdinText(): Promise<string>;
10
3
  /**
@@ -14,25 +7,25 @@ export declare function readStdinText(): Promise<string>;
14
7
  * comparing the resolved entry path (realpath, so npm/bun bin symlinks match).
15
8
  */
16
9
  export declare function isMainModule(meta: ImportMeta): boolean;
17
- export interface ProcessStdin {
18
- write(data: Uint8Array): void;
19
- end(): void;
20
- }
21
10
  export interface ProcessHandle {
22
11
  pid: number;
23
12
  stdout: ReadableStream<Uint8Array>;
24
13
  stderr: ReadableStream<Uint8Array>;
25
- stdin: ProcessStdin;
26
14
  exited: Promise<number>;
27
15
  kill(signal?: number | NodeJS.Signals): void;
28
16
  }
29
17
  export interface SpawnOptions {
30
18
  cwd?: string;
31
- env?: Record<string, string>;
32
- stdin?: "pipe" | "ignore";
33
19
  stdout?: "pipe" | "ignore";
34
20
  stderr?: "pipe" | "ignore";
35
21
  /** POSIX: run in a new process group so `process.kill(-pid)` reaches the tree. */
36
22
  detached?: boolean;
37
23
  }
38
24
  export declare function spawnProcess(args: string[], opts?: SpawnOptions): ProcessHandle;
25
+ /**
26
+ * Exit code for a child that ended, matching Bun's convention: a signal death
27
+ * is 128 + signal number (SIGKILL → 137). Node's `close` reports `code: null`
28
+ * with the signal name, so without this the same kill reads as 0 on Node and
29
+ * 137 on Bun — abort paths key on the code.
30
+ */
31
+ export declare function exitCodeFromClose(code: number | null, signal: NodeJS.Signals | null): number;
package/dist/runtime.js CHANGED
@@ -1,74 +1,19 @@
1
1
  // ---------------------------------------------------------------------------
2
- // Runtime shims: Bun APIs on Bun, node: equivalents on Node.
2
+ // Runtime shims: the few places where Bun and Node genuinely differ.
3
3
  //
4
- // The CLI runs on both runtimes. Bun's file/spawn/stdin/which globals have no
5
- // Node counterpart, so every call site goes through this module instead of
6
- // touching `Bun.*` directly. The Bun branches are guarded by `typeof Bun`, so
7
- // the emitted JS never touches the `Bun` global on Node.
8
- //
9
- // The subprocess shim normalizes both runtimes to one `ProcessHandle` (Web
10
- // ReadableStreams for stdout/stderr, a FileSink-like stdin, `.pid`, `.kill()`,
11
- // `.exited`) — the same shape `Bun.spawn` returns, so the shell/git/grep tools
12
- // run unchanged on either runtime.
4
+ // Everything else uses node: builtins, which Bun implements natively — only
5
+ // subprocess spawning (Bun.spawn vs node:child_process), stdin (Bun.stdin) and
6
+ // entry-point detection (import.meta.main) need a runtime branch. The Bun
7
+ // branches are guarded by `typeof Bun`, so the emitted JS never touches the
8
+ // `Bun` global on Node.
13
9
  // ---------------------------------------------------------------------------
14
10
  import { spawn } from "node:child_process";
15
- import { realpathSync, statSync } from "node:fs";
16
- import { readFile, stat, writeFile as writeFileFs } from "node:fs/promises";
11
+ import { realpathSync } from "node:fs";
17
12
  import { Buffer } from "node:buffer";
18
- import { delimiter, join } from "node:path";
13
+ import { constants as osConstants } from "node:os";
19
14
  import { Readable } from "node:stream";
20
15
  import { fileURLToPath } from "node:url";
21
- export const isBun = typeof Bun !== "undefined";
22
- // --- files -----------------------------------------------------------------
23
- export async function fileExists(path) {
24
- if (isBun)
25
- return Bun.file(path).exists();
26
- try {
27
- return (await stat(path)).isFile();
28
- }
29
- catch {
30
- return false;
31
- }
32
- }
33
- export async function readTextFile(path) {
34
- if (isBun)
35
- return Bun.file(path).text();
36
- return readFile(path, "utf8");
37
- }
38
- export async function readJsonFile(path) {
39
- return JSON.parse(await readTextFile(path));
40
- }
41
- export async function writeFile(path, data) {
42
- if (isBun) {
43
- await Bun.write(path, data);
44
- return;
45
- }
46
- await writeFileFs(path, data);
47
- }
48
- // --- which -----------------------------------------------------------------
49
- /** Resolve an executable on PATH (Bun.which on Bun, a PATH scan on Node). */
50
- export function which(cmd) {
51
- if (isBun && Bun.which)
52
- return Bun.which(cmd);
53
- const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
54
- const exts = process.platform === "win32"
55
- ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
56
- : [""];
57
- const names = cmd.includes(".") ? [cmd] : exts.map((ext) => cmd + ext);
58
- for (const dir of dirs) {
59
- for (const name of names) {
60
- const candidate = join(dir, name);
61
- try {
62
- if (statSync(candidate).isFile())
63
- return candidate;
64
- }
65
- catch {
66
- /* keep looking */
67
- }
68
- }
69
- }
70
- return null;
71
- }
16
+ const isBun = typeof Bun !== "undefined";
72
17
  // --- stdin -----------------------------------------------------------------
73
18
  /** Read all of stdin as UTF-8 text. Callers check `process.stdin.isTTY` first. */
74
19
  export async function readStdinText() {
@@ -103,23 +48,51 @@ export function isMainModule(meta) {
103
48
  export function spawnProcess(args, opts) {
104
49
  return isBun ? spawnBun(args, opts) : spawnNode(args, opts);
105
50
  }
51
+ /** An already-ended stream, for pipes that were not requested. */
52
+ function emptyStream() {
53
+ return new ReadableStream();
54
+ }
55
+ /**
56
+ * Exit code for a child that ended, matching Bun's convention: a signal death
57
+ * is 128 + signal number (SIGKILL → 137). Node's `close` reports `code: null`
58
+ * with the signal name, so without this the same kill reads as 0 on Node and
59
+ * 137 on Bun — abort paths key on the code.
60
+ */
61
+ export function exitCodeFromClose(code, signal) {
62
+ if (code !== null)
63
+ return code;
64
+ if (!signal)
65
+ return 0;
66
+ return 128 + (osConstants.signals[signal] ?? 0);
67
+ }
106
68
  function spawnBun(args, opts) {
107
- const proc = Bun.spawn(args, {
108
- cwd: opts?.cwd,
109
- env: opts?.env ? { ...process.env, ...opts.env } : undefined,
110
- stdin: opts?.stdin === "pipe" ? "pipe" : "ignore",
111
- stdout: opts?.stdout === "pipe" ? "pipe" : "ignore",
112
- stderr: opts?.stderr === "pipe" ? "pipe" : "ignore",
113
- detached: opts?.detached,
114
- });
69
+ let proc;
70
+ try {
71
+ proc = Bun.spawn(args, {
72
+ cwd: opts?.cwd,
73
+ stdin: "ignore",
74
+ stdout: opts?.stdout === "pipe" ? "pipe" : "ignore",
75
+ stderr: opts?.stderr === "pipe" ? "pipe" : "ignore",
76
+ detached: opts?.detached,
77
+ });
78
+ }
79
+ catch (err) {
80
+ // Bun throws synchronously for a missing binary; Node reports it through
81
+ // `exited` = -1. Normalize so callers see one contract on both runtimes.
82
+ if (err?.code !== "ENOENT")
83
+ throw err;
84
+ return {
85
+ pid: 0,
86
+ stdout: emptyStream(),
87
+ stderr: emptyStream(),
88
+ exited: Promise.resolve(-1),
89
+ kill: () => { },
90
+ };
91
+ }
115
92
  return {
116
93
  pid: proc.pid,
117
- stdout: proc.stdout,
118
- stderr: proc.stderr,
119
- stdin: {
120
- write: (data) => { proc.stdin.write(data); },
121
- end: () => { proc.stdin.end(); },
122
- },
94
+ stdout: opts?.stdout === "pipe" ? proc.stdout : emptyStream(),
95
+ stderr: opts?.stderr === "pipe" ? proc.stderr : emptyStream(),
123
96
  exited: proc.exited,
124
97
  kill: (signal) => { try {
125
98
  proc.kill(signal);
@@ -130,9 +103,8 @@ function spawnBun(args, opts) {
130
103
  function spawnNode(args, opts) {
131
104
  const child = spawn(args[0], args.slice(1), {
132
105
  cwd: opts?.cwd,
133
- env: opts?.env ? { ...process.env, ...opts.env } : process.env,
134
106
  stdio: [
135
- opts?.stdin === "pipe" ? "pipe" : "ignore",
107
+ "ignore",
136
108
  opts?.stdout === "pipe" ? "pipe" : "ignore",
137
109
  opts?.stderr === "pipe" ? "pipe" : "ignore",
138
110
  ],
@@ -140,23 +112,17 @@ function spawnNode(args, opts) {
140
112
  });
141
113
  // First of close/error wins: a spawn failure (ENOENT) may emit only `error`.
142
114
  const exited = new Promise((resolve) => {
143
- child.once("close", (code) => resolve(code ?? 0));
115
+ child.once("close", (code, signal) => resolve(exitCodeFromClose(code, signal)));
144
116
  child.once("error", () => resolve(-1));
145
117
  });
146
118
  return {
147
119
  pid: child.pid ?? 0,
148
120
  stdout: child.stdout
149
121
  ? Readable.toWeb(child.stdout)
150
- : new ReadableStream(),
122
+ : emptyStream(),
151
123
  stderr: child.stderr
152
124
  ? Readable.toWeb(child.stderr)
153
- : new ReadableStream(),
154
- stdin: {
155
- write: (data) => { if (child.stdin)
156
- child.stdin.write(Buffer.from(data)); },
157
- end: () => { if (child.stdin)
158
- child.stdin.end(); },
159
- },
125
+ : emptyStream(),
160
126
  exited,
161
127
  kill: (signal) => { try {
162
128
  child.kill(signal);
@@ -8,9 +8,8 @@
8
8
  // 2. <workspace>/.myagent/skills
9
9
  // 3. <configDir>/skills (created on first run so there's always a home)
10
10
  // -------------------------------------------------------------------
11
- import { mkdir, readdir, stat } from "node:fs/promises";
11
+ import { mkdir, readFile, readdir, stat } from "node:fs/promises";
12
12
  import { join, resolve } from "node:path";
13
- import { readTextFile } from "../runtime.js";
14
13
  /** Parse the leading `--- ... ---` frontmatter for name/description. */
15
14
  export function parseFrontmatter(md) {
16
15
  const match = md.match(/^---\r?\n([\s\S]*?)\r?\n---/);
@@ -27,7 +26,7 @@ export function parseFrontmatter(md) {
27
26
  async function loadSkillDir(dir, fallbackName) {
28
27
  let md;
29
28
  try {
30
- md = await readTextFile(join(dir, "SKILL.md"));
29
+ md = await readFile(join(dir, "SKILL.md"), "utf8");
31
30
  }
32
31
  catch {
33
32
  return null;
@@ -8,7 +8,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
8
8
  import { join, relative } from "node:path";
9
9
  import { defineTool, optionalString, requireString } from "./types.js";
10
10
  import { resolveToolPath } from "./paths.js";
11
- import { spawnProcess, which } from "../runtime.js";
11
+ import { spawnProcess } from "../runtime.js";
12
12
  const MAX_MATCHES = 100;
13
13
  /**
14
14
  * Wall-clock budget for the JS fallback walk. The pattern is LLM-authored and
@@ -31,7 +31,9 @@ function formatMatches(matches, workspace) {
31
31
  return lines.join("\n") + (truncated ? `\n… (results capped at ${MAX_MATCHES} matches)` : "");
32
32
  }
33
33
  /**
34
- * Search with ripgrep when available. Returns null when rg is unavailable.
34
+ * Search with ripgrep when available. Returns null when rg is unavailable
35
+ * (missing binary, rejected pattern, spawn failure) so the caller falls back
36
+ * to the JS walk.
35
37
  *
36
38
  * The cap is enforced HERE, on the parse: rg's `--max-count` is per FILE, so
37
39
  * on a large tree it can still produce a huge JSON stream. The output is read
@@ -39,9 +41,6 @@ function formatMatches(matches, workspace) {
39
41
  * buffer nor the child outlives the cap.
40
42
  */
41
43
  async function searchWithRipgrep(pattern, searchPath, include, signal) {
42
- const rg = which("rg");
43
- if (!rg)
44
- return null;
45
44
  // --max-count bounds the per-file work rg does; the global cap is ours.
46
45
  const args = ["--json", "--max-count", String(MAX_MATCHES), "--no-ignore", "-e", pattern];
47
46
  if (include)
@@ -50,7 +49,7 @@ async function searchWithRipgrep(pattern, searchPath, include, signal) {
50
49
  args.push("--glob", `!**/${dir}/**`);
51
50
  args.push(searchPath);
52
51
  try {
53
- const proc = spawnProcess([rg, ...args], { stdout: "pipe", stderr: "ignore" });
52
+ const proc = spawnProcess(["rg", ...args], { stdout: "pipe", stderr: "ignore" });
54
53
  const onAbort = () => { try {
55
54
  proc.kill();
56
55
  }
@@ -106,10 +105,10 @@ async function searchWithRipgrep(pattern, searchPath, include, signal) {
106
105
  if (buffer)
107
106
  consume(buffer);
108
107
  const code = await proc.exited;
109
- // rg exits 1 for "no matches" (an answer); any other non-zero exit is a
110
- // failure — e.g. a JS-valid pattern (backreference, lookahead) its engine
111
- // rejects — and must fall back to the JS walk rather than read as "none".
112
- if (code > 1)
108
+ // rg exits 0 (matches) or 1 (no matches); anything else — a rejected
109
+ // pattern, a spawn failure (-1) — falls back to the JS walk rather than
110
+ // reading as "none".
111
+ if (code !== 0 && code !== 1)
113
112
  return null;
114
113
  return matches;
115
114
  }
@@ -69,10 +69,8 @@ const shellTool = defineTool({
69
69
  const detached = process.platform !== "win32";
70
70
  const proc = spawnProcess(shell, {
71
71
  cwd: ctx.workspace,
72
- env: process.env,
73
72
  stdout: "pipe",
74
73
  stderr: "pipe",
75
- stdin: "ignore",
76
74
  // POSIX: a new process group lets us kill the whole pipeline (a bare
77
75
  // proc.kill() only signals the shell, and grandchildren keep the pipes
78
76
  // open, delaying the timeout unwind).
package/dist/ui/app.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // Owns the slash commands, keybindings, approvals, resume/render of remote
6
6
  // history and the git status polling that feeds the right panel.
7
7
  // ---------------------------------------------------------------------------
8
+ import { writeFile } from "node:fs/promises";
8
9
  import { basename, join, resolve } from "node:path";
9
10
  import { CombinedAutocompleteProvider, Box, Container, HStack, isViewportTUI, matchesKey, MouseRegion, ProcessTerminal, ScrollView, SelectList, Spacer, TuiAltScreen, VStack, visibleWidth, } from "@earendil-works/pi-tui";
10
11
  import { AgentRunner, usageFromSession } from "../agent/turn.js";
@@ -14,7 +15,6 @@ import { deriveTitle } from "../agent/sessions.js";
14
15
  import { TodoStore } from "../agent/todo.js";
15
16
  import { ApprovalPolicy } from "../approval/policy.js";
16
17
  import { collectFileDiff, collectGitStatus, EMPTY_GIT_STATUS } from "../git/status.js";
17
- import { writeFile } from "../runtime.js";
18
18
  import { bgRgb, isColorEnabled, modeBorder, overlayBackgroundRgb, panelBackgroundRgb, style, userMessageBackgroundRgb } from "./colors.js";
19
19
  import { helpLines } from "./help.js";
20
20
  import { buildModelItems, currentModelId } from "./model-list.js";
@@ -42,6 +42,9 @@ export declare class AssistantMessageComponent implements Component {
42
42
  /** Raw stream (unsanitized) — the sanitizing pass needs the whole text. */
43
43
  private raw;
44
44
  private text;
45
+ /** Set once any delta carried a control char: from then on the accumulated
46
+ * text must be re-sanitized (a sequence can span deltas). */
47
+ private dirty;
45
48
  constructor();
46
49
  append(delta: string): void;
47
50
  get content(): string;
@@ -51,6 +54,7 @@ export declare class AssistantMessageComponent implements Component {
51
54
  export declare class ReasoningBlockComponent implements Component {
52
55
  private raw;
53
56
  private text;
57
+ private dirty;
54
58
  expanded: boolean;
55
59
  append(delta: string): void;
56
60
  toggle(): void;
@@ -84,11 +84,21 @@ export class UserMessageComponent {
84
84
  ];
85
85
  }
86
86
  }
87
+ /**
88
+ * Characters `sanitizeForTerminal` would change (ESC and C0/C1 controls; tab
89
+ * and newline are kept). While a stream has never contained one, the sanitizer
90
+ * is the identity — re-running it over the whole accumulated text on every
91
+ * delta is O(n²) for no effect.
92
+ */
93
+ const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/;
87
94
  export class AssistantMessageComponent {
88
95
  md;
89
96
  /** Raw stream (unsanitized) — the sanitizing pass needs the whole text. */
90
97
  raw = "";
91
98
  text = "";
99
+ /** Set once any delta carried a control char: from then on the accumulated
100
+ * text must be re-sanitized (a sequence can span deltas). */
101
+ dirty = false;
92
102
  constructor() {
93
103
  this.md = new Markdown("", 0, 0, markdownTheme, { color: assistantColor });
94
104
  }
@@ -97,9 +107,16 @@ export class AssistantMessageComponent {
97
107
  // material (file bodies, fetched pages) verbatim — strip terminal
98
108
  // sequences, like tool output and headless deltas do. Sanitizing the
99
109
  // ACCUMULATED text, not the delta: a sequence split across two deltas
100
- // would slip through a per-delta pass.
110
+ // would slip through a per-delta pass. The clean fast path is exact: with
111
+ // no control char anywhere, the sanitizer returns the input unchanged.
101
112
  this.raw += delta;
102
- this.text = sanitizeForTerminal(this.raw);
113
+ if (!this.dirty && !CONTROL_CHARS.test(delta)) {
114
+ this.text = this.raw;
115
+ }
116
+ else {
117
+ this.dirty = true;
118
+ this.text = sanitizeForTerminal(this.raw);
119
+ }
103
120
  this.md.setText(this.text);
104
121
  }
105
122
  get content() {
@@ -164,12 +181,19 @@ export class AssistantMessageComponent {
164
181
  export class ReasoningBlockComponent {
165
182
  raw = "";
166
183
  text = "";
184
+ dirty = false;
167
185
  expanded = false;
168
186
  append(delta) {
169
187
  // Same rule as assistant text: reasoning is model output rendered to the
170
188
  // real terminal, and it quotes tool output freely.
171
189
  this.raw += delta;
172
- this.text = sanitizeForTerminal(this.raw);
190
+ if (!this.dirty && !CONTROL_CHARS.test(delta)) {
191
+ this.text = this.raw;
192
+ }
193
+ else {
194
+ this.dirty = true;
195
+ this.text = sanitizeForTerminal(this.raw);
196
+ }
173
197
  }
174
198
  toggle() {
175
199
  this.expanded = !this.expanded;
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@ocis/myagent-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Terminal coding agent for your local repository, powered by a remote MyAgent server",
5
5
  "type": "module",
6
6
  "bin": {
7
- "myagent": "./dist/index.js"
7
+ "myagent": "./bin/myagent.js"
8
8
  },
9
9
  "files": [
10
+ "bin",
10
11
  "dist/**/*.js",
11
12
  "dist/**/*.d.ts",
12
13
  "skills"