@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 +2 -0
- package/dist/commands/info.js +20 -0
- package/dist/harness/config.d.ts +3 -0
- package/dist/harness/hooks.d.ts +35 -1
- package/dist/harness/hooks.js +204 -35
- package/dist/harness/submit-handler.js +36 -2
- package/dist/providers/fallback.js +19 -7
- package/dist/providers/index.js +18 -2
- package/dist/providers/router.d.ts +4 -0
- package/dist/providers/router.js +19 -0
- package/dist/query/index.js +33 -1
- package/dist/query/tools.js +49 -11
- package/dist/query/types.d.ts +6 -0
- package/dist/tools/AgentTool/index.js +2 -2
- package/dist/tools/ScheduleWakeupTool/index.d.ts +2 -2
- package/package.json +1 -1
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.
|
package/dist/commands/info.js
CHANGED
|
@@ -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)) {
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -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[];
|
package/dist/harness/hooks.d.ts
CHANGED
|
@@ -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
|
package/dist/harness/hooks.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
hasYielded = true;
|
|
40
40
|
yield event;
|
|
41
41
|
}
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
package/dist/providers/index.js
CHANGED
|
@@ -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
|
|
33
|
-
|
|
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
|
package/dist/providers/router.js
CHANGED
|
@@ -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
|
package/dist/query/index.js
CHANGED
|
@@ -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
|
-
|
|
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" };
|
package/dist/query/tools.js
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/query/types.d.ts
CHANGED
|
@@ -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
|
/**
|