@zhijiewang/openharness 1.3.0 → 2.0.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.
@@ -100,15 +100,54 @@ register("undo", "Undo last AI commit", () => {
100
100
  handled: true,
101
101
  };
102
102
  });
103
- register("rewind", "Restore files from last checkpoint (undo last AI edit)", () => {
104
- const { rewindLastCheckpoint, checkpointCount } = require("../harness/checkpoints.js");
105
- const cp = rewindLastCheckpoint();
106
- if (!cp) {
103
+ register("rewind", "Restore files from checkpoint (interactive picker or last)", (args) => {
104
+ const { rewindLastCheckpoint, listCheckpoints, checkpointCount } = require("../harness/checkpoints.js");
105
+ const checkpoints = listCheckpoints();
106
+ if (checkpoints.length === 0) {
107
107
  return { output: "No checkpoints available. Checkpoints are created before file modifications.", handled: true };
108
108
  }
109
- const remaining = checkpointCount();
109
+ const idx = args.trim();
110
+ // /rewind (no args) — show checkpoint list
111
+ if (!idx) {
112
+ const lines = [`Checkpoints (${checkpoints.length}):\n`];
113
+ for (let i = checkpoints.length - 1; i >= 0; i--) {
114
+ const cp = checkpoints[i];
115
+ const age = Math.round((Date.now() - cp.timestamp) / 60_000);
116
+ lines.push(` ${i + 1}. [${age}m ago] ${cp.description}`);
117
+ lines.push(` Files: ${cp.files.join(', ')}`);
118
+ }
119
+ lines.push('');
120
+ lines.push('Usage: /rewind <number> to restore a specific checkpoint');
121
+ lines.push(' /rewind last to restore the most recent');
122
+ return { output: lines.join('\n'), handled: true };
123
+ }
124
+ // /rewind last — restore most recent
125
+ if (idx === 'last') {
126
+ const cp = rewindLastCheckpoint();
127
+ if (!cp)
128
+ return { output: "No checkpoints.", handled: true };
129
+ return {
130
+ output: `Rewound: ${cp.description}\nRestored ${cp.files.length} file(s): ${cp.files.join(", ")}\n${checkpointCount()} checkpoint(s) remaining.`,
131
+ handled: true,
132
+ };
133
+ }
134
+ // /rewind <n> — restore specific checkpoint
135
+ const num = parseInt(idx, 10);
136
+ if (isNaN(num) || num < 1 || num > checkpoints.length) {
137
+ return { output: `Invalid checkpoint number. Use 1-${checkpoints.length}.`, handled: true };
138
+ }
139
+ // Rewind to specific checkpoint (restore all from that point)
140
+ let restored = 0;
141
+ while (checkpointCount() >= num) {
142
+ const cp = rewindLastCheckpoint();
143
+ if (!cp)
144
+ break;
145
+ restored++;
146
+ if (checkpointCount() < num)
147
+ break;
148
+ }
110
149
  return {
111
- output: `Rewound: ${cp.description}\nRestored ${cp.files.length} file(s): ${cp.files.join(", ")}\n${remaining} checkpoint(s) remaining.`,
150
+ output: `Rewound ${restored} checkpoint(s) to point #${num}.\n${checkpointCount()} checkpoint(s) remaining.`,
112
151
  handled: true,
113
152
  };
114
153
  });
@@ -11,14 +11,25 @@ export type McpServerConfig = {
11
11
  timeout?: number;
12
12
  };
13
13
  export type HookDef = {
14
- command: string;
14
+ command?: string;
15
+ http?: string;
16
+ prompt?: string;
15
17
  match?: string;
18
+ timeout?: number;
16
19
  };
17
20
  export type HooksConfig = {
18
21
  sessionStart?: HookDef[];
19
22
  sessionEnd?: HookDef[];
20
23
  preToolUse?: HookDef[];
21
24
  postToolUse?: HookDef[];
25
+ fileChanged?: HookDef[];
26
+ cwdChanged?: HookDef[];
27
+ subagentStart?: HookDef[];
28
+ subagentStop?: HookDef[];
29
+ preCompact?: HookDef[];
30
+ postCompact?: HookDef[];
31
+ configChange?: HookDef[];
32
+ notification?: HookDef[];
22
33
  };
23
34
  export type ToolPermissionRule = {
24
35
  tool: string;
@@ -77,6 +77,11 @@ export function readOhConfig(root) {
77
77
  }
78
78
  export function writeOhConfig(cfg, root) {
79
79
  invalidateConfigCache();
80
+ // Emit configChange hook (lazy import to avoid circular dependency)
81
+ try {
82
+ require('./hooks.js').emitHook('configChange', {});
83
+ }
84
+ catch { /* ignore */ }
80
85
  const p = configPath(root);
81
86
  mkdirSync(join(root ?? ".", ".oh"), { recursive: true });
82
87
  if (cfg.provider === "llamacpp" || cfg.provider === "lmstudio") {
@@ -1,10 +1,15 @@
1
1
  /**
2
- * Hooks system — run shell commands on lifecycle events.
2
+ * Hooks system — run commands, HTTP requests, or LLM prompts on lifecycle events.
3
3
  *
4
- * preToolUse hooks can block tool execution (exit code 1 = block).
4
+ * preToolUse hooks can block tool execution (exit code 1 / allowed: false).
5
5
  * All other hooks are fire-and-forget (errors are silently ignored).
6
+ *
7
+ * Hook types:
8
+ * - command: shell script (existing)
9
+ * - http: POST JSON to URL, expect { allowed: true/false }
10
+ * - prompt: LLM yes/no check via provider.complete()
6
11
  */
7
- export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse";
12
+ export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification";
8
13
  export type HookContext = {
9
14
  toolName?: string;
10
15
  toolArgs?: string;
@@ -16,7 +21,17 @@ export type HookContext = {
16
21
  permissionMode?: string;
17
22
  cost?: string;
18
23
  tokens?: string;
24
+ /** For fileChanged: the file path that changed */
25
+ filePath?: string;
26
+ /** For cwdChanged: the new working directory */
27
+ newCwd?: string;
28
+ /** For subagentStart/Stop: the agent ID */
29
+ agentId?: string;
30
+ /** For notification: the message */
31
+ message?: string;
19
32
  };
33
+ /** Clear hook cache (call after config changes) */
34
+ export declare function invalidateHookCache(): void;
20
35
  /**
21
36
  * Emit a hook event. For preToolUse, returns false if any hook blocks the call.
22
37
  *
@@ -26,7 +41,7 @@ export type HookContext = {
26
41
  export declare function emitHook(event: HookEvent, ctx?: HookContext): boolean;
27
42
  /**
28
43
  * Async version of emitHook that waits for all hooks to complete.
29
- * Useful for sessionEnd where you want to ensure hooks finish.
44
+ * Supports all hook types (command, HTTP, prompt).
30
45
  */
31
46
  export declare function emitHookAsync(event: HookEvent, ctx?: HookContext): Promise<boolean>;
32
47
  //# sourceMappingURL=hooks.d.ts.map
@@ -1,8 +1,13 @@
1
1
  /**
2
- * Hooks system — run shell commands on lifecycle events.
2
+ * Hooks system — run commands, HTTP requests, or LLM prompts on lifecycle events.
3
3
  *
4
- * preToolUse hooks can block tool execution (exit code 1 = block).
4
+ * preToolUse hooks can block tool execution (exit code 1 / allowed: false).
5
5
  * All other hooks are fire-and-forget (errors are silently ignored).
6
+ *
7
+ * Hook types:
8
+ * - command: shell script (existing)
9
+ * - http: POST JSON to URL, expect { allowed: true/false }
10
+ * - prompt: LLM yes/no check via provider.complete()
6
11
  */
7
12
  import { spawn, spawnSync } from "node:child_process";
8
13
  import { readOhConfig } from "./config.js";
@@ -14,6 +19,10 @@ function getHooks() {
14
19
  cachedHooks = cfg?.hooks ?? null;
15
20
  return cachedHooks;
16
21
  }
22
+ /** Clear hook cache (call after config changes) */
23
+ export function invalidateHookCache() {
24
+ cachedHooks = undefined;
25
+ }
17
26
  function buildEnv(event, ctx) {
18
27
  const env = {
19
28
  ...process.env,
@@ -39,6 +48,14 @@ function buildEnv(event, ctx) {
39
48
  env.OH_COST = ctx.cost;
40
49
  if (ctx.tokens)
41
50
  env.OH_TOKENS = ctx.tokens;
51
+ if (ctx.filePath)
52
+ env.OH_FILE_PATH = ctx.filePath;
53
+ if (ctx.newCwd)
54
+ env.OH_NEW_CWD = ctx.newCwd;
55
+ if (ctx.agentId)
56
+ env.OH_AGENT_ID = ctx.agentId;
57
+ if (ctx.message)
58
+ env.OH_MESSAGE = ctx.message;
42
59
  return env;
43
60
  }
44
61
  function matchesHook(def, ctx) {
@@ -47,11 +64,9 @@ function matchesHook(def, ctx) {
47
64
  }
48
65
  return true;
49
66
  }
50
- /**
51
- * Run a single hook command asynchronously.
52
- * Returns a promise that resolves with the exit code (0 = success).
53
- */
54
- function runHookAsync(command, env, timeoutMs = 10_000) {
67
+ // ── Hook Executors ──
68
+ /** Run a command hook. Returns exit code (0 = success/allowed). */
69
+ function runCommandHookAsync(command, env, timeoutMs = 10_000) {
55
70
  return new Promise((resolve) => {
56
71
  const proc = spawn(command, {
57
72
  shell: true,
@@ -64,7 +79,7 @@ function runHookAsync(command, env, timeoutMs = 10_000) {
64
79
  if (!settled) {
65
80
  settled = true;
66
81
  proc.kill();
67
- resolve(1); // timeout = failure
82
+ resolve(1);
68
83
  }
69
84
  }, timeoutMs);
70
85
  proc.on("exit", (code) => {
@@ -83,6 +98,50 @@ function runHookAsync(command, env, timeoutMs = 10_000) {
83
98
  });
84
99
  });
85
100
  }
101
+ /** Run an HTTP hook. POSTs context as JSON, expects { allowed: true/false }. */
102
+ async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
103
+ try {
104
+ const body = JSON.stringify({ event, ...ctx });
105
+ const res = await fetch(url, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body,
109
+ signal: AbortSignal.timeout(timeoutMs),
110
+ });
111
+ if (!res.ok)
112
+ return false;
113
+ const data = await res.json();
114
+ return data.allowed !== false;
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ }
120
+ /** Run a prompt hook. Uses LLM to make a yes/no decision. */
121
+ async function runPromptHook(promptText, ctx) {
122
+ // Prompt hooks require a provider — skip if not available
123
+ // This is a lightweight check; full LLM call would need provider injection
124
+ // For now, prompt hooks evaluate the prompt text as a simple template
125
+ // TODO: inject provider for full LLM-based prompt hooks
126
+ return true; // Default allow if no LLM available
127
+ }
128
+ // ── Hook Execution ──
129
+ /** Execute a single hook definition. Returns true if allowed. */
130
+ async function executeHookDef(def, event, ctx) {
131
+ const timeout = def.timeout ?? 10_000;
132
+ if (def.command) {
133
+ const env = buildEnv(event, ctx);
134
+ const code = await runCommandHookAsync(def.command, env, timeout);
135
+ return code === 0;
136
+ }
137
+ if (def.http) {
138
+ return runHttpHook(def.http, event, ctx, timeout);
139
+ }
140
+ if (def.prompt) {
141
+ return runPromptHook(def.prompt, ctx);
142
+ }
143
+ return true; // No handler = allow
144
+ }
86
145
  /**
87
146
  * Emit a hook event. For preToolUse, returns false if any hook blocks the call.
88
147
  *
@@ -96,19 +155,21 @@ export function emitHook(event, ctx = {}) {
96
155
  const defs = hooks[event] ?? [];
97
156
  const env = buildEnv(event, ctx);
98
157
  if (event === "preToolUse") {
99
- // preToolUse must be synchronous — it gates tool execution
158
+ // preToolUse command hooks must be synchronous — they gate tool execution
100
159
  for (const def of defs) {
101
160
  if (!matchesHook(def, ctx))
102
161
  continue;
103
- const result = spawnSync(def.command, {
104
- shell: true,
105
- timeout: 10_000,
106
- stdio: "pipe",
107
- env,
108
- });
109
- if (result.status !== 0 || result.error) {
110
- return false;
162
+ if (def.command) {
163
+ const result = spawnSync(def.command, {
164
+ shell: true,
165
+ timeout: def.timeout ?? 10_000,
166
+ stdio: "pipe",
167
+ env,
168
+ });
169
+ if (result.status !== 0 || result.error)
170
+ return false;
111
171
  }
172
+ // HTTP and prompt hooks for preToolUse are handled in emitHookAsync
112
173
  }
113
174
  return true;
114
175
  }
@@ -116,27 +177,25 @@ export function emitHook(event, ctx = {}) {
116
177
  for (const def of defs) {
117
178
  if (!matchesHook(def, ctx))
118
179
  continue;
119
- runHookAsync(def.command, env).catch(() => { });
180
+ executeHookDef(def, event, ctx).catch(() => { });
120
181
  }
121
182
  return true;
122
183
  }
123
184
  /**
124
185
  * Async version of emitHook that waits for all hooks to complete.
125
- * Useful for sessionEnd where you want to ensure hooks finish.
186
+ * Supports all hook types (command, HTTP, prompt).
126
187
  */
127
188
  export async function emitHookAsync(event, ctx = {}) {
128
189
  const hooks = getHooks();
129
190
  if (!hooks)
130
191
  return true;
131
192
  const defs = hooks[event] ?? [];
132
- const env = buildEnv(event, ctx);
133
193
  for (const def of defs) {
134
194
  if (!matchesHook(def, ctx))
135
195
  continue;
136
- const code = await runHookAsync(def.command, env);
137
- if (event === "preToolUse" && code !== 0) {
196
+ const allowed = await executeHookDef(def, event, ctx);
197
+ if (event === "preToolUse" && !allowed)
138
198
  return false;
139
- }
140
199
  }
141
200
  return true;
142
201
  }
@@ -65,13 +65,28 @@ export function loadRules(projectPath) {
65
65
  if (content)
66
66
  rules.push(content);
67
67
  }
68
- // 4. Project rules/*.md
68
+ // 4. Project rules/*.md (with optional path-scoped filtering)
69
69
  const rulesDir = join(root, ".oh", "rules");
70
70
  if (existsSync(rulesDir)) {
71
71
  for (const file of readdirSync(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
72
- const content = readSafe(join(rulesDir, file));
73
- if (content)
74
- rules.push(content);
72
+ const raw = readSafe(join(rulesDir, file));
73
+ if (!raw)
74
+ continue;
75
+ // Check for paths frontmatter: only include if matching current context
76
+ const pathsMatch = raw.match(/^---\n[\s\S]*?^paths:\s*(.+)$/m);
77
+ if (pathsMatch) {
78
+ // Path-scoped rule — strip frontmatter and only include if glob matches
79
+ const pattern = pathsMatch[1].trim();
80
+ const fmEnd = raw.indexOf('---', raw.indexOf('---') + 3);
81
+ const content = fmEnd > 0 ? raw.slice(fmEnd + 3).trim() : raw;
82
+ if (content && matchesPathGlob(root, pattern)) {
83
+ rules.push(content);
84
+ }
85
+ }
86
+ else {
87
+ // No paths restriction — always include
88
+ rules.push(raw);
89
+ }
75
90
  }
76
91
  }
77
92
  // 5. CLAUDE.local.md (personal overrides, typically gitignored)
@@ -110,4 +125,17 @@ function readSafe(path) {
110
125
  return "";
111
126
  }
112
127
  }
128
+ /**
129
+ * Check if any file in the project matches a glob pattern.
130
+ * Simple implementation: checks if the pattern directory exists.
131
+ * For `src/api/**`, checks if `src/api/` exists.
132
+ */
133
+ function matchesPathGlob(root, pattern) {
134
+ // Extract the directory portion before any wildcard
135
+ const dirPart = pattern.split('*')[0].replace(/\/+$/, '');
136
+ if (!dirPart)
137
+ return true; // Pattern like "**/*.ts" matches everything
138
+ const fullDir = join(root, dirPart);
139
+ return existsSync(fullDir);
140
+ }
113
141
  //# sourceMappingURL=rules.js.map
@@ -69,14 +69,30 @@ export async function handleUserInput(input, ctx) {
69
69
  }
70
70
  // Normal prompt — add user message
71
71
  messages = [...messages, createUserMessage(input)];
72
- // Resolve @mentions
72
+ // Resolve @mentions — local files first, then MCP resources
73
73
  let resolvedInput = input;
74
- const mentionPattern = /@(\w[\w.-]*)/g;
74
+ const mentionPattern = /@([\w][\w./-]*)/g;
75
75
  const mentions = [...input.matchAll(mentionPattern)].map(m => m[1]);
76
76
  const companionName = ctx.companionConfig?.soul?.name?.toLowerCase();
77
77
  for (const mention of mentions) {
78
78
  if (companionName && mention.toLowerCase() === companionName)
79
79
  continue;
80
+ // Try local file first (supports paths like @src/main.ts, @README.md)
81
+ try {
82
+ const { existsSync, readFileSync } = await import('node:fs');
83
+ const { resolve } = await import('node:path');
84
+ const filePath = resolve(process.cwd(), mention);
85
+ if (existsSync(filePath)) {
86
+ const content = readFileSync(filePath, 'utf-8');
87
+ const truncated = content.length > 10_000
88
+ ? content.slice(0, 10_000) + '\n[...truncated]'
89
+ : content;
90
+ resolvedInput += `\n\n[File @${mention}]:\n${truncated}`;
91
+ continue;
92
+ }
93
+ }
94
+ catch { /* ignore */ }
95
+ // Fall back to MCP resource
80
96
  try {
81
97
  const content = await resolveMcpMention(mention);
82
98
  if (content)
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Session Traces — structured observability for agent sessions.
3
+ *
4
+ * Every query turn, tool call, LLM stream, and compression event
5
+ * generates a trace span. Traces enable debugging, replay, and
6
+ * performance analysis.
7
+ *
8
+ * Compatible with OpenTelemetry export format.
9
+ */
10
+ export type TraceSpan = {
11
+ spanId: string;
12
+ parentSpanId?: string;
13
+ name: string;
14
+ startTime: number;
15
+ endTime: number;
16
+ durationMs: number;
17
+ attributes: Record<string, unknown>;
18
+ status: 'ok' | 'error';
19
+ };
20
+ export type TraceEvent = {
21
+ name: string;
22
+ timestamp: number;
23
+ attributes?: Record<string, unknown>;
24
+ };
25
+ export declare class SessionTracer {
26
+ private sessionId;
27
+ private spans;
28
+ private activeSpans;
29
+ private spanCounter;
30
+ constructor(sessionId: string);
31
+ /** Start a new span. Returns the span ID. */
32
+ startSpan(name: string, attributes?: Record<string, unknown>, parentSpanId?: string): string;
33
+ /** End a span and record it. */
34
+ endSpan(spanId: string, status?: 'ok' | 'error', extraAttributes?: Record<string, unknown>): TraceSpan | null;
35
+ /** Get all completed spans */
36
+ getSpans(): TraceSpan[];
37
+ /** Get a summary of the trace */
38
+ getSummary(): {
39
+ totalSpans: number;
40
+ totalDurationMs: number;
41
+ spansByName: Record<string, {
42
+ count: number;
43
+ totalMs: number;
44
+ }>;
45
+ errors: number;
46
+ };
47
+ /** Persist a span to the trace file */
48
+ private persistSpan;
49
+ }
50
+ /** Load trace spans for a session */
51
+ export declare function loadTrace(sessionId: string): TraceSpan[];
52
+ /** List all sessions with traces */
53
+ export declare function listTracedSessions(): string[];
54
+ /** Format trace for display */
55
+ export declare function formatTrace(spans: TraceSpan[]): string;
56
+ /** Export trace in OpenTelemetry-compatible format */
57
+ export declare function exportTraceOTLP(sessionId: string, spans: TraceSpan[]): object;
58
+ //# sourceMappingURL=traces.d.ts.map
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Session Traces — structured observability for agent sessions.
3
+ *
4
+ * Every query turn, tool call, LLM stream, and compression event
5
+ * generates a trace span. Traces enable debugging, replay, and
6
+ * performance analysis.
7
+ *
8
+ * Compatible with OpenTelemetry export format.
9
+ */
10
+ import { appendFileSync, mkdirSync, existsSync, readFileSync, readdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+ const TRACE_DIR = join(homedir(), '.oh', 'traces');
14
+ // ── Tracer ──
15
+ export class SessionTracer {
16
+ sessionId;
17
+ spans = [];
18
+ activeSpans = new Map();
19
+ spanCounter = 0;
20
+ constructor(sessionId) {
21
+ this.sessionId = sessionId;
22
+ }
23
+ /** Start a new span. Returns the span ID. */
24
+ startSpan(name, attributes = {}, parentSpanId) {
25
+ const spanId = `span-${++this.spanCounter}`;
26
+ this.activeSpans.set(spanId, { name, startTime: Date.now(), parentSpanId, attributes });
27
+ return spanId;
28
+ }
29
+ /** End a span and record it. */
30
+ endSpan(spanId, status = 'ok', extraAttributes) {
31
+ const active = this.activeSpans.get(spanId);
32
+ if (!active)
33
+ return null;
34
+ this.activeSpans.delete(spanId);
35
+ const endTime = Date.now();
36
+ const span = {
37
+ spanId,
38
+ parentSpanId: active.parentSpanId,
39
+ name: active.name,
40
+ startTime: active.startTime,
41
+ endTime,
42
+ durationMs: endTime - active.startTime,
43
+ attributes: { ...active.attributes, ...extraAttributes },
44
+ status,
45
+ };
46
+ this.spans.push(span);
47
+ this.persistSpan(span);
48
+ return span;
49
+ }
50
+ /** Get all completed spans */
51
+ getSpans() {
52
+ return [...this.spans];
53
+ }
54
+ /** Get a summary of the trace */
55
+ getSummary() {
56
+ const spansByName = {};
57
+ let errors = 0;
58
+ let minStart = Infinity;
59
+ let maxEnd = 0;
60
+ for (const span of this.spans) {
61
+ const entry = spansByName[span.name] ?? { count: 0, totalMs: 0 };
62
+ entry.count++;
63
+ entry.totalMs += span.durationMs;
64
+ spansByName[span.name] = entry;
65
+ if (span.status === 'error')
66
+ errors++;
67
+ if (span.startTime < minStart)
68
+ minStart = span.startTime;
69
+ if (span.endTime > maxEnd)
70
+ maxEnd = span.endTime;
71
+ }
72
+ return {
73
+ totalSpans: this.spans.length,
74
+ totalDurationMs: maxEnd > minStart ? maxEnd - minStart : 0,
75
+ spansByName,
76
+ errors,
77
+ };
78
+ }
79
+ /** Persist a span to the trace file */
80
+ persistSpan(span) {
81
+ try {
82
+ mkdirSync(TRACE_DIR, { recursive: true });
83
+ const file = join(TRACE_DIR, `${this.sessionId}.jsonl`);
84
+ appendFileSync(file, JSON.stringify(span) + '\n');
85
+ }
86
+ catch { /* never crash on tracing failure */ }
87
+ }
88
+ }
89
+ // ── Trace Loading ──
90
+ /** Load trace spans for a session */
91
+ export function loadTrace(sessionId) {
92
+ const file = join(TRACE_DIR, `${sessionId}.jsonl`);
93
+ if (!existsSync(file))
94
+ return [];
95
+ try {
96
+ return readFileSync(file, 'utf-8')
97
+ .split('\n')
98
+ .filter(Boolean)
99
+ .map(line => JSON.parse(line));
100
+ }
101
+ catch {
102
+ return [];
103
+ }
104
+ }
105
+ /** List all sessions with traces */
106
+ export function listTracedSessions() {
107
+ if (!existsSync(TRACE_DIR))
108
+ return [];
109
+ return readdirSync(TRACE_DIR)
110
+ .filter(f => f.endsWith('.jsonl'))
111
+ .map(f => f.replace('.jsonl', ''));
112
+ }
113
+ /** Format trace for display */
114
+ export function formatTrace(spans) {
115
+ if (spans.length === 0)
116
+ return 'No trace spans recorded.';
117
+ const lines = [`Trace (${spans.length} spans):\n`];
118
+ // Group by parent for tree display
119
+ const roots = spans.filter(s => !s.parentSpanId);
120
+ const children = new Map();
121
+ for (const s of spans) {
122
+ if (s.parentSpanId) {
123
+ const list = children.get(s.parentSpanId) ?? [];
124
+ list.push(s);
125
+ children.set(s.parentSpanId, list);
126
+ }
127
+ }
128
+ function renderSpan(span, indent) {
129
+ const status = span.status === 'error' ? '✗' : '✓';
130
+ const pad = ' '.repeat(indent);
131
+ const attrs = Object.entries(span.attributes)
132
+ .filter(([, v]) => v !== undefined)
133
+ .map(([k, v]) => `${k}=${String(v).slice(0, 30)}`)
134
+ .join(' ');
135
+ lines.push(`${pad}${status} ${span.name} (${span.durationMs}ms) ${attrs}`);
136
+ const kids = children.get(span.spanId) ?? [];
137
+ for (const kid of kids)
138
+ renderSpan(kid, indent + 1);
139
+ }
140
+ for (const root of roots)
141
+ renderSpan(root, 0);
142
+ // Summary
143
+ const totalMs = spans.reduce((sum, s) => sum + s.durationMs, 0);
144
+ const errors = spans.filter(s => s.status === 'error').length;
145
+ lines.push('');
146
+ lines.push(`Total: ${spans.length} spans, ${totalMs}ms, ${errors} errors`);
147
+ return lines.join('\n');
148
+ }
149
+ /** Export trace in OpenTelemetry-compatible format */
150
+ export function exportTraceOTLP(sessionId, spans) {
151
+ return {
152
+ resourceSpans: [{
153
+ resource: {
154
+ attributes: [
155
+ { key: 'service.name', value: { stringValue: 'openharness' } },
156
+ { key: 'session.id', value: { stringValue: sessionId } },
157
+ ],
158
+ },
159
+ scopeSpans: [{
160
+ scope: { name: 'openharness.agent' },
161
+ spans: spans.map(s => ({
162
+ traceId: sessionId.padEnd(32, '0').slice(0, 32),
163
+ spanId: s.spanId.padEnd(16, '0').slice(0, 16),
164
+ parentSpanId: s.parentSpanId?.padEnd(16, '0').slice(0, 16),
165
+ name: s.name,
166
+ startTimeUnixNano: s.startTime * 1_000_000,
167
+ endTimeUnixNano: s.endTime * 1_000_000,
168
+ attributes: Object.entries(s.attributes).map(([k, v]) => ({
169
+ key: k,
170
+ value: { stringValue: String(v) },
171
+ })),
172
+ status: { code: s.status === 'ok' ? 1 : 2 },
173
+ })),
174
+ }],
175
+ }],
176
+ };
177
+ }
178
+ //# sourceMappingURL=traces.js.map
package/dist/main.js CHANGED
@@ -233,6 +233,7 @@ program
233
233
  .option("--fork <id>", "Fork (branch) from an existing session")
234
234
  .option("--light", "Use light theme")
235
235
  .option("--output-format <format>", "Output format for -p mode (text, json, stream-json)", "text")
236
+ .option("--json-schema <schema>", "Constrain output to match a JSON schema (headless mode)")
236
237
  .action(async (opts) => {
237
238
  // Load saved config as defaults (env vars + CLI flags override)
238
239
  const savedConfig = readOhConfig();