@zhijiewang/openharness 2.14.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/README.md CHANGED
@@ -57,6 +57,8 @@ oh
57
57
 
58
58
  That's it. OpenHarness auto-detects Ollama and starts chatting. No API key needed.
59
59
 
60
+ **Python SDK:** there's also an official Python SDK for driving `oh` from Python programs (notebooks, batch scripts, ML pipelines). Install with `pip install openharness` after the npm install, then `from openharness import query`. See [`python/README.md`](python/README.md).
61
+
60
62
  ```bash
61
63
  oh init # interactive setup wizard (provider + cybergotchi)
62
64
  oh # auto-detect local model
@@ -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;
@@ -230,7 +250,25 @@ program
230
250
  console.log(JSON.stringify({ type: "error", message: event.message }));
231
251
  }
232
252
  }
253
+ else if (event.type === "cost_update") {
254
+ if (outputFormat === "stream-json") {
255
+ console.log(JSON.stringify({
256
+ type: "cost_update",
257
+ inputTokens: event.inputTokens,
258
+ outputTokens: event.outputTokens,
259
+ cost: event.cost,
260
+ model: event.model,
261
+ }));
262
+ }
263
+ }
233
264
  else if (event.type === "turn_complete") {
265
+ if (outputFormat === "stream-json") {
266
+ console.log(JSON.stringify({ type: "turn_complete", reason: event.reason }));
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
+ }
234
272
  if (event.reason !== "completed") {
235
273
  process.exitCode = 1;
236
274
  }
@@ -243,6 +281,164 @@ program
243
281
  process.stdout.write("\n");
244
282
  }
245
283
  });
284
+ // ── `oh session`: long-lived stateful session for the Python SDK ──
285
+ program
286
+ .command("session")
287
+ .description("Long-lived session: read JSON prompts from stdin, stream NDJSON events on stdout (for the Python SDK)")
288
+ .option("-m, --model <model>", "Model to use")
289
+ .addOption(new Option("--permission-mode <mode>", "Permission mode")
290
+ .choices(["ask", "trust", "deny", "acceptEdits", "plan", "auto", "bypassPermissions"])
291
+ .default("trust"))
292
+ .option("--allowed-tools <tools>", "Comma-separated allowed tool names")
293
+ .option("--disallowed-tools <tools>", "Comma-separated disallowed tool names")
294
+ .option("--max-turns <n>", "Maximum turns per prompt", "20")
295
+ .option("--system-prompt <prompt>", "Override the system prompt")
296
+ .action(async (opts) => {
297
+ const savedConfig = readOhConfig();
298
+ const permissionMode = (opts.permissionMode ??
299
+ savedConfig?.permissionMode ??
300
+ "trust");
301
+ const { createProvider } = await import("./providers/index.js");
302
+ const effectiveModel = opts.model ?? savedConfig?.model;
303
+ const overrides = {};
304
+ if (savedConfig?.apiKey)
305
+ overrides.apiKey = savedConfig.apiKey;
306
+ if (savedConfig?.baseUrl)
307
+ overrides.baseUrl = savedConfig.baseUrl;
308
+ const { provider, model } = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined);
309
+ const { query } = await import("./query.js");
310
+ const { createAssistantMessage, createToolResultMessage, createUserMessage } = await import("./types/message.js");
311
+ let tools = getAllTools();
312
+ if (opts.allowedTools) {
313
+ const allowed = new Set(opts.allowedTools.split(",").map((s) => s.trim()));
314
+ tools = tools.filter((t) => allowed.has(t.name));
315
+ }
316
+ if (opts.disallowedTools) {
317
+ const disallowed = new Set(opts.disallowedTools.split(",").map((s) => s.trim()));
318
+ tools = tools.filter((t) => !disallowed.has(t.name));
319
+ }
320
+ const systemPrompt = opts.systemPrompt ?? buildSystemPrompt(model);
321
+ const config = {
322
+ provider,
323
+ tools,
324
+ systemPrompt,
325
+ permissionMode,
326
+ maxTurns: parseInt(opts.maxTurns, 10),
327
+ model,
328
+ };
329
+ // Conversation history, shared across all prompts for this process.
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
+ });
345
+ // Announce readiness so the client can send the first prompt.
346
+ console.log(JSON.stringify({ type: "ready" }));
347
+ const readline = await import("node:readline");
348
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
349
+ for await (const rawLine of rl) {
350
+ const line = rawLine.trim();
351
+ if (!line)
352
+ continue;
353
+ let request;
354
+ try {
355
+ request = JSON.parse(line);
356
+ }
357
+ catch {
358
+ console.log(JSON.stringify({ id: "", type: "error", message: "invalid JSON on stdin" }));
359
+ continue;
360
+ }
361
+ if (request.command === "exit")
362
+ break;
363
+ const id = request.id ?? "";
364
+ const prompt = request.prompt;
365
+ if (!id || !prompt) {
366
+ console.log(JSON.stringify({ id, type: "error", message: "missing 'id' or 'prompt' field" }));
367
+ continue;
368
+ }
369
+ // Accumulate this turn's assistant output so we can push a full message at the end.
370
+ let assistantText = "";
371
+ const turnToolCalls = [];
372
+ const callIdToName = {};
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 }));
384
+ for await (const event of query(prompt, config, conversation)) {
385
+ if (event.type === "text_delta") {
386
+ assistantText += event.content;
387
+ console.log(JSON.stringify({ id, type: "text", content: event.content }));
388
+ }
389
+ else if (event.type === "tool_call_start") {
390
+ callIdToName[event.callId] = event.toolName;
391
+ console.log(JSON.stringify({ id, type: "tool_start", tool: event.toolName }));
392
+ }
393
+ else if (event.type === "tool_call_complete") {
394
+ turnToolCalls.push({
395
+ id: event.callId,
396
+ toolName: callIdToName[event.callId] ?? event.callId,
397
+ arguments: event.arguments,
398
+ });
399
+ }
400
+ else if (event.type === "tool_call_end") {
401
+ toolResults.push({ callId: event.callId, output: event.output, isError: event.isError });
402
+ console.log(JSON.stringify({
403
+ id,
404
+ type: "tool_end",
405
+ tool: callIdToName[event.callId],
406
+ output: event.output,
407
+ error: event.isError,
408
+ }));
409
+ }
410
+ else if (event.type === "error") {
411
+ console.log(JSON.stringify({ id, type: "error", message: event.message }));
412
+ }
413
+ else if (event.type === "cost_update") {
414
+ console.log(JSON.stringify({
415
+ id,
416
+ type: "cost_update",
417
+ inputTokens: event.inputTokens,
418
+ outputTokens: event.outputTokens,
419
+ cost: event.cost,
420
+ model: event.model,
421
+ }));
422
+ }
423
+ else if (event.type === "turn_complete") {
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 }));
427
+ }
428
+ }
429
+ // Rebuild this turn's contribution to the conversation.
430
+ // The pattern mirrors query()'s internal accumulation at
431
+ // src/query/index.ts:119 (user msg pushed before turn) and 344 (assistant
432
+ // msg with tool calls pushed after each turn) — see the spec for detail.
433
+ conversation.push(createUserMessage(prompt));
434
+ if (assistantText || turnToolCalls.length > 0) {
435
+ conversation.push(createAssistantMessage(assistantText, turnToolCalls.length > 0 ? turnToolCalls : undefined));
436
+ }
437
+ for (const tr of toolResults) {
438
+ conversation.push(createToolResultMessage({ callId: tr.callId, output: tr.output, isError: tr.isError }));
439
+ }
440
+ }
441
+ });
246
442
  // ── Default command: just run `openharness` to start chatting ──
247
443
  program
248
444
  .command("chat", { isDefault: true })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.14.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": {