@zhijiewang/openharness 2.21.0 → 2.22.1

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.
@@ -4,11 +4,35 @@
4
4
  import type { Provider, ProviderConfig } from "./base.js";
5
5
  /**
6
6
  * Create a provider from a model string like "ollama/llama3" or "gpt-4o".
7
+ *
8
+ * `opts.fallbackModel` (audit B2) is the CLI override path for the existing
9
+ * `fallbackProviders` config — when set, REPLACES the config-file fallbacks
10
+ * with a single entry derived from the model string. Mirrors Claude Code's
11
+ * `--fallback-model <model>` for one-shot CI runs that want a fallback
12
+ * without editing `.oh/config.yaml`. Format matches `modelArg`:
13
+ * `provider/model` or just `model` (provider guessed). When unset, the
14
+ * existing config-file path is unchanged.
7
15
  */
8
- export declare function createProvider(modelArg?: string, overrides?: Partial<ProviderConfig>): Promise<{
16
+ export declare function createProvider(modelArg?: string, overrides?: Partial<ProviderConfig>, opts?: {
17
+ fallbackModel?: string;
18
+ }): Promise<{
9
19
  provider: Provider;
10
20
  model: string;
11
21
  }>;
22
+ /**
23
+ * Parse `--fallback-model <value>` into the same shape as a `fallbackProviders[]`
24
+ * entry. Accepts `provider/model` (explicit) or just `model` (provider guessed
25
+ * via `guessProviderFromModel`, same as the primary modelArg). Exposed for
26
+ * tests.
27
+ *
28
+ * @internal
29
+ */
30
+ export declare function parseFallbackModel(raw: string): {
31
+ provider: string;
32
+ model?: string;
33
+ apiKey?: string;
34
+ baseUrl?: string;
35
+ };
12
36
  export { createProviderInstance, guessProviderFromModel };
13
37
  declare function createProviderInstance(name: string, config: ProviderConfig): Provider;
14
38
  declare function guessProviderFromModel(model: string): string;
@@ -10,8 +10,16 @@ import { OpenAIProvider } from "./openai.js";
10
10
  import { OpenRouterProvider } from "./openrouter.js";
11
11
  /**
12
12
  * Create a provider from a model string like "ollama/llama3" or "gpt-4o".
13
+ *
14
+ * `opts.fallbackModel` (audit B2) is the CLI override path for the existing
15
+ * `fallbackProviders` config — when set, REPLACES the config-file fallbacks
16
+ * with a single entry derived from the model string. Mirrors Claude Code's
17
+ * `--fallback-model <model>` for one-shot CI runs that want a fallback
18
+ * without editing `.oh/config.yaml`. Format matches `modelArg`:
19
+ * `provider/model` or just `model` (provider guessed). When unset, the
20
+ * existing config-file path is unchanged.
13
21
  */
14
- export async function createProvider(modelArg, overrides) {
22
+ export async function createProvider(modelArg, overrides, opts = {}) {
15
23
  let providerName = "ollama";
16
24
  let model = "llama3";
17
25
  if (modelArg) {
@@ -32,7 +40,9 @@ export async function createProvider(modelArg, overrides) {
32
40
  ...overrides,
33
41
  };
34
42
  const primary = createProviderInstance(providerName, config);
35
- const fallbackCfgs = readOhConfig()?.fallbackProviders ?? [];
43
+ const fallbackCfgs = opts.fallbackModel
44
+ ? [parseFallbackModel(opts.fallbackModel)]
45
+ : (readOhConfig()?.fallbackProviders ?? []);
36
46
  if (fallbackCfgs.length === 0) {
37
47
  return { provider: primary, model };
38
48
  }
@@ -48,6 +58,21 @@ export async function createProvider(modelArg, overrides) {
48
58
  const wrapped = createFallbackProvider(primary, fallbacks);
49
59
  return { provider: wrapped, model };
50
60
  }
61
+ /**
62
+ * Parse `--fallback-model <value>` into the same shape as a `fallbackProviders[]`
63
+ * entry. Accepts `provider/model` (explicit) or just `model` (provider guessed
64
+ * via `guessProviderFromModel`, same as the primary modelArg). Exposed for
65
+ * tests.
66
+ *
67
+ * @internal
68
+ */
69
+ export function parseFallbackModel(raw) {
70
+ if (raw.includes("/")) {
71
+ const [p, m] = raw.split("/", 2);
72
+ return { provider: p, model: m };
73
+ }
74
+ return { provider: guessProviderFromModel(raw), model: raw };
75
+ }
51
76
  export { createProviderInstance, guessProviderFromModel };
52
77
  function createProviderInstance(name, config) {
53
78
  switch (name) {
@@ -311,7 +311,7 @@ export async function* query(userMessage, config, existingMessages = []) {
311
311
  // Execute remaining tools not started during streaming
312
312
  const remaining = toolCalls.filter((tc) => !executedIds.has(tc.id));
313
313
  if (remaining.length > 0) {
314
- yield* executeToolCalls(remaining, config.tools, toolContext, config.permissionMode, config.askUser, state);
314
+ yield* executeToolCalls(remaining, config.tools, toolContext, config.permissionMode, config.askUser, state, config.permissionPromptTool);
315
315
  }
316
316
  state.lastTurnHadTools = toolCalls.length > 0;
317
317
  state.lastTurnToolCount = toolCalls.length;
@@ -11,7 +11,7 @@ type Batch = {
11
11
  calls: ToolCall[];
12
12
  };
13
13
  export declare function partitionToolCalls(toolCalls: ToolCall[], tools: Tools): Batch[];
14
- export declare function executeSingleTool(toolCall: ToolCall, tools: Tools, context: ToolContext, permissionMode: PermissionMode, askUser?: AskUserFn): Promise<ToolResult>;
15
- export declare function executeToolCalls(toolCalls: ToolCall[], tools: Tools, context: ToolContext, permissionMode: PermissionMode, askUser?: AskUserFn, state?: QueryLoopState): AsyncGenerator<StreamEvent, void>;
14
+ export declare function executeSingleTool(toolCall: ToolCall, tools: Tools, context: ToolContext, permissionMode: PermissionMode, askUser?: AskUserFn, permissionPromptTool?: string): Promise<ToolResult>;
15
+ export declare function executeToolCalls(toolCalls: ToolCall[], tools: Tools, context: ToolContext, permissionMode: PermissionMode, askUser?: AskUserFn, state?: QueryLoopState, permissionPromptTool?: string): AsyncGenerator<StreamEvent, void>;
16
16
  export {};
17
17
  //# sourceMappingURL=tools.d.ts.map
@@ -8,6 +8,42 @@ import { createToolResultMessage } from "../types/message.js";
8
8
  import { checkPermission } from "../types/permissions.js";
9
9
  const MAX_TOOL_RESULT_CHARS = 100_000;
10
10
  const TOOL_TIMEOUT_MS = 120_000;
11
+ /**
12
+ * Invoke the configured `--permission-prompt-tool` (audit B1). The tool is
13
+ * looked up by name in the active tool registry (so MCP tools wired through
14
+ * `loadMcpTools` are reachable). Failure modes — missing tool, exception
15
+ * during call, malformed JSON, unknown `behavior` — collapse into
16
+ * `behavior: "fallthrough"` so the caller can try the next branch
17
+ * (interactive prompt or headless deny). A broken permission tool must
18
+ * not lock the user out.
19
+ */
20
+ async function callPermissionPromptTool(toolName, tools, context, permissionedToolName, permissionedInput) {
21
+ const promptTool = findToolByName(tools, toolName);
22
+ if (!promptTool)
23
+ return { behavior: "fallthrough" };
24
+ let raw;
25
+ try {
26
+ raw = await promptTool.call({ tool_name: permissionedToolName, input: permissionedInput }, context);
27
+ }
28
+ catch {
29
+ return { behavior: "fallthrough" };
30
+ }
31
+ if (raw.isError)
32
+ return { behavior: "fallthrough" };
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(raw.output);
36
+ }
37
+ catch {
38
+ return { behavior: "fallthrough" };
39
+ }
40
+ if (parsed.behavior === "allow")
41
+ return { behavior: "allow" };
42
+ if (parsed.behavior === "deny") {
43
+ return parsed.message ? { behavior: "deny", message: parsed.message } : { behavior: "deny" };
44
+ }
45
+ return { behavior: "fallthrough" };
46
+ }
11
47
  export function partitionToolCalls(toolCalls, tools) {
12
48
  const batches = [];
13
49
  let currentConcurrent = [];
@@ -30,7 +66,7 @@ export function partitionToolCalls(toolCalls, tools) {
30
66
  }
31
67
  return batches;
32
68
  }
33
- export async function executeSingleTool(toolCall, tools, context, permissionMode, askUser) {
69
+ export async function executeSingleTool(toolCall, tools, context, permissionMode, askUser, permissionPromptTool) {
34
70
  const tool = findToolByName(tools, toolCall.toolName);
35
71
  if (!tool) {
36
72
  return { output: `Error: Unknown tool '${toolCall.toolName}'`, isError: true };
@@ -72,6 +108,34 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
72
108
  const reason = hookOutcome.reason ? `: ${hookOutcome.reason}` : "";
73
109
  return denyAndEmit("hook", hookOutcome.reason ?? "hook denied", `Permission denied by hook${reason}`);
74
110
  }
111
+ else if (permissionPromptTool) {
112
+ // No hook decision → consult the configured MCP permission tool
113
+ // (audit B1). Mirrors Claude Code's --permission-prompt-tool. The
114
+ // tool returns JSON: { "behavior": "allow" | "deny", "message"?: string }.
115
+ // On any failure (tool missing, throws, malformed JSON, unknown
116
+ // behavior) we fall through to askUser / headless deny so a broken
117
+ // permission tool doesn't lock the user out.
118
+ const promptDecision = await callPermissionPromptTool(permissionPromptTool, tools, context, tool.name, parsed.data);
119
+ if (promptDecision.behavior === "allow") {
120
+ // Permission tool granted — proceed.
121
+ }
122
+ else if (promptDecision.behavior === "deny") {
123
+ return denyAndEmit("permission-prompt-tool", promptDecision.message ?? "denied", `Permission denied by ${permissionPromptTool}${promptDecision.message ? `: ${promptDecision.message}` : ""}`);
124
+ }
125
+ else if (askUser) {
126
+ // promptDecision.behavior === "fallthrough" — tool was unavailable
127
+ // or its response was malformed. Try the interactive prompt next.
128
+ const { formatToolArgs } = await import("../utils/tool-summary.js");
129
+ const description = formatToolArgs(tool.name, toolCall.arguments);
130
+ const allowed = await askUser(tool.name, description, tool.riskLevel);
131
+ if (!allowed) {
132
+ return denyAndEmit("user", "user declined", "Permission denied by user.");
133
+ }
134
+ }
135
+ else {
136
+ return denyAndEmit("headless", "permission-prompt-tool unavailable and no interactive prompt", `Permission denied: ${permissionPromptTool} did not produce a usable decision and no interactive prompt is available.`);
137
+ }
138
+ }
75
139
  else if (askUser) {
76
140
  // "ask" or no decision → interactive prompt when available
77
141
  const { formatToolArgs } = await import("../utils/tool-summary.js");
@@ -209,7 +273,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
209
273
  return { output: `Tool error: ${errMsg}`, isError: true };
210
274
  }
211
275
  }
212
- export async function* executeToolCalls(toolCalls, tools, context, permissionMode, askUser, state) {
276
+ export async function* executeToolCalls(toolCalls, tools, context, permissionMode, askUser, state, permissionPromptTool) {
213
277
  const batches = partitionToolCalls(toolCalls, tools);
214
278
  const outputChunks = [];
215
279
  const onOutputChunk = (callId, chunk) => {
@@ -218,7 +282,7 @@ export async function* executeToolCalls(toolCalls, tools, context, permissionMod
218
282
  const allToolNames = toolCalls.map((tc) => tc.toolName);
219
283
  for (const batch of batches) {
220
284
  if (batch.concurrent) {
221
- const results = await Promise.all(batch.calls.map((tc) => executeSingleTool(tc, tools, { ...context, callId: tc.id, onOutputChunk }, permissionMode, askUser)));
285
+ const results = await Promise.all(batch.calls.map((tc) => executeSingleTool(tc, tools, { ...context, callId: tc.id, onOutputChunk }, permissionMode, askUser, permissionPromptTool)));
222
286
  for (const chunk of outputChunks.splice(0))
223
287
  yield chunk;
224
288
  for (let i = 0; i < batch.calls.length; i++) {
@@ -230,7 +294,7 @@ export async function* executeToolCalls(toolCalls, tools, context, permissionMod
230
294
  }
231
295
  else {
232
296
  for (const tc of batch.calls) {
233
- const result = await executeSingleTool(tc, tools, { ...context, callId: tc.id, onOutputChunk }, permissionMode, askUser);
297
+ const result = await executeSingleTool(tc, tools, { ...context, callId: tc.id, onOutputChunk }, permissionMode, askUser, permissionPromptTool);
234
298
  for (const chunk of outputChunks.splice(0))
235
299
  yield chunk;
236
300
  yield { type: "tool_call_end", callId: tc.id, output: result.output, isError: result.isError };
@@ -22,6 +22,16 @@ export type QueryConfig = {
22
22
  gitCommitPerTool?: boolean;
23
23
  /** For sub-agent invocations: the agent role name (feeds into the model router). */
24
24
  role?: string;
25
+ /**
26
+ * MCP tool name (e.g. `mcp__myperm__check`) consulted when a tool needs
27
+ * approval and no permission hook gave a decision (audit B1). Mirrors
28
+ * Claude Code's `--permission-prompt-tool`. The tool is invoked with
29
+ * `{ tool_name, input }` and is expected to return a JSON string with
30
+ * shape `{ "behavior": "allow" | "deny", "message"?: string }`. Falls
31
+ * through to the interactive `askUser` prompt (or headless deny) when
32
+ * the tool is missing, throws, or returns malformed JSON.
33
+ */
34
+ permissionPromptTool?: string;
25
35
  };
26
36
  export type TransitionReason = "next_turn" | "retry_network" | "retry_prompt_too_long" | "retry_max_output_tokens";
27
37
  export type QueryLoopState = {
@@ -44,7 +44,7 @@ async function getClient(filePath, workingDir) {
44
44
  }
45
45
  export const DiagnosticsTool = {
46
46
  name: "Diagnostics",
47
- description: "Get code diagnostics (errors, warnings), go-to-definition, or find-references using the language server.",
47
+ description: "Language Server Protocol (LSP) code intelligence — pick action: diagnostics (errors/warnings), definition (go-to-def), references (find-refs), or hover (type info / docs). Supports TypeScript, JavaScript, Python, Go, Rust.",
48
48
  inputSchema,
49
49
  riskLevel: "low",
50
50
  isReadOnly() {
@@ -101,22 +101,10 @@ export const DiagnosticsTool = {
101
101
  return { output: "line and character are required for hover.", isError: true };
102
102
  }
103
103
  await client.openFile(input.file_path);
104
- // Hover uses textDocument/hover which returns MarkupContent
105
- try {
106
- const result = await client.send("textDocument/hover", {
107
- textDocument: { uri: `file://${input.file_path.replace(/\\/g, "/")}` },
108
- position: { line: input.line, character: input.character },
109
- });
110
- if (!result?.contents)
111
- return { output: "No hover information.", isError: false };
112
- const content = typeof result.contents === "string"
113
- ? result.contents
114
- : (result.contents.value ?? JSON.stringify(result.contents));
115
- return { output: content, isError: false };
116
- }
117
- catch {
118
- return { output: "Hover not supported by this language server.", isError: false };
119
- }
104
+ const content = await client.getHover(input.file_path, input.line, input.character);
105
+ if (!content)
106
+ return { output: "No hover information.", isError: false };
107
+ return { output: content, isError: false };
120
108
  }
121
109
  return { output: `Unknown action: ${input.action}`, isError: true };
122
110
  }
@@ -128,11 +116,11 @@ export const DiagnosticsTool = {
128
116
  }
129
117
  },
130
118
  prompt() {
131
- return `Get code intelligence from the language server. Supports TypeScript, JavaScript, Python, Go, and Rust. Actions:
132
- - diagnostics: Get errors and warnings for a file
133
- - definition: Go to definition of a symbol at a given position
134
- - references: Find all references to a symbol at a given position
135
- - hover: Get type information and documentation for a symbol
119
+ return `LSP code intelligence diagnostics, go-to-definition, find-references, hover. Supports TypeScript, JavaScript, Python, Go, Rust (a language server must be installed: typescript-language-server / pylsp / gopls / rust-analyzer). Actions:
120
+ - diagnostics: Errors and warnings for the file
121
+ - definition: Resolve the symbol at (line, character) to its declaration
122
+ - references: Find all references to the symbol at (line, character)
123
+ - hover: Type information and documentation for the symbol at (line, character)
136
124
  Parameters:
137
125
  - file_path (string, required): Absolute path to the file
138
126
  - action (string): "diagnostics" | "definition" | "references" | "hover" (default: diagnostics)
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Detect how the running OH CLI was installed (audit B7) so `oh update` can
3
+ * print the appropriate upgrade command. Pure function — `detectInstallMethod`
4
+ * inspects the process's own filesystem path and current working directory;
5
+ * exported for unit testing and reuse.
6
+ *
7
+ * Detection rules, in order:
8
+ * - "local-clone" → `dist/main.js` lives inside a git repo whose root is
9
+ * the package itself (the user is running from a clone).
10
+ * Suggest `git pull && npm install && npm run build`.
11
+ * - "npm-global" → `dist/main.js` lives under a directory containing the
12
+ * segment `node_modules/@zhijiewang/openharness/`. This
13
+ * is the standard npm global install layout. Suggest
14
+ * `npm install -g @zhijiewang/openharness@latest`.
15
+ * - "npx-cache" → `dist/main.js` lives under a path containing
16
+ * `_npx/` (npx caches packages there). npx auto-fetches
17
+ * the latest by default; suggest re-running with
18
+ * `@latest` to bypass cache.
19
+ * - "unknown" → Couldn't classify. Print all three options and let
20
+ * the user choose.
21
+ */
22
+ export type InstallMethod = "local-clone" | "npm-global" | "npx-cache" | "unknown";
23
+ export interface InstallMethodResult {
24
+ method: InstallMethod;
25
+ /** The detected install root, mostly for diagnostics. */
26
+ root: string;
27
+ /** Multi-line user-facing message describing the upgrade command. */
28
+ message: string;
29
+ }
30
+ /**
31
+ * Classify the install method given the running script's filesystem path.
32
+ * `mainPath` defaults to `import.meta.url`-derived path in the CLI; tests
33
+ * override it.
34
+ */
35
+ export declare function detectInstallMethod(mainPath: string): InstallMethodResult;
36
+ /**
37
+ * Default `mainPath` resolver — walks up from `process.argv[1]` to find the
38
+ * package root. Exported so tests can stub it. Falls back to argv[1] verbatim
39
+ * when nothing matches.
40
+ */
41
+ export declare function getDefaultMainPath(): string;
42
+ //# sourceMappingURL=install-method.d.ts.map
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Detect how the running OH CLI was installed (audit B7) so `oh update` can
3
+ * print the appropriate upgrade command. Pure function — `detectInstallMethod`
4
+ * inspects the process's own filesystem path and current working directory;
5
+ * exported for unit testing and reuse.
6
+ *
7
+ * Detection rules, in order:
8
+ * - "local-clone" → `dist/main.js` lives inside a git repo whose root is
9
+ * the package itself (the user is running from a clone).
10
+ * Suggest `git pull && npm install && npm run build`.
11
+ * - "npm-global" → `dist/main.js` lives under a directory containing the
12
+ * segment `node_modules/@zhijiewang/openharness/`. This
13
+ * is the standard npm global install layout. Suggest
14
+ * `npm install -g @zhijiewang/openharness@latest`.
15
+ * - "npx-cache" → `dist/main.js` lives under a path containing
16
+ * `_npx/` (npx caches packages there). npx auto-fetches
17
+ * the latest by default; suggest re-running with
18
+ * `@latest` to bypass cache.
19
+ * - "unknown" → Couldn't classify. Print all three options and let
20
+ * the user choose.
21
+ */
22
+ import { existsSync } from "node:fs";
23
+ import { dirname, join, sep } from "node:path";
24
+ /**
25
+ * Classify the install method given the running script's filesystem path.
26
+ * `mainPath` defaults to `import.meta.url`-derived path in the CLI; tests
27
+ * override it.
28
+ */
29
+ export function detectInstallMethod(mainPath) {
30
+ // Normalize to forward slashes so the substring tests below work on Windows.
31
+ const normalized = mainPath.replace(/\\/g, "/");
32
+ // npx-cache: path contains `/_npx/` (Node's npx puts packages there)
33
+ if (normalized.includes("/_npx/")) {
34
+ return {
35
+ method: "npx-cache",
36
+ root: dirname(mainPath),
37
+ message: [
38
+ "You're running OH via npx (auto-fetched on each invocation).",
39
+ "To force the latest version on the next run, use:",
40
+ "",
41
+ " npx @zhijiewang/openharness@latest",
42
+ "",
43
+ "Or install globally to avoid the npx cache entirely:",
44
+ " npm install -g @zhijiewang/openharness@latest",
45
+ ].join("\n"),
46
+ };
47
+ }
48
+ // local-clone: walk up to find a package.json whose name matches AND a .git dir
49
+ let dir = dirname(mainPath);
50
+ while (dir && dir !== dirname(dir)) {
51
+ const pkgPath = join(dir, "package.json");
52
+ if (existsSync(pkgPath)) {
53
+ const isClone = existsSync(join(dir, ".git"));
54
+ if (isClone) {
55
+ return {
56
+ method: "local-clone",
57
+ root: dir,
58
+ message: [
59
+ `Detected a local clone at: ${dir}`,
60
+ "Pull the latest and rebuild:",
61
+ "",
62
+ ` cd ${dir}`,
63
+ " git pull && npm install && npm run build",
64
+ ].join("\n"),
65
+ };
66
+ }
67
+ // npm-global: the package.json belongs to OH and lives under a global
68
+ // node_modules directory.
69
+ if (normalized.includes("/node_modules/@zhijiewang/openharness/")) {
70
+ return {
71
+ method: "npm-global",
72
+ root: dir,
73
+ message: [
74
+ `Detected a global npm install at: ${dir}`,
75
+ "Upgrade with:",
76
+ "",
77
+ " npm install -g @zhijiewang/openharness@latest",
78
+ ].join("\n"),
79
+ };
80
+ }
81
+ break;
82
+ }
83
+ dir = dirname(dir);
84
+ }
85
+ return {
86
+ method: "unknown",
87
+ root: dirname(mainPath),
88
+ message: [
89
+ "Could not determine how OH was installed. Pick the option that matches your setup:",
90
+ "",
91
+ " Global npm install: npm install -g @zhijiewang/openharness@latest",
92
+ " npx (one-shot): npx @zhijiewang/openharness@latest",
93
+ " Local clone: git pull && npm install && npm run build",
94
+ ].join("\n"),
95
+ };
96
+ }
97
+ /**
98
+ * Default `mainPath` resolver — walks up from `process.argv[1]` to find the
99
+ * package root. Exported so tests can stub it. Falls back to argv[1] verbatim
100
+ * when nothing matches.
101
+ */
102
+ export function getDefaultMainPath() {
103
+ const entry = process.argv[1] ?? "";
104
+ if (!entry)
105
+ return "";
106
+ // If argv[1] points at a `dist/main.js`, that's already the right anchor.
107
+ // Otherwise return as-is and let `detectInstallMethod` figure it out.
108
+ return entry.split(sep).join("/");
109
+ }
110
+ //# sourceMappingURL=install-method.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.21.0",
3
+ "version": "2.22.1",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {