@zhijiewang/openharness 2.30.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/dist/commands/ai.js +4 -4
- package/dist/commands/git.js +1 -1
- package/dist/commands/info.js +2 -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 +24 -1
- package/dist/harness/traces.js +72 -8
- package/dist/providers/anthropic.js +4 -1
- package/dist/repl.js +1 -1
- package/dist/services/AgentDispatcher.js +15 -28
- package/dist/services/StreamingToolExecutor.js +97 -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/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
|
@@ -12,10 +12,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
14
|
import { formatTrace, listTracedSessions, loadTrace } from "../harness/traces.js";
|
|
15
|
-
import { invalidateVerificationCache } from "../harness/verification.js";
|
|
15
|
+
import { getVerificationConfig, invalidateVerificationCache } from "../harness/verification.js";
|
|
16
16
|
import { normalizeMcpConfig } from "../mcp/config-normalize.js";
|
|
17
17
|
import { connectedMcpServers, disconnectMcpClients, loadMcpTools } from "../mcp/loader.js";
|
|
18
18
|
import { getAuthStatus } from "../mcp/oauth.js";
|
|
19
|
+
import { formatRegistry, generateConfigBlock, MCP_REGISTRY, searchRegistry } from "../mcp/registry.js";
|
|
19
20
|
import { getRouteSelection } from "../providers/router.js";
|
|
20
21
|
import { formatHooksReport } from "./hooks-report.js";
|
|
21
22
|
import { mcpLoginHandler, mcpLogoutHandler } from "./mcp-auth.js";
|
|
@@ -326,7 +327,6 @@ export function registerInfoCommands(register, getCommandMap) {
|
|
|
326
327
|
const globalCfg = existsSync(join(homedir(), ".oh", "config.yaml"));
|
|
327
328
|
lines.push(` Global config: ${globalCfg ? "~/.oh/config.yaml ✓" : "not set (optional)"}`);
|
|
328
329
|
try {
|
|
329
|
-
const { getVerificationConfig } = require("../harness/verification.js");
|
|
330
330
|
const vCfg = getVerificationConfig();
|
|
331
331
|
if (vCfg?.enabled) {
|
|
332
332
|
lines.push(` Verification: ✓ (${vCfg.rules.length} rules, mode: ${vCfg.mode})`);
|
|
@@ -478,7 +478,6 @@ export function registerInfoCommands(register, getCommandMap) {
|
|
|
478
478
|
return { output: lines.join("\n"), handled: true };
|
|
479
479
|
});
|
|
480
480
|
register("mcp-registry", "Browse and add MCP servers from the curated registry", (args) => {
|
|
481
|
-
const { searchRegistry, formatRegistry, generateConfigBlock, MCP_REGISTRY } = require("../mcp/registry.js");
|
|
482
481
|
const query = args.trim();
|
|
483
482
|
if (!query) {
|
|
484
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
|
@@ -32,13 +32,36 @@ export declare class SessionTracer {
|
|
|
32
32
|
private activeSpans;
|
|
33
33
|
private spanCounter;
|
|
34
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;
|
|
35
44
|
constructor(sessionId: string, otlp?: OTLPConfig);
|
|
36
45
|
/** Start a new span. Returns the span ID. */
|
|
37
46
|
startSpan(name: string, attributes?: Record<string, unknown>, parentSpanId?: string): string;
|
|
38
47
|
/** End a span and record it. */
|
|
39
48
|
endSpan(spanId: string, status?: "ok" | "error", extraAttributes?: Record<string, unknown>): TraceSpan | null;
|
|
40
|
-
/**
|
|
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
|
+
*/
|
|
41
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>;
|
|
42
65
|
/** Get all completed spans */
|
|
43
66
|
getSpans(): TraceSpan[];
|
|
44
67
|
/** Get a summary of the trace */
|
package/dist/harness/traces.js
CHANGED
|
@@ -19,6 +19,15 @@ export class SessionTracer {
|
|
|
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();
|
|
22
31
|
constructor(sessionId, otlp) {
|
|
23
32
|
this.sessionId = sessionId;
|
|
24
33
|
this.otlp = otlp;
|
|
@@ -56,19 +65,60 @@ export class SessionTracer {
|
|
|
56
65
|
this.shipSpanOTLP(span);
|
|
57
66
|
return span;
|
|
58
67
|
}
|
|
59
|
-
/**
|
|
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
|
+
*/
|
|
60
74
|
shipSpanOTLP(span) {
|
|
61
75
|
if (!this.otlp)
|
|
62
76
|
return;
|
|
63
|
-
|
|
64
|
-
|
|
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, {
|
|
65
95
|
method: "POST",
|
|
66
96
|
headers: { "Content-Type": "application/json", ...(this.otlp.headers ?? {}) },
|
|
67
97
|
body: JSON.stringify(payload),
|
|
68
|
-
}).
|
|
69
|
-
|
|
98
|
+
}).then(() => undefined, () => undefined);
|
|
99
|
+
this.otlpInFlight.add(p);
|
|
100
|
+
p.finally(() => {
|
|
101
|
+
this.otlpInFlight.delete(p);
|
|
70
102
|
});
|
|
71
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
|
+
}
|
|
72
122
|
/** Get all completed spans */
|
|
73
123
|
getSpans() {
|
|
74
124
|
return [...this.spans];
|
|
@@ -170,8 +220,22 @@ export function formatTrace(spans) {
|
|
|
170
220
|
lines.push(`Total: ${spans.length} spans, ${totalMs}ms, ${errors} errors`);
|
|
171
221
|
return lines.join("\n");
|
|
172
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
|
+
}
|
|
173
236
|
/** Export trace in OpenTelemetry-compatible format */
|
|
174
237
|
export function exportTraceOTLP(sessionId, spans) {
|
|
238
|
+
const traceId = toHexId(sessionId, 32);
|
|
175
239
|
return {
|
|
176
240
|
resourceSpans: [
|
|
177
241
|
{
|
|
@@ -185,9 +249,9 @@ export function exportTraceOTLP(sessionId, spans) {
|
|
|
185
249
|
{
|
|
186
250
|
scope: { name: "openharness.agent" },
|
|
187
251
|
spans: spans.map((s) => ({
|
|
188
|
-
traceId
|
|
189
|
-
spanId: s.spanId
|
|
190
|
-
parentSpanId: s.parentSpanId
|
|
252
|
+
traceId,
|
|
253
|
+
spanId: toHexId(s.spanId, 16),
|
|
254
|
+
parentSpanId: s.parentSpanId ? toHexId(s.parentSpanId, 16) : undefined,
|
|
191
255
|
name: s.name,
|
|
192
256
|
startTimeUnixNano: s.startTime * 1_000_000,
|
|
193
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:")) {
|
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";
|
|
@@ -185,7 +186,6 @@ export async function startREPL(config) {
|
|
|
185
186
|
const dir = lastSep >= 0 ? expanded.slice(0, lastSep + 1) : ".";
|
|
186
187
|
const prefix = lastSep >= 0 ? expanded.slice(lastSep + 1) : expanded;
|
|
187
188
|
try {
|
|
188
|
-
const { readdirSync, statSync } = require("node:fs");
|
|
189
189
|
const entries = readdirSync(dir)
|
|
190
190
|
.filter((name) => name.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
191
191
|
.slice(0, 10);
|
|
@@ -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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 };
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Tool execution during LLM streaming — concurrent tool execution
|
|
3
3
|
* with permission checks and queue management.
|
|
4
4
|
*/
|
|
5
|
+
import { getAffectedFiles } from "../harness/checkpoints.js";
|
|
6
|
+
import { emitHook, emitHookWithOutcome } from "../harness/hooks.js";
|
|
5
7
|
import { findToolByName } from "../Tool.js";
|
|
6
8
|
import { checkPermission } from "../types/permissions.js";
|
|
7
9
|
const MAX_CONCURRENCY = 10;
|
|
@@ -54,23 +56,69 @@ export class StreamingToolExecutor {
|
|
|
54
56
|
tracked.status = "completed";
|
|
55
57
|
return;
|
|
56
58
|
}
|
|
59
|
+
const argsPreview = JSON.stringify(tracked.toolCall.arguments).slice(0, 1000);
|
|
57
60
|
// Permission check
|
|
58
61
|
const perm = checkPermission(this.permissionMode, tool.riskLevel, tool.isReadOnly(tracked.toolCall.arguments), tool.name, tracked.toolCall.arguments);
|
|
59
|
-
if (!perm.allowed
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
if (!perm.allowed) {
|
|
63
|
+
if (perm.reason === "needs-approval") {
|
|
64
|
+
// Hook: permissionRequest — give configured hooks first say. If they
|
|
65
|
+
// explicitly allow/deny, that wins; otherwise fall through to the
|
|
66
|
+
// interactive prompt or to a fail-closed deny in headless mode.
|
|
67
|
+
const hookOutcome = await emitHookWithOutcome("permissionRequest", {
|
|
68
|
+
toolName: tool.name,
|
|
69
|
+
toolArgs: argsPreview,
|
|
70
|
+
toolInputJson: JSON.stringify(tracked.toolCall.arguments).slice(0, 1000),
|
|
71
|
+
permissionMode: this.permissionMode,
|
|
72
|
+
permissionAction: "ask",
|
|
73
|
+
});
|
|
74
|
+
const denyAndEmit = (source, reason, output) => {
|
|
75
|
+
emitHook("permissionDenied", {
|
|
76
|
+
toolName: tool.name,
|
|
77
|
+
toolArgs: argsPreview,
|
|
78
|
+
permissionMode: this.permissionMode,
|
|
79
|
+
denySource: source,
|
|
80
|
+
denyReason: reason,
|
|
81
|
+
});
|
|
82
|
+
tracked.result = { output, isError: true };
|
|
83
|
+
tracked.status = "completed";
|
|
84
|
+
};
|
|
85
|
+
if (hookOutcome.permissionDecision === "allow") {
|
|
86
|
+
// Hook granted — proceed.
|
|
87
|
+
}
|
|
88
|
+
else if (hookOutcome.permissionDecision === "deny" || !hookOutcome.allowed) {
|
|
89
|
+
const reason = hookOutcome.reason ? `: ${hookOutcome.reason}` : "";
|
|
90
|
+
denyAndEmit("hook", hookOutcome.reason ?? "hook denied", `Permission denied by hook${reason}`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
else if (this.askUser) {
|
|
94
|
+
const { formatToolArgs } = await import("../utils/tool-summary.js");
|
|
95
|
+
const description = formatToolArgs(tool.name, tracked.toolCall.arguments);
|
|
96
|
+
const allowed = await this.askUser(tool.name, description, tool.riskLevel);
|
|
97
|
+
if (!allowed) {
|
|
98
|
+
denyAndEmit("user", "user declined", "Permission denied by user.");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// Headless mode with no hook decision and no interactive prompt.
|
|
104
|
+
denyAndEmit("headless", "no hook decision and no interactive prompt available", "Permission denied: needs-approval (no interactive prompt available; configure a permissionRequest hook to gate this tool)");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Auto-mode policy block (deny / acceptEdits / etc) — symmetric event.
|
|
110
|
+
emitHook("permissionDenied", {
|
|
111
|
+
toolName: tool.name,
|
|
112
|
+
toolArgs: argsPreview,
|
|
113
|
+
permissionMode: this.permissionMode,
|
|
114
|
+
denySource: "policy",
|
|
115
|
+
denyReason: perm.reason,
|
|
116
|
+
});
|
|
117
|
+
tracked.result = { output: `Denied: ${perm.reason}`, isError: true };
|
|
65
118
|
tracked.status = "completed";
|
|
66
119
|
return;
|
|
67
120
|
}
|
|
68
121
|
}
|
|
69
|
-
else if (!perm.allowed) {
|
|
70
|
-
tracked.result = { output: `Denied: ${perm.reason}`, isError: true };
|
|
71
|
-
tracked.status = "completed";
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
122
|
// Validate input
|
|
75
123
|
const parsed = tool.inputSchema.safeParse(tracked.toolCall.arguments);
|
|
76
124
|
if (!parsed.success) {
|
|
@@ -84,6 +132,17 @@ export class StreamingToolExecutor {
|
|
|
84
132
|
tracked.status = "completed";
|
|
85
133
|
return;
|
|
86
134
|
}
|
|
135
|
+
// Hook: preToolUse — last gate before execution. A hook that returns
|
|
136
|
+
// false (exit code 1 / { allowed: false }) blocks the call.
|
|
137
|
+
const preAllowed = emitHook("preToolUse", {
|
|
138
|
+
toolName: tool.name,
|
|
139
|
+
toolArgs: argsPreview,
|
|
140
|
+
});
|
|
141
|
+
if (!preAllowed) {
|
|
142
|
+
tracked.result = { output: "Blocked by preToolUse hook.", isError: true };
|
|
143
|
+
tracked.status = "completed";
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
87
146
|
// Execute with per-call context (streaming output chunks + abort signal)
|
|
88
147
|
const callId = tracked.toolCall.id;
|
|
89
148
|
const callContext = {
|
|
@@ -138,6 +197,33 @@ export class StreamingToolExecutor {
|
|
|
138
197
|
if (toolSpanId)
|
|
139
198
|
callContext.tracer?.endSpan(toolSpanId, "error", { error: tracked.result.output });
|
|
140
199
|
}
|
|
200
|
+
// Hook: postToolUse / postToolUseFailure (mutually exclusive — strict CC parity)
|
|
201
|
+
if (tracked.result) {
|
|
202
|
+
const outputPreview = tracked.result.output.slice(0, 1000);
|
|
203
|
+
if (tracked.result.isError) {
|
|
204
|
+
emitHook("postToolUseFailure", {
|
|
205
|
+
toolName: tool.name,
|
|
206
|
+
toolArgs: argsPreview,
|
|
207
|
+
toolOutput: outputPreview,
|
|
208
|
+
toolError: "ReportedError",
|
|
209
|
+
errorMessage: outputPreview,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
emitHook("postToolUse", {
|
|
214
|
+
toolName: tool.name,
|
|
215
|
+
toolArgs: argsPreview,
|
|
216
|
+
toolOutput: outputPreview,
|
|
217
|
+
});
|
|
218
|
+
// Emit fileChanged hook for file-modifying tools
|
|
219
|
+
if (["Edit", "Write", "MultiEdit"].includes(tool.name)) {
|
|
220
|
+
const filePaths = getAffectedFiles(tool.name, parsed.data);
|
|
221
|
+
for (const fp of filePaths) {
|
|
222
|
+
emitHook("fileChanged", { filePath: fp, toolName: tool.name });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
141
227
|
tracked.status = "completed";
|
|
142
228
|
this.processQueue(); // Process next queued tools
|
|
143
229
|
}
|
|
@@ -6,13 +6,13 @@ declare const createSchema: z.ZodObject<{
|
|
|
6
6
|
schedule: z.ZodString;
|
|
7
7
|
prompt: z.ZodString;
|
|
8
8
|
}, "strip", z.ZodTypeAny, {
|
|
9
|
-
action: "create";
|
|
10
9
|
name: string;
|
|
10
|
+
action: "create";
|
|
11
11
|
prompt: string;
|
|
12
12
|
schedule: string;
|
|
13
13
|
}, {
|
|
14
|
-
action: "create";
|
|
15
14
|
name: string;
|
|
15
|
+
action: "create";
|
|
16
16
|
prompt: string;
|
|
17
17
|
schedule: string;
|
|
18
18
|
}>;
|
|
@@ -6,8 +6,8 @@ declare const inputSchema: z.ZodObject<{
|
|
|
6
6
|
line: z.ZodOptional<z.ZodNumber>;
|
|
7
7
|
character: z.ZodOptional<z.ZodNumber>;
|
|
8
8
|
}, "strip", z.ZodTypeAny, {
|
|
9
|
-
action: "diagnostics" | "definition" | "references" | "hover";
|
|
10
9
|
file_path: string;
|
|
10
|
+
action: "diagnostics" | "definition" | "references" | "hover";
|
|
11
11
|
line?: number | undefined;
|
|
12
12
|
character?: number | undefined;
|
|
13
13
|
}, {
|
|
@@ -17,9 +17,9 @@ declare const inputSchema: z.ZodObject<{
|
|
|
17
17
|
"-n": z.ZodOptional<z.ZodBoolean>;
|
|
18
18
|
}, "strip", z.ZodTypeAny, {
|
|
19
19
|
pattern: string;
|
|
20
|
+
path?: string | undefined;
|
|
20
21
|
type?: string | undefined;
|
|
21
22
|
"-i"?: boolean | undefined;
|
|
22
|
-
path?: string | undefined;
|
|
23
23
|
context?: number | undefined;
|
|
24
24
|
glob?: string | undefined;
|
|
25
25
|
offset?: number | undefined;
|
|
@@ -32,9 +32,9 @@ declare const inputSchema: z.ZodObject<{
|
|
|
32
32
|
"-n"?: boolean | undefined;
|
|
33
33
|
}, {
|
|
34
34
|
pattern: string;
|
|
35
|
+
path?: string | undefined;
|
|
35
36
|
type?: string | undefined;
|
|
36
37
|
"-i"?: boolean | undefined;
|
|
37
|
-
path?: string | undefined;
|
|
38
38
|
context?: number | undefined;
|
|
39
39
|
glob?: string | undefined;
|
|
40
40
|
offset?: number | undefined;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
const inputSchema = z.object({
|
|
4
4
|
command: z.string().describe("PowerShell command to execute"),
|
|
@@ -21,7 +21,16 @@ export const PowerShellTool = {
|
|
|
21
21
|
}
|
|
22
22
|
const timeout = input.timeout ?? 120_000;
|
|
23
23
|
try {
|
|
24
|
-
|
|
24
|
+
// execFileSync(file, args[]) spawns powershell.exe directly without a
|
|
25
|
+
// cmd.exe wrapper, so cmd.exe metachars (& | < > ^ %VAR%) are inert.
|
|
26
|
+
// The user's command is passed as a single -Command arg; PowerShell
|
|
27
|
+
// parses it as PowerShell, not as a doubly-parsed shell string.
|
|
28
|
+
const output = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", input.command], {
|
|
29
|
+
encoding: "utf-8",
|
|
30
|
+
timeout,
|
|
31
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
32
|
+
windowsHide: true,
|
|
33
|
+
});
|
|
25
34
|
return { output: output.trim(), isError: false };
|
|
26
35
|
}
|
|
27
36
|
catch (err) {
|