@zhijiewang/openharness 2.12.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.
package/README.md CHANGED
@@ -317,6 +317,8 @@ hooks:
317
317
 
318
318
  Use `match` to restrict a hook to a specific tool name (e.g., `match: Bash` only triggers for the Bash tool).
319
319
 
320
+ See [docs/hooks.md](docs/hooks.md) for the full event reference including the new `userPromptSubmit`, `permissionRequest`, and `postToolUseFailure` events.
321
+
320
322
  ## Cybergotchi
321
323
 
322
324
  OpenHarness ships with a Tamagotchi-style companion that lives in the side panel. It reacts to your session in real time — celebrating streaks, complaining when tools fail, and getting hungry if you ignore it.
@@ -11,6 +11,7 @@ import { getContextWindow } from "../harness/cost.js";
11
11
  import { normalizeMcpConfig } from "../mcp/config-normalize.js";
12
12
  import { connectedMcpServers } from "../mcp/loader.js";
13
13
  import { getAuthStatus } from "../mcp/oauth.js";
14
+ import { getRouteSelection } from "../providers/router.js";
14
15
  import { mcpLoginHandler, mcpLogoutHandler } from "./mcp-auth.js";
15
16
  export function registerInfoCommands(register, getCommandMap) {
16
17
  register("help", "Show available commands", () => {
@@ -45,6 +46,7 @@ export function registerInfoCommands(register, getCommandMap) {
45
46
  "mcp-login",
46
47
  "mcp-logout",
47
48
  "mcp-registry",
49
+ "router",
48
50
  "init",
49
51
  "bug",
50
52
  "feedback",
@@ -468,6 +470,24 @@ export function registerInfoCommands(register, getCommandMap) {
468
470
  register("mcp-logout", "Wipe local OAuth tokens for an MCP server", async (args) => {
469
471
  return mcpLogoutHandler(args);
470
472
  });
473
+ register("router", "Show the model router state", (_args, ctx) => {
474
+ const cfg = readOhConfig()?.modelRouter;
475
+ const defaultModel = ctx.model ?? "unknown";
476
+ if (!cfg || (!cfg.fast && !cfg.balanced && !cfg.powerful)) {
477
+ return { output: `Router: off (single model: ${defaultModel})`, handled: true };
478
+ }
479
+ const last = ctx.sessionId ? getRouteSelection(ctx.sessionId) : undefined;
480
+ const lines = [
481
+ "Model router:",
482
+ ` fast ${cfg.fast ?? `(default: ${defaultModel})`}`,
483
+ ` balanced ${cfg.balanced ?? `(default: ${defaultModel})`}`,
484
+ ` powerful ${cfg.powerful ?? `(default: ${defaultModel})`}`,
485
+ ];
486
+ if (last) {
487
+ lines.push("", `Last selection: ${last.tier} — "${last.reason}"`);
488
+ }
489
+ return { output: lines.join("\n"), handled: true };
490
+ });
471
491
  register("init", "Initialize project with .oh/ config", () => {
472
492
  const ohDir = join(process.cwd(), ".oh");
473
493
  if (existsSync(ohDir)) {
@@ -51,6 +51,9 @@ export type HooksConfig = {
51
51
  sessionEnd?: HookDef[];
52
52
  preToolUse?: HookDef[];
53
53
  postToolUse?: HookDef[];
54
+ postToolUseFailure?: HookDef[];
55
+ userPromptSubmit?: HookDef[];
56
+ permissionRequest?: HookDef[];
54
57
  fileChanged?: HookDef[];
55
58
  cwdChanged?: HookDef[];
56
59
  subagentStart?: HookDef[];
@@ -10,7 +10,7 @@
10
10
  * - prompt: LLM yes/no check via provider.complete()
11
11
  */
12
12
  import type { HookDef } from "./config.js";
13
- export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification";
13
+ export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "postToolUseFailure" | "userPromptSubmit" | "permissionRequest" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification";
14
14
  export type HookContext = {
15
15
  toolName?: string;
16
16
  toolArgs?: string;
@@ -30,6 +30,14 @@ export type HookContext = {
30
30
  agentId?: string;
31
31
  /** For notification: the message */
32
32
  message?: string;
33
+ /** For userPromptSubmit: the raw prompt text the user is about to submit */
34
+ prompt?: string;
35
+ /** For postToolUseFailure: short error label ("TimeoutError", "ExecutionError", "ReportedError") */
36
+ toolError?: string;
37
+ /** For postToolUseFailure: full error message */
38
+ errorMessage?: string;
39
+ /** For permissionRequest: the decision OH would take absent the hook ("ask", "allow", "deny") — informational */
40
+ permissionAction?: "ask" | "allow" | "deny";
33
41
  };
34
42
  /** Clear hook cache (call after config changes) */
35
43
  export declare function invalidateHookCache(): void;
@@ -58,4 +66,30 @@ export declare function emitHook(event: HookEvent, ctx?: HookContext): boolean;
58
66
  * Supports all hook types (command, HTTP, prompt).
59
67
  */
60
68
  export declare function emitHookAsync(event: HookEvent, ctx?: HookContext): Promise<boolean>;
69
+ /** Parsed shape of a jsonIO hook's stdout JSON response. */
70
+ export type ParsedJsonIoResponse = {
71
+ decision?: "allow" | "deny";
72
+ reason?: string;
73
+ additionalContext?: string;
74
+ permissionDecision?: "allow" | "deny" | "ask";
75
+ };
76
+ /** Parse a hook's stdout as a jsonIO envelope. Returns an empty object on malformed input. */
77
+ export declare function parseJsonIoResponse(raw: string): ParsedJsonIoResponse;
78
+ export type HookOutcome = {
79
+ allowed: boolean;
80
+ additionalContext?: string;
81
+ permissionDecision?: "allow" | "deny" | "ask";
82
+ reason?: string;
83
+ };
84
+ /**
85
+ * Emit a hook event and return a structured HookOutcome parsed from jsonIO responses.
86
+ *
87
+ * Merge semantics:
88
+ * - First `deny` (or `permissionDecision: "deny"`) short-circuits: {allowed: false, ...}.
89
+ * - `permissionDecision: "allow"` short-circuits: {allowed: true, permissionDecision: "allow"}.
90
+ * - `additionalContext` from multiple hooks is concatenated in order, "\n\n" separated.
91
+ * - For NOTIFY_ONLY_OUTCOME_EVENTS (postToolUseFailure), decision/permissionDecision
92
+ * from hooks is ignored — outcome.allowed is always true. additionalContext is still collected.
93
+ */
94
+ export declare function emitHookWithOutcome(event: HookEvent, ctx?: HookContext): Promise<HookOutcome>;
61
95
  //# sourceMappingURL=hooks.d.ts.map
@@ -56,6 +56,17 @@ function buildEnv(event, ctx) {
56
56
  env.OH_AGENT_ID = ctx.agentId;
57
57
  if (ctx.message)
58
58
  env.OH_MESSAGE = ctx.message;
59
+ if (ctx.prompt !== undefined) {
60
+ // Cap at 8KB to avoid Windows env-var length limits.
61
+ const PROMPT_MAX = 8 * 1024;
62
+ env.OH_PROMPT = ctx.prompt.length > PROMPT_MAX ? ctx.prompt.slice(0, PROMPT_MAX) : ctx.prompt;
63
+ }
64
+ if (ctx.toolError !== undefined)
65
+ env.OH_TOOL_ERROR = ctx.toolError;
66
+ if (ctx.errorMessage !== undefined)
67
+ env.OH_ERROR_MESSAGE = ctx.errorMessage;
68
+ if (ctx.permissionAction !== undefined)
69
+ env.OH_PERMISSION_ACTION = ctx.permissionAction;
59
70
  return env;
60
71
  }
61
72
  /**
@@ -142,20 +153,15 @@ function runCommandHookAsync(command, env, timeoutMs = 10_000) {
142
153
  });
143
154
  }
144
155
  /**
145
- * Run a JSON-mode command hook (Claude Code convention).
146
- *
147
- * Sends `{event, ...context}` as JSON on stdin. Parses stdout as JSON
148
- * `{ decision: "allow" | "deny", reason?: string, hookSpecificOutput?: any }`.
156
+ * Run a JSON-mode command hook and return the raw stdout string.
149
157
  *
150
- * Gating logic:
151
- * - `decision: "deny"` blocks (returns false).
152
- * - `decision: "allow"` or omitted decision allow (returns true).
153
- * - Non-zero exit code → block.
154
- * - Invalid/empty JSON on stdout → fall back to exit code (0 = allow).
155
- * - Timeout or spawn error → block.
158
+ * Rejects (throws) on timeout or spawn error so callers can decide how to
159
+ * interpret the failure. Returns an empty string when stdout is empty.
160
+ * Rejects when the process exits with a non-zero code (callers treat this as
161
+ * a block).
156
162
  */
157
- function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
158
- return new Promise((resolve) => {
163
+ function runJsonIoHookCaptureStdout(command, env, event, ctx, timeoutMs = 10_000) {
164
+ return new Promise((resolve, reject) => {
159
165
  const proc = spawn(command, {
160
166
  shell: true,
161
167
  timeout: timeoutMs,
@@ -168,7 +174,7 @@ function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
168
174
  if (!settled) {
169
175
  settled = true;
170
176
  proc.kill();
171
- resolve(false);
177
+ reject(new Error("hook timed out"));
172
178
  }
173
179
  }, timeoutMs);
174
180
  proc.stdout?.on("data", (chunk) => {
@@ -188,39 +194,56 @@ function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
188
194
  return;
189
195
  settled = true;
190
196
  clearTimeout(timer);
191
- // Non-zero exit is always a block, regardless of stdout.
192
197
  if ((code ?? 1) !== 0) {
193
- resolve(false);
194
- return;
195
- }
196
- // Empty stdout → treat exit code as the signal (allow for exit 0).
197
- if (!stdoutBuf.trim()) {
198
- resolve(true);
198
+ reject(new Error(`hook exited with code ${code ?? 1}`));
199
199
  return;
200
200
  }
201
- try {
202
- const parsed = JSON.parse(stdoutBuf);
203
- if (parsed.decision === "deny") {
204
- resolve(false);
205
- }
206
- else {
207
- resolve(true); // "allow" or omitted → allow
208
- }
209
- }
210
- catch {
211
- // Malformed JSON with a zero exit — fail closed conservatively.
212
- resolve(false);
213
- }
201
+ resolve(stdoutBuf);
214
202
  });
215
- proc.on("error", () => {
203
+ proc.on("error", (err) => {
216
204
  if (!settled) {
217
205
  settled = true;
218
206
  clearTimeout(timer);
219
- resolve(false);
207
+ reject(err);
220
208
  }
221
209
  });
222
210
  });
223
211
  }
212
+ /**
213
+ * Run a JSON-mode command hook (Claude Code convention).
214
+ *
215
+ * Sends `{event, ...context}` as JSON on stdin. Parses stdout as JSON
216
+ * `{ decision: "allow" | "deny", reason?: string, hookSpecificOutput?: any }`.
217
+ *
218
+ * Gating logic:
219
+ * - `decision: "deny"` → blocks (returns false).
220
+ * - `decision: "allow"` or omitted decision → allow (returns true).
221
+ * - Non-zero exit code → block.
222
+ * - Invalid/empty JSON on stdout → fall back to exit code (0 = allow).
223
+ * - Timeout or spawn error → block.
224
+ */
225
+ async function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
226
+ let stdout;
227
+ try {
228
+ stdout = await runJsonIoHookCaptureStdout(command, env, event, ctx, timeoutMs);
229
+ }
230
+ catch {
231
+ // timeout, spawn error, or non-zero exit — block
232
+ return false;
233
+ }
234
+ // Empty stdout → treat exit code as the signal (allow for exit 0).
235
+ if (!stdout.trim()) {
236
+ return true;
237
+ }
238
+ try {
239
+ const parsed = JSON.parse(stdout);
240
+ return parsed.decision !== "deny";
241
+ }
242
+ catch {
243
+ // Malformed JSON with a zero exit — fail closed conservatively.
244
+ return false;
245
+ }
246
+ }
224
247
  /** Run an HTTP hook. POSTs context as JSON, expects { allowed: true/false }. */
225
248
  async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
226
249
  try {
@@ -398,4 +421,150 @@ export async function emitHookAsync(event, ctx = {}) {
398
421
  }
399
422
  return true;
400
423
  }
424
+ /** Parse a hook's stdout as a jsonIO envelope. Returns an empty object on malformed input. */
425
+ export function parseJsonIoResponse(raw) {
426
+ let obj;
427
+ try {
428
+ obj = JSON.parse(raw);
429
+ }
430
+ catch {
431
+ return {};
432
+ }
433
+ if (!obj || typeof obj !== "object" || Array.isArray(obj))
434
+ return {};
435
+ const rec = obj;
436
+ const out = {};
437
+ if (rec.decision === "allow" || rec.decision === "deny")
438
+ out.decision = rec.decision;
439
+ if (typeof rec.reason === "string")
440
+ out.reason = rec.reason;
441
+ const hso = rec.hookSpecificOutput;
442
+ if (hso && typeof hso === "object" && !Array.isArray(hso)) {
443
+ const hsoRec = hso;
444
+ if (typeof hsoRec.additionalContext === "string")
445
+ out.additionalContext = hsoRec.additionalContext;
446
+ if (hsoRec.decision === "allow" || hsoRec.decision === "deny" || hsoRec.decision === "ask") {
447
+ out.permissionDecision = hsoRec.decision;
448
+ }
449
+ if (typeof hsoRec.reason === "string" && !out.reason)
450
+ out.reason = hsoRec.reason;
451
+ }
452
+ return out;
453
+ }
454
+ /** Events for which "notify-only" semantics apply — outcome.allowed is always true. */
455
+ const NOTIFY_ONLY_OUTCOME_EVENTS = new Set(["postToolUseFailure"]);
456
+ /**
457
+ * Map a command-hook's boolean (exit 0 / nonzero) result to a ParsedJsonIoResponse
458
+ * for the given event, applying per-event semantics:
459
+ *
460
+ * - userPromptSubmit: exit 0 → allow ({}); nonzero → deny.
461
+ * - permissionRequest: exit 0 → "ask" (fall through to user); nonzero → deny.
462
+ * - postToolUseFailure: notify-only — exit code is irrelevant, always return {}.
463
+ * - All other events: same as userPromptSubmit (exit 0 allow, nonzero deny).
464
+ */
465
+ function mapEnvExitToOutcome(event, allowed) {
466
+ switch (event) {
467
+ case "permissionRequest":
468
+ return allowed
469
+ ? { permissionDecision: "ask" }
470
+ : { permissionDecision: "deny", decision: "deny", reason: "hook denied (exit code)" };
471
+ case "postToolUseFailure":
472
+ // notify-only; exit code is irrelevant
473
+ return {};
474
+ default:
475
+ return allowed ? {} : { decision: "deny", reason: "hook denied (exit code)" };
476
+ }
477
+ }
478
+ /**
479
+ * Execute a single hook definition and return a ParsedJsonIoResponse for outcome merging.
480
+ * Private to this module — not exported.
481
+ */
482
+ async function runHookForOutcome(def, event, ctx) {
483
+ if (def.jsonIO && def.command) {
484
+ const env = buildEnv(event, ctx);
485
+ let raw;
486
+ try {
487
+ raw = await runJsonIoHookCaptureStdout(def.command, env, event, ctx, def.timeout ?? 10_000);
488
+ }
489
+ catch {
490
+ // timeout, spawn error, non-zero exit — treat as deny for gating events
491
+ return { decision: "deny", reason: "hook failed (timeout or non-zero exit)" };
492
+ }
493
+ if (!raw.trim()) {
494
+ // empty stdout with exit 0 — treat as allow (no decision)
495
+ return {};
496
+ }
497
+ return parseJsonIoResponse(raw);
498
+ }
499
+ if (def.command) {
500
+ // env-var mode — apply per-event exit-code semantics
501
+ const env = buildEnv(event, ctx);
502
+ const code = await runCommandHookAsync(def.command, env, def.timeout ?? 10_000);
503
+ return mapEnvExitToOutcome(event, code === 0);
504
+ }
505
+ if (def.http) {
506
+ const allowed = await runHttpHook(def.http, event, ctx, def.timeout ?? 10_000);
507
+ return mapEnvExitToOutcome(event, allowed);
508
+ }
509
+ if (def.prompt) {
510
+ const allowed = await runPromptHook(def.prompt, ctx, def.timeout ?? 10_000);
511
+ return allowed ? {} : { decision: "deny", reason: "prompt hook denied" };
512
+ }
513
+ return {};
514
+ }
515
+ /**
516
+ * Emit a hook event and return a structured HookOutcome parsed from jsonIO responses.
517
+ *
518
+ * Merge semantics:
519
+ * - First `deny` (or `permissionDecision: "deny"`) short-circuits: {allowed: false, ...}.
520
+ * - `permissionDecision: "allow"` short-circuits: {allowed: true, permissionDecision: "allow"}.
521
+ * - `additionalContext` from multiple hooks is concatenated in order, "\n\n" separated.
522
+ * - For NOTIFY_ONLY_OUTCOME_EVENTS (postToolUseFailure), decision/permissionDecision
523
+ * from hooks is ignored — outcome.allowed is always true. additionalContext is still collected.
524
+ */
525
+ export async function emitHookWithOutcome(event, ctx = {}) {
526
+ const hooks = getHooks();
527
+ const list = hooks?.[event];
528
+ if (!list || list.length === 0)
529
+ return { allowed: true };
530
+ const notifyOnly = NOTIFY_ONLY_OUTCOME_EVENTS.has(event);
531
+ const additionalContexts = [];
532
+ let reason;
533
+ let askSeen = false;
534
+ for (const def of list) {
535
+ if (def.match && !matchesHook(def, ctx))
536
+ continue;
537
+ const parsed = await runHookForOutcome(def, event, ctx);
538
+ if (!notifyOnly) {
539
+ if (parsed.decision === "deny" || parsed.permissionDecision === "deny") {
540
+ return {
541
+ allowed: false,
542
+ reason: parsed.reason ?? reason,
543
+ permissionDecision: parsed.permissionDecision,
544
+ };
545
+ }
546
+ if (parsed.permissionDecision === "allow") {
547
+ if (parsed.additionalContext)
548
+ additionalContexts.push(parsed.additionalContext);
549
+ return {
550
+ allowed: true,
551
+ permissionDecision: "allow",
552
+ additionalContext: additionalContexts.length ? additionalContexts.join("\n\n") : undefined,
553
+ };
554
+ }
555
+ if (parsed.permissionDecision === "ask")
556
+ askSeen = true;
557
+ }
558
+ if (parsed.additionalContext)
559
+ additionalContexts.push(parsed.additionalContext);
560
+ if (!reason && parsed.reason)
561
+ reason = parsed.reason;
562
+ }
563
+ return {
564
+ allowed: true,
565
+ additionalContext: additionalContexts.length ? additionalContexts.join("\n\n") : undefined,
566
+ permissionDecision: askSeen ? "ask" : undefined,
567
+ reason,
568
+ };
569
+ }
401
570
  //# sourceMappingURL=hooks.js.map
@@ -6,6 +6,7 @@ import { processSlashCommand } from "../commands/index.js";
6
6
  import { cybergotchiEvents } from "../cybergotchi/events.js";
7
7
  import { resolveMcpMention } from "../mcp/loader.js";
8
8
  import { createInfoMessage, createUserMessage } from "../types/message.js";
9
+ import { emitHookWithOutcome } from "./hooks.js";
9
10
  /**
10
11
  * Process user input: handle exit, companion mentions, slash commands,
11
12
  * @mentions, and prepare the prompt for the LLM.
@@ -78,10 +79,28 @@ export async function handleUserInput(input, ctx) {
78
79
  }
79
80
  if (result.prependToPrompt) {
80
81
  messages = [...messages, createUserMessage(input)];
82
+ const prependPrompt = result.prependToPrompt;
83
+ const prependOutcome = await emitHookWithOutcome("userPromptSubmit", {
84
+ prompt: prependPrompt,
85
+ sessionId: ctx.sessionId,
86
+ model: ctx.currentModel,
87
+ provider: ctx.providerName,
88
+ permissionMode: ctx.permissionMode,
89
+ });
90
+ if (!prependOutcome.allowed) {
91
+ const reason = prependOutcome.reason ? `: ${prependOutcome.reason}` : "";
92
+ return {
93
+ handled: true,
94
+ messages: [...messages, createInfoMessage(`Blocked by userPromptSubmit hook${reason}`)],
95
+ };
96
+ }
97
+ const finalPrependPrompt = prependOutcome.additionalContext
98
+ ? `${prependOutcome.additionalContext}\n\n${prependPrompt}`
99
+ : prependPrompt;
81
100
  return {
82
101
  handled: false,
83
102
  messages,
84
- prompt: result.prependToPrompt,
103
+ prompt: finalPrependPrompt,
85
104
  newModel: result.newModel ?? undefined,
86
105
  };
87
106
  }
@@ -136,6 +155,21 @@ export async function handleUserInput(input, ctx) {
136
155
  /* ignore */
137
156
  }
138
157
  }
139
- return { handled: false, messages, prompt: resolvedInput };
158
+ const outcome = await emitHookWithOutcome("userPromptSubmit", {
159
+ prompt: resolvedInput,
160
+ sessionId: ctx.sessionId,
161
+ model: ctx.currentModel,
162
+ provider: ctx.providerName,
163
+ permissionMode: ctx.permissionMode,
164
+ });
165
+ if (!outcome.allowed) {
166
+ const reason = outcome.reason ? `: ${outcome.reason}` : "";
167
+ return {
168
+ handled: true,
169
+ messages: [...messages, createInfoMessage(`Blocked by userPromptSubmit hook${reason}`)],
170
+ };
171
+ }
172
+ const finalPrompt = outcome.additionalContext ? `${outcome.additionalContext}\n\n${resolvedInput}` : resolvedInput;
173
+ return { handled: false, messages, prompt: finalPrompt };
140
174
  }
141
175
  //# sourceMappingURL=submit-handler.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.12.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": {