@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.
- package/README.md +8 -5
- package/README.zh-CN.md +8 -5
- package/dist/Tool.d.ts +4 -0
- package/dist/commands/ai.js +4 -4
- package/dist/commands/git.js +1 -1
- package/dist/commands/info.js +30 -3
- package/dist/commands/session.js +1 -2
- package/dist/commands/settings.js +1 -1
- package/dist/commands/skills.js +2 -5
- package/dist/components/InitWizard.js +1 -1
- package/dist/harness/config.js +3 -7
- package/dist/harness/plugins.js +1 -1
- package/dist/harness/telemetry.js +18 -12
- package/dist/harness/traces.d.ts +31 -1
- package/dist/harness/traces.js +85 -4
- package/dist/providers/anthropic.js +4 -1
- package/dist/query/index.js +208 -195
- package/dist/query/tools.js +5 -0
- package/dist/query/types.d.ts +3 -0
- package/dist/repl.js +22 -1
- package/dist/services/AgentDispatcher.js +15 -28
- package/dist/services/StreamingToolExecutor.js +102 -11
- package/dist/tools/CronTool/index.d.ts +2 -2
- package/dist/tools/DiagnosticsTool/index.d.ts +1 -1
- package/dist/tools/GrepTool/index.d.ts +2 -2
- package/dist/tools/PowerShellTool/index.js +11 -2
- package/package.json +1 -1
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
|
-
[](https://www.npmjs.com/package/@zhijiewang/openharness) [](https://www.npmjs.com/package/@zhijiewang/openharness) [](LICENSE) ](https://www.npmjs.com/package/@zhijiewang/openharness) [](https://www.npmjs.com/package/@zhijiewang/openharness) [](LICENSE)     [](https://github.com/zhijiewong/openharness) [](https://github.com/zhijiewong/openharness/issues) [](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 (
|
|
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
|
|
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 (
|
|
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
|
-
[](https://www.npmjs.com/package/@zhijiewang/openharness) [](https://www.npmjs.com/package/@zhijiewang/openharness) [](LICENSE) ](https://www.npmjs.com/package/@zhijiewang/openharness) [](https://www.npmjs.com/package/@zhijiewang/openharness) [](LICENSE)     [](https://github.com/zhijiewong/openharness) [](https://github.com/zhijiewong/openharness/issues) [](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
|
-
- [工具(
|
|
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
|
-
- **流光加载动画** ——
|
|
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
|
-
## 工具(
|
|
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;
|
package/dist/commands/ai.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AI commands — /plan, /review, /roles, /agents, /plugins, /btw, /loop
|
|
3
3
|
*/
|
|
4
|
+
import { listRoles } from "../agents/roles.js";
|
|
4
5
|
import { gitDiff, isGitRepo } from "../git/index.js";
|
|
6
|
+
import { addMarketplace, formatInstalledPlugins, formatMarketplaceSearch, getInstalledPlugins, installPlugin, listMarketplaces, removeMarketplace, searchMarketplace, uninstallPlugin, } from "../harness/marketplace.js";
|
|
7
|
+
import { discoverPlugins, discoverSkills } from "../harness/plugins.js";
|
|
8
|
+
import { discoverAgents } from "../services/a2a.js";
|
|
5
9
|
import { handleCybergotchiCommand } from "./cybergotchi.js";
|
|
6
10
|
export function registerAICommands(register) {
|
|
7
11
|
register("btw", "Ask a side question (ephemeral, no tools, not saved to history)", (args) => {
|
|
@@ -71,7 +75,6 @@ export function registerAICommands(register) {
|
|
|
71
75
|
};
|
|
72
76
|
});
|
|
73
77
|
register("roles", "List available agent specialization roles", () => {
|
|
74
|
-
const { listRoles } = require("../agents/roles.js");
|
|
75
78
|
const roles = listRoles();
|
|
76
79
|
const lines = ["Available agent roles:\n"];
|
|
77
80
|
for (const role of roles) {
|
|
@@ -86,7 +89,6 @@ export function registerAICommands(register) {
|
|
|
86
89
|
return { output: lines.join("\n"), handled: true };
|
|
87
90
|
});
|
|
88
91
|
register("agents", "Discover running openHarness agents on this machine", () => {
|
|
89
|
-
const { discoverAgents } = require("../services/a2a.js");
|
|
90
92
|
const agents = discoverAgents();
|
|
91
93
|
if (agents.length === 0) {
|
|
92
94
|
return {
|
|
@@ -110,8 +112,6 @@ export function registerAICommands(register) {
|
|
|
110
112
|
return { output: lines.join("\n"), handled: true };
|
|
111
113
|
});
|
|
112
114
|
const pluginsHandler = (args) => {
|
|
113
|
-
const { discoverPlugins, discoverSkills } = require("../harness/plugins.js");
|
|
114
|
-
const { searchMarketplace, installPlugin, uninstallPlugin, getInstalledPlugins, listMarketplaces, addMarketplace, removeMarketplace, formatMarketplaceSearch, formatInstalledPlugins, } = require("../harness/marketplace.js");
|
|
115
115
|
const parts = args.trim().split(/\s+/);
|
|
116
116
|
const subcommand = parts[0] ?? "";
|
|
117
117
|
const rest = parts.slice(1).join(" ");
|
package/dist/commands/git.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { execSync } from "node:child_process";
|
|
5
5
|
import { gitBranch, gitCommit, gitDiff, gitLog, gitUndo, isGitRepo } from "../git/index.js";
|
|
6
|
+
import { checkpointCount, listCheckpoints, rewindLastCheckpoint } from "../harness/checkpoints.js";
|
|
6
7
|
export function registerGitCommands(register) {
|
|
7
8
|
register("diff", "Show uncommitted git changes", () => {
|
|
8
9
|
if (!isGitRepo()) {
|
|
@@ -22,7 +23,6 @@ export function registerGitCommands(register) {
|
|
|
22
23
|
};
|
|
23
24
|
});
|
|
24
25
|
register("rewind", "Restore files from checkpoint (interactive picker or last)", (args) => {
|
|
25
|
-
const { rewindLastCheckpoint, listCheckpoints, checkpointCount } = require("../harness/checkpoints.js");
|
|
26
26
|
const checkpoints = listCheckpoints();
|
|
27
27
|
if (checkpoints.length === 0) {
|
|
28
28
|
return { output: "No checkpoints available. Checkpoints are created before file modifications.", handled: true };
|
package/dist/commands/info.js
CHANGED
|
@@ -11,10 +11,12 @@ 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 {
|
|
14
|
+
import { formatTrace, listTracedSessions, loadTrace } from "../harness/traces.js";
|
|
15
|
+
import { getVerificationConfig, invalidateVerificationCache } from "../harness/verification.js";
|
|
15
16
|
import { normalizeMcpConfig } from "../mcp/config-normalize.js";
|
|
16
17
|
import { connectedMcpServers, disconnectMcpClients, loadMcpTools } from "../mcp/loader.js";
|
|
17
18
|
import { getAuthStatus } from "../mcp/oauth.js";
|
|
19
|
+
import { formatRegistry, generateConfigBlock, MCP_REGISTRY, searchRegistry } from "../mcp/registry.js";
|
|
18
20
|
import { getRouteSelection } from "../providers/router.js";
|
|
19
21
|
import { formatHooksReport } from "./hooks-report.js";
|
|
20
22
|
import { mcpLoginHandler, mcpLogoutHandler } from "./mcp-auth.js";
|
|
@@ -47,6 +49,7 @@ export function registerInfoCommands(register, getCommandMap) {
|
|
|
47
49
|
"memory",
|
|
48
50
|
"doctor",
|
|
49
51
|
"hooks",
|
|
52
|
+
"traces",
|
|
50
53
|
"context",
|
|
51
54
|
"mcp",
|
|
52
55
|
"mcp-login",
|
|
@@ -324,7 +327,6 @@ export function registerInfoCommands(register, getCommandMap) {
|
|
|
324
327
|
const globalCfg = existsSync(join(homedir(), ".oh", "config.yaml"));
|
|
325
328
|
lines.push(` Global config: ${globalCfg ? "~/.oh/config.yaml ✓" : "not set (optional)"}`);
|
|
326
329
|
try {
|
|
327
|
-
const { getVerificationConfig } = require("../harness/verification.js");
|
|
328
330
|
const vCfg = getVerificationConfig();
|
|
329
331
|
if (vCfg?.enabled) {
|
|
330
332
|
lines.push(` Verification: ✓ (${vCfg.rules.length} rules, mode: ${vCfg.mode})`);
|
|
@@ -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;
|
|
@@ -450,7 +478,6 @@ export function registerInfoCommands(register, getCommandMap) {
|
|
|
450
478
|
return { output: lines.join("\n"), handled: true };
|
|
451
479
|
});
|
|
452
480
|
register("mcp-registry", "Browse and add MCP servers from the curated registry", (args) => {
|
|
453
|
-
const { searchRegistry, formatRegistry, generateConfigBlock, MCP_REGISTRY } = require("../mcp/registry.js");
|
|
454
481
|
const query = args.trim();
|
|
455
482
|
if (!query) {
|
|
456
483
|
const output = `MCP Server Registry (${MCP_REGISTRY.length} servers)\n${"─".repeat(50)}\n\n${formatRegistry()}\n\nUsage:\n /mcp-registry <name> Show install config for a server\n /mcp-registry <keyword> Search by name, description, or category`;
|
package/dist/commands/session.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Session commands — /clear, /compact, /copy, /export, /history, /browse, /resume, /fork, /pin, /unpin
|
|
3
3
|
*/
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { homedir, platform } from "node:os";
|
|
7
7
|
import { dirname, join, resolve } from "node:path";
|
|
8
8
|
import { getContextWindow } from "../harness/cost.js";
|
|
@@ -134,7 +134,6 @@ export function registerSessionCommands(register) {
|
|
|
134
134
|
const body = asJson ? JSON.stringify(ctx.messages, null, 2) : formatMessagesAsMarkdown(ctx.messages);
|
|
135
135
|
try {
|
|
136
136
|
mkdirSync(dirname(filename), { recursive: true });
|
|
137
|
-
const { writeFileSync } = require("node:fs");
|
|
138
137
|
writeFileSync(filename, body);
|
|
139
138
|
return { output: `Exported ${ctx.messages.length} messages to ${filename}`, handled: true };
|
|
140
139
|
}
|
|
@@ -8,6 +8,7 @@ import { dirname, join } from "node:path";
|
|
|
8
8
|
import { readApprovalLog } from "../harness/approvals.js";
|
|
9
9
|
import { readOhConfig } from "../harness/config.js";
|
|
10
10
|
import { loadKeybindings } from "../harness/keybindings.js";
|
|
11
|
+
import { sandboxStatus } from "../harness/sandbox.js";
|
|
11
12
|
import { isTrusted, listTrusted, trust } from "../harness/trust.js";
|
|
12
13
|
const KEYBINDINGS_TEMPLATE = `[
|
|
13
14
|
{ "key": "ctrl+d", "action": "/diff" },
|
|
@@ -136,7 +137,6 @@ export function registerSettingsCommands(register) {
|
|
|
136
137
|
return { output: `Effort level set to: ${level}`, handled: true };
|
|
137
138
|
});
|
|
138
139
|
register("sandbox", "Show sandbox status and restrictions", () => {
|
|
139
|
-
const { sandboxStatus } = require("../harness/sandbox.js");
|
|
140
140
|
return { output: `${sandboxStatus()}\n\nConfigure in .oh/config.yaml under sandbox:`, handled: true };
|
|
141
141
|
});
|
|
142
142
|
register("permissions", "View or change permission mode (or 'log' for approval history)", (args, ctx) => {
|
package/dist/commands/skills.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Skill management commands — /skills, /skill-create, /skill-delete, /skill-edit, /skill-search, /skill-install
|
|
3
3
|
*/
|
|
4
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { discoverSkills } from "../harness/plugins.js";
|
|
6
|
+
import { discoverSkills, findSkill } from "../harness/plugins.js";
|
|
7
7
|
export function registerSkillCommands(register) {
|
|
8
8
|
register("skills", "List all available skills", () => {
|
|
9
9
|
const skills = discoverSkills();
|
|
@@ -76,12 +76,10 @@ How to confirm the skill worked correctly.
|
|
|
76
76
|
const name = args.trim();
|
|
77
77
|
if (!name)
|
|
78
78
|
return { output: "Usage: /skill-delete <name>", handled: true };
|
|
79
|
-
const { findSkill } = require("../harness/plugins.js");
|
|
80
79
|
const skill = findSkill(name);
|
|
81
80
|
if (!skill)
|
|
82
81
|
return { output: `Skill "${name}" not found.`, handled: true };
|
|
83
82
|
try {
|
|
84
|
-
const { unlinkSync } = require("node:fs");
|
|
85
83
|
unlinkSync(skill.filePath);
|
|
86
84
|
return { output: `Deleted skill: ${skill.filePath}`, handled: true };
|
|
87
85
|
}
|
|
@@ -93,7 +91,6 @@ How to confirm the skill worked correctly.
|
|
|
93
91
|
const name = args.trim();
|
|
94
92
|
if (!name)
|
|
95
93
|
return { output: "Usage: /skill-edit <name>", handled: true };
|
|
96
|
-
const { findSkill } = require("../harness/plugins.js");
|
|
97
94
|
const skill = findSkill(name);
|
|
98
95
|
if (!skill)
|
|
99
96
|
return { output: `Skill "${name}" not found.`, handled: true };
|
|
@@ -15,6 +15,7 @@ import { Box, Text, useInput } from "ink";
|
|
|
15
15
|
import TextInput from "ink-text-input";
|
|
16
16
|
import { useCallback, useState } from "react";
|
|
17
17
|
import { writeOhConfig } from "../harness/config.js";
|
|
18
|
+
import { MCP_REGISTRY } from "../mcp/registry.js";
|
|
18
19
|
import CybergotchiSetup from "./CybergotchiSetup.js";
|
|
19
20
|
const PROVIDERS = [
|
|
20
21
|
{
|
|
@@ -125,7 +126,6 @@ export default function InitWizard({ onDone }) {
|
|
|
125
126
|
let mcpServers;
|
|
126
127
|
if (selectedMcp.size > 0) {
|
|
127
128
|
try {
|
|
128
|
-
const { MCP_REGISTRY } = require("../mcp/registry.js");
|
|
129
129
|
mcpServers = [...selectedMcp]
|
|
130
130
|
.map((name) => MCP_REGISTRY.find((e) => e.name === name))
|
|
131
131
|
.filter(Boolean)
|
package/dist/harness/config.js
CHANGED
|
@@ -127,13 +127,9 @@ export function appendToolPermission(toolName, action = "allow", root) {
|
|
|
127
127
|
}
|
|
128
128
|
export function writeOhConfig(cfg, root) {
|
|
129
129
|
invalidateConfigCache();
|
|
130
|
-
// Emit configChange hook (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
/* ignore */
|
|
136
|
-
}
|
|
130
|
+
// Emit configChange hook (dynamic import to avoid circular dependency
|
|
131
|
+
// with hooks.ts, which imports readOhConfig from this module).
|
|
132
|
+
import("./hooks.js").then((m) => m.emitHook("configChange", {})).catch(() => { });
|
|
137
133
|
const p = configPath(root);
|
|
138
134
|
mkdirSync(join(root ?? ".", ".oh"), { recursive: true });
|
|
139
135
|
if (cfg.provider === "llamacpp" || cfg.provider === "lmstudio") {
|
package/dist/harness/plugins.js
CHANGED
|
@@ -14,6 +14,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { dirname, join, relative } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { getInstalledPlugins } from "./marketplace.js";
|
|
17
18
|
const PROJECT_SKILLS_DIR = join(".oh", "skills");
|
|
18
19
|
const GLOBAL_SKILLS_DIR = join(homedir(), ".oh", "skills");
|
|
19
20
|
// Claude Code ecosystem mirror paths (Anthropic convention)
|
|
@@ -178,7 +179,6 @@ export function discoverSkills() {
|
|
|
178
179
|
skills.push(...loadSkillsFromDir(CC_GLOBAL_SKILLS_DIR, "global"));
|
|
179
180
|
// Load skills from installed marketplace plugins (namespaced as plugin-name:skill-name)
|
|
180
181
|
try {
|
|
181
|
-
const { getInstalledPlugins } = require("./marketplace.js");
|
|
182
182
|
for (const plugin of getInstalledPlugins()) {
|
|
183
183
|
const pluginSkillsDir = join(plugin.cachePath, "skills");
|
|
184
184
|
const pluginSkills = loadSkillsFromDir(pluginSkillsDir, "plugin");
|
|
@@ -15,10 +15,15 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from
|
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { readOhConfig } from "./config.js";
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Telemetry directory. Resolved on each access so `OH_TELEMETRY_DIR` env-var
|
|
20
|
+
* overrides (used by tests) take effect even after this module loads.
|
|
21
|
+
*/
|
|
22
|
+
function telemetryDir() {
|
|
23
|
+
return process.env.OH_TELEMETRY_DIR ?? join(homedir(), ".oh", "telemetry");
|
|
24
|
+
}
|
|
19
25
|
// ── State ──
|
|
20
26
|
let _enabled;
|
|
21
|
-
let _sessionFile = null;
|
|
22
27
|
function isEnabled() {
|
|
23
28
|
if (_enabled !== undefined)
|
|
24
29
|
return _enabled;
|
|
@@ -26,12 +31,13 @@ function isEnabled() {
|
|
|
26
31
|
_enabled = config?.telemetry?.enabled === true;
|
|
27
32
|
return _enabled;
|
|
28
33
|
}
|
|
34
|
+
/** Resolve the JSONL path for a sessionId. Stateless — was previously a module-level
|
|
35
|
+
* singleton that ignored `sessionId` after the first call, causing multi-session
|
|
36
|
+
* processes (e.g. `--resume`) to write every session into the first one's file. */
|
|
29
37
|
function getSessionFile(sessionId) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
_sessionFile = join(TELEMETRY_DIR, `${sessionId}.jsonl`);
|
|
34
|
-
return _sessionFile;
|
|
38
|
+
const dir = telemetryDir();
|
|
39
|
+
mkdirSync(dir, { recursive: true });
|
|
40
|
+
return join(dir, `${sessionId}.jsonl`);
|
|
35
41
|
}
|
|
36
42
|
// ── Public API ──
|
|
37
43
|
/** Record a telemetry event (no-op if telemetry disabled) */
|
|
@@ -84,7 +90,7 @@ export function recordError(sessionId, category) {
|
|
|
84
90
|
}
|
|
85
91
|
/** Read local telemetry events for a session */
|
|
86
92
|
export function readSessionEvents(sessionId) {
|
|
87
|
-
const file = join(
|
|
93
|
+
const file = join(telemetryDir(), `${sessionId}.jsonl`);
|
|
88
94
|
if (!existsSync(file))
|
|
89
95
|
return [];
|
|
90
96
|
try {
|
|
@@ -99,15 +105,16 @@ export function readSessionEvents(sessionId) {
|
|
|
99
105
|
}
|
|
100
106
|
/** Get aggregate stats across all sessions */
|
|
101
107
|
export function getAggregateStats() {
|
|
102
|
-
|
|
108
|
+
const dir = telemetryDir();
|
|
109
|
+
if (!existsSync(dir))
|
|
103
110
|
return { totalSessions: 0, totalEvents: 0, toolUsage: {}, errorCategories: {} };
|
|
104
|
-
const files = readdirSync(
|
|
111
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
105
112
|
const toolUsage = {};
|
|
106
113
|
const errorCategories = {};
|
|
107
114
|
let totalEvents = 0;
|
|
108
115
|
for (const file of files) {
|
|
109
116
|
try {
|
|
110
|
-
const lines = readFileSync(join(
|
|
117
|
+
const lines = readFileSync(join(dir, file), "utf-8").split("\n").filter(Boolean);
|
|
111
118
|
totalEvents += lines.length;
|
|
112
119
|
for (const line of lines) {
|
|
113
120
|
const event = JSON.parse(line);
|
|
@@ -128,6 +135,5 @@ export function getAggregateStats() {
|
|
|
128
135
|
/** Reset telemetry cache (for testing or config changes) */
|
|
129
136
|
export function resetTelemetry() {
|
|
130
137
|
_enabled = undefined;
|
|
131
|
-
_sessionFile = null;
|
|
132
138
|
}
|
|
133
139
|
//# sourceMappingURL=telemetry.js.map
|
package/dist/harness/traces.d.ts
CHANGED
|
@@ -22,16 +22,46 @@ 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
|
-
|
|
34
|
+
private otlp?;
|
|
35
|
+
/**
|
|
36
|
+
* Pending spans that have ended but not yet been POSTed to OTLP. Drained
|
|
37
|
+
* by a microtask-debounced flush (one POST per microtask boundary even if
|
|
38
|
+
* many spans end in the same tick) and by the public `flush()` method.
|
|
39
|
+
*/
|
|
40
|
+
private otlpBuffer;
|
|
41
|
+
private otlpFlushScheduled;
|
|
42
|
+
/** In-flight fetches so `flush()` can await any POSTs already on the wire. */
|
|
43
|
+
private otlpInFlight;
|
|
44
|
+
constructor(sessionId: string, otlp?: OTLPConfig);
|
|
31
45
|
/** Start a new span. Returns the span ID. */
|
|
32
46
|
startSpan(name: string, attributes?: Record<string, unknown>, parentSpanId?: string): string;
|
|
33
47
|
/** End a span and record it. */
|
|
34
48
|
endSpan(spanId: string, status?: "ok" | "error", extraAttributes?: Record<string, unknown>): TraceSpan | null;
|
|
49
|
+
/**
|
|
50
|
+
* Buffer the span for OTLP shipping. The actual POST is deferred to a
|
|
51
|
+
* microtask so multiple spans ending in the same tick coalesce into a
|
|
52
|
+
* single batch POST instead of one fetch each. Errors are swallowed —
|
|
53
|
+
* telemetry must never crash the agent.
|
|
54
|
+
*/
|
|
55
|
+
private shipSpanOTLP;
|
|
56
|
+
/** Send whatever is in `otlpBuffer` as a single fire-and-forget POST. The
|
|
57
|
+
* returned promise is tracked in `otlpInFlight` so `flush()` can await it. */
|
|
58
|
+
private drainOTLPBuffer;
|
|
59
|
+
/**
|
|
60
|
+
* Drain any pending OTLP buffer and await every in-flight POST. Call this at
|
|
61
|
+
* session end so spans aren't dropped on `process.exit`. No-op when OTLP is
|
|
62
|
+
* not configured. Errors are swallowed (already, by `drainOTLPBuffer`).
|
|
63
|
+
*/
|
|
64
|
+
flush(): Promise<void>;
|
|
35
65
|
/** Get all completed spans */
|
|
36
66
|
getSpans(): TraceSpan[];
|
|
37
67
|
/** Get a summary of the trace */
|
package/dist/harness/traces.js
CHANGED
|
@@ -18,8 +18,19 @@ export class SessionTracer {
|
|
|
18
18
|
spans = [];
|
|
19
19
|
activeSpans = new Map();
|
|
20
20
|
spanCounter = 0;
|
|
21
|
-
|
|
21
|
+
otlp;
|
|
22
|
+
/**
|
|
23
|
+
* Pending spans that have ended but not yet been POSTed to OTLP. Drained
|
|
24
|
+
* by a microtask-debounced flush (one POST per microtask boundary even if
|
|
25
|
+
* many spans end in the same tick) and by the public `flush()` method.
|
|
26
|
+
*/
|
|
27
|
+
otlpBuffer = [];
|
|
28
|
+
otlpFlushScheduled = false;
|
|
29
|
+
/** In-flight fetches so `flush()` can await any POSTs already on the wire. */
|
|
30
|
+
otlpInFlight = new Set();
|
|
31
|
+
constructor(sessionId, otlp) {
|
|
22
32
|
this.sessionId = sessionId;
|
|
33
|
+
this.otlp = otlp;
|
|
23
34
|
}
|
|
24
35
|
/** Start a new span. Returns the span ID. */
|
|
25
36
|
startSpan(name, attributes = {}, parentSpanId) {
|
|
@@ -50,8 +61,64 @@ export class SessionTracer {
|
|
|
50
61
|
this.spans = this.spans.slice(-MAX_IN_MEMORY_SPANS);
|
|
51
62
|
}
|
|
52
63
|
this.persistSpan(span);
|
|
64
|
+
if (this.otlp)
|
|
65
|
+
this.shipSpanOTLP(span);
|
|
53
66
|
return span;
|
|
54
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Buffer the span for OTLP shipping. The actual POST is deferred to a
|
|
70
|
+
* microtask so multiple spans ending in the same tick coalesce into a
|
|
71
|
+
* single batch POST instead of one fetch each. Errors are swallowed —
|
|
72
|
+
* telemetry must never crash the agent.
|
|
73
|
+
*/
|
|
74
|
+
shipSpanOTLP(span) {
|
|
75
|
+
if (!this.otlp)
|
|
76
|
+
return;
|
|
77
|
+
this.otlpBuffer.push(span);
|
|
78
|
+
if (this.otlpFlushScheduled)
|
|
79
|
+
return;
|
|
80
|
+
this.otlpFlushScheduled = true;
|
|
81
|
+
queueMicrotask(() => {
|
|
82
|
+
this.otlpFlushScheduled = false;
|
|
83
|
+
this.drainOTLPBuffer();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Send whatever is in `otlpBuffer` as a single fire-and-forget POST. The
|
|
87
|
+
* returned promise is tracked in `otlpInFlight` so `flush()` can await it. */
|
|
88
|
+
drainOTLPBuffer() {
|
|
89
|
+
if (!this.otlp || this.otlpBuffer.length === 0)
|
|
90
|
+
return;
|
|
91
|
+
const batch = this.otlpBuffer;
|
|
92
|
+
this.otlpBuffer = [];
|
|
93
|
+
const payload = exportTraceOTLP(this.sessionId, batch);
|
|
94
|
+
const p = fetch(this.otlp.endpoint, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "Content-Type": "application/json", ...(this.otlp.headers ?? {}) },
|
|
97
|
+
body: JSON.stringify(payload),
|
|
98
|
+
}).then(() => undefined, () => undefined);
|
|
99
|
+
this.otlpInFlight.add(p);
|
|
100
|
+
p.finally(() => {
|
|
101
|
+
this.otlpInFlight.delete(p);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Drain any pending OTLP buffer and await every in-flight POST. Call this at
|
|
106
|
+
* session end so spans aren't dropped on `process.exit`. No-op when OTLP is
|
|
107
|
+
* not configured. Errors are swallowed (already, by `drainOTLPBuffer`).
|
|
108
|
+
*/
|
|
109
|
+
async flush() {
|
|
110
|
+
if (!this.otlp)
|
|
111
|
+
return;
|
|
112
|
+
// Drain any not-yet-shipped buffer first; cancel pending microtask flush
|
|
113
|
+
// (the buffer becomes empty so the microtask would no-op anyway, but
|
|
114
|
+
// clearing the flag is explicit).
|
|
115
|
+
this.otlpFlushScheduled = false;
|
|
116
|
+
this.drainOTLPBuffer();
|
|
117
|
+
// Wait for every fetch we've kicked off (microtask-shipped or just now).
|
|
118
|
+
if (this.otlpInFlight.size > 0) {
|
|
119
|
+
await Promise.allSettled(Array.from(this.otlpInFlight));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
55
122
|
/** Get all completed spans */
|
|
56
123
|
getSpans() {
|
|
57
124
|
return [...this.spans];
|
|
@@ -153,8 +220,22 @@ export function formatTrace(spans) {
|
|
|
153
220
|
lines.push(`Total: ${spans.length} spans, ${totalMs}ms, ${errors} errors`);
|
|
154
221
|
return lines.join("\n");
|
|
155
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Coerce an arbitrary string (UUID with hyphens, "span-N", etc.) into a fixed-length
|
|
225
|
+
* lowercase hex string suitable for OTLP. OTLP collectors (Jaeger, Tempo, OTel
|
|
226
|
+
* Collector) validate that traceId is 32 hex chars and spanId is 16 hex chars and
|
|
227
|
+
* reject anything containing `-` or non-hex letters. We strip non-hex chars, then
|
|
228
|
+
* pad-left with zeros (or truncate from the left) to the target length.
|
|
229
|
+
*/
|
|
230
|
+
function toHexId(input, length) {
|
|
231
|
+
const hex = input.toLowerCase().replace(/[^0-9a-f]/g, "");
|
|
232
|
+
if (hex.length === 0)
|
|
233
|
+
return "0".repeat(length);
|
|
234
|
+
return hex.length >= length ? hex.slice(0, length) : hex.padStart(length, "0");
|
|
235
|
+
}
|
|
156
236
|
/** Export trace in OpenTelemetry-compatible format */
|
|
157
237
|
export function exportTraceOTLP(sessionId, spans) {
|
|
238
|
+
const traceId = toHexId(sessionId, 32);
|
|
158
239
|
return {
|
|
159
240
|
resourceSpans: [
|
|
160
241
|
{
|
|
@@ -168,9 +249,9 @@ export function exportTraceOTLP(sessionId, spans) {
|
|
|
168
249
|
{
|
|
169
250
|
scope: { name: "openharness.agent" },
|
|
170
251
|
spans: spans.map((s) => ({
|
|
171
|
-
traceId
|
|
172
|
-
spanId: s.spanId
|
|
173
|
-
parentSpanId: s.parentSpanId
|
|
252
|
+
traceId,
|
|
253
|
+
spanId: toHexId(s.spanId, 16),
|
|
254
|
+
parentSpanId: s.parentSpanId ? toHexId(s.parentSpanId, 16) : undefined,
|
|
174
255
|
name: s.name,
|
|
175
256
|
startTimeUnixNano: s.startTime * 1_000_000,
|
|
176
257
|
endTimeUnixNano: s.endTime * 1_000_000,
|
|
@@ -134,6 +134,10 @@ export class AnthropicProvider {
|
|
|
134
134
|
let currentToolId = "";
|
|
135
135
|
let currentToolName = "";
|
|
136
136
|
let currentToolArgs = "";
|
|
137
|
+
// Persist across chunk boundaries: a TCP/TLS framing boundary can land
|
|
138
|
+
// between the SSE `event:` and `data:` lines, leaving the event type
|
|
139
|
+
// staged for the next chunk's first `data:` line.
|
|
140
|
+
let currentEvent = "";
|
|
137
141
|
while (true) {
|
|
138
142
|
const { done, value } = await reader.read();
|
|
139
143
|
if (done)
|
|
@@ -141,7 +145,6 @@ export class AnthropicProvider {
|
|
|
141
145
|
buffer += decoder.decode(value, { stream: true });
|
|
142
146
|
const lines = buffer.split("\n");
|
|
143
147
|
buffer = lines.pop() ?? "";
|
|
144
|
-
let currentEvent = "";
|
|
145
148
|
for (const line of lines) {
|
|
146
149
|
const trimmed = line.trim();
|
|
147
150
|
if (trimmed.startsWith("event:")) {
|