@zhijiewang/openharness 2.15.0 → 2.16.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/dist/harness/config.d.ts +4 -0
- package/dist/harness/hooks.d.ts +23 -1
- package/dist/harness/hooks.js +88 -5
- package/dist/main.js +51 -1
- package/package.json +1 -1
package/dist/harness/config.d.ts
CHANGED
|
@@ -62,6 +62,10 @@ export type HooksConfig = {
|
|
|
62
62
|
postCompact?: HookDef[];
|
|
63
63
|
configChange?: HookDef[];
|
|
64
64
|
notification?: HookDef[];
|
|
65
|
+
/** Fires at the start of each top-level agent turn (after a user prompt is accepted, before model call). */
|
|
66
|
+
turnStart?: HookDef[];
|
|
67
|
+
/** Fires at the end of each top-level agent turn (after the model either completes or errors). Matches Claude Code's Stop hook. */
|
|
68
|
+
turnStop?: HookDef[];
|
|
65
69
|
};
|
|
66
70
|
export type ToolPermissionRule = {
|
|
67
71
|
tool: string;
|
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" | "postToolUseFailure" | "userPromptSubmit" | "permissionRequest" | "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" | "turnStart" | "turnStop";
|
|
14
14
|
export type HookContext = {
|
|
15
15
|
toolName?: string;
|
|
16
16
|
toolArgs?: string;
|
|
@@ -38,6 +38,10 @@ export type HookContext = {
|
|
|
38
38
|
errorMessage?: string;
|
|
39
39
|
/** For permissionRequest: the decision OH would take absent the hook ("ask", "allow", "deny") — informational */
|
|
40
40
|
permissionAction?: "ask" | "allow" | "deny";
|
|
41
|
+
/** For turnStart/turnStop: zero-indexed turn number within the current session */
|
|
42
|
+
turnNumber?: string;
|
|
43
|
+
/** For turnStop: reason the turn ended ("completed", "max_turns", "error", "interrupted") */
|
|
44
|
+
turnReason?: string;
|
|
41
45
|
};
|
|
42
46
|
/** Clear hook cache (call after config changes) */
|
|
43
47
|
export declare function invalidateHookCache(): void;
|
|
@@ -92,4 +96,22 @@ export type HookOutcome = {
|
|
|
92
96
|
* from hooks is ignored — outcome.allowed is always true. additionalContext is still collected.
|
|
93
97
|
*/
|
|
94
98
|
export declare function emitHookWithOutcome(event: HookEvent, ctx?: HookContext): Promise<HookOutcome>;
|
|
99
|
+
export type HookDecisionNotification = {
|
|
100
|
+
event: HookEvent;
|
|
101
|
+
/** Present when the hook fired in a tool-specific context. */
|
|
102
|
+
tool?: string;
|
|
103
|
+
/** The effective decision. "ask" means defer to the user / default permission flow. */
|
|
104
|
+
decision: "allow" | "deny" | "ask";
|
|
105
|
+
/** Reason returned by the hook, if any. */
|
|
106
|
+
reason?: string;
|
|
107
|
+
};
|
|
108
|
+
type HookDecisionObserver = (n: HookDecisionNotification) => void;
|
|
109
|
+
/**
|
|
110
|
+
* Register (or clear) a single observer that receives one notification per
|
|
111
|
+
* `emitHookWithOutcome` call that produces a decision. Used by
|
|
112
|
+
* `oh run --output-format stream-json` to emit `hook_decision` NDJSON events
|
|
113
|
+
* so the Python SDK can surface permission outcomes in real time.
|
|
114
|
+
*/
|
|
115
|
+
export declare function setHookDecisionObserver(cb: HookDecisionObserver | null): void;
|
|
116
|
+
export {};
|
|
95
117
|
//# sourceMappingURL=hooks.d.ts.map
|
package/dist/harness/hooks.js
CHANGED
|
@@ -67,6 +67,10 @@ function buildEnv(event, ctx) {
|
|
|
67
67
|
env.OH_ERROR_MESSAGE = ctx.errorMessage;
|
|
68
68
|
if (ctx.permissionAction !== undefined)
|
|
69
69
|
env.OH_PERMISSION_ACTION = ctx.permissionAction;
|
|
70
|
+
if (ctx.turnNumber !== undefined)
|
|
71
|
+
env.OH_TURN_NUMBER = ctx.turnNumber;
|
|
72
|
+
if (ctx.turnReason !== undefined)
|
|
73
|
+
env.OH_TURN_REASON = ctx.turnReason;
|
|
70
74
|
return env;
|
|
71
75
|
}
|
|
72
76
|
/**
|
|
@@ -263,6 +267,50 @@ async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
|
|
|
263
267
|
return false;
|
|
264
268
|
}
|
|
265
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Run an HTTP hook and return its full structured response. POSTs `{event, ...ctx}`
|
|
272
|
+
* as JSON and parses the response body with the same jsonIO envelope shape used
|
|
273
|
+
* by command hooks:
|
|
274
|
+
* { decision?: "allow" | "deny", reason?: string,
|
|
275
|
+
* hookSpecificOutput?: { decision?: "allow" | "deny" | "ask", reason?, additionalContext? } }
|
|
276
|
+
*
|
|
277
|
+
* Also honors a legacy `{ allowed: boolean }` shape (downgrades to decision).
|
|
278
|
+
*
|
|
279
|
+
* Network errors / non-2xx responses / malformed JSON all return a "deny" outcome
|
|
280
|
+
* so the caller can fail closed.
|
|
281
|
+
*/
|
|
282
|
+
async function runHttpHookDetailed(url, event, ctx, timeoutMs = 10_000) {
|
|
283
|
+
try {
|
|
284
|
+
const body = JSON.stringify({ event, ...ctx });
|
|
285
|
+
const res = await fetch(url, {
|
|
286
|
+
method: "POST",
|
|
287
|
+
headers: { "Content-Type": "application/json" },
|
|
288
|
+
body,
|
|
289
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
290
|
+
});
|
|
291
|
+
if (!res.ok)
|
|
292
|
+
return { decision: "deny", reason: `hook HTTP ${res.status}` };
|
|
293
|
+
const text = await res.text();
|
|
294
|
+
if (!text.trim())
|
|
295
|
+
return {};
|
|
296
|
+
const parsed = parseJsonIoResponse(text);
|
|
297
|
+
// If the response only used the legacy `{ allowed: false }` shape, surface as deny.
|
|
298
|
+
if (!parsed.decision && !parsed.permissionDecision) {
|
|
299
|
+
try {
|
|
300
|
+
const legacy = JSON.parse(text);
|
|
301
|
+
if (legacy.allowed === false)
|
|
302
|
+
return { decision: "deny", reason: "hook denied" };
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// already handled by parseJsonIoResponse
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return parsed;
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return { decision: "deny", reason: "hook HTTP error" };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
266
314
|
/**
|
|
267
315
|
* Run a prompt hook. Uses an LLM to make a yes/no allow/deny decision.
|
|
268
316
|
*
|
|
@@ -503,8 +551,7 @@ async function runHookForOutcome(def, event, ctx) {
|
|
|
503
551
|
return mapEnvExitToOutcome(event, code === 0);
|
|
504
552
|
}
|
|
505
553
|
if (def.http) {
|
|
506
|
-
|
|
507
|
-
return mapEnvExitToOutcome(event, allowed);
|
|
554
|
+
return await runHttpHookDetailed(def.http, event, ctx, def.timeout ?? 10_000);
|
|
508
555
|
}
|
|
509
556
|
if (def.prompt) {
|
|
510
557
|
const allowed = await runPromptHook(def.prompt, ctx, def.timeout ?? 10_000);
|
|
@@ -537,20 +584,24 @@ export async function emitHookWithOutcome(event, ctx = {}) {
|
|
|
537
584
|
const parsed = await runHookForOutcome(def, event, ctx);
|
|
538
585
|
if (!notifyOnly) {
|
|
539
586
|
if (parsed.decision === "deny" || parsed.permissionDecision === "deny") {
|
|
540
|
-
|
|
587
|
+
const outcome = {
|
|
541
588
|
allowed: false,
|
|
542
589
|
reason: parsed.reason ?? reason,
|
|
543
590
|
permissionDecision: parsed.permissionDecision,
|
|
544
591
|
};
|
|
592
|
+
notifyHookDecision(event, ctx, outcome);
|
|
593
|
+
return outcome;
|
|
545
594
|
}
|
|
546
595
|
if (parsed.permissionDecision === "allow") {
|
|
547
596
|
if (parsed.additionalContext)
|
|
548
597
|
additionalContexts.push(parsed.additionalContext);
|
|
549
|
-
|
|
598
|
+
const outcome = {
|
|
550
599
|
allowed: true,
|
|
551
600
|
permissionDecision: "allow",
|
|
552
601
|
additionalContext: additionalContexts.length ? additionalContexts.join("\n\n") : undefined,
|
|
553
602
|
};
|
|
603
|
+
notifyHookDecision(event, ctx, outcome);
|
|
604
|
+
return outcome;
|
|
554
605
|
}
|
|
555
606
|
if (parsed.permissionDecision === "ask")
|
|
556
607
|
askSeen = true;
|
|
@@ -560,11 +611,43 @@ export async function emitHookWithOutcome(event, ctx = {}) {
|
|
|
560
611
|
if (!reason && parsed.reason)
|
|
561
612
|
reason = parsed.reason;
|
|
562
613
|
}
|
|
563
|
-
|
|
614
|
+
const outcome = {
|
|
564
615
|
allowed: true,
|
|
565
616
|
additionalContext: additionalContexts.length ? additionalContexts.join("\n\n") : undefined,
|
|
566
617
|
permissionDecision: askSeen ? "ask" : undefined,
|
|
567
618
|
reason,
|
|
568
619
|
};
|
|
620
|
+
notifyHookDecision(event, ctx, outcome);
|
|
621
|
+
return outcome;
|
|
622
|
+
}
|
|
623
|
+
let hookDecisionObserver = null;
|
|
624
|
+
/**
|
|
625
|
+
* Register (or clear) a single observer that receives one notification per
|
|
626
|
+
* `emitHookWithOutcome` call that produces a decision. Used by
|
|
627
|
+
* `oh run --output-format stream-json` to emit `hook_decision` NDJSON events
|
|
628
|
+
* so the Python SDK can surface permission outcomes in real time.
|
|
629
|
+
*/
|
|
630
|
+
export function setHookDecisionObserver(cb) {
|
|
631
|
+
hookDecisionObserver = cb;
|
|
632
|
+
}
|
|
633
|
+
function notifyHookDecision(event, ctx, outcome) {
|
|
634
|
+
if (!hookDecisionObserver)
|
|
635
|
+
return;
|
|
636
|
+
// Prefer the richer permissionDecision when present; fall back to deny if the hook blocked.
|
|
637
|
+
let decision = outcome.permissionDecision;
|
|
638
|
+
if (!decision) {
|
|
639
|
+
if (!outcome.allowed)
|
|
640
|
+
decision = "deny";
|
|
641
|
+
else if (outcome.reason)
|
|
642
|
+
decision = "allow";
|
|
643
|
+
}
|
|
644
|
+
if (!decision)
|
|
645
|
+
return;
|
|
646
|
+
try {
|
|
647
|
+
hookDecisionObserver({ event, tool: ctx.toolName, decision, reason: outcome.reason });
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
// Observer errors must not break the hook pipeline.
|
|
651
|
+
}
|
|
569
652
|
}
|
|
570
653
|
//# sourceMappingURL=hooks.js.map
|
package/dist/main.js
CHANGED
|
@@ -16,7 +16,7 @@ import { join } from "node:path";
|
|
|
16
16
|
import { Command, Option } from "commander";
|
|
17
17
|
import { render } from "ink";
|
|
18
18
|
import { readOhConfig } from "./harness/config.js";
|
|
19
|
-
import { emitHook } from "./harness/hooks.js";
|
|
19
|
+
import { emitHook, setHookDecisionObserver } from "./harness/hooks.js";
|
|
20
20
|
import { loadActiveMemories, memoriesToPrompt, userProfileToPrompt } from "./harness/memory.js";
|
|
21
21
|
import { detectProject, projectContextToPrompt } from "./harness/onboarding.js";
|
|
22
22
|
import { discoverSkills, skillsToPrompt } from "./harness/plugins.js";
|
|
@@ -189,6 +189,26 @@ program
|
|
|
189
189
|
let fullOutput = "";
|
|
190
190
|
const toolResults = [];
|
|
191
191
|
const callIdToName = {};
|
|
192
|
+
if (outputFormat === "stream-json") {
|
|
193
|
+
setHookDecisionObserver((n) => {
|
|
194
|
+
console.log(JSON.stringify({
|
|
195
|
+
type: "hook_decision",
|
|
196
|
+
event: n.event,
|
|
197
|
+
tool: n.tool,
|
|
198
|
+
decision: n.decision,
|
|
199
|
+
reason: n.reason,
|
|
200
|
+
}));
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
emitHook("turnStart", {
|
|
204
|
+
turnNumber: "0",
|
|
205
|
+
model,
|
|
206
|
+
provider: typeof config.provider === "string" ? config.provider : undefined,
|
|
207
|
+
permissionMode,
|
|
208
|
+
});
|
|
209
|
+
if (outputFormat === "stream-json") {
|
|
210
|
+
console.log(JSON.stringify({ type: "turnStart", turnNumber: 0 }));
|
|
211
|
+
}
|
|
192
212
|
for await (const event of query(prompt, config)) {
|
|
193
213
|
if (event.type === "text_delta") {
|
|
194
214
|
fullOutput += event.content;
|
|
@@ -245,6 +265,10 @@ program
|
|
|
245
265
|
if (outputFormat === "stream-json") {
|
|
246
266
|
console.log(JSON.stringify({ type: "turn_complete", reason: event.reason }));
|
|
247
267
|
}
|
|
268
|
+
emitHook("turnStop", { turnNumber: "0", turnReason: event.reason, model, permissionMode });
|
|
269
|
+
if (outputFormat === "stream-json") {
|
|
270
|
+
console.log(JSON.stringify({ type: "turnStop", turnNumber: 0, reason: event.reason }));
|
|
271
|
+
}
|
|
248
272
|
if (event.reason !== "completed") {
|
|
249
273
|
process.exitCode = 1;
|
|
250
274
|
}
|
|
@@ -304,6 +328,20 @@ program
|
|
|
304
328
|
};
|
|
305
329
|
// Conversation history, shared across all prompts for this process.
|
|
306
330
|
const conversation = [];
|
|
331
|
+
let turnCounter = 0;
|
|
332
|
+
// Will be set to the current prompt id before each turn so hook_decision
|
|
333
|
+
// events can be demultiplexed by the client.
|
|
334
|
+
let activePromptId = "";
|
|
335
|
+
setHookDecisionObserver((n) => {
|
|
336
|
+
console.log(JSON.stringify({
|
|
337
|
+
id: activePromptId,
|
|
338
|
+
type: "hook_decision",
|
|
339
|
+
event: n.event,
|
|
340
|
+
tool: n.tool,
|
|
341
|
+
decision: n.decision,
|
|
342
|
+
reason: n.reason,
|
|
343
|
+
}));
|
|
344
|
+
});
|
|
307
345
|
// Announce readiness so the client can send the first prompt.
|
|
308
346
|
console.log(JSON.stringify({ type: "ready" }));
|
|
309
347
|
const readline = await import("node:readline");
|
|
@@ -333,6 +371,16 @@ program
|
|
|
333
371
|
const turnToolCalls = [];
|
|
334
372
|
const callIdToName = {};
|
|
335
373
|
const toolResults = [];
|
|
374
|
+
const turnIdx = turnCounter++;
|
|
375
|
+
const turnNumber = String(turnIdx);
|
|
376
|
+
activePromptId = id;
|
|
377
|
+
emitHook("turnStart", {
|
|
378
|
+
turnNumber,
|
|
379
|
+
model,
|
|
380
|
+
provider: typeof config.provider === "string" ? config.provider : undefined,
|
|
381
|
+
permissionMode,
|
|
382
|
+
});
|
|
383
|
+
console.log(JSON.stringify({ id, type: "turnStart", turnNumber: turnIdx }));
|
|
336
384
|
for await (const event of query(prompt, config, conversation)) {
|
|
337
385
|
if (event.type === "text_delta") {
|
|
338
386
|
assistantText += event.content;
|
|
@@ -374,6 +422,8 @@ program
|
|
|
374
422
|
}
|
|
375
423
|
else if (event.type === "turn_complete") {
|
|
376
424
|
console.log(JSON.stringify({ id, type: "turn_complete", reason: event.reason }));
|
|
425
|
+
emitHook("turnStop", { turnNumber, turnReason: event.reason, model, permissionMode });
|
|
426
|
+
console.log(JSON.stringify({ id, type: "turnStop", turnNumber: turnIdx, reason: event.reason }));
|
|
377
427
|
}
|
|
378
428
|
}
|
|
379
429
|
// Rebuild this turn's contribution to the conversation.
|