@zhijiewang/openharness 2.37.0 → 2.39.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.
@@ -10,7 +10,7 @@ import { estimateMessageTokens } from "../harness/context-warning.js";
10
10
  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
- import { formatTrace, listTracedSessions, loadTrace } from "../harness/traces.js";
13
+ import { formatFlameGraph, formatTrace, listTracedSessions, loadTrace } from "../harness/traces.js";
14
14
  import { getVerificationConfig, invalidateVerificationCache } from "../harness/verification.js";
15
15
  import { normalizeMcpConfig } from "../mcp/config-normalize.js";
16
16
  import { connectedMcpServers, disconnectMcpClients, loadMcpTools } from "../mcp/loader.js";
@@ -358,13 +358,18 @@ export function registerInfoCommands(register, getCommandMap) {
358
358
  register("hooks", "List loaded hooks grouped by event", () => {
359
359
  return { output: formatHooksReport(getHooks()), handled: true };
360
360
  });
361
- register("traces", "List sessions with persisted OTel-style traces (or show one with /traces <sessionId>)", (args) => {
362
- const id = args.trim();
361
+ register("traces", "List sessions with persisted OTel-style traces (or show one with /traces <sessionId>; add --flame for a flame-graph view)", (args) => {
362
+ // Parse: `<sessionId>` for tree view, `<sessionId> --flame` (or `--flamegraph`)
363
+ // for the time-axis flame view. Order doesn't matter — accept the flag
364
+ // before or after the id.
365
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
366
+ const flame = tokens.some((t) => t === "--flame" || t === "--flamegraph" || t === "--flame-graph");
367
+ const id = tokens.find((t) => !t.startsWith("--"));
363
368
  if (id) {
364
369
  const spans = loadTrace(id);
365
370
  if (spans.length === 0)
366
371
  return { output: `No trace found for session ${id}.`, handled: true };
367
- return { output: formatTrace(spans), handled: true };
372
+ return { output: flame ? formatFlameGraph(spans) : formatTrace(spans), handled: true };
368
373
  }
369
374
  const sessions = listTracedSessions();
370
375
  if (sessions.length === 0) {
@@ -83,6 +83,31 @@ export declare function loadTrace(sessionId: string): TraceSpan[];
83
83
  export declare function listTracedSessions(): string[];
84
84
  /** Format trace for display */
85
85
  export declare function formatTrace(spans: TraceSpan[]): string;
86
+ /**
87
+ * Render spans as a flame-graph (icicle-graph really — top-down by depth).
88
+ * Each span gets one row: indent by tree depth, then a bar of `█` characters
89
+ * positioned along a wall-time axis sized to `width` columns. Bars start at
90
+ * the column corresponding to the span's `startTime` relative to the trace's
91
+ * minimum startTime, and span as many columns as their `durationMs` requires
92
+ * (minimum 1 column so even sub-millisecond spans are visible).
93
+ *
94
+ * Total trace duration sets the time-axis scale: a 5-second trace and a
95
+ * 50-second trace both fit the same `width`, so the same view works at any
96
+ * scale without scrolling. Per-span ms label appears to the right of the bar;
97
+ * span name appears at the left, indented by parent depth.
98
+ *
99
+ * Errored spans (status: "error") render in red; others use a stable
100
+ * per-name color so the same tool keeps the same color across the trace.
101
+ *
102
+ * The bottom row is a time ruler with ticks at 0ms, 25%, 50%, 75%, 100%.
103
+ *
104
+ * @param spans the spans to render — typically `loadTrace(sessionId)`
105
+ * @param width target width in columns (defaults to terminal width or 100)
106
+ * @param opts.color emit ANSI color codes (defaults to true; set false for tests)
107
+ */
108
+ export declare function formatFlameGraph(spans: TraceSpan[], width?: number, opts?: {
109
+ color?: boolean;
110
+ }): string;
86
111
  /** Export trace in OpenTelemetry-compatible format */
87
112
  export declare function exportTraceOTLP(sessionId: string, spans: TraceSpan[]): object;
88
113
  //# sourceMappingURL=traces.d.ts.map
@@ -220,6 +220,174 @@ export function formatTrace(spans) {
220
220
  lines.push(`Total: ${spans.length} spans, ${totalMs}ms, ${errors} errors`);
221
221
  return lines.join("\n");
222
222
  }
223
+ // ── Flame-graph rendering ──
224
+ /** ANSI 256 colors picked for distinguishability across span names. */
225
+ const FLAME_COLORS = [
226
+ "\x1b[38;5;202m", // orange (query)
227
+ "\x1b[38;5;39m", // light blue (tool:Read)
228
+ "\x1b[38;5;208m", // bright orange (tool:Bash)
229
+ "\x1b[38;5;105m", // purple (tool:Edit)
230
+ "\x1b[38;5;118m", // green (tool:Glob/Grep)
231
+ "\x1b[38;5;226m", // yellow (tool:Web*)
232
+ "\x1b[38;5;213m", // pink (think tools)
233
+ "\x1b[38;5;245m", // grey (other)
234
+ ];
235
+ const ANSI_RESET = "\x1b[0m";
236
+ const ANSI_DIM = "\x1b[2m";
237
+ const ANSI_RED = "\x1b[38;5;196m";
238
+ function colorForSpan(name) {
239
+ // Stable hash so the same span name always lands the same color across renders.
240
+ let hash = 0;
241
+ for (let i = 0; i < name.length; i++)
242
+ hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
243
+ return FLAME_COLORS[hash % FLAME_COLORS.length];
244
+ }
245
+ /**
246
+ * Render spans as a flame-graph (icicle-graph really — top-down by depth).
247
+ * Each span gets one row: indent by tree depth, then a bar of `█` characters
248
+ * positioned along a wall-time axis sized to `width` columns. Bars start at
249
+ * the column corresponding to the span's `startTime` relative to the trace's
250
+ * minimum startTime, and span as many columns as their `durationMs` requires
251
+ * (minimum 1 column so even sub-millisecond spans are visible).
252
+ *
253
+ * Total trace duration sets the time-axis scale: a 5-second trace and a
254
+ * 50-second trace both fit the same `width`, so the same view works at any
255
+ * scale without scrolling. Per-span ms label appears to the right of the bar;
256
+ * span name appears at the left, indented by parent depth.
257
+ *
258
+ * Errored spans (status: "error") render in red; others use a stable
259
+ * per-name color so the same tool keeps the same color across the trace.
260
+ *
261
+ * The bottom row is a time ruler with ticks at 0ms, 25%, 50%, 75%, 100%.
262
+ *
263
+ * @param spans the spans to render — typically `loadTrace(sessionId)`
264
+ * @param width target width in columns (defaults to terminal width or 100)
265
+ * @param opts.color emit ANSI color codes (defaults to true; set false for tests)
266
+ */
267
+ export function formatFlameGraph(spans, width = process.stdout.columns || 100, opts = {}) {
268
+ if (spans.length === 0)
269
+ return "No trace spans recorded.";
270
+ const useColor = opts.color !== false;
271
+ const c = (style, text) => (useColor ? `${style}${text}${ANSI_RESET}` : text);
272
+ // Trace bounds — every other timestamp is relative to minStart.
273
+ let minStart = Infinity;
274
+ let maxEnd = 0;
275
+ for (const s of spans) {
276
+ if (s.startTime < minStart)
277
+ minStart = s.startTime;
278
+ if (s.endTime > maxEnd)
279
+ maxEnd = s.endTime;
280
+ }
281
+ const totalMs = maxEnd > minStart ? maxEnd - minStart : 1;
282
+ // Layout: name column gets up to 30 chars; ms label gets up to 10; the rest
283
+ // is the bar canvas. We need at least ~20 cols of bar canvas to be useful.
284
+ const NAME_WIDTH = 30;
285
+ const MS_WIDTH = 10;
286
+ const PADDING = 3; // spaces between sections
287
+ const barWidth = Math.max(20, width - NAME_WIDTH - MS_WIDTH - PADDING);
288
+ // Build the depth map by walking the parent chain (spans are typically in
289
+ // start-order but we don't rely on it). Caps recursion to prevent infinite
290
+ // loops on a malformed trace where parent references form a cycle.
291
+ const byId = new Map(spans.map((s) => [s.spanId, s]));
292
+ const depthOf = new Map();
293
+ function depth(span, hops = 0) {
294
+ if (hops > 50)
295
+ return hops;
296
+ if (depthOf.has(span.spanId))
297
+ return depthOf.get(span.spanId);
298
+ let d = 0;
299
+ if (span.parentSpanId) {
300
+ const parent = byId.get(span.parentSpanId);
301
+ if (parent)
302
+ d = depth(parent, hops + 1) + 1;
303
+ }
304
+ depthOf.set(span.spanId, d);
305
+ return d;
306
+ }
307
+ for (const s of spans)
308
+ depth(s);
309
+ // Sort by start time, ties broken by depth (parents before children).
310
+ const sorted = [...spans].sort((a, b) => a.startTime - b.startTime || depthOf.get(a.spanId) - depthOf.get(b.spanId));
311
+ const lines = [];
312
+ for (const span of sorted) {
313
+ const d = depthOf.get(span.spanId);
314
+ const offset = Math.floor(((span.startTime - minStart) / totalMs) * barWidth);
315
+ const length = Math.max(1, Math.floor((span.durationMs / totalMs) * barWidth));
316
+ const indent = " ".repeat(Math.min(d, 4)); // visual cap at 4 indent levels
317
+ const name = `${indent}${span.name}`.padEnd(NAME_WIDTH).slice(0, NAME_WIDTH);
318
+ const bar = " ".repeat(offset) + "█".repeat(Math.min(length, barWidth - offset));
319
+ const paddedBar = bar.padEnd(barWidth);
320
+ const color = span.status === "error" ? ANSI_RED : colorForSpan(span.name);
321
+ const msLabel = `${span.durationMs}ms`.padStart(MS_WIDTH);
322
+ lines.push(`${name} ${c(color, paddedBar)} ${c(ANSI_DIM, msLabel)}`);
323
+ }
324
+ // Time ruler: 3-5 ticks depending on canvas width. We need ~8 columns per
325
+ // tick to fit timestamp labels without overlap; choose count that fits.
326
+ const tickCount = barWidth >= 50 ? 5 : barWidth >= 30 ? 3 : 2;
327
+ const tickPcts = [];
328
+ for (let i = 0; i < tickCount; i++)
329
+ tickPcts.push(i / (tickCount - 1));
330
+ const tickValues = tickPcts.map((pct) => `${Math.round(totalMs * pct)}ms`);
331
+ const rulerLine = " ".repeat(NAME_WIDTH + 3) + buildTimeRuler(barWidth, tickValues);
332
+ lines.push("");
333
+ lines.push(c(ANSI_DIM, rulerLine));
334
+ // Per-name summary: count + total ms, descending by total ms.
335
+ const summary = {};
336
+ for (const s of spans) {
337
+ const e = summary[s.name] ?? { count: 0, totalMs: 0 };
338
+ e.count++;
339
+ e.totalMs += s.durationMs;
340
+ summary[s.name] = e;
341
+ }
342
+ const ranked = Object.entries(summary).sort((a, b) => b[1].totalMs - a[1].totalMs);
343
+ lines.push("");
344
+ lines.push(c(ANSI_DIM, "Span breakdown (top by total time):"));
345
+ for (const [name, { count, totalMs: tms }] of ranked.slice(0, 10)) {
346
+ const pct = totalMs > 0 ? Math.round((tms / totalMs) * 100) : 0;
347
+ lines.push(` ${c(colorForSpan(name), "█")} ${name.padEnd(28)} ${count.toString().padStart(4)}× ${tms.toString().padStart(6)}ms ${pct}%`);
348
+ }
349
+ const errors = spans.filter((s) => s.status === "error").length;
350
+ lines.push("");
351
+ lines.push(c(ANSI_DIM, `${spans.length} spans, ${totalMs}ms total${errors > 0 ? `, ${errors} error(s)` : ""}`));
352
+ return lines.join("\n");
353
+ }
354
+ /**
355
+ * Build a time ruler line of exactly `width` columns with N tick labels
356
+ * distributed evenly. Strategy: anchor the last tick right-aligned to the
357
+ * width, then place earlier ticks at their proportional positions while
358
+ * truncating any label that would overlap the next tick (or the last
359
+ * tick's reserved start). Produces a clean ruler at any (width × N).
360
+ *
361
+ * The last tick's right-anchor means the rightmost timestamp always lands
362
+ * exactly at the canvas edge, matching where bars end.
363
+ */
364
+ function buildTimeRuler(width, ticks) {
365
+ if (ticks.length === 0 || width <= 0)
366
+ return "";
367
+ const buf = new Array(width).fill(" ");
368
+ // Step 1: place last tick right-aligned. Its start column constrains all
369
+ // earlier ticks (they must end before lastStart - 1 so there's a gap).
370
+ const lastLabel = ticks[ticks.length - 1];
371
+ const lastStart = Math.max(0, width - lastLabel.length);
372
+ for (let j = 0; j < lastLabel.length && lastStart + j < width; j++) {
373
+ buf[lastStart + j] = lastLabel[j];
374
+ }
375
+ // Step 2: place earlier ticks left-to-right. Each can occupy from its
376
+ // proportional start column up to either the next tick's start (minus 1
377
+ // for a separator space) or, for the second-to-last tick, lastStart - 1.
378
+ for (let i = 0; i < ticks.length - 1; i++) {
379
+ const label = ticks[i];
380
+ const start = Math.round((i / (ticks.length - 1)) * (width - 1));
381
+ const nextProportional = Math.round(((i + 1) / (ticks.length - 1)) * (width - 1));
382
+ const isPenultimate = i === ticks.length - 2;
383
+ const endExclusive = isPenultimate ? lastStart - 1 : nextProportional - 1;
384
+ const maxLen = Math.max(0, endExclusive - start);
385
+ const out = label.slice(0, maxLen);
386
+ for (let j = 0; j < out.length; j++)
387
+ buf[start + j] = out[j];
388
+ }
389
+ return buf.join("");
390
+ }
223
391
  /**
224
392
  * Coerce an arbitrary string (UUID with hyphens, "span-N", etc.) into a fixed-length
225
393
  * lowercase hex string suitable for OTLP. OTLP collectors (Jaeger, Tempo, OTel
@@ -8,7 +8,7 @@
8
8
  import type { Provider } from "../providers/base.js";
9
9
  import type { Tools } from "../Tool.js";
10
10
  import type { StreamEvent, ToolCallComplete, ToolCallEnd, ToolCallStart, ToolOutputDelta } from "../types/events.js";
11
- import type { PermissionMode } from "../types/permissions.js";
11
+ import { type PermissionMode } from "../types/permissions.js";
12
12
  /**
13
13
  * Forward inner-loop tool events to the outer stream, stamping parentCallId.
14
14
  * Exported for direct unit testing.
@@ -20,6 +20,15 @@ export type AgentTask = {
20
20
  description?: string;
21
21
  blockedBy?: string[];
22
22
  allowedTools?: string[];
23
+ /**
24
+ * Per-task permission mode override — narrowing-only, same contract as
25
+ * AgentTool's `permission_mode` (v2.36). When set, the task's effective
26
+ * mode is `clampSubagentPermissionMode(dispatcher.permissionMode, task.permissionMode)`,
27
+ * so a task can be the same strictness as the outer call or stricter,
28
+ * never looser. Use to mark specific tasks in a parallel batch as
29
+ * read-only review/audit while letting siblings keep full write access.
30
+ */
31
+ permissionMode?: PermissionMode;
23
32
  };
24
33
  export type AgentTaskResult = {
25
34
  id: string;
@@ -6,6 +6,7 @@
6
6
  * and triggers dependent tasks when their blockers complete.
7
7
  */
8
8
  import { createWorktree, isGitRepo, removeWorktree } from "../git/index.js";
9
+ import { clampSubagentPermissionMode } from "../types/permissions.js";
9
10
  /**
10
11
  * Forward inner-loop tool events to the outer stream, stamping parentCallId.
11
12
  * Exported for direct unit testing.
@@ -168,11 +169,15 @@ export class AgentDispatcher {
168
169
  // matching `process.chdir(originalCwd)` in `finally` — but since
169
170
  // `process.cwd()` is process-wide, two concurrent tasks would clobber
170
171
  // each other's directory mid-execution.
172
+ // Per-task permission mode — narrowing-only clamp applied so a task
173
+ // can override only to a same-or-stricter mode than the dispatcher's
174
+ // outer mode (#115 contract).
175
+ const taskPermissionMode = clampSubagentPermissionMode(this.permissionMode, task.permissionMode);
171
176
  const config = {
172
177
  provider: this.provider,
173
178
  tools: taskTools,
174
179
  systemPrompt: this.systemPrompt,
175
- permissionMode: this.permissionMode,
180
+ permissionMode: taskPermissionMode,
176
181
  model: this.model,
177
182
  maxTurns: 20,
178
183
  abortSignal: this.abortSignal,
@@ -23,7 +23,7 @@ declare const inputSchema: z.ZodObject<{
23
23
  context?: number | undefined;
24
24
  glob?: string | undefined;
25
25
  offset?: number | undefined;
26
- output_mode?: "content" | "files_with_matches" | "count" | undefined;
26
+ output_mode?: "content" | "count" | "files_with_matches" | undefined;
27
27
  head_limit?: number | undefined;
28
28
  multiline?: boolean | undefined;
29
29
  "-A"?: number | undefined;
@@ -38,7 +38,7 @@ declare const inputSchema: z.ZodObject<{
38
38
  context?: number | undefined;
39
39
  glob?: string | undefined;
40
40
  offset?: number | undefined;
41
- output_mode?: "content" | "files_with_matches" | "count" | undefined;
41
+ output_mode?: "content" | "count" | "files_with_matches" | undefined;
42
42
  head_limit?: number | undefined;
43
43
  multiline?: boolean | undefined;
44
44
  "-A"?: number | undefined;
@@ -6,15 +6,21 @@ declare const inputSchema: z.ZodObject<{
6
6
  prompt: z.ZodString;
7
7
  description: z.ZodOptional<z.ZodString>;
8
8
  blockedBy: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
9
+ allowed_tools: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
10
+ permission_mode: z.ZodOptional<z.ZodEnum<["ask", "trust", "deny", "acceptEdits", "plan", "auto", "bypassPermissions"]>>;
9
11
  }, "strip", z.ZodTypeAny, {
10
12
  id: string;
11
13
  prompt: string;
12
14
  description?: string | undefined;
15
+ allowed_tools?: string[] | undefined;
16
+ permission_mode?: "ask" | "deny" | "trust" | "acceptEdits" | "plan" | "auto" | "bypassPermissions" | undefined;
13
17
  blockedBy?: string[] | undefined;
14
18
  }, {
15
19
  id: string;
16
20
  prompt: string;
17
21
  description?: string | undefined;
22
+ allowed_tools?: string[] | undefined;
23
+ permission_mode?: "ask" | "deny" | "trust" | "acceptEdits" | "plan" | "auto" | "bypassPermissions" | undefined;
18
24
  blockedBy?: string[] | undefined;
19
25
  }>, "many">;
20
26
  }, "strip", z.ZodTypeAny, {
@@ -22,6 +28,8 @@ declare const inputSchema: z.ZodObject<{
22
28
  id: string;
23
29
  prompt: string;
24
30
  description?: string | undefined;
31
+ allowed_tools?: string[] | undefined;
32
+ permission_mode?: "ask" | "deny" | "trust" | "acceptEdits" | "plan" | "auto" | "bypassPermissions" | undefined;
25
33
  blockedBy?: string[] | undefined;
26
34
  }[];
27
35
  }, {
@@ -29,6 +37,8 @@ declare const inputSchema: z.ZodObject<{
29
37
  id: string;
30
38
  prompt: string;
31
39
  description?: string | undefined;
40
+ allowed_tools?: string[] | undefined;
41
+ permission_mode?: "ask" | "deny" | "trust" | "acceptEdits" | "plan" | "auto" | "bypassPermissions" | undefined;
32
42
  blockedBy?: string[] | undefined;
33
43
  }[];
34
44
  }>;
@@ -5,6 +5,11 @@ const taskSchema = z.object({
5
5
  prompt: z.string(),
6
6
  description: z.string().optional(),
7
7
  blockedBy: z.array(z.string()).optional(),
8
+ allowed_tools: z.array(z.string()).optional(),
9
+ permission_mode: z
10
+ .enum(["ask", "trust", "deny", "acceptEdits", "plan", "auto", "bypassPermissions"])
11
+ .optional()
12
+ .describe("Restrict THIS task's permission mode. Narrowing-only — clamps to the outer mode if a less-restrictive value is requested. Use to mark a single task as read-only review/audit while sibling tasks keep full write access."),
8
13
  });
9
14
  const inputSchema = z.object({
10
15
  tasks: z.array(taskSchema).min(1),
@@ -27,7 +32,18 @@ export const ParallelAgentTool = {
27
32
  const systemPrompt = context.systemPrompt ?? "You are a sub-agent. Complete the delegated task concisely.";
28
33
  const dispatcher = new AgentDispatcher(context.provider, context.tools, systemPrompt, context.permissionMode ?? "trust", context.model, context.workingDir, context.abortSignal, 4, // maxConcurrency default
29
34
  context.callId, context.emitChildEvent);
30
- dispatcher.addTasks(input.tasks);
35
+ // Map snake_case input fields to the AgentTask camelCase shape — the
36
+ // input schema uses `allowed_tools` / `permission_mode` to stay
37
+ // consistent with AgentTool, but the dispatcher's task type uses
38
+ // `allowedTools` / `permissionMode`.
39
+ dispatcher.addTasks(input.tasks.map((t) => ({
40
+ id: t.id,
41
+ prompt: t.prompt,
42
+ description: t.description,
43
+ blockedBy: t.blockedBy,
44
+ allowedTools: t.allowed_tools,
45
+ permissionMode: t.permission_mode,
46
+ })));
31
47
  const results = await dispatcher.execute();
32
48
  const output = results
33
49
  .map((r) => {
@@ -48,12 +64,13 @@ Parameters:
48
64
  - prompt (string): Instructions for the sub-agent
49
65
  - description (string, optional): Short label
50
66
  - blockedBy (string[], optional): IDs of tasks that must complete first
67
+ - allowed_tools (string[], optional): Restrict THIS task's agent to specific tools
68
+ - permission_mode (string, optional): Override THIS task's permission mode. Narrowing-only — a less-restrictive value clamps to the outer mode. Useful for marking review/audit tasks as "plan" or "deny" while sibling tasks keep full write access.
51
69
 
52
- Example: Run task A and B in parallel, then task C after both complete:
70
+ Example: parallel test-write + read-only review:
53
71
  tasks: [
54
- { id: "a", prompt: "..." },
55
- { id: "b", prompt: "..." },
56
- { id: "c", prompt: "...", blockedBy: ["a", "b"] }
72
+ { id: "tests", prompt: "Add tests for the new auth module" },
73
+ { id: "review", prompt: "Audit the new auth module for security issues", permission_mode: "plan" }
57
74
  ]`;
58
75
  },
59
76
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.37.0",
3
+ "version": "2.39.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {