@zhijiewang/openharness 2.29.0 → 2.30.1

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.
@@ -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
@@ -2,6 +2,7 @@
2
2
  * Imperative REPL — extracted business logic from React REPL.tsx.
3
3
  * Uses TerminalRenderer for display instead of Ink.
4
4
  */
5
+ import { readdirSync, statSync } from "node:fs";
5
6
  import { homedir } from "node:os";
6
7
  import { getCommandEntries } from "./commands/index.js";
7
8
  import { roll } from "./cybergotchi/bones.js";
@@ -64,6 +65,26 @@ export async function startREPL(config) {
64
65
  // Initialize checkpoints for file rewind
65
66
  const { initCheckpoints } = await import("./harness/checkpoints.js");
66
67
  initCheckpoints(session.id);
68
+ // Optional session-wide tracer. Opt-in via OH_TRACE=1 env var.
69
+ // Persists OTel-style spans to ~/.oh/traces/<sessionId>.jsonl.
70
+ // When OH_OTLP_ENDPOINT is also set, ships each ended span via fire-and-forget
71
+ // HTTP POST to the configured collector (Jaeger, Honeycomb, Grafana Tempo, etc.).
72
+ // OH_OTLP_HEADERS is a JSON-encoded headers object, e.g. '{"Authorization":"Bearer ..."}'.
73
+ let tracer;
74
+ if (process.env.OH_TRACE === "1") {
75
+ const { SessionTracer } = await import("./harness/traces.js");
76
+ const otlpEndpoint = process.env.OH_OTLP_ENDPOINT;
77
+ let otlpHeaders;
78
+ if (process.env.OH_OTLP_HEADERS) {
79
+ try {
80
+ otlpHeaders = JSON.parse(process.env.OH_OTLP_HEADERS);
81
+ }
82
+ catch {
83
+ /* malformed JSON in env — skip headers, ship without auth */
84
+ }
85
+ }
86
+ tracer = new SessionTracer(session.id, otlpEndpoint ? { endpoint: otlpEndpoint, headers: otlpHeaders } : undefined);
87
+ }
67
88
  // Start background cron executor
68
89
  const { CronExecutor } = await import("./services/CronExecutor.js");
69
90
  const cronExecutor = new CronExecutor(config.provider, config.tools, config.systemPrompt, config.permissionMode, config.model);
@@ -165,7 +186,6 @@ export async function startREPL(config) {
165
186
  const dir = lastSep >= 0 ? expanded.slice(0, lastSep + 1) : ".";
166
187
  const prefix = lastSep >= 0 ? expanded.slice(lastSep + 1) : expanded;
167
188
  try {
168
- const { readdirSync, statSync } = require("node:fs");
169
189
  const entries = readdirSync(dir)
170
190
  .filter((name) => name.toLowerCase().startsWith(prefix.toLowerCase()))
171
191
  .slice(0, 10);
@@ -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)) {
@@ -161,6 +161,13 @@ export class AgentDispatcher {
161
161
  if (filtered.length > 0)
162
162
  taskTools = filtered;
163
163
  }
164
+ // Plumb cwd through config.workingDir so parallel runTask calls don't
165
+ // race on the global process.cwd(). The query loop seeds ToolContext
166
+ // with this value; built-in tools (FileRead, Glob, Bash, …) honor it.
167
+ // Previously this method called `process.chdir(worktreePath)` and a
168
+ // matching `process.chdir(originalCwd)` in `finally` — but since
169
+ // `process.cwd()` is process-wide, two concurrent tasks would clobber
170
+ // each other's directory mid-execution.
164
171
  const config = {
165
172
  provider: this.provider,
166
173
  tools: taskTools,
@@ -169,6 +176,7 @@ export class AgentDispatcher {
169
176
  model: this.model,
170
177
  maxTurns: 20,
171
178
  abortSignal: this.abortSignal,
179
+ workingDir: worktreePath ?? cwd,
172
180
  };
173
181
  // Inject blocker results as context
174
182
  let promptWithContext = task.prompt;
@@ -184,37 +192,16 @@ export class AgentDispatcher {
184
192
  promptWithContext = `${blockerContext}\n\n---\n\n${task.prompt}`;
185
193
  }
186
194
  }
187
- const originalCwd = process.cwd();
188
- if (worktreePath) {
189
- try {
190
- process.chdir(worktreePath);
191
- }
192
- catch {
193
- /* ignore */
194
- }
195
- }
196
195
  let output = "";
197
196
  let errorMessage = null;
198
- try {
199
- for await (const event of query(promptWithContext, config)) {
200
- if (event.type === "text_delta")
201
- output += event.content;
202
- if (event.type === "error") {
203
- errorMessage = event.message;
204
- break;
205
- }
206
- forwardChildEvent(event, taskCallId, this.emitChildEvent);
207
- }
208
- }
209
- finally {
210
- if (worktreePath) {
211
- try {
212
- process.chdir(originalCwd);
213
- }
214
- catch {
215
- /* ignore */
216
- }
197
+ for await (const event of query(promptWithContext, config)) {
198
+ if (event.type === "text_delta")
199
+ output += event.content;
200
+ if (event.type === "error") {
201
+ errorMessage = event.message;
202
+ break;
217
203
  }
204
+ forwardChildEvent(event, taskCallId, this.emitChildEvent);
218
205
  }
219
206
  if (errorMessage !== null) {
220
207
  result = { id: task.id, output: `Error: ${errorMessage}`, isError: true, durationMs: Date.now() - start };