@oh-my-pi/pi-coding-agent 15.7.2 → 15.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/CHANGELOG.md +114 -6
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/extension-flags.d.ts +36 -0
  4. package/dist/types/config/config-file.d.ts +4 -0
  5. package/dist/types/config/file-lock.d.ts +23 -0
  6. package/dist/types/config/keybindings.d.ts +2 -1
  7. package/dist/types/config/model-registry.d.ts +6 -0
  8. package/dist/types/config/settings-schema.d.ts +69 -65
  9. package/dist/types/edit/hashline/diff.d.ts +3 -3
  10. package/dist/types/eval/__tests__/agent-bridge.test.d.ts +1 -0
  11. package/dist/types/eval/__tests__/budget-bridge.test.d.ts +1 -0
  12. package/dist/types/eval/__tests__/idle-timeout.test.d.ts +1 -0
  13. package/dist/types/eval/agent-bridge.d.ts +25 -0
  14. package/dist/types/eval/backend.d.ts +17 -2
  15. package/dist/types/eval/budget-bridge.d.ts +29 -0
  16. package/dist/types/eval/idle-timeout.d.ts +28 -0
  17. package/dist/types/eval/js/executor.d.ts +8 -0
  18. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  19. package/dist/types/eval/py/executor.d.ts +13 -0
  20. package/dist/types/exec/bash-executor.d.ts +1 -0
  21. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  22. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  23. package/dist/types/extensibility/plugins/git-url.d.ts +11 -1
  24. package/dist/types/extensibility/plugins/manager.d.ts +12 -1
  25. package/dist/types/extensibility/shared-events.d.ts +2 -2
  26. package/dist/types/internal-urls/local-protocol.d.ts +19 -9
  27. package/dist/types/internal-urls/types.d.ts +14 -0
  28. package/dist/types/lsp/client.d.ts +3 -0
  29. package/dist/types/mcp/manager.d.ts +14 -5
  30. package/dist/types/memory-backend/index.d.ts +1 -1
  31. package/dist/types/memory-backend/resolve.d.ts +1 -1
  32. package/dist/types/memory-backend/types.d.ts +3 -3
  33. package/dist/types/mnemopi/backend.d.ts +4 -0
  34. package/dist/types/mnemopi/config.d.ts +29 -0
  35. package/dist/types/mnemopi/state.d.ts +72 -0
  36. package/dist/types/modes/components/custom-editor.d.ts +2 -2
  37. package/dist/types/modes/components/omfg-panel.d.ts +19 -0
  38. package/dist/types/modes/controllers/command-controller.d.ts +6 -0
  39. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  40. package/dist/types/modes/controllers/omfg-controller.d.ts +10 -0
  41. package/dist/types/modes/controllers/omfg-rule.d.ts +26 -0
  42. package/dist/types/modes/gradient-highlight.d.ts +5 -1
  43. package/dist/types/modes/interactive-mode.d.ts +7 -3
  44. package/dist/types/modes/magic-keywords.d.ts +14 -0
  45. package/dist/types/modes/markdown-prose.d.ts +27 -0
  46. package/dist/types/modes/orchestrate.d.ts +7 -2
  47. package/dist/types/modes/shared.d.ts +1 -1
  48. package/dist/types/modes/turn-budget.d.ts +18 -0
  49. package/dist/types/modes/types.d.ts +7 -3
  50. package/dist/types/modes/ultrathink.d.ts +7 -2
  51. package/dist/types/modes/workflow.d.ts +15 -0
  52. package/dist/types/sdk.d.ts +13 -3
  53. package/dist/types/session/agent-session.d.ts +36 -17
  54. package/dist/types/session/session-manager.d.ts +18 -0
  55. package/dist/types/session/session-storage.d.ts +6 -0
  56. package/dist/types/session/shake-types.d.ts +24 -0
  57. package/dist/types/task/executor.d.ts +2 -2
  58. package/dist/types/task/repair-args.d.ts +52 -0
  59. package/dist/types/tiny/models.d.ts +1 -1
  60. package/dist/types/tiny/title-client.d.ts +28 -2
  61. package/dist/types/tiny/title-protocol.d.ts +8 -5
  62. package/dist/types/tools/find.d.ts +1 -1
  63. package/dist/types/tools/index.d.ts +19 -3
  64. package/dist/types/tools/memory-edit.d.ts +1 -1
  65. package/dist/types/tools/path-utils.d.ts +7 -0
  66. package/dist/types/tui/output-block.d.ts +7 -7
  67. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  68. package/package.json +10 -10
  69. package/scripts/build-binary.ts +0 -1
  70. package/src/autoresearch/tools/run-experiment.ts +45 -113
  71. package/src/cli/args.ts +39 -16
  72. package/src/cli/extension-flags.ts +48 -0
  73. package/src/cli/plugin-cli.ts +11 -2
  74. package/src/cli.ts +59 -0
  75. package/src/config/config-file.ts +98 -13
  76. package/src/config/file-lock.ts +60 -17
  77. package/src/config/keybindings.ts +78 -27
  78. package/src/config/model-registry.ts +7 -1
  79. package/src/config/settings-schema.ts +73 -67
  80. package/src/config/settings.ts +22 -0
  81. package/src/edit/hashline/diff.ts +81 -24
  82. package/src/edit/renderer.ts +16 -12
  83. package/src/eval/__tests__/agent-bridge.test.ts +433 -0
  84. package/src/eval/__tests__/budget-bridge.test.ts +69 -0
  85. package/src/eval/__tests__/idle-timeout.test.ts +66 -0
  86. package/src/eval/__tests__/shared-executors.test.ts +21 -0
  87. package/src/eval/agent-bridge.ts +295 -0
  88. package/src/eval/backend.ts +17 -2
  89. package/src/eval/budget-bridge.ts +48 -0
  90. package/src/eval/idle-timeout.ts +80 -0
  91. package/src/eval/js/executor.ts +35 -7
  92. package/src/eval/js/index.ts +2 -1
  93. package/src/eval/js/shared/prelude.txt +85 -1
  94. package/src/eval/js/tool-bridge.ts +9 -0
  95. package/src/eval/py/executor.ts +41 -14
  96. package/src/eval/py/index.ts +2 -1
  97. package/src/eval/py/prelude.py +132 -1
  98. package/src/exec/bash-executor.ts +2 -3
  99. package/src/extensibility/custom-tools/types.ts +2 -2
  100. package/src/extensibility/extensions/runner.ts +12 -2
  101. package/src/extensibility/plugins/git-url.ts +90 -4
  102. package/src/extensibility/plugins/manager.ts +103 -7
  103. package/src/extensibility/shared-events.ts +2 -2
  104. package/src/internal-urls/docs-index.generated.ts +88 -88
  105. package/src/internal-urls/local-protocol.ts +23 -11
  106. package/src/internal-urls/types.ts +15 -0
  107. package/src/lsp/client.ts +28 -5
  108. package/src/main.ts +44 -55
  109. package/src/mcp/manager.ts +87 -4
  110. package/src/memory-backend/index.ts +1 -1
  111. package/src/memory-backend/resolve.ts +3 -3
  112. package/src/memory-backend/types.ts +3 -3
  113. package/src/{mnemosyne → mnemopi}/backend.ts +70 -70
  114. package/src/{mnemosyne → mnemopi}/config.ts +36 -36
  115. package/src/{mnemosyne → mnemopi}/state.ts +67 -67
  116. package/src/modes/components/agent-dashboard.ts +6 -6
  117. package/src/modes/components/custom-editor.ts +4 -11
  118. package/src/modes/components/extensions/state-manager.ts +3 -4
  119. package/src/modes/components/footer.ts +8 -9
  120. package/src/modes/components/hook-selector.ts +86 -20
  121. package/src/modes/components/oauth-selector.ts +93 -21
  122. package/src/modes/components/omfg-panel.ts +141 -0
  123. package/src/modes/components/settings-defs.ts +2 -2
  124. package/src/modes/components/tips.txt +2 -1
  125. package/src/modes/components/tool-execution.ts +38 -19
  126. package/src/modes/components/tree-selector.ts +4 -3
  127. package/src/modes/components/user-message-selector.ts +94 -19
  128. package/src/modes/components/user-message.ts +8 -1
  129. package/src/modes/controllers/command-controller.ts +25 -0
  130. package/src/modes/controllers/event-controller.ts +68 -3
  131. package/src/modes/controllers/input-controller.ts +14 -11
  132. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  133. package/src/modes/controllers/omfg-controller.ts +283 -0
  134. package/src/modes/controllers/omfg-rule.ts +647 -0
  135. package/src/modes/controllers/selector-controller.ts +1 -0
  136. package/src/modes/gradient-highlight.ts +23 -6
  137. package/src/modes/interactive-mode.ts +41 -7
  138. package/src/modes/magic-keywords.ts +20 -0
  139. package/src/modes/markdown-prose.ts +247 -0
  140. package/src/modes/orchestrate.ts +17 -11
  141. package/src/modes/shared.ts +3 -11
  142. package/src/modes/turn-budget.ts +31 -0
  143. package/src/modes/types.ts +7 -1
  144. package/src/modes/ultrathink.ts +16 -10
  145. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  146. package/src/modes/workflow.ts +42 -0
  147. package/src/prompts/system/omfg-user.md +51 -0
  148. package/src/prompts/system/project-prompt.md +3 -2
  149. package/src/prompts/system/subagent-system-prompt.md +12 -8
  150. package/src/prompts/system/system-prompt.md +9 -6
  151. package/src/prompts/system/workflow-notice.md +70 -0
  152. package/src/prompts/tools/eval.md +13 -1
  153. package/src/prompts/tools/memory-edit.md +1 -1
  154. package/src/sdk.ts +63 -33
  155. package/src/session/agent-session.ts +290 -55
  156. package/src/session/session-manager.ts +32 -0
  157. package/src/session/session-storage.ts +68 -8
  158. package/src/session/shake-types.ts +43 -0
  159. package/src/slash-commands/builtin-registry.ts +39 -16
  160. package/src/task/executor.ts +17 -7
  161. package/src/task/index.ts +9 -8
  162. package/src/task/repair-args.ts +117 -0
  163. package/src/tiny/models.ts +2 -2
  164. package/src/tiny/title-client.ts +133 -43
  165. package/src/tiny/title-protocol.ts +10 -5
  166. package/src/tiny/worker.ts +3 -46
  167. package/src/tools/ast-edit.ts +3 -0
  168. package/src/tools/ast-grep.ts +3 -0
  169. package/src/tools/eval.ts +202 -26
  170. package/src/tools/find.ts +20 -6
  171. package/src/tools/gh.ts +1 -0
  172. package/src/tools/grouped-file-output.ts +9 -2
  173. package/src/tools/index.ts +17 -5
  174. package/src/tools/memory-edit.ts +4 -4
  175. package/src/tools/memory-recall.ts +5 -5
  176. package/src/tools/memory-reflect.ts +5 -5
  177. package/src/tools/memory-retain.ts +4 -4
  178. package/src/tools/path-utils.ts +13 -2
  179. package/src/tools/read.ts +1 -0
  180. package/src/tools/render-utils.ts +2 -1
  181. package/src/tools/search.ts +491 -76
  182. package/src/tui/output-block.ts +37 -75
  183. package/src/utils/git.ts +9 -3
  184. package/dist/types/mnemosyne/backend.d.ts +0 -4
  185. package/dist/types/mnemosyne/config.d.ts +0 -29
  186. package/dist/types/mnemosyne/state.d.ts +0 -72
  187. /package/dist/types/{mnemosyne → mnemopi}/index.d.ts +0 -0
  188. /package/src/{mnemosyne → mnemopi}/index.ts +0 -0
@@ -1,12 +1,12 @@
1
- import * as childProcess from "node:child_process";
2
1
  import * as fs from "node:fs";
3
2
  import * as path from "node:path";
4
3
  import { Text } from "@oh-my-pi/pi-tui";
5
4
  import { formatBytes } from "@oh-my-pi/pi-utils";
6
5
  import * as z from "zod/v4";
6
+ import { executeBash } from "../../exec/bash-executor";
7
7
  import type { ToolDefinition } from "../../extensibility/extensions";
8
8
  import type { Theme } from "../../modes/theme/theme";
9
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "../../session/streaming-output";
9
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, TailBuffer, truncateTail } from "../../session/streaming-output";
10
10
  import { replaceTabs, shortenPath } from "../../tools/render-utils";
11
11
  import * as git from "../../utils/git";
12
12
  import { parseWorkDirDirtyPaths } from "../git";
@@ -15,7 +15,6 @@ import {
15
15
  EXPERIMENT_MAX_LINES,
16
16
  formatElapsed,
17
17
  formatNum,
18
- killTree,
19
18
  parseAsiLines,
20
19
  parseMetricLines,
21
20
  tryGitPrefix,
@@ -117,7 +116,7 @@ export function createRunExperimentTool(
117
116
  let execution: ProcessExecutionResult;
118
117
  try {
119
118
  execution = await executeProcess({
120
- command: ["bash", "-lc", resolvedCommand],
119
+ command: resolvedCommand,
121
120
  cwd: ctx.cwd,
122
121
  logPath: benchmarkLogPath,
123
122
  timeoutMs,
@@ -268,68 +267,18 @@ export function createRunExperimentTool(
268
267
  };
269
268
  }
270
269
  async function executeProcess(opts: {
271
- command: string[];
270
+ command: string;
272
271
  cwd: string;
273
272
  logPath: string;
274
273
  timeoutMs: number;
275
274
  signal?: AbortSignal;
276
275
  onProgress?(details: ProgressSnapshot): void;
277
276
  }): Promise<ProcessExecutionResult> {
278
- const { promise, resolve, reject } = Promise.withResolvers<ProcessExecutionResult>();
279
- const child = childProcess.spawn(opts.command[0] ?? "bash", opts.command.slice(1), {
280
- cwd: opts.cwd,
281
- detached: true,
282
- stdio: ["ignore", "pipe", "pipe"],
283
- });
284
-
285
- const tailChunks: Buffer[] = [];
286
- let chunksBytes = 0;
287
- let killedByTimeout = false;
288
- let resolved = false;
289
- let writeStream: fs.WriteStream | undefined = fs.createWriteStream(opts.logPath);
290
- let forceKillTimeout: NodeJS.Timeout | undefined;
291
-
292
- const closeWriteStream = (): Promise<void> => {
293
- if (!writeStream) return Promise.resolve();
294
- const stream = writeStream;
295
- writeStream = undefined;
296
- return new Promise<void>((resolveClose, rejectClose) => {
297
- stream.end((error?: Error | null) => {
298
- if (error) {
299
- rejectClose(error);
300
- return;
301
- }
302
- resolveClose();
303
- });
304
- });
305
- };
306
-
307
- const cleanup = (): void => {
308
- if (progressTimer) clearInterval(progressTimer);
309
- if (timeoutHandle) clearTimeout(timeoutHandle);
310
- if (forceKillTimeout) clearTimeout(forceKillTimeout);
311
- opts.signal?.removeEventListener("abort", abortHandler);
312
- };
313
-
314
- const finish = (callback: () => void): void => {
315
- if (resolved) return;
316
- resolved = true;
317
- cleanup();
318
- callback();
319
- };
320
-
321
- const appendChunk = (data: Buffer): void => {
322
- writeStream?.write(data);
323
- tailChunks.push(data);
324
- chunksBytes += data.length;
325
- while (chunksBytes > DEFAULT_MAX_BYTES * 2 && tailChunks.length > 1) {
326
- const removed = tailChunks.shift();
327
- if (removed) chunksBytes -= removed.length;
328
- }
329
- };
277
+ const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES * 2);
330
278
 
279
+ const startedAt = Date.now();
331
280
  const snapshot = (): ProgressSnapshot => {
332
- const tail = truncateTail(Buffer.concat(tailChunks).toString("utf8"), {
281
+ const tail = truncateTail(tailBuffer.text(), {
333
282
  maxBytes: DEFAULT_MAX_BYTES,
334
283
  maxLines: DEFAULT_MAX_LINES,
335
284
  });
@@ -342,71 +291,54 @@ async function executeProcess(opts: {
342
291
  };
343
292
  };
344
293
 
345
- const killTreeWithEscalation = (): void => {
346
- if (!child.pid) return;
347
- killTree(child.pid);
348
- forceKillTimeout = setTimeout(() => {
349
- if (child.pid) killTree(child.pid, "SIGKILL");
350
- }, 1_000);
351
- forceKillTimeout.unref?.();
352
- };
353
-
354
- const startedAt = Date.now();
355
294
  const progressTimer = opts.onProgress
356
295
  ? setInterval(() => {
357
296
  opts.onProgress?.(snapshot());
358
297
  }, 1000)
359
298
  : undefined;
360
- const timeoutHandle =
361
- opts.timeoutMs > 0
362
- ? setTimeout(() => {
363
- killedByTimeout = true;
364
- killTreeWithEscalation();
365
- }, opts.timeoutMs)
366
- : undefined;
367
299
 
368
- const abortHandler = (): void => {
369
- killTreeWithEscalation();
300
+ const logSink = Bun.file(opts.logPath).writer();
301
+ let logSinkClosed = false;
302
+ const closeLogSink = async (): Promise<void> => {
303
+ if (logSinkClosed) return;
304
+ logSinkClosed = true;
305
+ await logSink.end();
370
306
  };
371
- if (opts.signal?.aborted) {
372
- abortHandler();
373
- } else {
374
- opts.signal?.addEventListener("abort", abortHandler, { once: true });
375
- }
376
-
377
- child.stdout?.on("data", data => {
378
- appendChunk(data);
379
- });
380
- child.stderr?.on("data", data => {
381
- appendChunk(data);
382
- });
383
- child.on("error", error => {
384
- void closeWriteStream().finally(() => {
385
- finish(() => reject(error));
307
+ try {
308
+ const result = await executeBash(opts.command, {
309
+ cwd: opts.cwd,
310
+ sessionKey: `autoresearch:${opts.cwd}`,
311
+ timeout: opts.timeoutMs > 0 ? opts.timeoutMs : 2_147_000_000,
312
+ signal: opts.signal,
313
+ chunkThrottleMs: 0,
314
+ onChunk: chunk => {
315
+ tailBuffer.append(chunk);
316
+ logSink.write(chunk);
317
+ },
386
318
  });
387
- });
388
- child.on("close", async code => {
389
- try {
390
- await closeWriteStream();
391
- if (opts.signal?.aborted) {
392
- finish(() => reject(new Error("aborted")));
393
- return;
394
- }
395
- const output = await fs.promises.readFile(opts.logPath, "utf8");
396
- finish(() =>
397
- resolve({
398
- exitCode: code,
399
- killed: killedByTimeout,
400
- logPath: opts.logPath,
401
- output,
402
- }),
403
- );
404
- } catch (error) {
405
- finish(() => reject(error));
319
+ await closeLogSink();
320
+ if (opts.signal?.aborted) {
321
+ throw new Error("aborted");
406
322
  }
407
- });
408
323
 
409
- return promise;
324
+ const output = await fs.promises.readFile(opts.logPath, "utf8");
325
+
326
+ return {
327
+ exitCode: result.exitCode ?? null,
328
+ killed: result.cancelled,
329
+ logPath: opts.logPath,
330
+ output,
331
+ };
332
+ } finally {
333
+ if (progressTimer) clearInterval(progressTimer);
334
+ if (!logSinkClosed) {
335
+ try {
336
+ await closeLogSink();
337
+ } catch {
338
+ // Preserve the command failure when cleanup is best-effort.
339
+ }
340
+ }
341
+ }
410
342
  }
411
343
 
412
344
  function buildRunText(details: RunDetails, outputPreview: string, bestMetric: number | null): string {
package/src/cli/args.ts CHANGED
@@ -54,7 +54,12 @@ export interface Args {
54
54
  unknownFlags: Map<string, boolean | string>;
55
55
  }
56
56
 
57
- export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
57
+ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
58
+ // Work on a copy: the `--option=value` handling below splices the value
59
+ // into the array, and callers reuse the same argv (the post-extension
60
+ // reparse in `runRootCommand` parses it a second time). Mutating the input
61
+ // would corrupt that later parse, so never touch the caller's array.
62
+ const args = [...inputArgs];
58
63
  const result: Args = {
59
64
  messages: [],
60
65
  fileArgs: [],
@@ -63,17 +68,41 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
63
68
 
64
69
  for (let i = 0; i < args.length; i++) {
65
70
  let arg = args[i];
71
+ const flagIndex = i;
66
72
 
67
- // Support --flag=value syntax (e.g. --tools=ask,read)
73
+ // Support --flag=value syntax (e.g. --tools=ask,read). The value is
74
+ // spliced in as the next token so value-consuming flags pick it up via
75
+ // `args[++i]`; a non-consuming flag (e.g. a boolean) leaves it behind and
76
+ // the post-loop guard drops it so it is not mistaken for a message.
77
+ let equalsValueIndex = -1;
68
78
  if (arg.startsWith("--") && arg.includes("=")) {
69
79
  const eqIdx = arg.indexOf("=");
70
80
  const value = arg.slice(eqIdx + 1);
71
81
  arg = arg.slice(0, eqIdx);
72
- // Insert the value so the existing "args[++i]" logic picks it up
73
82
  args.splice(i + 1, 0, value);
83
+ equalsValueIndex = i + 1;
74
84
  }
75
85
 
76
- if (arg === "--help" || arg === "-h") {
86
+ // Extension-registered flags take precedence over built-ins: a flag an
87
+ // extension owns (e.g. plan-mode's boolean `--plan`) is parsed with the
88
+ // extension's semantics rather than falling into a built-in branch. For a
89
+ // value-taking built-in (`--plan`, `--model`, …) that branch would consume
90
+ // the following token — eating the user's message and setting the wrong
91
+ // built-in field — so registered flags shadow same-named built-ins here.
92
+ const extFlag = arg.startsWith("--") ? extensionFlags?.get(arg.slice(2)) : undefined;
93
+ if (extFlag) {
94
+ const flagName = arg.slice(2);
95
+ if (extFlag.type === "boolean") {
96
+ result.unknownFlags.set(flagName, true);
97
+ } else if (extFlag.type === "string" && i + 1 < args.length) {
98
+ // Consume the value in `--flag=value` form, or when the next token is
99
+ // not flag-looking. A `-`-prefixed token in space form is left to be
100
+ // its own flag; pass a flag-looking value as `--flag=value`.
101
+ if (equalsValueIndex !== -1 || !args[i + 1].startsWith("-")) {
102
+ result.unknownFlags.set(flagName, args[++i]);
103
+ }
104
+ }
105
+ } else if (arg === "--help" || arg === "-h") {
77
106
  result.help = true;
78
107
  } else if (arg === "--version" || arg === "-v") {
79
108
  result.version = true;
@@ -198,21 +227,15 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
198
227
  }
199
228
  } else if (arg.startsWith("@")) {
200
229
  result.fileArgs.push(arg.slice(1)); // Remove @ prefix
201
- } else if (arg.startsWith("--") && extensionFlags) {
202
- // Check if it's an extension-registered flag
203
- const flagName = arg.slice(2);
204
- const extFlag = extensionFlags.get(flagName);
205
- if (extFlag) {
206
- if (extFlag.type === "boolean") {
207
- result.unknownFlags.set(flagName, true);
208
- } else if (extFlag.type === "string" && i + 1 < args.length) {
209
- result.unknownFlags.set(flagName, args[++i]);
210
- }
211
- }
212
- // Unknown flags without extensionFlags are silently ignored (first pass)
213
230
  } else if (!arg.startsWith("-")) {
214
231
  result.messages.push(arg);
215
232
  }
233
+ // Drop an unconsumed `--flag=value` value (e.g. a boolean flag): when no
234
+ // branch advanced past the spliced token, remove it so it does not fall
235
+ // through to a later iteration and become a positional message.
236
+ if (equalsValueIndex !== -1 && i === flagIndex) {
237
+ args.splice(equalsValueIndex, 1);
238
+ }
216
239
  }
217
240
 
218
241
  return result;
@@ -0,0 +1,48 @@
1
+ import { type Args, parseArgs } from "./args";
2
+
3
+ /**
4
+ * Minimal extension-runner surface needed to resolve CLI flag values. The real
5
+ * `ExtensionRunner` satisfies this structurally; depending only on the surface
6
+ * keeps this module free of the heavier runner/session imports and unit-testable
7
+ * with a fake.
8
+ */
9
+ export interface ExtensionFlagSink {
10
+ getFlags(): Map<string, { type: "boolean" | "string" }>;
11
+ setFlagValue(name: string, value: boolean | string): void;
12
+ }
13
+
14
+ /**
15
+ * Resolve extension-registered CLI flags from `rawArgs` once the flag set is
16
+ * known, push the resolved values onto the sink, and return the parsed
17
+ * {@link Args} (whose `messages` and `fileArgs` now reflect those flags).
18
+ *
19
+ * The startup parse runs before extensions load, so it cannot recognise their
20
+ * flags: a string flag's value (`--spawn-peer reviewer` or `--spawn-peer=reviewer`)
21
+ * is otherwise left in `messages` and leaks into the initial prompt. Re-parsing
22
+ * here — through the *same* {@link parseArgs} the startup pass uses, now seeded
23
+ * with the registered flags — consumes every flag form (`--flag`, `--flag value`,
24
+ * `--flag=value`).
25
+ *
26
+ * {@link parseArgs} lets a registered flag shadow a same-named built-in, so even
27
+ * a built-in-colliding flag (e.g. plan-mode's boolean `--plan`, which would
28
+ * otherwise hit the built-in plan-model branch) is parsed with the extension's
29
+ * semantics and surfaces in `unknownFlags` — without consuming the following
30
+ * message or overwriting the built-in field. No built-in name list to maintain.
31
+ *
32
+ * Returns `null` when there is no sink or no registered extension flags, in
33
+ * which case the caller keeps its original startup parse (an extension-aware
34
+ * re-parse would be identical anyway).
35
+ */
36
+ export function applyExtensionFlags(runner: ExtensionFlagSink | undefined, rawArgs: string[]): Args | null {
37
+ const extensionFlags = runner?.getFlags();
38
+ if (!runner || !extensionFlags || extensionFlags.size === 0) {
39
+ return null;
40
+ }
41
+ const parsed = parseArgs(rawArgs, extensionFlags);
42
+ // `parseArgs` only records registered extension flags in `unknownFlags`, so
43
+ // every entry here is a flag this runner owns that was actually passed.
44
+ for (const [name, value] of parsed.unknownFlags) {
45
+ runner.setFlagValue(name, value);
46
+ }
47
+ return parsed;
48
+ }
@@ -348,10 +348,12 @@ async function handleInstall(
348
348
  flags: { json?: boolean; force?: boolean; dryRun?: boolean; scope?: "user" | "project" },
349
349
  ): Promise<void> {
350
350
  if (packages.length === 0) {
351
- console.error(chalk.red(`Usage: ${APP_NAME} plugin install <package[@version]>[features] ...`));
351
+ console.error(chalk.red(`Usage: ${APP_NAME} plugin install <source>[features] ...`));
352
352
  console.error(chalk.dim("Examples:"));
353
353
  console.error(chalk.dim(` ${APP_NAME} plugin install @oh-my-pi/exa`));
354
354
  console.error(chalk.dim(` ${APP_NAME} plugin install name@marketplace`));
355
+ console.error(chalk.dim(` ${APP_NAME} plugin install github:user/repo`));
356
+ console.error(chalk.dim(` ${APP_NAME} plugin install https://github.com/user/repo#v1.0`));
355
357
  process.exit(1);
356
358
  }
357
359
 
@@ -898,7 +900,7 @@ export function printPluginHelp(): void {
898
900
  console.log(`${chalk.bold(`${APP_NAME} plugin`)} - Plugin lifecycle management
899
901
 
900
902
  ${chalk.bold("Commands:")}
901
- install <pkg[@ver]>[features] Install plugins from npm
903
+ install <source>[features] Install plugins from npm, GitHub, or git URL
902
904
  uninstall <pkg> Remove plugins
903
905
  list Show installed plugins
904
906
  link <path> Link local plugin for development
@@ -916,6 +918,12 @@ ${chalk.bold("Feature Syntax:")}
916
918
  pkg[*] Install with all features
917
919
  pkg[] Install with no optional features
918
920
 
921
+ ${chalk.bold("Sources:")}
922
+ pkg, pkg@1.2.3 npm package (optionally pinned)
923
+ github:user/repo[#ref] GitHub shorthand (also gitlab:, bitbucket:, codeberg:, sourcehut:)
924
+ https://github.com/user/repo Full git URL (https, ssh, or git protocol)
925
+ name@marketplace Marketplace plugin (see marketplace command)
926
+
919
927
  ${chalk.bold("Config Subcommands:")}
920
928
  config list <pkg> List all settings
921
929
  config get <pkg> <key> Get a setting value
@@ -938,5 +946,6 @@ ${chalk.bold("Examples:")}
938
946
  ${APP_NAME} plugin config set my-plugin apiKey sk-xxx
939
947
  ${APP_NAME} plugin doctor --fix
940
948
  ${APP_NAME} plugin install --scope project name@marketplace
949
+ ${APP_NAME} plugin install github:user/repo#v1.0
941
950
  `);
942
951
  }
package/src/cli.ts CHANGED
@@ -1,4 +1,15 @@
1
1
  #!/usr/bin/env bun
2
+ // Strip macOS malloc-stack-logging vars in the parent entrypoint, before any
3
+ // subprocess/worker spawn. libmalloc reads MallocStackLogging /
4
+ // MallocStackLoggingNoCompact during malloc bootstrap (pre-main) in every child
5
+ // and warns when they're present but set to "off"; a child cannot suppress its
6
+ // own warning, so the only fix is to keep them out of the inherited env here.
7
+ // (They must be unset, not set — presence is the trigger.)
8
+ try {
9
+ delete process.env.MallocStackLogging;
10
+ delete process.env.MallocStackLoggingNoCompact;
11
+ } catch {}
12
+
2
13
  /**
3
14
  * CLI entry point — registers all commands explicitly and delegates to the
4
15
  * lightweight CLI runner from pi-utils.
@@ -43,12 +54,60 @@ async function runSmokeTest(): Promise<void> {
43
54
  process.stdout.write("smoke-test: ok\n");
44
55
  }
45
56
 
57
+ /**
58
+ * Hidden subcommand that boots the tiny-model worker inside this process
59
+ * over the parent's IPC channel. The agent's main process spawns the same
60
+ * binary with this flag so `onnxruntime-node` (loaded transitively by
61
+ * `@huggingface/transformers`) lives in a child address space. The parent
62
+ * `SIGKILL`s the child on shutdown so the NAPI finalizer never runs in
63
+ * either process — that finalizer segfaults Bun on Windows (issue #1606).
64
+ */
65
+ async function runTinyWorker(): Promise<void> {
66
+ const { startTinyTitleWorker } = await import("./tiny/worker");
67
+ const { promise: shuttingDown, resolve: shutdown } = Promise.withResolvers<void>();
68
+ const send = (message: unknown): void => {
69
+ // `process.send` only exists when spawned with an IPC channel; the
70
+ // parent always spawns us that way. If it's missing, the parent
71
+ // vanished and there's no one to talk to.
72
+ const sender = (process as NodeJS.Process & { send?: (m: unknown) => boolean }).send;
73
+ if (!sender) {
74
+ shutdown();
75
+ return;
76
+ }
77
+ try {
78
+ sender.call(process, message);
79
+ } catch {
80
+ shutdown();
81
+ }
82
+ };
83
+ startTinyTitleWorker({
84
+ send,
85
+ onMessage(handler) {
86
+ const wrap = (data: unknown): void => handler(data as never);
87
+ process.on("message", wrap);
88
+ return () => {
89
+ process.off("message", wrap);
90
+ };
91
+ },
92
+ });
93
+ // Parent went away (crashed, SIGKILL, etc.) — commit suicide so we don't
94
+ // linger as an orphan. SIGKILL via `process.kill` keeps us symmetrical
95
+ // with the parent's hard-kill on shutdown: skip every JS/native finalizer.
96
+ process.on("disconnect", () => shutdown());
97
+ await shuttingDown;
98
+ process.kill(process.pid, "SIGKILL");
99
+ }
100
+
46
101
  /** Run the CLI with the given argv (no `process.argv` prefix). */
47
102
  export async function runCli(argv: string[]): Promise<void> {
48
103
  if (argv[0] === "--smoke-test") {
49
104
  await runSmokeTest();
50
105
  return;
51
106
  }
107
+ if (argv[0] === "--tiny-worker") {
108
+ await runTinyWorker();
109
+ return;
110
+ }
52
111
  // --help and --version are handled by run() directly, don't rewrite those.
53
112
  // Everything else that isn't a known subcommand routes to "launch".
54
113
  const first = argv[0];
@@ -10,18 +10,44 @@ interface ConfigSchemaError {
10
10
  message: string | undefined;
11
11
  }
12
12
 
13
+ /**
14
+ * Module-private cache of (jsonPath, ymlPath) pairs we already migrated this
15
+ * process. Prevents `ConfigFile.relocate()` / repeated `tryLoad()` calls from
16
+ * re-running the migration over and over on the boot path.
17
+ */
18
+ const migratedPaths = new Set<string>();
19
+
20
+ function migrationKey(jsonPath: string, ymlPath: string): string {
21
+ return `${jsonPath}\u0000${ymlPath}`;
22
+ }
23
+
24
+ /**
25
+ * Synchronous JSON → YAML migration kept for callers that still want the
26
+ * eager path (settings init, tests that observe migration completion).
27
+ * Idempotent — re-running is a no-op.
28
+ */
13
29
  function migrateJsonToYml(jsonPath: string, ymlPath: string) {
30
+ const key = migrationKey(jsonPath, ymlPath);
31
+ if (migratedPaths.has(key)) return;
14
32
  try {
15
- if (fs.existsSync(ymlPath)) return;
16
- if (!fs.existsSync(jsonPath)) return;
33
+ if (fs.existsSync(ymlPath)) {
34
+ migratedPaths.add(key);
35
+ return;
36
+ }
37
+ if (!fs.existsSync(jsonPath)) {
38
+ migratedPaths.add(key);
39
+ return;
40
+ }
17
41
 
18
42
  const content = fs.readFileSync(jsonPath, "utf-8");
19
43
  const parsed = JSON.parse(content);
20
44
  if (!parsed) {
21
45
  logger.warn("migrateJsonToYml: invalid json structure", { path: jsonPath });
46
+ migratedPaths.add(key);
22
47
  return;
23
48
  }
24
49
  fs.writeFileSync(ymlPath, YAML.stringify(parsed, null, 2));
50
+ migratedPaths.add(key);
25
51
  } catch (error) {
26
52
  logger.warn("migrateJsonToYml: migration failed", { error: String(error) });
27
53
  }
@@ -97,6 +123,7 @@ export type LoadResult<T> =
97
123
 
98
124
  export class ConfigFile<T> implements IConfigFile<T> {
99
125
  readonly #basePath: string;
126
+ readonly #jsonMigrationPath: string | null;
100
127
  #cache?: LoadResult<T>;
101
128
  #auxValidate?: (value: T) => void;
102
129
 
@@ -107,22 +134,32 @@ export class ConfigFile<T> implements IConfigFile<T> {
107
134
  ) {
108
135
  this.#basePath = configPath;
109
136
  if (configPath.endsWith(".yml")) {
110
- const jsonPath = `${configPath.slice(0, -4)}.json`;
111
- migrateJsonToYml(jsonPath, configPath);
137
+ this.#jsonMigrationPath = `${configPath.slice(0, -4)}.json`;
112
138
  } else if (configPath.endsWith(".yaml")) {
113
- const jsonPath = `${configPath.slice(0, -5)}.json`;
114
- migrateJsonToYml(jsonPath, configPath);
139
+ this.#jsonMigrationPath = `${configPath.slice(0, -5)}.json`;
115
140
  } else if (configPath.endsWith(".json") || configPath.endsWith(".jsonc")) {
116
141
  // JSON configs are still supported without migration.
142
+ this.#jsonMigrationPath = null;
117
143
  } else {
118
144
  throw new Error(`Invalid config file path: ${configPath}`);
119
145
  }
120
146
  }
121
147
 
148
+ /**
149
+ * Run the JSON → YAML migration synchronously, if applicable. Idempotent.
150
+ * Sync callers (tests, settings init) hit this implicitly via {@link tryLoad}.
151
+ */
152
+ #ensureMigrated(): void {
153
+ if (this.#jsonMigrationPath) {
154
+ migrateJsonToYml(this.#jsonMigrationPath, this.#basePath);
155
+ }
156
+ }
157
+
122
158
  relocate(configPath?: string): ConfigFile<T> {
123
159
  if (!configPath || configPath === this.#basePath) return this;
124
160
  const result = new ConfigFile<T>(this.id, this.schema, configPath);
125
161
  result.#auxValidate = this.#auxValidate;
162
+ result.#ensureMigrated();
126
163
  return result;
127
164
  }
128
165
 
@@ -135,6 +172,13 @@ export class ConfigFile<T> implements IConfigFile<T> {
135
172
  }
136
173
  }
137
174
 
175
+ async getMtimeMsAsync(): Promise<number | null> {
176
+ const file = Bun.file(this.path());
177
+ if (!(await file.exists())) return null;
178
+ const lm = file.lastModified;
179
+ return typeof lm === "number" && Number.isFinite(lm) ? lm : null;
180
+ }
181
+
138
182
  withValidation(name: string, validate: (value: T) => void): this {
139
183
  const prev = this.#auxValidate;
140
184
  this.#auxValidate = (value: T) => {
@@ -164,12 +208,8 @@ export class ConfigFile<T> implements IConfigFile<T> {
164
208
  return result;
165
209
  }
166
210
 
167
- tryLoad(): LoadResult<T> {
168
- if (this.#cache) return this.#cache;
169
-
211
+ #parseContent(content: string): LoadResult<T> {
170
212
  try {
171
- const content = fs.readFileSync(this.path(), "utf-8").trim();
172
-
173
213
  let parsed: unknown;
174
214
  if (this.#basePath.endsWith(".json") || this.#basePath.endsWith(".jsonc")) {
175
215
  parsed = JSONC.parse(content);
@@ -202,26 +242,71 @@ export class ConfigFile<T> implements IConfigFile<T> {
202
242
  return this.#storeCache({ error: wrapped, status: "error" });
203
243
  }
204
244
  return this.#storeCache({ value, status: "ok" });
245
+ } catch (error) {
246
+ logger.warn("Failed to parse config file", { path: this.path(), error });
247
+ return this.#storeCache({
248
+ error: new ConfigError(this.id, undefined, { err: error, stage: "Unexpected" }),
249
+ status: "error",
250
+ });
251
+ }
252
+ }
253
+
254
+ tryLoad(): LoadResult<T> {
255
+ if (this.#cache) return this.#cache;
256
+ this.#ensureMigrated();
257
+
258
+ let content: string;
259
+ try {
260
+ content = fs.readFileSync(this.path(), "utf-8").trim();
205
261
  } catch (error) {
206
262
  if (isEnoent(error)) {
207
263
  return this.#storeCache({ status: "not-found" });
208
264
  }
209
- logger.warn("Failed to parse config file", { path: this.path(), error });
265
+ logger.warn("Failed to read config file", { path: this.path(), error });
210
266
  return this.#storeCache({
211
- error: new ConfigError(this.id, undefined, { err: error, stage: "Unexpected" }),
267
+ error: new ConfigError(this.id, undefined, { err: error, stage: "Read" }),
268
+ status: "error",
269
+ });
270
+ }
271
+ return this.#parseContent(content);
272
+ }
273
+
274
+ async tryLoadAsync(): Promise<LoadResult<T>> {
275
+ if (this.#cache) return this.#cache;
276
+ this.#ensureMigrated();
277
+
278
+ let content: string;
279
+ try {
280
+ content = (await Bun.file(this.path()).text()).trim();
281
+ } catch (error) {
282
+ if (isEnoent(error)) {
283
+ return this.#storeCache({ status: "not-found" });
284
+ }
285
+ logger.warn("Failed to read config file", { path: this.path(), error });
286
+ return this.#storeCache({
287
+ error: new ConfigError(this.id, undefined, { err: error, stage: "Read" }),
212
288
  status: "error",
213
289
  });
214
290
  }
291
+ return this.#parseContent(content);
215
292
  }
216
293
 
217
294
  load(): T | null {
218
295
  return this.tryLoad().value ?? null;
219
296
  }
220
297
 
298
+ async loadAsync(): Promise<T | null> {
299
+ return (await this.tryLoadAsync()).value ?? null;
300
+ }
301
+
221
302
  loadOrDefault(): T {
222
303
  return this.tryLoad().value ?? this.createDefault();
223
304
  }
224
305
 
306
+ async loadOrDefaultAsync(): Promise<T> {
307
+ return (await this.tryLoadAsync()).value ?? this.createDefault();
308
+ }
309
+
225
310
  path(): string {
226
311
  return this.#basePath;
227
312
  }