@zhijiewang/openharness 2.11.0 → 2.13.0

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.
@@ -1,4 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
2
3
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
4
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
5
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
@@ -9,7 +10,7 @@ export class RemoteAuthRequiredError extends Error {
9
10
  wwwAuthenticate;
10
11
  constructor(serverName, wwwAuthenticate) {
11
12
  super(`MCP server '${serverName}' requires authentication. ` +
12
- `Add headers.Authorization to your config (OAuth flow is not yet supported).`);
13
+ `Add 'auth: oauth' to enable the OAuth 2.1 flow, or set headers.Authorization for a static bearer token.`);
13
14
  this.name = "RemoteAuthRequiredError";
14
15
  this.serverName = serverName;
15
16
  this.wwwAuthenticate = wwwAuthenticate;
@@ -41,7 +42,7 @@ export class ProtocolError extends Error {
41
42
  * Construct an SDK Transport for a normalized config.
42
43
  * Does NOT call .start() — caller (Client.connect) handles that.
43
44
  */
44
- export async function buildTransport(cfg) {
45
+ export async function buildTransport(cfg, opts = {}) {
45
46
  if (cfg.type === "stdio") {
46
47
  return new StdioClientTransport({
47
48
  command: cfg.command,
@@ -52,11 +53,13 @@ export async function buildTransport(cfg) {
52
53
  if (cfg.type === "http") {
53
54
  return new StreamableHTTPClientTransport(new URL(cfg.url), {
54
55
  requestInit: cfg.headers ? { headers: cfg.headers } : undefined,
56
+ authProvider: opts.authProvider,
55
57
  });
56
58
  }
57
59
  if (cfg.type === "sse") {
58
60
  return new SSEClientTransport(new URL(cfg.url), {
59
61
  requestInit: cfg.headers ? { headers: cfg.headers } : undefined,
62
+ authProvider: opts.authProvider,
60
63
  });
61
64
  }
62
65
  throw new Error(`unknown transport type: ${cfg.type}`);
@@ -112,7 +115,6 @@ export async function connectWithFallback(cfg, doConnect) {
112
115
  if (!isFallbackCandidate(err))
113
116
  throw err;
114
117
  // Log + retry
115
- // biome-ignore lint/suspicious/noConsole: user-facing diagnostic
116
118
  console.warn(`[mcp] ${cfg.name}: Streamable HTTP failed (${err.message}); trying legacy SSE`);
117
119
  const sseCfg = { ...cfg, type: "sse" };
118
120
  return await doConnect(sseCfg);
@@ -120,25 +122,87 @@ export async function connectWithFallback(cfg, doConnect) {
120
122
  }
121
123
  const DEFAULT_TIMEOUT_MS = 5_000;
122
124
  const CLIENT_INFO = { name: "openharness", version: pkg.version };
125
+ /** Duck-type check: does this provider expose awaitCallback (our OhOAuthProvider)? */
126
+ function hasAwaitCallback(p) {
127
+ return typeof p.awaitCallback === "function";
128
+ }
123
129
  /**
124
130
  * Build a connected SDK Client for a normalized config.
125
131
  * Maps connect-time errors into OH's typed error taxonomy.
132
+ *
133
+ * When the auth provider exposes `awaitCallback()` (i.e. OhOAuthProvider), this
134
+ * function handles the full OAuth callback → finishAuth → reconnect loop so callers
135
+ * don't need to orchestrate it manually.
126
136
  */
127
- export async function buildClient(cfg) {
128
- const transport = await buildTransport(cfg);
137
+ export async function buildClient(cfg, opts = {}) {
138
+ const transport = await buildTransport(cfg, opts);
129
139
  const client = new Client(CLIENT_INFO, { capabilities: {} });
130
140
  const timeoutMs = cfg.timeout ?? DEFAULT_TIMEOUT_MS;
131
- let timer = null;
141
+ async function tryConnect() {
142
+ let timer = null;
143
+ try {
144
+ await Promise.race([
145
+ client.connect(transport),
146
+ new Promise((_, reject) => {
147
+ timer = setTimeout(() => reject(new Error(`init timeout after ${timeoutMs}ms`)), timeoutMs);
148
+ }),
149
+ ]);
150
+ }
151
+ finally {
152
+ if (timer !== null)
153
+ clearTimeout(timer);
154
+ }
155
+ }
132
156
  try {
133
- await Promise.race([
134
- client.connect(transport),
135
- new Promise((_, reject) => {
136
- timer = setTimeout(() => reject(new Error(`init timeout after ${timeoutMs}ms`)), timeoutMs);
137
- }),
138
- ]);
157
+ await tryConnect();
139
158
  return client;
140
159
  }
141
160
  catch (err) {
161
+ // If the SDK requires a browser-based OAuth flow (UnauthorizedError after REDIRECT),
162
+ // and our provider knows how to await the callback, complete the loop here.
163
+ // Per the SDK design, after finishAuth we must create a fresh transport + client
164
+ // because the original transport is already in a "started" state.
165
+ if (err instanceof UnauthorizedError && opts.authProvider && hasAwaitCallback(opts.authProvider)) {
166
+ try {
167
+ const { code } = await opts.authProvider.awaitCallback();
168
+ await transport.finishAuth(code);
169
+ // Close the old transport before constructing a fresh one — the SDK's
170
+ // Transport is one-shot after an UnauthorizedError; leaving it open leaks
171
+ // the underlying TCP socket / event stream.
172
+ try {
173
+ await transport.close?.();
174
+ }
175
+ catch {
176
+ // best-effort
177
+ }
178
+ // Build a fresh transport + client for the authenticated retry
179
+ const freshTransport = await buildTransport(cfg, opts);
180
+ const freshClient = new Client(CLIENT_INFO, { capabilities: {} });
181
+ let freshTimer = null;
182
+ try {
183
+ await Promise.race([
184
+ freshClient.connect(freshTransport),
185
+ new Promise((_, reject) => {
186
+ freshTimer = setTimeout(() => reject(new Error(`init timeout after ${timeoutMs}ms`)), timeoutMs);
187
+ }),
188
+ ]);
189
+ }
190
+ finally {
191
+ if (freshTimer !== null)
192
+ clearTimeout(freshTimer);
193
+ }
194
+ return freshClient;
195
+ }
196
+ catch (oauthErr) {
197
+ // Classify the retry error the same way as the primary path
198
+ if (oauthErr instanceof RemoteAuthRequiredError ||
199
+ oauthErr instanceof UnreachableError ||
200
+ oauthErr instanceof ProtocolError) {
201
+ throw oauthErr;
202
+ }
203
+ throw new ProtocolError(cfg.name, oauthErr);
204
+ }
205
+ }
142
206
  // Leave RemoteAuthRequiredError / UnreachableError / ProtocolError as-is
143
207
  if (err instanceof RemoteAuthRequiredError || err instanceof UnreachableError || err instanceof ProtocolError) {
144
208
  throw err;
@@ -151,9 +215,5 @@ export async function buildClient(cfg) {
151
215
  // Otherwise protocol-shaped
152
216
  throw new ProtocolError(cfg.name, err);
153
217
  }
154
- finally {
155
- if (timer !== null)
156
- clearTimeout(timer);
157
- }
158
218
  }
159
219
  //# sourceMappingURL=transport.js.map
@@ -33,20 +33,26 @@ export function createFallbackProvider(primary, fallbacks) {
33
33
  ];
34
34
  for (let i = 0; i < providers.length; i++) {
35
35
  const p = providers[i];
36
+ let hasYielded = false;
36
37
  try {
37
- let _hasYielded = false;
38
38
  for await (const event of p.provider.stream(messages, systemPrompt, tools, p.model)) {
39
- _hasYielded = true;
39
+ hasYielded = true;
40
40
  yield event;
41
41
  }
42
- _activeFallback = i === 0 ? null : p.provider.name;
42
+ if (i > 0) {
43
+ console.warn(`[provider] fell back from ${primary.name} to ${p.provider.name}`);
44
+ _activeFallback = p.provider.name;
45
+ }
46
+ else {
47
+ _activeFallback = null;
48
+ }
43
49
  return;
44
50
  }
45
51
  catch (err) {
46
- // Mid-stream failure: can't un-send events, propagate error
47
- if (i > 0 || !isRetriableError(err))
52
+ // Mid-stream failure OR non-retriable OR fallback error: propagate.
53
+ if (i > 0 || !isRetriableError(err) || hasYielded)
48
54
  throw err;
49
- // Pre-stream failure on primary: try next provider
55
+ // Pre-stream retriable failure on primary only: try next provider.
50
56
  _activeFallback = null;
51
57
  }
52
58
  }
@@ -63,7 +69,13 @@ export function createFallbackProvider(primary, fallbacks) {
63
69
  const p = providers[i];
64
70
  try {
65
71
  const result = await p.provider.complete(messages, systemPrompt, tools, p.model);
66
- _activeFallback = i === 0 ? null : p.provider.name;
72
+ if (i > 0) {
73
+ console.warn(`[provider] fell back from ${primary.name} to ${p.provider.name}`);
74
+ _activeFallback = p.provider.name;
75
+ }
76
+ else {
77
+ _activeFallback = null;
78
+ }
67
79
  return result;
68
80
  }
69
81
  catch (err) {
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Provider factory — create the right provider from a model string.
3
3
  */
4
+ import { readOhConfig } from "../harness/config.js";
4
5
  import { AnthropicProvider } from "./anthropic.js";
6
+ import { createFallbackProvider } from "./fallback.js";
5
7
  import { LlamaCppProvider } from "./llamacpp.js";
6
8
  import { OllamaProvider } from "./ollama.js";
7
9
  import { OpenAIProvider } from "./openai.js";
@@ -29,8 +31,22 @@ export async function createProvider(modelArg, overrides) {
29
31
  defaultModel: model,
30
32
  ...overrides,
31
33
  };
32
- const provider = createProviderInstance(providerName, config);
33
- return { provider, model };
34
+ const primary = createProviderInstance(providerName, config);
35
+ const fallbackCfgs = readOhConfig()?.fallbackProviders ?? [];
36
+ if (fallbackCfgs.length === 0) {
37
+ return { provider: primary, model };
38
+ }
39
+ const fallbacks = fallbackCfgs.map((fb) => ({
40
+ provider: createProviderInstance(fb.provider, {
41
+ name: fb.provider,
42
+ apiKey: fb.apiKey ?? process.env[`${fb.provider.toUpperCase()}_API_KEY`],
43
+ baseUrl: fb.baseUrl,
44
+ defaultModel: fb.model ?? model,
45
+ }),
46
+ model: fb.model,
47
+ }));
48
+ const wrapped = createFallbackProvider(primary, fallbacks);
49
+ return { provider: wrapped, model };
34
50
  }
35
51
  export { createProviderInstance, guessProviderFromModel };
36
52
  function createProviderInstance(name, config) {
@@ -45,4 +45,8 @@ export declare class ModelRouter {
45
45
  /** Get all configured tiers */
46
46
  get tiers(): Record<ModelTier, string>;
47
47
  }
48
+ /** Record the router's selection for a session. Keeps only the most recent 256 sessions. */
49
+ export declare function recordRouteSelection(sessionId: string, result: RouteResult): void;
50
+ /** Retrieve the most recent selection for a session, or undefined. */
51
+ export declare function getRouteSelection(sessionId: string): RouteResult | undefined;
48
52
  //# sourceMappingURL=router.d.ts.map
@@ -58,4 +58,23 @@ export class ModelRouter {
58
58
  };
59
59
  }
60
60
  }
61
+ const ROUTE_SELECTION_CAP = 256;
62
+ const routeSelections = new Map();
63
+ /** Record the router's selection for a session. Keeps only the most recent 256 sessions. */
64
+ export function recordRouteSelection(sessionId, result) {
65
+ // Map preserves insertion order. Delete-then-set moves the key to the end,
66
+ // so oldest is always keys().next().
67
+ if (routeSelections.has(sessionId))
68
+ routeSelections.delete(sessionId);
69
+ routeSelections.set(sessionId, result);
70
+ if (routeSelections.size > ROUTE_SELECTION_CAP) {
71
+ const oldest = routeSelections.keys().next().value;
72
+ if (oldest !== undefined)
73
+ routeSelections.delete(oldest);
74
+ }
75
+ }
76
+ /** Retrieve the most recent selection for a session, or undefined. */
77
+ export function getRouteSelection(sessionId) {
78
+ return routeSelections.get(sessionId);
79
+ }
61
80
  //# sourceMappingURL=router.js.map
@@ -8,7 +8,9 @@
8
8
  * - types.ts — shared types
9
9
  */
10
10
  import { DeferredTool } from "../DeferredTool.js";
11
+ import { readOhConfig } from "../harness/config.js";
11
12
  import { getContextWindow } from "../harness/cost.js";
13
+ import { ModelRouter } from "../providers/router.js";
12
14
  import { StreamingToolExecutor } from "../services/StreamingToolExecutor.js";
13
15
  import { toolToAPIFormat } from "../Tool.js";
14
16
  import { createAssistantMessage, createToolResultMessage, createUserMessage } from "../types/message.js";
@@ -18,8 +20,27 @@ import { isNetworkError, isOverloadError, isPromptTooLongError, isRateLimitError
18
20
  import { executeToolCalls } from "./tools.js";
19
21
  export { compressMessages } from "./compress.js";
20
22
  const DEFAULT_MAX_TURNS = 50;
23
+ /** Rough context-usage estimate in [0, 1]. Returns undefined when tokenization is unavailable. */
24
+ function estimateRouteContextUsage(messages, provider, model) {
25
+ const estimate = provider.estimateTokens?.bind(provider);
26
+ if (!estimate)
27
+ return undefined;
28
+ const info = provider.getModelInfo?.(model);
29
+ const window = info?.contextWindow;
30
+ if (!window || window <= 0)
31
+ return undefined;
32
+ let total = 0;
33
+ for (const m of messages) {
34
+ if (typeof m.content === "string")
35
+ total += estimate(m.content);
36
+ // Non-string content (tool calls etc.) is skipped — rough estimate only.
37
+ }
38
+ return Math.min(1, total / window);
39
+ }
21
40
  export async function* query(userMessage, config, existingMessages = []) {
22
41
  const maxTurns = config.maxTurns ?? DEFAULT_MAX_TURNS;
42
+ const routerCfg = readOhConfig()?.modelRouter ?? {};
43
+ const router = new ModelRouter(routerCfg, config.model ?? "");
23
44
  const toolContext = {
24
45
  workingDir: config.workingDir ?? process.cwd(),
25
46
  abortSignal: config.abortSignal,
@@ -160,7 +181,16 @@ export async function* query(userMessage, config, existingMessages = []) {
160
181
  let streamError = null;
161
182
  const streamingExecutor = new StreamingToolExecutor(config.tools, toolContext, config.permissionMode, config.askUser, config.abortSignal);
162
183
  try {
163
- for await (const event of config.provider.stream(state.messages, turnPrompt, apiTools, config.model)) {
184
+ const ctxUsage = estimateRouteContextUsage(state.messages, config.provider, config.model ?? "");
185
+ const selection = router.select({
186
+ turn: state.turn,
187
+ hadToolCalls: state.lastTurnHadTools ?? false,
188
+ toolCallCount: state.lastTurnToolCount ?? 0,
189
+ contextUsage: ctxUsage,
190
+ isFinalResponse: (state.lastTurnHadTools === false || state.lastTurnHadTools === undefined) && state.turn > 1,
191
+ role: config.role,
192
+ });
193
+ for await (const event of config.provider.stream(state.messages, turnPrompt, apiTools, selection.model)) {
164
194
  if (config.abortSignal?.aborted)
165
195
  break;
166
196
  switch (event.type) {
@@ -283,6 +313,8 @@ export async function* query(userMessage, config, existingMessages = []) {
283
313
  if (remaining.length > 0) {
284
314
  yield* executeToolCalls(remaining, config.tools, toolContext, config.permissionMode, config.askUser, state);
285
315
  }
316
+ state.lastTurnHadTools = toolCalls.length > 0;
317
+ state.lastTurnToolCount = toolCalls.length;
286
318
  state.transition = "next_turn";
287
319
  }
288
320
  yield { type: "turn_complete", reason: "max_turns" };
@@ -2,7 +2,7 @@
2
2
  * Tool execution — permission checking, batching, output capping.
3
3
  */
4
4
  import { createCheckpoint, getAffectedFiles } from "../harness/checkpoints.js";
5
- import { emitHook } from "../harness/hooks.js";
5
+ import { emitHook, emitHookWithOutcome } from "../harness/hooks.js";
6
6
  import { findToolByName } from "../Tool.js";
7
7
  import { createToolResultMessage } from "../types/message.js";
8
8
  import { checkPermission } from "../types/permissions.js";
@@ -45,9 +45,28 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
45
45
  if (perm.reason === "needs-approval" && askUser) {
46
46
  const { formatToolArgs } = await import("../utils/tool-summary.js");
47
47
  const description = formatToolArgs(tool.name, toolCall.arguments);
48
- const allowed = await askUser(tool.name, description, tool.riskLevel);
49
- if (!allowed) {
50
- return { output: "Permission denied by user.", isError: true };
48
+ // Hook: permissionRequest fires between preToolUse and the interactive askUser prompt.
49
+ // Only fires when checkPermission says "needs-approval" AND askUser is provided.
50
+ const hookOutcome = await emitHookWithOutcome("permissionRequest", {
51
+ toolName: tool.name,
52
+ toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
53
+ toolInputJson: JSON.stringify(parsed.data).slice(0, 1000),
54
+ permissionMode,
55
+ permissionAction: "ask",
56
+ });
57
+ if (hookOutcome.permissionDecision === "allow") {
58
+ // Hook granted permission — skip interactive prompt and proceed to execution.
59
+ }
60
+ else if (hookOutcome.permissionDecision === "deny" || !hookOutcome.allowed) {
61
+ const reason = hookOutcome.reason ? `: ${hookOutcome.reason}` : "";
62
+ return { output: `Permission denied by hook${reason}`, isError: true };
63
+ }
64
+ else {
65
+ // "ask" or no decision → fall through to interactive prompt
66
+ const allowed = await askUser(tool.name, description, tool.riskLevel);
67
+ if (!allowed) {
68
+ return { output: "Permission denied by user.", isError: true };
69
+ }
51
70
  }
52
71
  }
53
72
  else {
@@ -79,12 +98,23 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
79
98
  toolAbort.addEventListener("abort", () => reject(new Error(`Tool '${tool.name}' timed out after ${TOOL_TIMEOUT_MS / 1000}s`)));
80
99
  }),
81
100
  ]);
82
- // Hook: postToolUse
83
- emitHook("postToolUse", {
84
- toolName: tool.name,
85
- toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
86
- toolOutput: result.output.slice(0, 1000),
87
- });
101
+ // Hook: postToolUse / postToolUseFailure (mutually exclusive — strict CC parity)
102
+ if (result.isError) {
103
+ emitHook("postToolUseFailure", {
104
+ toolName: tool.name,
105
+ toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
106
+ toolOutput: result.output.slice(0, 1000),
107
+ toolError: "ReportedError",
108
+ errorMessage: result.output.slice(0, 1000),
109
+ });
110
+ }
111
+ else {
112
+ emitHook("postToolUse", {
113
+ toolName: tool.name,
114
+ toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
115
+ toolOutput: result.output.slice(0, 1000),
116
+ });
117
+ }
88
118
  // Emit fileChanged hook for file-modifying tools
89
119
  if (!result.isError && ["Edit", "Write", "MultiEdit"].includes(tool.name)) {
90
120
  const filePaths = getAffectedFiles(tool.name, parsed.data);
@@ -141,7 +171,15 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
141
171
  return { output, isError: result.isError };
142
172
  }
143
173
  catch (err) {
144
- return { output: `Tool error: ${err instanceof Error ? err.message : String(err)}`, isError: true };
174
+ const errMsg = err instanceof Error ? err.message : String(err);
175
+ const errName = err instanceof Error ? err.name : "ExecutionError";
176
+ emitHook("postToolUseFailure", {
177
+ toolName: tool.name,
178
+ toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
179
+ errorMessage: errMsg,
180
+ toolError: errName,
181
+ });
182
+ return { output: `Tool error: ${errMsg}`, isError: true };
145
183
  }
146
184
  }
147
185
  export async function* executeToolCalls(toolCalls, tools, context, permissionMode, askUser, state) {
@@ -20,6 +20,8 @@ export type QueryConfig = {
20
20
  workingDir?: string;
21
21
  /** Auto-commit after each file-modifying tool */
22
22
  gitCommitPerTool?: boolean;
23
+ /** For sub-agent invocations: the agent role name (feeds into the model router). */
24
+ role?: string;
23
25
  };
24
26
  export type TransitionReason = "next_turn" | "retry_network" | "retry_prompt_too_long" | "retry_max_output_tokens";
25
27
  export type QueryLoopState = {
@@ -33,5 +35,9 @@ export type QueryLoopState = {
33
35
  promptTooLongRetries?: number;
34
36
  /** Track consecutive compression failures for circuit breaker */
35
37
  compressionFailures?: number;
38
+ /** Whether the previous turn made any tool calls (feeds ModelRouter) */
39
+ lastTurnHadTools?: boolean;
40
+ /** Number of tool calls in the previous turn (feeds ModelRouter) */
41
+ lastTurnToolCount?: number;
36
42
  };
37
43
  //# sourceMappingURL=types.d.ts.map
@@ -99,7 +99,7 @@ export const AgentTool = {
99
99
  const runAgent = async () => {
100
100
  let finalText = "";
101
101
  try {
102
- for await (const event of query(input.prompt, config)) {
102
+ for await (const event of query(input.prompt, { ...config, role: role?.id })) {
103
103
  if (event.type === "text_delta")
104
104
  finalText += event.content;
105
105
  }
@@ -137,7 +137,7 @@ export const AgentTool = {
137
137
  let finalText = "";
138
138
  try {
139
139
  try {
140
- for await (const event of query(input.prompt, config)) {
140
+ for await (const event of query(input.prompt, { ...config, role: role?.id })) {
141
141
  if (event.type === "text_delta") {
142
142
  finalText += event.content;
143
143
  }
@@ -5,12 +5,12 @@ declare const inputSchema: z.ZodObject<{
5
5
  reason: z.ZodString;
6
6
  prompt: z.ZodString;
7
7
  }, "strip", z.ZodTypeAny, {
8
- prompt: string;
9
8
  reason: string;
9
+ prompt: string;
10
10
  delaySeconds: number;
11
11
  }, {
12
- prompt: string;
13
12
  reason: string;
13
+ prompt: string;
14
14
  delaySeconds: number;
15
15
  }>;
16
16
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,6 +44,7 @@
44
44
  "ink-spinner": "^5.0.0",
45
45
  "ink-text-input": "^6.0.0",
46
46
  "marked": "^17.0.5",
47
+ "open": "^11.0.0",
47
48
  "react": "^18.3.1",
48
49
  "yaml": "^2.7.0",
49
50
  "zod": "^3.24.0"