@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 +2 -0
- 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 +197 -1
- package/package.json +1 -1
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
|
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;
|
|
@@ -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 })
|