@ocis/myagent-cli 0.2.1 → 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/bin/myagent.js CHANGED
@@ -21,13 +21,18 @@ for (const sig of ["SIGTERM", "SIGHUP"]) {
21
21
  process.on(sig, () => { try { child?.kill(sig); } catch { /* already gone */ } });
22
22
  }
23
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
+
24
30
  function launch(runtime) {
25
31
  child = spawn(runtime, [entry, ...args], { stdio: "inherit" });
26
32
 
27
33
  child.once("error", (err) => {
28
34
  if (err.code === "ENOENT" && runtime !== process.execPath) {
29
- const [major, minor] = process.versions.node.split(".").map(Number);
30
- if (major < 22 || (major === 22 && minor < 19)) {
35
+ if (nodeTooOld()) {
31
36
  console.error(
32
37
  `myagent requires Bun or Node.js >= 22.19 (found Node ${process.versions.node}).\n` +
33
38
  "Install Bun: https://bun.sh",
@@ -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/index.js CHANGED
@@ -8,8 +8,8 @@
8
8
  import { createInterface } from "node:readline/promises";
9
9
  import { stdin as input, stdout as output } from "node:process";
10
10
  import { homedir } from "node:os";
11
- import { readFile } from "node:fs/promises";
12
11
  import { resolve } from "node:path";
12
+ import pkg from "../package.json" with { type: "json" };
13
13
  import { IntegrationClient } from "./protocol/client.js";
14
14
  import { ToolRegistry } from "./tools/registry.js";
15
15
  import { combinedIntegrationUrl, configDir, isConfigured, loadConfig, migrateLegacyConfigDir, resolveConfig, saveConfig, sessionsPath, splitIntegrationUrl, } from "./config.js";
@@ -21,18 +21,11 @@ import { discoverSkills, formatSkillList, resolveSkillDirs } from "./skills/disc
21
21
  import { sanitizeForTerminal } from "./sanitize.js";
22
22
  import { isMainModule, readStdinText } from "./runtime.js";
23
23
  /**
24
- * Package version, read from package.json so npm releases never drift from
25
- * `--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).
26
27
  */
27
- async function readVersion() {
28
- try {
29
- const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
30
- return pkg.version ?? "0.0.0";
31
- }
32
- catch {
33
- return "0.0.0";
34
- }
35
- }
28
+ const VERSION = pkg.version;
36
29
  const HELP = `myagent — a coding agent for your local repository, powered by MyAgent
37
30
 
38
31
  Usage:
@@ -304,7 +297,7 @@ async function main() {
304
297
  return 0;
305
298
  }
306
299
  if (args.command === "version") {
307
- process.stdout.write(`${await readVersion()}\n`);
300
+ process.stdout.write(`${VERSION}\n`);
308
301
  return 0;
309
302
  }
310
303
  const stored = await loadConfig();
package/dist/runtime.d.ts CHANGED
@@ -1,5 +1,3 @@
1
- /** Resolve an executable on PATH (Bun.which on Bun, a PATH scan on Node). */
2
- export declare function which(cmd: string): string | null;
3
1
  /** Read all of stdin as UTF-8 text. Callers check `process.stdin.isTTY` first. */
4
2
  export declare function readStdinText(): Promise<string>;
5
3
  /**
@@ -24,3 +22,10 @@ export interface SpawnOptions {
24
22
  detached?: boolean;
25
23
  }
26
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
@@ -2,40 +2,18 @@
2
2
  // Runtime shims: the few places where Bun and Node genuinely differ.
3
3
  //
4
4
  // Everything else uses node: builtins, which Bun implements natively — only
5
- // subprocess spawning (Bun.spawn vs node:child_process), PATH lookup
6
- // (Bun.which), stdin (Bun.stdin) and entry-point detection (import.meta.main)
7
- // need a runtime branch. The Bun branches are guarded by `typeof Bun`, so the
8
- // emitted JS never touches the `Bun` global on Node.
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.
9
9
  // ---------------------------------------------------------------------------
10
10
  import { spawn } from "node:child_process";
11
- import { realpathSync, statSync } from "node:fs";
11
+ import { realpathSync } from "node:fs";
12
12
  import { Buffer } from "node:buffer";
13
- import { delimiter, join } from "node:path";
13
+ import { constants as osConstants } from "node:os";
14
14
  import { Readable } from "node:stream";
15
15
  import { fileURLToPath } from "node:url";
16
16
  const isBun = typeof Bun !== "undefined";
17
- // --- which -----------------------------------------------------------------
18
- /** Resolve an executable on PATH (Bun.which on Bun, a PATH scan on Node). */
19
- export function which(cmd) {
20
- if (isBun && Bun.which)
21
- return Bun.which(cmd);
22
- const exts = process.platform === "win32"
23
- ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
24
- : [""];
25
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
26
- for (const ext of exts) {
27
- const candidate = join(dir, cmd + ext);
28
- try {
29
- if (statSync(candidate).isFile())
30
- return candidate;
31
- }
32
- catch {
33
- /* keep looking */
34
- }
35
- }
36
- }
37
- return null;
38
- }
39
17
  // --- stdin -----------------------------------------------------------------
40
18
  /** Read all of stdin as UTF-8 text. Callers check `process.stdin.isTTY` first. */
41
19
  export async function readStdinText() {
@@ -70,18 +48,51 @@ export function isMainModule(meta) {
70
48
  export function spawnProcess(args, opts) {
71
49
  return isBun ? spawnBun(args, opts) : spawnNode(args, opts);
72
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
+ }
73
68
  function spawnBun(args, opts) {
74
- const proc = Bun.spawn(args, {
75
- cwd: opts?.cwd,
76
- stdin: "ignore",
77
- stdout: opts?.stdout === "pipe" ? "pipe" : "ignore",
78
- stderr: opts?.stderr === "pipe" ? "pipe" : "ignore",
79
- detached: opts?.detached,
80
- });
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
+ }
81
92
  return {
82
93
  pid: proc.pid,
83
- stdout: proc.stdout,
84
- stderr: proc.stderr,
94
+ stdout: opts?.stdout === "pipe" ? proc.stdout : emptyStream(),
95
+ stderr: opts?.stderr === "pipe" ? proc.stderr : emptyStream(),
85
96
  exited: proc.exited,
86
97
  kill: (signal) => { try {
87
98
  proc.kill(signal);
@@ -101,17 +112,17 @@ function spawnNode(args, opts) {
101
112
  });
102
113
  // First of close/error wins: a spawn failure (ENOENT) may emit only `error`.
103
114
  const exited = new Promise((resolve) => {
104
- child.once("close", (code) => resolve(code ?? 0));
115
+ child.once("close", (code, signal) => resolve(exitCodeFromClose(code, signal)));
105
116
  child.once("error", () => resolve(-1));
106
117
  });
107
118
  return {
108
119
  pid: child.pid ?? 0,
109
120
  stdout: child.stdout
110
121
  ? Readable.toWeb(child.stdout)
111
- : new ReadableStream(),
122
+ : emptyStream(),
112
123
  stderr: child.stderr
113
124
  ? Readable.toWeb(child.stderr)
114
- : new ReadableStream(),
125
+ : emptyStream(),
115
126
  exited,
116
127
  kill: (signal) => { try {
117
128
  child.kill(signal);
@@ -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
  }
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ocis/myagent-cli",
3
- "version": "0.2.1",
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": {