@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.
@@ -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;
@@ -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
@@ -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
- const allowed = await runHttpHook(def.http, event, ctx, def.timeout ?? 10_000);
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
- return {
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
- return {
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
- return {
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.15.0",
3
+ "version": "2.16.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {