jeopi-cli 16.4.1 → 16.4.3

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 (125) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/cli.js +3179 -3140
  3. package/dist/types/advisor/config.d.ts +4 -3
  4. package/dist/types/cli/args.d.ts +2 -0
  5. package/dist/types/cli/update-cli.d.ts +4 -1
  6. package/dist/types/cli/usage-error.d.ts +4 -0
  7. package/dist/types/commit/agentic/agent.d.ts +1 -0
  8. package/dist/types/config/model-resolver.d.ts +7 -1
  9. package/dist/types/config/settings-schema.d.ts +3 -3
  10. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  11. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  13. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  14. package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
  15. package/dist/types/internal-urls/types.d.ts +7 -0
  16. package/dist/types/irc/bus.d.ts +4 -0
  17. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  18. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  19. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  20. package/dist/types/modes/components/welcome.d.ts +4 -0
  21. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  22. package/dist/types/modes/orchestrate.d.ts +1 -1
  23. package/dist/types/modes/ultrathink.d.ts +1 -1
  24. package/dist/types/modes/workflow.d.ts +1 -1
  25. package/dist/types/session/agent-session.d.ts +5 -0
  26. package/dist/types/session/auth-broker-config.d.ts +2 -2
  27. package/dist/types/tools/bash-skill-urls.d.ts +3 -2
  28. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  29. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  30. package/dist/types/tools/browser/launch.d.ts +1 -0
  31. package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
  32. package/dist/types/tools/browser/run-output.d.ts +25 -0
  33. package/dist/types/tools/browser/tab-worker.d.ts +22 -0
  34. package/dist/types/utils/changelog.d.ts +35 -1
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +13 -0
  37. package/src/advisor/__tests__/config.test.ts +36 -0
  38. package/src/advisor/config.ts +11 -8
  39. package/src/autolearn/controller.ts +14 -2
  40. package/src/cli/args.ts +12 -0
  41. package/src/cli/flag-tables.ts +21 -7
  42. package/src/cli/update-cli.ts +63 -13
  43. package/src/cli/usage-error.ts +7 -0
  44. package/src/commands/acp.ts +11 -2
  45. package/src/commands/launch.ts +12 -3
  46. package/src/commit/agentic/agent.ts +4 -0
  47. package/src/commit/agentic/index.ts +46 -21
  48. package/src/config/model-resolver.ts +24 -6
  49. package/src/config/settings-schema.ts +3 -3
  50. package/src/edit/index.ts +10 -76
  51. package/src/edit/renderer.ts +13 -10
  52. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  53. package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
  54. package/src/eval/executor-base.ts +84 -7
  55. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  56. package/src/eval/py/tool-bridge.ts +13 -14
  57. package/src/exec/non-interactive-env.ts +0 -1
  58. package/src/export/ttsr.ts +0 -3
  59. package/src/extensibility/custom-tools/loader.ts +70 -37
  60. package/src/extensibility/custom-tools/types.ts +3 -0
  61. package/src/extensibility/extensions/runner.ts +3 -0
  62. package/src/extensibility/extensions/types.ts +3 -0
  63. package/src/extensibility/plugins/manager.ts +21 -0
  64. package/src/internal-urls/docs-index.generated.txt +1 -1
  65. package/src/internal-urls/memory-protocol.ts +13 -8
  66. package/src/internal-urls/skill-protocol.ts +1 -1
  67. package/src/internal-urls/types.ts +7 -0
  68. package/src/irc/bus.ts +48 -18
  69. package/src/lsp/client.ts +7 -1
  70. package/src/lsp/deferred-diagnostics.ts +66 -0
  71. package/src/main.ts +13 -13
  72. package/src/mcp/tool-bridge.ts +57 -6
  73. package/src/mcp/transports/stdio.test.ts +45 -1
  74. package/src/mcp/transports/stdio.ts +6 -3
  75. package/src/modes/acp/acp-agent.ts +73 -2
  76. package/src/modes/acp/acp-event-mapper.ts +5 -0
  77. package/src/modes/acp/acp-mode.ts +11 -0
  78. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  79. package/src/modes/components/advisor-config.ts +15 -7
  80. package/src/modes/components/move-overlay.ts +2 -3
  81. package/src/modes/components/plan-review-overlay.ts +7 -0
  82. package/src/modes/components/tips.txt +1 -1
  83. package/src/modes/components/welcome.ts +13 -14
  84. package/src/modes/controllers/command-controller.ts +8 -10
  85. package/src/modes/controllers/event-controller.ts +17 -30
  86. package/src/modes/controllers/input-controller.ts +9 -6
  87. package/src/modes/controllers/selector-controller.ts +3 -2
  88. package/src/modes/controllers/tan-command-controller.ts +40 -1
  89. package/src/modes/interactive-mode.ts +13 -0
  90. package/src/modes/magic-keyword-boundary.ts +23 -0
  91. package/src/modes/orchestrate.ts +6 -5
  92. package/src/modes/print-mode.ts +9 -0
  93. package/src/modes/ultrathink.ts +6 -5
  94. package/src/modes/workflow.ts +5 -4
  95. package/src/prompts/system/eager-task.md +2 -2
  96. package/src/prompts/system/tan-context-switch.md +17 -0
  97. package/src/prompts/tools/browser.md +5 -5
  98. package/src/sdk.ts +3 -0
  99. package/src/session/agent-session.ts +237 -50
  100. package/src/session/auth-broker-config.ts +4 -4
  101. package/src/session/snapcompact-inline.ts +3 -19
  102. package/src/session/streaming-output.ts +40 -12
  103. package/src/slash-commands/builtin-registry.ts +8 -8
  104. package/src/task/executor.ts +8 -0
  105. package/src/tools/bash-skill-urls.ts +7 -4
  106. package/src/tools/bash.ts +30 -22
  107. package/src/tools/browser/cmux/cmux-tab.ts +49 -50
  108. package/src/tools/browser/cmux/rpc.ts +50 -0
  109. package/src/tools/browser/launch.ts +8 -4
  110. package/src/tools/browser/run-cancellation.ts +44 -5
  111. package/src/tools/browser/run-output.ts +76 -0
  112. package/src/tools/browser/tab-worker.ts +280 -133
  113. package/src/tools/eval-render.ts +16 -14
  114. package/src/tools/glob.ts +20 -6
  115. package/src/tools/grep.ts +1 -0
  116. package/src/tools/image-gen.ts +3 -4
  117. package/src/tools/irc.ts +27 -13
  118. package/src/tools/read.ts +14 -7
  119. package/src/tools/write.ts +22 -5
  120. package/src/tools/yield.ts +29 -1
  121. package/src/utils/changelog.ts +106 -9
  122. package/src/utils/git.ts +0 -1
  123. package/src/utils/title-generator.ts +68 -7
  124. package/src/web/search/index.ts +14 -6
  125. package/src/web/search/providers/perplexity.ts +18 -2
@@ -55,6 +55,27 @@ describe("discoverAdvisorConfigs", () => {
55
55
  expect(sharedInstructions).toBe("Shared baseline for all advisors.");
56
56
  });
57
57
 
58
+ it("distinguishes omitted tools, explicit no-tools, and invalid-only lists", async () => {
59
+ const yaml = [
60
+ "advisors:",
61
+ " - name: No Tools",
62
+ " tools: []",
63
+ " - name: Default Tools",
64
+ " - name: Invalid Only",
65
+ " tools: [reed]",
66
+ ].join("\n");
67
+ await Bun.write(path.join(tmp, "WATCHDOG.yml"), yaml);
68
+
69
+ const { advisors } = await discoverAdvisorConfigs(tmp, agentDir);
70
+ const noTools = advisors.find(a => a.name === "No Tools");
71
+ const defaultTools = advisors.find(a => a.name === "Default Tools");
72
+ const invalidOnly = advisors.find(a => a.name === "Invalid Only");
73
+
74
+ expect(noTools?.tools).toEqual([]);
75
+ expect(defaultTools?.tools).toBeUndefined();
76
+ expect(invalidOnly?.tools).toBeUndefined();
77
+ });
78
+
58
79
  it("ignores a malformed YAML file without throwing", async () => {
59
80
  await Bun.write(path.join(tmp, "WATCHDOG.yml"), "advisors: [unclosed bracket");
60
81
  const result = await discoverAdvisorConfigs(tmp, agentDir);
@@ -122,6 +143,21 @@ describe("WATCHDOG.yml file round-trip", () => {
122
143
  expect(sharedInstructions).toContain("Shared baseline.");
123
144
  });
124
145
 
146
+ it("round-trips an explicit empty tools list without collapsing it into the default", async () => {
147
+ const file = path.join(tmp, "WATCHDOG.yml");
148
+ const explicitNoToolsDoc: WatchdogConfigDoc = {
149
+ advisors: [{ name: "No Tools", tools: [] }, { name: "Default Tools" }],
150
+ };
151
+
152
+ await saveWatchdogConfigFile(file, explicitNoToolsDoc);
153
+ const serializedDoc = await loadWatchdogConfigFile(file);
154
+ expect(serializedDoc).toEqual(explicitNoToolsDoc);
155
+
156
+ const { advisors } = await discoverAdvisorConfigs(tmp, tmp);
157
+ expect(advisors.find(a => a.name === "No Tools")?.tools).toEqual([]);
158
+ expect(advisors.find(a => a.name === "Default Tools")?.tools).toBeUndefined();
159
+ });
160
+
125
161
  it("removes the file when the doc is empty so legacy discovery resumes", async () => {
126
162
  const file = path.join(tmp, "WATCHDOG.yml");
127
163
  await saveWatchdogConfigFile(file, doc);
@@ -12,9 +12,10 @@ import { collectConfigCandidates } from "./watchdog";
12
12
  * with an optional `:level` thinking suffix (e.g. `x-ai/grok-code-fast:high`),
13
13
  * resolved exactly like any other model override; `tools` is a subset of
14
14
  * `BUILTIN_TOOL_NAMES` — any built-in name, including mutating tools such as
15
- * `edit`/`write`/`bash` (the advisor is a full agent). Omitted or empty falls
16
- * back to the default `read`/`grep`/`glob` subset. `instructions` is the
17
- * advisor's specialization, appended to the shared baseline.
15
+ * `edit`/`write`/`bash` (the advisor is a full agent). Omitted falls back to
16
+ * the default `read`/`grep`/`glob` subset; an explicit empty list grants no
17
+ * tools. `instructions` is the advisor's specialization, appended to the shared
18
+ * baseline.
18
19
  */
19
20
  export interface AdvisorConfig {
20
21
  name: string;
@@ -65,11 +66,13 @@ const KNOWN_TOOL_NAMES = new Set<string>(BUILTIN_TOOL_NAMES);
65
66
  /**
66
67
  * Keep only valid tool names from an advisor's `tools` list, dropping unknowns
67
68
  * with a warning. The advisor is a full agent, so any built tool may be granted;
68
- * the runtime further filters to what's actually available this session. An empty
69
- * result (or no list) means "use the default subset" (read/grep/glob).
69
+ * the runtime further filters to what's actually available this session.
70
+ * `undefined` means "use the default subset" (read/grep/glob); only an explicit
71
+ * raw empty list means "no tools".
70
72
  */
71
73
  function filterAdvisorTools(tools: string[] | undefined, sourcePath: string): string[] | undefined {
72
- if (!tools || tools.length === 0) return undefined;
74
+ if (tools === undefined) return undefined;
75
+ if (tools.length === 0) return [];
73
76
  // Normalize legacy aliases (search→grep, find→glob) and dedupe before validating.
74
77
  const filtered = normalizeToolNames(tools).filter(name => {
75
78
  if (KNOWN_TOOL_NAMES.has(name)) return true;
@@ -210,7 +213,7 @@ export async function loadWatchdogConfigFile(filePath: string): Promise<Watchdog
210
213
  advisors: (result.advisors ?? []).map(a => ({
211
214
  name: a.name,
212
215
  model: a.model?.trim() || undefined,
213
- tools: a.tools?.length ? [...a.tools] : undefined,
216
+ tools: a.tools === undefined ? undefined : [...a.tools],
214
217
  instructions: a.instructions?.trim() ? a.instructions : undefined,
215
218
  })),
216
219
  };
@@ -229,7 +232,7 @@ export function serializeWatchdogConfig(doc: WatchdogConfigDoc): string {
229
232
  out.advisors = doc.advisors.map(a => {
230
233
  const entry: AdvisorConfig = { name: a.name };
231
234
  if (a.model?.trim()) entry.model = a.model;
232
- if (a.tools?.length) entry.tools = [...a.tools];
235
+ if (a.tools !== undefined) entry.tools = [...a.tools];
233
236
  if (a.instructions?.trim()) entry.instructions = a.instructions;
234
237
  return entry;
235
238
  });
@@ -84,11 +84,11 @@ export class AutoLearnController {
84
84
  return;
85
85
  }
86
86
  if (event.type === "agent_end") {
87
- this.#onAgentEnd();
87
+ this.#onAgentEnd(event);
88
88
  }
89
89
  }
90
90
 
91
- #onAgentEnd(): void {
91
+ #onAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>): void {
92
92
  // Snapshot and reset every turn: the counter describes only the
93
93
  // just-finished turn, so below-threshold, disabled, and plan-mode stops
94
94
  // must not let tool calls accumulate into a later turn.
@@ -107,6 +107,18 @@ export class AutoLearnController {
107
107
  this.#suppressNext = false;
108
108
  return;
109
109
  }
110
+ // Never nudge a turn that ended in an abort (ESC, cancel, etc.). The
111
+ // abort flag on the session is unreliable by the time agent_end is
112
+ // deferred to subscribers; read stopReason from the event messages.
113
+ for (let i = event.messages.length - 1; i >= 0; i--) {
114
+ const message = event.messages[i];
115
+ if (message && typeof message === "object" && "role" in message && message.role === "assistant") {
116
+ if ("stopReason" in message && message.stopReason === "aborted") {
117
+ return;
118
+ }
119
+ break;
120
+ }
121
+ }
110
122
  // Honor a live opt-out: the subscription outlives the setting, so re-check
111
123
  // the current flag rather than trusting install-time state.
112
124
  if (!this.#settings.get("autolearn.enabled")) return;
package/src/cli/args.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  STRING_SETTERS,
15
15
  STRING_VALUE_FLAGS,
16
16
  } from "./flag-tables";
17
+ import { CliUsageError } from "./usage-error";
17
18
 
18
19
  export type Mode = "text" | "json" | "rpc" | "acp" | "rpc-ui";
19
20
 
@@ -299,6 +300,17 @@ export function reportUnrecognizedFlags(
299
300
  return true;
300
301
  }
301
302
 
303
+ /** Emit a clean CLI usage error without an internal stack trace. */
304
+ export function reportCliUsageError(
305
+ error: unknown,
306
+ write: (text: string) => void = text => process.stderr.write(text),
307
+ ): boolean {
308
+ if (!(error instanceof CliUsageError)) return false;
309
+ write(`${chalk.red(`Error: ${error.message}`)}\n`);
310
+ write(`Run \`${APP_NAME} --help\` for available flags.\n`);
311
+ return true;
312
+ }
313
+
302
314
  export function getExtraHelpText(): string {
303
315
  return `${chalk.bold("Environment Variables:")}
304
316
  ${chalk.dim("# Core Providers")}
@@ -32,6 +32,7 @@
32
32
 
33
33
  import type { ConfiguredThinkingLevel } from "../thinking";
34
34
  import type { Args } from "./args";
35
+ import { CliUsageError } from "./usage-error";
35
36
 
36
37
  /**
37
38
  * Runtime dependencies injected into setters that need to validate input or
@@ -85,6 +86,24 @@ const setResume: OptionalSetter = (result, value) => {
85
86
  result.resume = value !== undefined ? value : true;
86
87
  };
87
88
 
89
+ const MAX_TIME_DURATION_RE = /^(\d+(?:\.\d+)?)([smh])$/;
90
+
91
+ function maxTimeMultiplier(unit: string | undefined): number {
92
+ if (unit === "h") return 3600;
93
+ if (unit === "m") return 60;
94
+ return 1;
95
+ }
96
+
97
+ function parseMaxTimeSeconds(value: string): number {
98
+ const trimmed = value.trim();
99
+ const duration = MAX_TIME_DURATION_RE.exec(trimmed);
100
+ const seconds = duration ? Number(duration[1]) * maxTimeMultiplier(duration[2]) : Number(trimmed);
101
+ if (Number.isFinite(seconds) && seconds > 0) return seconds;
102
+ throw new CliUsageError(
103
+ `Invalid --max-time value: ${JSON.stringify(value)}. Expected a positive number of seconds or duration like "5s", "10m", "1h".`,
104
+ );
105
+ }
106
+
88
107
  /**
89
108
  * Setters for flags with string values. Most built-ins consume the next argv
90
109
  * token even when it starts with `-`; flags listed in
@@ -121,13 +140,8 @@ export const STRING_SETTERS: Record<string, StringSetter> = {
121
140
  "--plan": (result, value) => {
122
141
  result.plan = value;
123
142
  },
124
- "--max-time": (result, value, deps) => {
125
- const seconds = Number(value);
126
- if (Number.isFinite(seconds) && seconds > 0) {
127
- result.maxTime = seconds;
128
- } else {
129
- deps.logger.warn("Invalid seconds passed to --max-time", { value });
130
- }
143
+ "--max-time": (result, value) => {
144
+ result.maxTime = parseMaxTimeSeconds(value);
131
145
  },
132
146
  "--api-key": (result, value) => {
133
147
  result.apiKey = value;
@@ -112,6 +112,19 @@ async function getBunGlobalBinDir(): Promise<string | undefined> {
112
112
  }
113
113
  }
114
114
 
115
+ async function getNpmGlobalBinDir(): Promise<string | undefined> {
116
+ if (!$which("npm")) return undefined;
117
+ try {
118
+ const result = await $`npm prefix -g`.quiet().nothrow();
119
+ if (result.exitCode !== 0) return undefined;
120
+ const prefix = result.text().trim();
121
+ if (prefix.length === 0) return undefined;
122
+ return process.platform === "win32" ? prefix : path.join(prefix, "bin");
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ }
127
+
115
128
  async function getHomebrewFormulaPrefix(): Promise<string | undefined> {
116
129
  if (!$which("brew")) return undefined;
117
130
  for (const formula of [HOMEBREW_FORMULA, APP_NAME]) {
@@ -192,26 +205,36 @@ function isPathInDirectory(filePath: string, directoryPath: string): boolean {
192
205
  return isPathInDirectoryLexical(resolvedFile, dirReal);
193
206
  }
194
207
 
195
- type UpdateMethod = "brew" | "mise" | "bun" | "binary";
208
+ type UpdateMethod = "brew" | "mise" | "bun" | "npm" | "binary";
196
209
 
197
210
  interface UpdateMethodResolutionOptions {
198
211
  homebrewPrefix?: string;
199
212
  miseBinDirs?: readonly string[];
200
213
  miseDataDir?: string;
214
+ npmBinDir?: string;
201
215
  }
202
216
 
203
- type UpdateTarget = { method: "brew" } | { method: "mise" } | { method: "bun" } | { method: "binary"; path: string };
217
+ type UpdateTarget =
218
+ | { method: "brew" }
219
+ | { method: "mise" }
220
+ | { method: "bun" }
221
+ | { method: "npm" }
222
+ | { method: "binary"; path: string };
204
223
 
205
224
  function resolveUpdateMethod(
206
225
  jeopiPath: string,
207
226
  bunBinDir: string | undefined,
208
227
  options: UpdateMethodResolutionOptions = {},
209
228
  ): UpdateMethod {
210
- const { homebrewPrefix, miseBinDirs = [], miseDataDir } = options;
229
+ const { homebrewPrefix, miseBinDirs = [], miseDataDir, npmBinDir } = options;
230
+ const launcherExtension = path.extname(jeopiPath).toLowerCase();
231
+ const isWindowsScriptLauncher =
232
+ launcherExtension === ".cmd" || launcherExtension === ".ps1" || launcherExtension === ".bat";
211
233
  if (homebrewPrefix && isPathInDirectory(jeopiPath, path.join(homebrewPrefix, "bin"))) return "brew";
212
234
  if (miseBinDirs.some(dir => isPathInDirectory(jeopiPath, dir))) return "mise";
213
235
  if (miseDataDir && isPathInDirectory(jeopiPath, path.join(miseDataDir, "shims"))) return "mise";
214
236
  if (bunBinDir && isPathInDirectory(jeopiPath, bunBinDir)) return "bun";
237
+ if ((npmBinDir && isPathInDirectory(jeopiPath, npmBinDir)) || isWindowsScriptLauncher) return "npm";
215
238
  return "binary";
216
239
  }
217
240
 
@@ -224,6 +247,7 @@ export function resolveUpdateMethodForTest(
224
247
  }
225
248
  async function resolveUpdateTarget(): Promise<UpdateTarget> {
226
249
  const bunBinDir = await getBunGlobalBinDir();
250
+ const npmBinDir = await getNpmGlobalBinDir();
227
251
  const homebrewPrefix = await getHomebrewFormulaPrefix();
228
252
  const miseAvailable = $which("mise") !== undefined;
229
253
  const miseBinDirs = miseAvailable ? await getMiseBinDirs() : [];
@@ -231,7 +255,7 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
231
255
  const jeopiPath = resolveJeopiPath();
232
256
 
233
257
  if (jeopiPath) {
234
- const method = resolveUpdateMethod(jeopiPath, bunBinDir, { homebrewPrefix, miseBinDirs, miseDataDir });
258
+ const method = resolveUpdateMethod(jeopiPath, bunBinDir, { homebrewPrefix, miseBinDirs, miseDataDir, npmBinDir });
235
259
  if (method === "binary") return { method, path: jeopiPath };
236
260
  return { method };
237
261
  }
@@ -874,6 +898,15 @@ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions):
874
898
  }
875
899
  }
876
900
 
901
+ function buildVersionedPackageInstallArgs(expectedVersion: string, nativeTag: string): string[] {
902
+ const args = [`${NPM_PACKAGE}@${expectedVersion}`, `${NATIVES_PACKAGE}@${expectedVersion}`];
903
+ const leafPackage = NATIVE_LEAF_PACKAGES.get(nativeTag);
904
+ if (leafPackage) {
905
+ args.push(`${leafPackage}@${expectedVersion}`);
906
+ }
907
+ return args;
908
+ }
909
+
877
910
  /**
878
911
  * Build the bun argv used to globally install a specific jeopi version.
879
912
  *
@@ -905,19 +938,23 @@ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions):
905
938
  * See #1824.
906
939
  */
907
940
  export function buildBunInstallArgs(expectedVersion: string, nativeTag: string = currentNativeTag()): string[] {
908
- const args = [
941
+ return [
909
942
  "install",
910
943
  "-g",
911
944
  "--no-cache",
912
945
  `--registry=${NPM_REGISTRY}`,
913
- `${NPM_PACKAGE}@${expectedVersion}`,
914
- `${NATIVES_PACKAGE}@${expectedVersion}`,
946
+ ...buildVersionedPackageInstallArgs(expectedVersion, nativeTag),
947
+ ];
948
+ }
949
+
950
+ /** Build the npm argv used to update npm-managed global installs. */
951
+ export function buildNpmInstallArgs(expectedVersion: string, nativeTag: string = currentNativeTag()): string[] {
952
+ return [
953
+ "install",
954
+ "-g",
955
+ `--registry=${NPM_REGISTRY}`,
956
+ ...buildVersionedPackageInstallArgs(expectedVersion, nativeTag),
915
957
  ];
916
- const leafPackage = NATIVE_LEAF_PACKAGES.get(nativeTag);
917
- if (leafPackage) {
918
- args.push(`${leafPackage}@${expectedVersion}`);
919
- }
920
- return args;
921
958
  }
922
959
 
923
960
  export function buildHomebrewUpdateArgs(force: boolean): string[] {
@@ -933,7 +970,7 @@ export function buildMiseForceInstallArgs(expectedVersion: string): string[] {
933
970
  }
934
971
 
935
972
  /**
936
- * Update via bun package manager.
973
+ * Update via package manager.
937
974
  */
938
975
  async function updateViaBun(expectedVersion: string): Promise<void> {
939
976
  console.log(chalk.dim("Updating via bun..."));
@@ -954,6 +991,17 @@ async function updateViaBun(expectedVersion: string): Promise<void> {
954
991
  }
955
992
  }
956
993
 
994
+ async function updateViaNpm(expectedVersion: string): Promise<void> {
995
+ console.log(chalk.dim("Updating via npm..."));
996
+ const args = buildNpmInstallArgs(expectedVersion);
997
+ const result = await $`npm ${args}`.nothrow();
998
+ if (result.exitCode !== 0) {
999
+ throw new Error(`npm install failed with exit code ${result.exitCode}`);
1000
+ }
1001
+
1002
+ await printVerification(expectedVersion);
1003
+ }
1004
+
957
1005
  async function updateViaHomebrew(expectedVersion: string, force: boolean): Promise<void> {
958
1006
  console.log(chalk.dim("Updating Homebrew formulae..."));
959
1007
  const update = await $`brew update`.nothrow();
@@ -1089,6 +1137,8 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
1089
1137
  await updateViaMise(release.version, opts.force);
1090
1138
  } else if (target.method === "bun") {
1091
1139
  await updateViaBun(release.version);
1140
+ } else if (target.method === "npm") {
1141
+ await updateViaNpm(release.version);
1092
1142
  } else {
1093
1143
  await updateViaBinaryAt(target.path, release.version);
1094
1144
  }
@@ -0,0 +1,7 @@
1
+ /** Command-line input error that should be rendered without a stack trace. */
2
+ export class CliUsageError extends Error {
3
+ constructor(message: string) {
4
+ super(message);
5
+ this.name = "CliUsageError";
6
+ }
7
+ }
@@ -5,7 +5,7 @@
5
5
  * ACP terminal-auth flag asks the same command to open the interactive TUI.
6
6
  */
7
7
  import { Command } from "jeopi-utils/cli";
8
- import { parseArgs } from "../cli/args";
8
+ import { type Args as ParsedArgs, parseArgs, reportCliUsageError } from "../cli/args";
9
9
  import { runRootCommand } from "../main";
10
10
  import { prepareAcpTerminalAuthArgs } from "../modes/acp/terminal-auth";
11
11
 
@@ -15,7 +15,16 @@ export default class Acp extends Command {
15
15
 
16
16
  async run(): Promise<void> {
17
17
  const { args, terminalAuth } = prepareAcpTerminalAuthArgs(this.argv);
18
- const parsed = parseArgs(args);
18
+ let parsed: ParsedArgs;
19
+ try {
20
+ parsed = parseArgs(args);
21
+ } catch (error) {
22
+ if (reportCliUsageError(error)) {
23
+ process.exitCode = 2;
24
+ return;
25
+ }
26
+ throw error;
27
+ }
19
28
  if (!terminalAuth) {
20
29
  parsed.mode = "acp";
21
30
  }
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { APP_NAME } from "jeopi-utils";
6
6
  import { Args, Command, Flags } from "jeopi-utils/cli";
7
- import { parseArgs } from "../cli/args";
7
+ import { type Args as ParsedArgs, parseArgs, reportCliUsageError } from "../cli/args";
8
8
  import { runRootCommand } from "../main";
9
9
  import { prepareAcpTerminalAuthArgs } from "../modes/acp/terminal-auth";
10
10
  import { CLI_THINKING_LEVELS } from "../thinking";
@@ -140,7 +140,7 @@ export default class Index extends Command {
140
140
  description: "Include thinking blocks in print mode text output",
141
141
  }),
142
142
  "max-time": Flags.string({
143
- description: "Stop the session after this many seconds",
143
+ description: "Stop the session after this duration (e.g., 600, 10m, 1h)",
144
144
  }),
145
145
  // `--auto-approve` / `--yolo`: declared here so oclif's auto-generated `--help` lists it.
146
146
  // Runtime parsing happens in `cli/args.ts parseArgs` (line 176 in that file) — `runRootCommand`
@@ -176,7 +176,16 @@ export default class Index extends Command {
176
176
 
177
177
  async run(): Promise<void> {
178
178
  const { args } = prepareAcpTerminalAuthArgs(this.argv);
179
- const parsed = parseArgs(args);
179
+ let parsed: ParsedArgs;
180
+ try {
181
+ parsed = parseArgs(args);
182
+ } catch (error) {
183
+ if (reportCliUsageError(error)) {
184
+ process.exitCode = 2;
185
+ return;
186
+ }
187
+ throw error;
188
+ }
180
189
  await runRootCommand(parsed, args);
181
190
  }
182
191
  }
@@ -29,6 +29,7 @@ export interface CommitAgentInput {
29
29
  requireChangelog: boolean;
30
30
  diffText?: string;
31
31
  existingChangelogEntries?: ExistingChangelogEntries[];
32
+ onComplete?: (state: CommitAgentState) => Promise<void> | void;
32
33
  }
33
34
 
34
35
  export interface ExistingChangelogEntries {
@@ -175,6 +176,9 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
175
176
  });
176
177
  }
177
178
 
179
+ if (input.onComplete) {
180
+ await input.onComplete(state);
181
+ }
178
182
  return state;
179
183
  } finally {
180
184
  unsubscribe();
@@ -6,7 +6,7 @@ import { detectChangelogBoundaries } from "../../commit/changelog/detect";
6
6
  import { parseUnreleasedSection } from "../../commit/changelog/parse";
7
7
  import { formatCommitMessage } from "../../commit/message";
8
8
  import { resolvePrimaryModel, resolveSmolModel } from "../../commit/model-selection";
9
- import type { CommitCommandArgs, ConventionalAnalysis } from "../../commit/types";
9
+ import type { CommitCommandArgs, ConventionalAnalysis, NumstatEntry } from "../../commit/types";
10
10
  import { ModelRegistry } from "../../config/model-registry";
11
11
  import { Settings } from "../../config/settings";
12
12
  import { discoverAuthStorage, discoverContextFiles } from "../../sdk";
@@ -121,11 +121,10 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
121
121
  }
122
122
 
123
123
  process.stdout.write("● Starting commit agent...\n");
124
- let commitState: CommitAgentState;
125
- let usedFallback = false;
124
+ let agentSessionCompleted = false;
126
125
 
127
126
  try {
128
- commitState = await runCommitAgentSession({
127
+ await runCommitAgentSession({
129
128
  cwd,
130
129
  model: agentModel,
131
130
  thinkingLevel: agentThinkingLevel,
@@ -138,42 +137,67 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
138
137
  requireChangelog: !args.noChangelog && changelogTargets.length > 0,
139
138
  diffText: diff,
140
139
  existingChangelogEntries,
140
+ onComplete: async commitState => {
141
+ agentSessionCompleted = true;
142
+ await completeAgentCommitState(commitState, {
143
+ cwd,
144
+ dryRun: args.dryRun,
145
+ push: args.push,
146
+ noChangelog: args.noChangelog,
147
+ changelogTargets,
148
+ numstat,
149
+ });
150
+ },
141
151
  });
152
+ return;
142
153
  } catch (error) {
154
+ if (agentSessionCompleted) {
155
+ throw error;
156
+ }
143
157
  const errorMessage = error instanceof Error ? error.message : String(error);
144
158
  process.stderr.write(`Agent error: ${errorMessage}\n`);
145
159
  if (error instanceof Error && error.stack && $env.DEBUG) {
146
160
  process.stderr.write(`${error.stack}\n`);
147
161
  }
148
162
  process.stdout.write("● Using fallback commit generation...\n");
149
- commitState = { proposal: generateFallbackProposal(numstat) };
150
- usedFallback = true;
163
+ const fallbackProposal = generateFallbackProposal(numstat);
164
+ await runSingleCommit(fallbackProposal, { cwd, dryRun: args.dryRun, push: args.push });
165
+ return;
151
166
  }
167
+ }
152
168
 
153
- if (!usedFallback && !commitState.proposal && !commitState.splitProposal) {
169
+ async function completeAgentCommitState(
170
+ commitState: CommitAgentState,
171
+ ctx: CommitExecutionContext & {
172
+ noChangelog: boolean;
173
+ changelogTargets: string[];
174
+ numstat: NumstatEntry[];
175
+ },
176
+ ): Promise<void> {
177
+ let usedFallback = false;
178
+ if (!commitState.proposal && !commitState.splitProposal) {
154
179
  if ($env.PI_COMMIT_NO_FALLBACK?.toLowerCase() !== "true") {
155
180
  process.stdout.write("● Agent did not provide proposal, using fallback...\n");
156
- commitState.proposal = generateFallbackProposal(numstat);
181
+ commitState.proposal = generateFallbackProposal(ctx.numstat);
157
182
  usedFallback = true;
158
183
  }
159
184
  }
160
185
 
161
186
  let updatedChangelogFiles: string[] = [];
162
- if (!args.noChangelog && changelogTargets.length > 0 && !usedFallback) {
187
+ if (!ctx.noChangelog && ctx.changelogTargets.length > 0 && !usedFallback) {
163
188
  if (!commitState.changelogProposal) {
164
- process.stderr.write("Commit agent did not provide changelog entries.\n");
165
- return;
189
+ throw new Error("Commit agent did not provide changelog entries.");
166
190
  }
167
191
  process.stdout.write("● Applying changelog entries...\n");
168
192
  const updated = await applyChangelogProposals({
169
- cwd,
193
+ cwd: ctx.cwd,
170
194
  proposals: commitState.changelogProposal.entries,
171
- dryRun: args.dryRun,
195
+ dryRun: ctx.dryRun,
172
196
  onProgress: message => {
173
197
  process.stdout.write(` ├─ ${message}\n`);
174
198
  },
175
199
  });
176
- updatedChangelogFiles = updated.map(filePath => path.relative(cwd, filePath));
200
+ updatedChangelogFiles = updated.map(filePath => path.relative(ctx.cwd, filePath));
177
201
  if (updated.length > 0) {
178
202
  for (const filePath of updated) {
179
203
  process.stdout.write(` └─ ${filePath}\n`);
@@ -184,21 +208,21 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
184
208
  }
185
209
 
186
210
  if (commitState.proposal) {
187
- await runSingleCommit(commitState.proposal, { cwd, dryRun: args.dryRun, push: args.push });
211
+ await runSingleCommit(commitState.proposal, ctx);
188
212
  return;
189
213
  }
190
214
 
191
215
  if (commitState.splitProposal) {
192
216
  await runSplitCommit(commitState.splitProposal, {
193
- cwd,
194
- dryRun: args.dryRun,
195
- push: args.push,
217
+ cwd: ctx.cwd,
218
+ dryRun: ctx.dryRun,
219
+ push: ctx.push,
196
220
  additionalFiles: updatedChangelogFiles,
197
221
  });
198
222
  return;
199
223
  }
200
224
 
201
- process.stderr.write("Commit agent did not provide a proposal.\n");
225
+ throw new Error("Commit agent did not provide a proposal.");
202
226
  }
203
227
 
204
228
  async function runSingleCommit(proposal: CommitProposal, ctx: CommitExecutionContext): Promise<void> {
@@ -211,6 +235,7 @@ async function runSingleCommit(proposal: CommitProposal, ctx: CommitExecutionCon
211
235
  process.stdout.write(`${commitMessage}\n`);
212
236
  return;
213
237
  }
238
+ process.stdout.write("● Creating commit...\n");
214
239
  await git.commit(ctx.cwd, commitMessage);
215
240
  process.stdout.write("Commit created.\n");
216
241
  if (ctx.push) {
@@ -233,8 +258,7 @@ async function runSplitCommit(
233
258
  const plannedFiles = new Set(plan.commits.flatMap(commit => commit.changes.map(change => change.path)));
234
259
  const missingFiles = stagedFiles.filter(file => !plannedFiles.has(file));
235
260
  if (missingFiles.length > 0) {
236
- process.stderr.write(`Split commit plan missing staged files: ${missingFiles.join(", ")}\n`);
237
- return;
261
+ throw new Error(`Split commit plan missing staged files: ${missingFiles.join(", ")}`);
238
262
  }
239
263
 
240
264
  if (ctx.dryRun) {
@@ -266,6 +290,7 @@ async function runSplitCommit(
266
290
  throw new Error(order.error);
267
291
  }
268
292
 
293
+ process.stdout.write("● Creating split commits...\n");
269
294
  const stagedDiff = await git.diff(ctx.cwd, { cached: true });
270
295
  await git.stage.reset(ctx.cwd);
271
296
  for (const commitIndex of order) {