@zhijiewang/openharness 2.29.0 → 2.30.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
@@ -21,7 +21,7 @@ AI coding agent in your terminal. Works with any LLM -- free local models or clo
21
21
  <img src="assets/openharness_v0.11.1_4.gif" alt="OpenHarness demo" width="800" />
22
22
  </p>
23
23
 
24
- [![npm version](https://img.shields.io/npm/v/@zhijiewang/openharness)](https://www.npmjs.com/package/@zhijiewang/openharness) [![npm downloads](https://img.shields.io/npm/dm/@zhijiewang/openharness)](https://www.npmjs.com/package/@zhijiewang/openharness) [![license](https://img.shields.io/npm/l/@zhijiewang/openharness)](LICENSE) ![tests](https://img.shields.io/badge/tests-890-brightgreen) ![tools](https://img.shields.io/badge/tools-42-blue) ![Node.js 18+](https://img.shields.io/badge/node-18%2B-green) ![TypeScript](https://img.shields.io/badge/typescript-strict-blue) [![GitHub stars](https://img.shields.io/github/stars/zhijiewong/openharness)](https://github.com/zhijiewong/openharness) [![GitHub issues](https://img.shields.io/github/issues-raw/zhijiewong/openharness)](https://github.com/zhijiewong/openharness/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](https://github.com/zhijiewong/openharness/pulls)
24
+ [![npm version](https://img.shields.io/npm/v/@zhijiewang/openharness)](https://www.npmjs.com/package/@zhijiewang/openharness) [![npm downloads](https://img.shields.io/npm/dm/@zhijiewang/openharness)](https://www.npmjs.com/package/@zhijiewang/openharness) [![license](https://img.shields.io/npm/l/@zhijiewang/openharness)](LICENSE) ![tests](https://img.shields.io/badge/tests-1502-brightgreen) ![tools](https://img.shields.io/badge/tools-44-blue) ![Node.js 18+](https://img.shields.io/badge/node-18%2B-green) ![TypeScript](https://img.shields.io/badge/typescript-strict-blue) [![GitHub stars](https://img.shields.io/github/stars/zhijiewong/openharness)](https://github.com/zhijiewong/openharness) [![GitHub issues](https://img.shields.io/github/issues-raw/zhijiewong/openharness)](https://github.com/zhijiewong/openharness/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](https://github.com/zhijiewong/openharness/pulls)
25
25
 
26
26
  **English** | [简体中文](README.zh-CN.md)
27
27
 
@@ -32,7 +32,7 @@ AI coding agent in your terminal. Works with any LLM -- free local models or clo
32
32
  - [Quick Start](#quick-start)
33
33
  - [Why OpenHarness?](#why-openharness)
34
34
  - [Terminal UI](#terminal-ui)
35
- - [Tools (43)](#tools-43)
35
+ - [Tools (44)](#tools-43)
36
36
  - [Slash Commands](#slash-commands)
37
37
  - [Permission Modes](#permission-modes)
38
38
  - [Hooks](#hooks)
@@ -114,8 +114,11 @@ Scrolling is handled by the terminal's native scrollbar. Completed messages flow
114
114
  - **Syntax highlighting** — keywords, strings, comments, numbers, types (JS/TS/Python/Rust/Go and 20+ languages)
115
115
  - **Collapsible code blocks** — blocks over 8 lines auto-collapse; `Ctrl+K` to expand all
116
116
  - **Collapsible thinking** — thinking blocks collapse to a one-line summary after completion; `Ctrl+O` to expand
117
- - **Shimmer spinner** — animated "Thinking" indicator with color transitions (magenta → yellow at 30s → red at 60s)
118
- - **Tool call display** — args preview, live streaming output, result summaries (line counts, elapsed time), expand/collapse with `Tab`
117
+ - **Shimmer spinner** — animated indicator with stage label (`Thinking`, `Running <Tool>`, `Calling <server>:<tool>`, `Running N tools`) and color transitions (magenta → yellow at 30s → red at 60s)
118
+ - **Tool call display** — args preview, live streaming output, result summaries (line counts, elapsed time), expand/collapse with `Tab`. Tool name color-coded by category (read tools cyan, mutating tools yellow, exec tools magenta, MCP tools green)
119
+ - **Rich tool output** — JSON files render as a colored static tree (depth-3 collapse, line truncation); markdown files render with full styling (headings, code blocks, tables) instead of plain split-on-newline. Renderer dispatches via `outputType` field stamped by FileReadTool / WebFetchTool, with a heuristic fallback for unstamped tools
120
+ - **Nested tool calls** — when `Agent` or `ParallelAgents` spawns inner tool calls (Read, Bash, Edit), the children render indented under their spawning parent. ParallelAgents shows per-task `Task` wrapper rows so child calls group by task instead of flat under the bundled parent. Depth-3 indent limit with `… (N more level)` collapse marker
121
+ - **Multi-line input wrap glyph** — every non-last line of a multi-line input ends with a dim `↵` continuation marker so the wrap is visually obvious
119
122
  - **Permission prompts** — bordered box with risk coloring, bold colored **Y**es/**N**o/**D**iff keys, syntax-highlighted inline diffs
120
123
  - **Status line** — model name, token count, cost, context usage bar (customizable via config)
121
124
  - **Context warning** — yellow alert when context window exceeds 75%
@@ -146,7 +149,7 @@ statusLineFormat: '{model} │ {tokens} │ {cost} │ {ctx}'
146
149
 
147
150
  Available variables: `{model}`, `{tokens}` (input↑ output↓), `{cost}` ($X.XXXX), `{ctx}` (context usage bar). Empty sections are automatically collapsed.
148
151
 
149
- ## Tools (43)
152
+ ## Tools (44)
150
153
 
151
154
  | Tool | Risk | Description |
152
155
  |------|------|-------------|
package/README.zh-CN.md CHANGED
@@ -21,7 +21,7 @@
21
21
  <img src="assets/openharness_v0.11.1_4.gif" alt="OpenHarness demo" width="800" />
22
22
  </p>
23
23
 
24
- [![npm version](https://img.shields.io/npm/v/@zhijiewang/openharness)](https://www.npmjs.com/package/@zhijiewang/openharness) [![npm downloads](https://img.shields.io/npm/dm/@zhijiewang/openharness)](https://www.npmjs.com/package/@zhijiewang/openharness) [![license](https://img.shields.io/npm/l/@zhijiewang/openharness)](LICENSE) ![tests](https://img.shields.io/badge/tests-890-brightgreen) ![tools](https://img.shields.io/badge/tools-42-blue) ![Node.js 18+](https://img.shields.io/badge/node-18%2B-green) ![TypeScript](https://img.shields.io/badge/typescript-strict-blue) [![GitHub stars](https://img.shields.io/github/stars/zhijiewong/openharness)](https://github.com/zhijiewong/openharness) [![GitHub issues](https://img.shields.io/github/issues-raw/zhijiewong/openharness)](https://github.com/zhijiewong/openharness/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](https://github.com/zhijiewong/openharness/pulls)
24
+ [![npm version](https://img.shields.io/npm/v/@zhijiewang/openharness)](https://www.npmjs.com/package/@zhijiewang/openharness) [![npm downloads](https://img.shields.io/npm/dm/@zhijiewang/openharness)](https://www.npmjs.com/package/@zhijiewang/openharness) [![license](https://img.shields.io/npm/l/@zhijiewang/openharness)](LICENSE) ![tests](https://img.shields.io/badge/tests-1502-brightgreen) ![tools](https://img.shields.io/badge/tools-44-blue) ![Node.js 18+](https://img.shields.io/badge/node-18%2B-green) ![TypeScript](https://img.shields.io/badge/typescript-strict-blue) [![GitHub stars](https://img.shields.io/github/stars/zhijiewong/openharness)](https://github.com/zhijiewong/openharness) [![GitHub issues](https://img.shields.io/github/issues-raw/zhijiewong/openharness)](https://github.com/zhijiewong/openharness/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](https://github.com/zhijiewong/openharness/pulls)
25
25
 
26
26
  [English](README.md) | **简体中文**
27
27
 
@@ -32,7 +32,7 @@
32
32
  - [快速开始](#快速开始)
33
33
  - [为什么选择 OpenHarness?](#为什么选择-openharness)
34
34
  - [终端界面](#终端界面)
35
- - [工具(43 个)](#工具43-个)
35
+ - [工具(44 个)](#工具43-个)
36
36
  - [斜杠命令](#斜杠命令)
37
37
  - [权限模式](#权限模式)
38
38
  - [钩子](#钩子)
@@ -114,8 +114,11 @@ OpenHarness 采用受 Ink/Claude Code 默认模式启发的顺序式终端渲染
114
114
  - **语法高亮** —— 关键字、字符串、注释、数字、类型(支持 JS/TS/Python/Rust/Go 等 20+ 种语言)
115
115
  - **可折叠代码块** —— 超过 8 行的代码块会自动折叠;按 `Ctrl+K` 全部展开
116
116
  - **可折叠思考块** —— 思考块在完成后会折叠为一行摘要;按 `Ctrl+O` 展开
117
- - **流光加载动画** —— 带颜色过渡的 "Thinking" 指示器(30 秒后洋红 → 黄,60 秒后 → 红)
118
- - **工具调用显示** —— 参数预览、实时流式输出、结果摘要(行数、耗时),按 `Tab` 展开/折叠
117
+ - **流光加载动画** —— 带阶段标签(`Thinking`、`Running <Tool>`、`Calling <server>:<tool>`、`Running N tools`)和颜色过渡的指示器(30 秒后洋红 → 黄,60 秒后 → 红)
118
+ - **工具调用显示** —— 参数预览、实时流式输出、结果摘要(行数、耗时),按 `Tab` 展开/折叠。工具名称按类别着色(读取类青色、修改类黄色、执行类品红色、MCP 类绿色)
119
+ - **富工具输出** —— JSON 文件以彩色静态树形渲染(深度 3 级折叠、行数截断);Markdown 文件完整渲染样式(标题、代码块、表格),不再是普通的按行拆分。渲染器通过 `outputType` 字段分发(FileReadTool / WebFetchTool 会标注),未标注的工具走启发式回退路径
120
+ - **嵌套工具调用** —— 当 `Agent` 或 `ParallelAgents` 派生内层工具调用(Read、Bash、Edit)时,子调用会缩进显示在派生它的父调用之下。ParallelAgents 还会显示每个任务的 `Task` 包装行,使子调用按任务分组,而不是平铺在合并的父调用之下。深度 3 级缩进上限,超过显示 `… (N more level)` 折叠标记
121
+ - **多行输入折行符** —— 多行输入的每一非末尾行都以暗色 `↵` 续行符结尾,使折行视觉清晰
119
122
  - **权限提示** —— 带边框的提示框,按风险级别着色,醒目的 **Y**es/**N**o/**D**iff 按键,内联 diff 带语法高亮
120
123
  - **状态栏** —— 显示模型名称、token 计数、费用、上下文占用条(可通过配置自定义)
121
124
  - **上下文告警** —— 上下文窗口超过 75% 时显示黄色警告
@@ -146,7 +149,7 @@ statusLineFormat: '{model} │ {tokens} │ {cost} │ {ctx}'
146
149
 
147
150
  可用变量:`{model}`、`{tokens}`(输入↑ 输出↓)、`{cost}`($X.XXXX)、`{ctx}`(上下文占用条)。空片段会自动折叠。
148
151
 
149
- ## 工具(43 个)
152
+ ## 工具(44 个)
150
153
 
151
154
  | 工具 | 风险 | 描述 |
152
155
  |------|------|-------------|
package/dist/Tool.d.ts CHANGED
@@ -29,6 +29,10 @@ export type ToolContext = {
29
29
  gitCommitPerTool?: boolean;
30
30
  /** Forward an inner-query tool event to the outer event stream, stamped with the parent's callId. Used by AgentTool and AgentDispatcher to surface nested tool calls. */
31
31
  emitChildEvent?: (event: ToolCallStart | ToolCallComplete | ToolCallEnd | ToolOutputDelta) => void;
32
+ /** Optional session tracer for OTel-style span emission around tool execution. */
33
+ tracer?: import("./harness/traces.js").SessionTracer;
34
+ /** Optional parent span ID for the current tool execution (set by query loop). */
35
+ parentSpanId?: string;
32
36
  };
33
37
  export type Tool<Input extends z.ZodType = z.ZodType> = {
34
38
  readonly name: string;
@@ -11,6 +11,7 @@ import { getContextWindow } from "../harness/cost.js";
11
11
  import { getHooks, invalidateHookCache } from "../harness/hooks.js";
12
12
  import { discoverPlugins, discoverSkills } from "../harness/plugins.js";
13
13
  import { invalidateSandboxCache } from "../harness/sandbox.js";
14
+ import { formatTrace, listTracedSessions, loadTrace } from "../harness/traces.js";
14
15
  import { invalidateVerificationCache } from "../harness/verification.js";
15
16
  import { normalizeMcpConfig } from "../mcp/config-normalize.js";
16
17
  import { connectedMcpServers, disconnectMcpClients, loadMcpTools } from "../mcp/loader.js";
@@ -47,6 +48,7 @@ export function registerInfoCommands(register, getCommandMap) {
47
48
  "memory",
48
49
  "doctor",
49
50
  "hooks",
51
+ "traces",
50
52
  "context",
51
53
  "mcp",
52
54
  "mcp-login",
@@ -358,6 +360,32 @@ export function registerInfoCommands(register, getCommandMap) {
358
360
  register("hooks", "List loaded hooks grouped by event", () => {
359
361
  return { output: formatHooksReport(getHooks()), handled: true };
360
362
  });
363
+ register("traces", "List sessions with persisted OTel-style traces (or show one with /traces <sessionId>)", (args) => {
364
+ const id = args.trim();
365
+ if (id) {
366
+ const spans = loadTrace(id);
367
+ if (spans.length === 0)
368
+ return { output: `No trace found for session ${id}.`, handled: true };
369
+ return { output: formatTrace(spans), handled: true };
370
+ }
371
+ const sessions = listTracedSessions();
372
+ if (sessions.length === 0) {
373
+ return {
374
+ output: "No persisted traces. Tracing is opt-in — start oh with OH_TRACE=1 to record spans to ~/.oh/traces/.",
375
+ handled: true,
376
+ };
377
+ }
378
+ const lines = [`${sessions.length} session(s) with traces (most recent first):`, ""];
379
+ for (const sid of sessions.slice(0, 20)) {
380
+ const spans = loadTrace(sid);
381
+ const totalMs = spans.reduce((sum, s) => sum + s.durationMs, 0);
382
+ const errors = spans.filter((s) => s.status === "error").length;
383
+ const errSuffix = errors > 0 ? ` ${errors} error(s)` : "";
384
+ lines.push(` ${sid} ${spans.length} spans, ${totalMs}ms total${errSuffix}`);
385
+ }
386
+ lines.push("", "Run `/traces <sessionId>` to see the full span tree.");
387
+ return { output: lines.join("\n"), handled: true };
388
+ });
361
389
  register("context", "Show context window usage breakdown", (_args, ctx) => {
362
390
  const ctxWindow = getContextWindow(ctx.model);
363
391
  let userTokens = 0, assistantTokens = 0, toolTokens = 0, systemTokens = 0;
@@ -22,16 +22,23 @@ export type TraceEvent = {
22
22
  timestamp: number;
23
23
  attributes?: Record<string, unknown>;
24
24
  };
25
+ export type OTLPConfig = {
26
+ endpoint: string;
27
+ headers?: Record<string, string>;
28
+ };
25
29
  export declare class SessionTracer {
26
30
  private sessionId;
27
31
  private spans;
28
32
  private activeSpans;
29
33
  private spanCounter;
30
- constructor(sessionId: string);
34
+ private otlp?;
35
+ constructor(sessionId: string, otlp?: OTLPConfig);
31
36
  /** Start a new span. Returns the span ID. */
32
37
  startSpan(name: string, attributes?: Record<string, unknown>, parentSpanId?: string): string;
33
38
  /** End a span and record it. */
34
39
  endSpan(spanId: string, status?: "ok" | "error", extraAttributes?: Record<string, unknown>): TraceSpan | null;
40
+ /** Fire-and-forget POST of a single span to the configured OTLP HTTP endpoint. Errors swallowed — telemetry must never crash the agent. */
41
+ private shipSpanOTLP;
35
42
  /** Get all completed spans */
36
43
  getSpans(): TraceSpan[];
37
44
  /** Get a summary of the trace */
@@ -18,8 +18,10 @@ export class SessionTracer {
18
18
  spans = [];
19
19
  activeSpans = new Map();
20
20
  spanCounter = 0;
21
- constructor(sessionId) {
21
+ otlp;
22
+ constructor(sessionId, otlp) {
22
23
  this.sessionId = sessionId;
24
+ this.otlp = otlp;
23
25
  }
24
26
  /** Start a new span. Returns the span ID. */
25
27
  startSpan(name, attributes = {}, parentSpanId) {
@@ -50,8 +52,23 @@ export class SessionTracer {
50
52
  this.spans = this.spans.slice(-MAX_IN_MEMORY_SPANS);
51
53
  }
52
54
  this.persistSpan(span);
55
+ if (this.otlp)
56
+ this.shipSpanOTLP(span);
53
57
  return span;
54
58
  }
59
+ /** Fire-and-forget POST of a single span to the configured OTLP HTTP endpoint. Errors swallowed — telemetry must never crash the agent. */
60
+ shipSpanOTLP(span) {
61
+ if (!this.otlp)
62
+ return;
63
+ const payload = exportTraceOTLP(this.sessionId, [span]);
64
+ fetch(this.otlp.endpoint, {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/json", ...(this.otlp.headers ?? {}) },
67
+ body: JSON.stringify(payload),
68
+ }).catch(() => {
69
+ /* swallow — telemetry must not interfere with the agent */
70
+ });
71
+ }
55
72
  /** Get all completed spans */
56
73
  getSpans() {
57
74
  return [...this.spans];
@@ -41,6 +41,11 @@ export async function* query(userMessage, config, existingMessages = []) {
41
41
  const maxTurns = config.maxTurns ?? DEFAULT_MAX_TURNS;
42
42
  const routerCfg = readOhConfig()?.modelRouter ?? {};
43
43
  const router = new ModelRouter(routerCfg, config.model ?? "");
44
+ const querySpanId = config.tracer?.startSpan("query", {
45
+ model: config.model,
46
+ permissionMode: config.permissionMode,
47
+ toolCount: config.tools.length,
48
+ });
44
49
  const toolContext = {
45
50
  workingDir: config.workingDir ?? process.cwd(),
46
51
  abortSignal: config.abortSignal,
@@ -51,6 +56,8 @@ export async function* query(userMessage, config, existingMessages = []) {
51
56
  permissionMode: config.permissionMode,
52
57
  askUserQuestion: config.askUserQuestion,
53
58
  gitCommitPerTool: config.gitCommitPerTool,
59
+ tracer: config.tracer,
60
+ parentSpanId: querySpanId,
54
61
  };
55
62
  const estimateTokens = makeTokenEstimator(config.provider);
56
63
  const contextManager = new ContextManager(undefined, config.model);
@@ -99,224 +106,230 @@ export async function* query(userMessage, config, existingMessages = []) {
99
106
  consecutiveErrors: 0,
100
107
  };
101
108
  // ── Main loop ──
102
- while (state.turn < maxTurns) {
103
- state.turn++;
104
- if (config.abortSignal?.aborted) {
105
- yield { type: "turn_complete", reason: "aborted" };
106
- return;
107
- }
108
- if (config.maxCost && config.maxCost > 0 && state.totalCost >= config.maxCost) {
109
- yield { type: "error", message: `Budget exceeded: $${state.totalCost.toFixed(4)}` };
110
- yield { type: "turn_complete", reason: "budget_exceeded" };
111
- return;
112
- }
113
- // Context window management
114
- // ── Context window management with circuit breaker ──
115
- const contextWindow = getContextWindow(config.model);
116
- const estimatedTokens = estimateMessagesTokens(state.messages, estimateTokens);
117
- const MAX_COMPRESSION_FAILURES = 3;
118
- if (estimatedTokens > contextWindow * 0.8 && (state.compressionFailures ?? 0) < MAX_COMPRESSION_FAILURES) {
119
- const tokensBefore = estimatedTokens;
120
- let strategy = "basic";
121
- state.messages = compressMessages(state.messages, Math.floor(contextWindow * 0.6));
122
- const afterBasic = estimateMessagesTokens(state.messages, estimateTokens);
123
- if (afterBasic > contextWindow * 0.7 && state.messages.length > 4) {
124
- try {
125
- state.messages = await summarizeConversation(config.provider, state.messages, config.model, Math.floor(contextWindow * 0.5));
126
- strategy = "llm-summarization";
127
- state.compressionFailures = 0; // Reset on success
109
+ try {
110
+ while (state.turn < maxTurns) {
111
+ state.turn++;
112
+ if (config.abortSignal?.aborted) {
113
+ yield { type: "turn_complete", reason: "aborted" };
114
+ return;
115
+ }
116
+ if (config.maxCost && config.maxCost > 0 && state.totalCost >= config.maxCost) {
117
+ yield { type: "error", message: `Budget exceeded: $${state.totalCost.toFixed(4)}` };
118
+ yield { type: "turn_complete", reason: "budget_exceeded" };
119
+ return;
120
+ }
121
+ // Context window management
122
+ // ── Context window management with circuit breaker ──
123
+ const contextWindow = getContextWindow(config.model);
124
+ const estimatedTokens = estimateMessagesTokens(state.messages, estimateTokens);
125
+ const MAX_COMPRESSION_FAILURES = 3;
126
+ if (estimatedTokens > contextWindow * 0.8 && (state.compressionFailures ?? 0) < MAX_COMPRESSION_FAILURES) {
127
+ const tokensBefore = estimatedTokens;
128
+ let strategy = "basic";
129
+ state.messages = compressMessages(state.messages, Math.floor(contextWindow * 0.6));
130
+ const afterBasic = estimateMessagesTokens(state.messages, estimateTokens);
131
+ if (afterBasic > contextWindow * 0.7 && state.messages.length > 4) {
132
+ try {
133
+ state.messages = await summarizeConversation(config.provider, state.messages, config.model, Math.floor(contextWindow * 0.5));
134
+ strategy = "llm-summarization";
135
+ state.compressionFailures = 0; // Reset on success
136
+ }
137
+ catch {
138
+ state.compressionFailures = (state.compressionFailures ?? 0) + 1;
139
+ strategy = "basic-only (llm failed)";
140
+ }
128
141
  }
129
- catch {
130
- state.compressionFailures = (state.compressionFailures ?? 0) + 1;
131
- strategy = "basic-only (llm failed)";
142
+ const tokensAfter = estimateMessagesTokens(state.messages, estimateTokens);
143
+ yield {
144
+ type: "error",
145
+ message: `Context compressed (${strategy}): ${tokensBefore} → ${tokensAfter} tokens. Re-read any files you need.`,
146
+ };
147
+ }
148
+ else if (estimatedTokens > contextWindow * 0.8) {
149
+ yield {
150
+ type: "error",
151
+ message: "Context compression disabled (3 consecutive failures). Consider starting a new session.",
152
+ };
153
+ }
154
+ // ── Dynamic prompt: refresh memories if changed, inject warnings ──
155
+ try {
156
+ const { memoryVersion, loadActiveMemories, memoriesToPrompt } = await import("../harness/memory.js");
157
+ const currentVer = memoryVersion();
158
+ if (currentVer > lastMemoryVer) {
159
+ const fresh = memoriesToPrompt(loadActiveMemories());
160
+ // Replace or append memory section in fullSystemPrompt
161
+ if (fullSystemPrompt.includes("# Remembered Context")) {
162
+ fullSystemPrompt = fullSystemPrompt.replace(/# Remembered Context[\s\S]*?(?=\n# |$)/, fresh);
163
+ }
164
+ else if (fresh) {
165
+ fullSystemPrompt += `\n\n${fresh}`;
166
+ }
167
+ lastMemoryVer = currentVer;
132
168
  }
133
169
  }
134
- const tokensAfter = estimateMessagesTokens(state.messages, estimateTokens);
135
- yield {
136
- type: "error",
137
- message: `Context compressed (${strategy}): ${tokensBefore} → ${tokensAfter} tokens. Re-read any files you need.`,
138
- };
139
- }
140
- else if (estimatedTokens > contextWindow * 0.8) {
141
- yield {
142
- type: "error",
143
- message: "Context compression disabled (3 consecutive failures). Consider starting a new session.",
144
- };
145
- }
146
- // ── Dynamic prompt: refresh memories if changed, inject warnings ──
147
- try {
148
- const { memoryVersion, loadActiveMemories, memoriesToPrompt } = await import("../harness/memory.js");
149
- const currentVer = memoryVersion();
150
- if (currentVer > lastMemoryVer) {
151
- const fresh = memoriesToPrompt(loadActiveMemories());
152
- // Replace or append memory section in fullSystemPrompt
153
- if (fullSystemPrompt.includes("# Remembered Context")) {
154
- fullSystemPrompt = fullSystemPrompt.replace(/# Remembered Context[\s\S]*?(?=\n# |$)/, fresh);
170
+ catch {
171
+ /* memory refresh optional */
172
+ }
173
+ let turnPrompt = fullSystemPrompt;
174
+ if (config.maxCost && config.maxCost > 0) {
175
+ const pct = state.totalCost / config.maxCost;
176
+ if (pct >= 0.9) {
177
+ turnPrompt += `\n\n⚠️ BUDGET CRITICAL: Only $${(config.maxCost - state.totalCost).toFixed(4)} remaining. Provide final response NOW.`;
155
178
  }
156
- else if (fresh) {
157
- fullSystemPrompt += `\n\n${fresh}`;
179
+ else if (pct >= 0.7) {
180
+ turnPrompt += `\n\n⚠️ BUDGET WARNING: ${Math.round((1 - pct) * 100)}% budget remaining. Start consolidating.`;
158
181
  }
159
- lastMemoryVer = currentVer;
160
182
  }
161
- }
162
- catch {
163
- /* memory refresh optional */
164
- }
165
- let turnPrompt = fullSystemPrompt;
166
- if (config.maxCost && config.maxCost > 0) {
167
- const pct = state.totalCost / config.maxCost;
168
- if (pct >= 0.9) {
169
- turnPrompt += `\n\n⚠️ BUDGET CRITICAL: Only $${(config.maxCost - state.totalCost).toFixed(4)} remaining. Provide final response NOW.`;
183
+ if (state.turn >= maxTurns * 0.9 && maxTurns > 1) {
184
+ turnPrompt += `\n\n⚠️ TURN LIMIT: ${maxTurns - state.turn} turn(s) remaining. Wrap up.`;
170
185
  }
171
- else if (pct >= 0.7) {
172
- turnPrompt += `\n\n⚠️ BUDGET WARNING: ${Math.round((1 - pct) * 100)}% budget remaining. Start consolidating.`;
173
- }
174
- }
175
- if (state.turn >= maxTurns * 0.9 && maxTurns > 1) {
176
- turnPrompt += `\n\n⚠️ TURN LIMIT: ${maxTurns - state.turn} turn(s) remaining. Wrap up.`;
177
- }
178
- // ── LLM call with streaming ──
179
- let assistantContent = "";
180
- const toolCalls = [];
181
- let streamError = null;
182
- const streamingExecutor = new StreamingToolExecutor(config.tools, toolContext, config.permissionMode, config.askUser, config.abortSignal);
183
- try {
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)) {
194
- if (config.abortSignal?.aborted)
195
- break;
196
- switch (event.type) {
197
- case "text_delta":
198
- assistantContent += event.content;
199
- yield event;
200
- break;
201
- case "tool_call_start":
202
- toolCalls.push({ id: event.callId, toolName: event.toolName, arguments: {} });
203
- yield event;
186
+ // ── LLM call with streaming ──
187
+ let assistantContent = "";
188
+ const toolCalls = [];
189
+ let streamError = null;
190
+ const streamingExecutor = new StreamingToolExecutor(config.tools, toolContext, config.permissionMode, config.askUser, config.abortSignal);
191
+ try {
192
+ const ctxUsage = estimateRouteContextUsage(state.messages, config.provider, config.model ?? "");
193
+ const selection = router.select({
194
+ turn: state.turn,
195
+ hadToolCalls: state.lastTurnHadTools ?? false,
196
+ toolCallCount: state.lastTurnToolCount ?? 0,
197
+ contextUsage: ctxUsage,
198
+ isFinalResponse: (state.lastTurnHadTools === false || state.lastTurnHadTools === undefined) && state.turn > 1,
199
+ role: config.role,
200
+ });
201
+ for await (const event of config.provider.stream(state.messages, turnPrompt, apiTools, selection.model)) {
202
+ if (config.abortSignal?.aborted)
204
203
  break;
205
- case "tool_call_complete": {
206
- const tc = toolCalls.find((t) => t.id === event.callId);
207
- if (tc) {
208
- const idx = toolCalls.indexOf(tc);
209
- toolCalls[idx] = { ...tc, arguments: event.arguments };
204
+ switch (event.type) {
205
+ case "text_delta":
206
+ assistantContent += event.content;
207
+ yield event;
208
+ break;
209
+ case "tool_call_start":
210
+ toolCalls.push({ id: event.callId, toolName: event.toolName, arguments: {} });
211
+ yield event;
212
+ break;
213
+ case "tool_call_complete": {
214
+ const tc = toolCalls.find((t) => t.id === event.callId);
215
+ if (tc) {
216
+ const idx = toolCalls.indexOf(tc);
217
+ toolCalls[idx] = { ...tc, arguments: event.arguments };
218
+ }
219
+ if (streamingExecutor) {
220
+ streamingExecutor.addTool({ id: event.callId, toolName: event.toolName, arguments: event.arguments });
221
+ }
222
+ break;
210
223
  }
211
- if (streamingExecutor) {
212
- streamingExecutor.addTool({ id: event.callId, toolName: event.toolName, arguments: event.arguments });
213
- }
214
- break;
224
+ case "cost_update":
225
+ state.totalCost += event.cost;
226
+ state.totalInputTokens += event.inputTokens;
227
+ state.totalOutputTokens += event.outputTokens;
228
+ yield event;
229
+ break;
230
+ case "error":
231
+ yield event;
232
+ break;
215
233
  }
216
- case "cost_update":
217
- state.totalCost += event.cost;
218
- state.totalInputTokens += event.inputTokens;
219
- state.totalOutputTokens += event.outputTokens;
220
- yield event;
221
- break;
222
- case "error":
223
- yield event;
224
- break;
225
234
  }
235
+ state.consecutiveErrors = 0;
226
236
  }
227
- state.consecutiveErrors = 0;
228
- }
229
- catch (err) {
230
- streamError = err instanceof Error ? err : new Error(String(err));
231
- state.consecutiveErrors++;
232
- // Circuit breaker
233
- if (state.consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
234
- yield {
235
- type: "error",
236
- message: `Too many consecutive errors (${state.consecutiveErrors}): ${streamError.message}`,
237
- };
237
+ catch (err) {
238
+ streamError = err instanceof Error ? err : new Error(String(err));
239
+ state.consecutiveErrors++;
240
+ // Circuit breaker
241
+ if (state.consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
242
+ yield {
243
+ type: "error",
244
+ message: `Too many consecutive errors (${state.consecutiveErrors}): ${streamError.message}`,
245
+ };
246
+ yield { type: "turn_complete", reason: "error" };
247
+ return;
248
+ }
249
+ // Error recovery cascade
250
+ if (isRateLimitError(streamError) || isOverloadError(streamError)) {
251
+ const attempt = state.consecutiveErrors;
252
+ const isOverload = isOverloadError(streamError);
253
+ if (attempt <= MAX_RATE_LIMIT_RETRIES) {
254
+ const baseRetry = 2 ** attempt * (isOverload ? 2 : 1);
255
+ const retryIn = baseRetry * (0.5 + Math.random());
256
+ yield { type: "rate_limited", retryIn: Math.round(retryIn), attempt };
257
+ await new Promise((r) => setTimeout(r, retryIn * 1000));
258
+ continue;
259
+ }
260
+ yield {
261
+ type: "error",
262
+ message: `${isOverload ? "Server overloaded" : "Rate limit exceeded"} after ${MAX_RATE_LIMIT_RETRIES} retries.`,
263
+ };
264
+ yield { type: "turn_complete", reason: "error" };
265
+ return;
266
+ }
267
+ if (isPromptTooLongError(streamError)) {
268
+ state.promptTooLongRetries = (state.promptTooLongRetries ?? 0) + 1;
269
+ if (state.promptTooLongRetries > 2) {
270
+ yield { type: "error", message: "Context still too long after 2 compression attempts." };
271
+ yield { type: "turn_complete", reason: "error" };
272
+ return;
273
+ }
274
+ state.messages = compressMessages(state.messages, Math.floor(contextWindow * 0.5));
275
+ state.transition = "retry_prompt_too_long";
276
+ yield { type: "error", message: "Context too long, compressing history..." };
277
+ continue;
278
+ }
279
+ if (isNetworkError(streamError)) {
280
+ state.transition = "retry_network";
281
+ const delay = 1000 * 2 ** (state.consecutiveErrors - 1);
282
+ yield { type: "error", message: `Network error, retrying in ${delay / 1000}s...` };
283
+ await new Promise((r) => setTimeout(r, delay));
284
+ continue;
285
+ }
286
+ yield { type: "error", message: streamError.message };
238
287
  yield { type: "turn_complete", reason: "error" };
239
288
  return;
240
289
  }
241
- // Error recovery cascade
242
- if (isRateLimitError(streamError) || isOverloadError(streamError)) {
243
- const attempt = state.consecutiveErrors;
244
- const isOverload = isOverloadError(streamError);
245
- if (attempt <= MAX_RATE_LIMIT_RETRIES) {
246
- const baseRetry = 2 ** attempt * (isOverload ? 2 : 1);
247
- const retryIn = baseRetry * (0.5 + Math.random());
248
- yield { type: "rate_limited", retryIn: Math.round(retryIn), attempt };
249
- await new Promise((r) => setTimeout(r, retryIn * 1000));
250
- continue;
251
- }
290
+ if (config.abortSignal?.aborted) {
291
+ yield { type: "turn_complete", reason: "aborted" };
292
+ return;
293
+ }
294
+ if (assistantContent === "" && toolCalls.length === 0) {
252
295
  yield {
253
296
  type: "error",
254
- message: `${isOverload ? "Server overloaded" : "Rate limit exceeded"} after ${MAX_RATE_LIMIT_RETRIES} retries.`,
297
+ message: "No response received. Check that your model server is running and the model name is correct.",
255
298
  };
256
- yield { type: "turn_complete", reason: "error" };
257
299
  return;
258
300
  }
259
- if (isPromptTooLongError(streamError)) {
260
- state.promptTooLongRetries = (state.promptTooLongRetries ?? 0) + 1;
261
- if (state.promptTooLongRetries > 2) {
262
- yield { type: "error", message: "Context still too long after 2 compression attempts." };
263
- yield { type: "turn_complete", reason: "error" };
264
- return;
265
- }
266
- state.messages = compressMessages(state.messages, Math.floor(contextWindow * 0.5));
267
- state.transition = "retry_prompt_too_long";
268
- yield { type: "error", message: "Context too long, compressing history..." };
269
- continue;
301
+ state.messages.push(createAssistantMessage(assistantContent, toolCalls.length > 0 ? toolCalls : undefined));
302
+ if (toolCalls.length === 0) {
303
+ yield { type: "turn_complete", reason: "completed" };
304
+ return;
270
305
  }
271
- if (isNetworkError(streamError)) {
272
- state.transition = "retry_network";
273
- const delay = 1000 * 2 ** (state.consecutiveErrors - 1);
274
- yield { type: "error", message: `Network error, retrying in ${delay / 1000}s...` };
275
- await new Promise((r) => setTimeout(r, delay));
276
- continue;
306
+ // Collect streaming tool results
307
+ await streamingExecutor.waitForAll();
308
+ const completedResults = [...streamingExecutor.getCompletedResults()];
309
+ const executedIds = new Set(completedResults.map((r) => r.toolCall.id));
310
+ for (const { callId, chunk } of streamingExecutor.outputChunks) {
311
+ yield { type: "tool_output_delta", callId, chunk };
277
312
  }
278
- yield { type: "error", message: streamError.message };
279
- yield { type: "turn_complete", reason: "error" };
280
- return;
281
- }
282
- if (config.abortSignal?.aborted) {
283
- yield { type: "turn_complete", reason: "aborted" };
284
- return;
285
- }
286
- if (assistantContent === "" && toolCalls.length === 0) {
287
- yield {
288
- type: "error",
289
- message: "No response received. Check that your model server is running and the model name is correct.",
290
- };
291
- return;
292
- }
293
- state.messages.push(createAssistantMessage(assistantContent, toolCalls.length > 0 ? toolCalls : undefined));
294
- if (toolCalls.length === 0) {
295
- yield { type: "turn_complete", reason: "completed" };
296
- return;
297
- }
298
- // Collect streaming tool results
299
- await streamingExecutor.waitForAll();
300
- const completedResults = [...streamingExecutor.getCompletedResults()];
301
- const executedIds = new Set(completedResults.map((r) => r.toolCall.id));
302
- for (const { callId, chunk } of streamingExecutor.outputChunks) {
303
- yield { type: "tool_output_delta", callId, chunk };
304
- }
305
- for (const { toolCall: tc, result } of completedResults) {
306
- yield { type: "tool_call_end", callId: tc.id, output: result.output, isError: result.isError };
307
- // Apply context budget to tool output
308
- const budgetedOutput = contextManager.enforceToolBudget(tc.toolName, result.output);
309
- state.messages.push(createToolResultMessage({ callId: tc.id, output: budgetedOutput, isError: result.isError }));
310
- }
311
- // Execute remaining tools not started during streaming
312
- const remaining = toolCalls.filter((tc) => !executedIds.has(tc.id));
313
- if (remaining.length > 0) {
314
- yield* executeToolCalls(remaining, config.tools, toolContext, config.permissionMode, config.askUser, state, config.permissionPromptTool);
313
+ for (const { toolCall: tc, result } of completedResults) {
314
+ yield { type: "tool_call_end", callId: tc.id, output: result.output, isError: result.isError };
315
+ // Apply context budget to tool output
316
+ const budgetedOutput = contextManager.enforceToolBudget(tc.toolName, result.output);
317
+ state.messages.push(createToolResultMessage({ callId: tc.id, output: budgetedOutput, isError: result.isError }));
318
+ }
319
+ // Execute remaining tools not started during streaming
320
+ const remaining = toolCalls.filter((tc) => !executedIds.has(tc.id));
321
+ if (remaining.length > 0) {
322
+ yield* executeToolCalls(remaining, config.tools, toolContext, config.permissionMode, config.askUser, state, config.permissionPromptTool);
323
+ }
324
+ state.lastTurnHadTools = toolCalls.length > 0;
325
+ state.lastTurnToolCount = toolCalls.length;
326
+ state.transition = "next_turn";
315
327
  }
316
- state.lastTurnHadTools = toolCalls.length > 0;
317
- state.lastTurnToolCount = toolCalls.length;
318
- state.transition = "next_turn";
328
+ yield { type: "turn_complete", reason: "max_turns" };
329
+ }
330
+ finally {
331
+ if (querySpanId)
332
+ config.tracer?.endSpan(querySpanId, "ok", { turns: state.turn });
319
333
  }
320
- yield { type: "turn_complete", reason: "max_turns" };
321
334
  }
322
335
  //# sourceMappingURL=index.js.map
@@ -216,6 +216,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
216
216
  return { output: "Blocked by preToolUse hook.", isError: true };
217
217
  }
218
218
  // Execute with timeout and result budgeting
219
+ const toolSpanId = context.tracer?.startSpan(`tool:${tool.name}`, { riskLevel: tool.riskLevel }, context.parentSpanId);
219
220
  try {
220
221
  const toolAbort = AbortSignal.timeout(TOOL_TIMEOUT_MS);
221
222
  const contextWithTimeout = { ...context, abortSignal: context.abortSignal ?? toolAbort };
@@ -225,6 +226,8 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
225
226
  toolAbort.addEventListener("abort", () => reject(new Error(`Tool '${tool.name}' timed out after ${TOOL_TIMEOUT_MS / 1000}s`)));
226
227
  }),
227
228
  ]);
229
+ if (toolSpanId)
230
+ context.tracer?.endSpan(toolSpanId, result.isError ? "error" : "ok");
228
231
  // Hook: postToolUse / postToolUseFailure (mutually exclusive — strict CC parity)
229
232
  if (result.isError) {
230
233
  emitHook("postToolUseFailure", {
@@ -300,6 +303,8 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
300
303
  catch (err) {
301
304
  const errMsg = err instanceof Error ? err.message : String(err);
302
305
  const errName = err instanceof Error ? err.name : "ExecutionError";
306
+ if (toolSpanId)
307
+ context.tracer?.endSpan(toolSpanId, "error", { error: errMsg });
303
308
  emitHook("postToolUseFailure", {
304
309
  toolName: tool.name,
305
310
  toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Shared types for the query loop sub-modules.
3
3
  */
4
+ import type { SessionTracer } from "../harness/traces.js";
4
5
  import type { Provider } from "../providers/base.js";
5
6
  import type { Tools } from "../Tool.js";
6
7
  import type { Message } from "../types/message.js";
@@ -32,6 +33,8 @@ export type QueryConfig = {
32
33
  * the tool is missing, throws, or returns malformed JSON.
33
34
  */
34
35
  permissionPromptTool?: string;
36
+ /** Optional session tracer. When set, query() emits `query` and `tool:<Name>` spans. */
37
+ tracer?: SessionTracer;
35
38
  };
36
39
  export type TransitionReason = "next_turn" | "retry_network" | "retry_prompt_too_long" | "retry_max_output_tokens";
37
40
  export type QueryLoopState = {
package/dist/repl.js CHANGED
@@ -64,6 +64,26 @@ export async function startREPL(config) {
64
64
  // Initialize checkpoints for file rewind
65
65
  const { initCheckpoints } = await import("./harness/checkpoints.js");
66
66
  initCheckpoints(session.id);
67
+ // Optional session-wide tracer. Opt-in via OH_TRACE=1 env var.
68
+ // Persists OTel-style spans to ~/.oh/traces/<sessionId>.jsonl.
69
+ // When OH_OTLP_ENDPOINT is also set, ships each ended span via fire-and-forget
70
+ // HTTP POST to the configured collector (Jaeger, Honeycomb, Grafana Tempo, etc.).
71
+ // OH_OTLP_HEADERS is a JSON-encoded headers object, e.g. '{"Authorization":"Bearer ..."}'.
72
+ let tracer;
73
+ if (process.env.OH_TRACE === "1") {
74
+ const { SessionTracer } = await import("./harness/traces.js");
75
+ const otlpEndpoint = process.env.OH_OTLP_ENDPOINT;
76
+ let otlpHeaders;
77
+ if (process.env.OH_OTLP_HEADERS) {
78
+ try {
79
+ otlpHeaders = JSON.parse(process.env.OH_OTLP_HEADERS);
80
+ }
81
+ catch {
82
+ /* malformed JSON in env — skip headers, ship without auth */
83
+ }
84
+ }
85
+ tracer = new SessionTracer(session.id, otlpEndpoint ? { endpoint: otlpEndpoint, headers: otlpHeaders } : undefined);
86
+ }
67
87
  // Start background cron executor
68
88
  const { CronExecutor } = await import("./services/CronExecutor.js");
69
89
  const cronExecutor = new CronExecutor(config.provider, config.tools, config.systemPrompt, config.permissionMode, config.model);
@@ -900,6 +920,7 @@ export async function startREPL(config) {
900
920
  askUserQuestion,
901
921
  model: currentModel || undefined,
902
922
  abortSignal: abortController.signal,
923
+ tracer,
903
924
  };
904
925
  try {
905
926
  for await (const event of query(prompt, queryConfig, messages)) {
@@ -94,8 +94,11 @@ export class StreamingToolExecutor {
94
94
  this.outputChunks.push({ callId: id, chunk });
95
95
  },
96
96
  };
97
+ const toolSpanId = callContext.tracer?.startSpan(`tool:${tool.name}`, { riskLevel: tool.riskLevel }, callContext.parentSpanId);
97
98
  try {
98
99
  tracked.result = await tool.call(parsed.data, callContext);
100
+ if (toolSpanId)
101
+ callContext.tracer?.endSpan(toolSpanId, tracked.result.isError ? "error" : "ok");
99
102
  // Verification loop: auto-run lint/typecheck after file-modifying tools
100
103
  if (tracked.result && !tracked.result.isError && ["Edit", "Write", "MultiEdit"].includes(tool.name)) {
101
104
  try {
@@ -132,6 +135,8 @@ export class StreamingToolExecutor {
132
135
  output: `Error: ${err instanceof Error ? err.message : String(err)}`,
133
136
  isError: true,
134
137
  };
138
+ if (toolSpanId)
139
+ callContext.tracer?.endSpan(toolSpanId, "error", { error: tracked.result.output });
135
140
  }
136
141
  tracked.status = "completed";
137
142
  this.processQueue(); // Process next queued tools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.29.0",
3
+ "version": "2.30.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {