@zhijiewang/openharness 1.3.0 → 1.4.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)
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();
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { createUserMessage } from "../types/message.js";
6
6
  import { defaultEstimateTokens } from "../providers/base.js";
7
+ import { emitHook } from "../harness/hooks.js";
7
8
  const DEFAULT_KEEP_LAST = 10;
8
9
  /**
9
10
  * Semantic importance scoring for messages.
@@ -61,6 +62,7 @@ export function estimateMessagesTokens(messages, estimateTokens = (t) => Math.ce
61
62
  export function compressMessages(messages, targetTokens) {
62
63
  if (messages.length <= 2)
63
64
  return messages;
65
+ emitHook("preCompact", {});
64
66
  const result = [...messages];
65
67
  const keepLast = DEFAULT_KEEP_LAST;
66
68
  // MicroCompact: Truncate long tool results and assistant messages
@@ -114,12 +116,14 @@ export function compressMessages(messages, targetTokens) {
114
116
  validCallIds.add(tc.id);
115
117
  }
116
118
  }
117
- return result.filter((msg) => {
119
+ const filtered = result.filter((msg) => {
118
120
  if (msg.role !== "tool")
119
121
  return true;
120
122
  return (msg.toolResults?.length ?? 0) > 0 &&
121
123
  msg.toolResults.every((tr) => validCallIds.has(tr.callId));
122
124
  });
125
+ emitHook("postCompact", {});
126
+ return filtered;
123
127
  }
124
128
  /**
125
129
  * LLM-assisted summarization of older messages.
@@ -85,6 +85,13 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
85
85
  toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
86
86
  toolOutput: result.output.slice(0, 1000),
87
87
  });
88
+ // Emit fileChanged hook for file-modifying tools
89
+ if (!result.isError && ['Edit', 'Write', 'MultiEdit'].includes(tool.name)) {
90
+ const filePaths = getAffectedFiles(tool.name, parsed.data);
91
+ for (const fp of filePaths) {
92
+ emitHook("fileChanged", { filePath: fp, toolName: tool.name });
93
+ }
94
+ }
88
95
  // Verification loop: auto-run lint/typecheck after file-modifying tools
89
96
  let verificationSuffix = '';
90
97
  if (!result.isError && ['Edit', 'Write', 'MultiEdit'].includes(tool.name)) {
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { createWorktree, removeWorktree, hasWorktreeChanges, isGitRepo } from "../../git/index.js";
3
+ import { emitHook } from "../../harness/hooks.js";
3
4
  const inputSchema = z.object({
4
5
  prompt: z.string(),
5
6
  description: z.string().optional(),
@@ -80,9 +81,11 @@ export const AgentTool = {
80
81
  maxTurns: 20,
81
82
  abortSignal: context.abortSignal,
82
83
  };
84
+ const agentId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
85
+ emitHook("subagentStart", { agentId, toolName: input.subagent_type ?? 'general' });
83
86
  // Background execution: start agent and return immediately
84
87
  if (input.run_in_background) {
85
- const bgId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
88
+ const bgId = agentId;
86
89
  const runAgent = async () => {
87
90
  let finalText = "";
88
91
  const originalCwd = process.cwd();
@@ -190,6 +193,7 @@ export const AgentTool = {
190
193
  }
191
194
  }
192
195
  }
196
+ emitHook("subagentStop", { agentId });
193
197
  return { output: finalText || "(sub-agent completed with no text output)", isError: false };
194
198
  },
195
199
  prompt() {
@@ -2,17 +2,17 @@ import { z } from "zod";
2
2
  import type { Tool } from "../../Tool.js";
3
3
  declare const inputSchema: z.ZodObject<{
4
4
  file_path: z.ZodString;
5
- action: z.ZodDefault<z.ZodEnum<["diagnostics", "definition", "references"]>>;
5
+ action: z.ZodDefault<z.ZodEnum<["diagnostics", "definition", "references", "hover"]>>;
6
6
  line: z.ZodOptional<z.ZodNumber>;
7
7
  character: z.ZodOptional<z.ZodNumber>;
8
8
  }, "strip", z.ZodTypeAny, {
9
- action: "diagnostics" | "definition" | "references";
10
9
  file_path: string;
10
+ action: "diagnostics" | "definition" | "references" | "hover";
11
11
  line?: number | undefined;
12
12
  character?: number | undefined;
13
13
  }, {
14
14
  file_path: string;
15
- action?: "diagnostics" | "definition" | "references" | undefined;
15
+ action?: "diagnostics" | "definition" | "references" | "hover" | undefined;
16
16
  line?: number | undefined;
17
17
  character?: number | undefined;
18
18
  }>;
@@ -2,8 +2,8 @@ import { z } from "zod";
2
2
  import { LspClient } from "../../lsp/client.js";
3
3
  const inputSchema = z.object({
4
4
  file_path: z.string().describe("Absolute path to the file to check"),
5
- action: z.enum(["diagnostics", "definition", "references"]).default("diagnostics")
6
- .describe("Action: diagnostics (errors/warnings), definition (go-to-def), references (find-refs)"),
5
+ action: z.enum(["diagnostics", "definition", "references", "hover"]).default("diagnostics")
6
+ .describe("Action: diagnostics (errors/warnings), definition (go-to-def), references (find-refs), hover (type info)"),
7
7
  line: z.number().optional().describe("Line number (0-indexed) for definition/references"),
8
8
  character: z.number().optional().describe("Column number (0-indexed) for definition/references"),
9
9
  });
@@ -16,6 +16,12 @@ function getLspCommand(filePath) {
16
16
  if (filePath.endsWith('.py')) {
17
17
  return { command: 'pylsp', args: [] };
18
18
  }
19
+ if (filePath.endsWith('.go')) {
20
+ return { command: 'gopls', args: ['serve'] };
21
+ }
22
+ if (filePath.endsWith('.rs')) {
23
+ return { command: 'rust-analyzer', args: [] };
24
+ }
19
25
  return null;
20
26
  }
21
27
  async function getClient(filePath, workingDir) {
@@ -84,6 +90,28 @@ export const DiagnosticsTool = {
84
90
  const lines = refs.map(r => `${r.uri.replace('file://', '')}:${r.range.start.line + 1}:${r.range.start.character}`);
85
91
  return { output: `${refs.length} reference(s):\n${lines.join('\n')}`, isError: false };
86
92
  }
93
+ if (input.action === "hover") {
94
+ if (input.line === undefined || input.character === undefined) {
95
+ return { output: "line and character are required for hover.", isError: true };
96
+ }
97
+ await client.openFile(input.file_path);
98
+ // Hover uses textDocument/hover which returns MarkupContent
99
+ try {
100
+ const result = await client.send('textDocument/hover', {
101
+ textDocument: { uri: `file://${input.file_path.replace(/\\/g, '/')}` },
102
+ position: { line: input.line, character: input.character },
103
+ });
104
+ if (!result || !result.contents)
105
+ return { output: "No hover information.", isError: false };
106
+ const content = typeof result.contents === 'string'
107
+ ? result.contents
108
+ : result.contents.value ?? JSON.stringify(result.contents);
109
+ return { output: content, isError: false };
110
+ }
111
+ catch {
112
+ return { output: "Hover not supported by this language server.", isError: false };
113
+ }
114
+ }
87
115
  return { output: `Unknown action: ${input.action}`, isError: true };
88
116
  }
89
117
  catch (err) {
@@ -94,15 +122,16 @@ export const DiagnosticsTool = {
94
122
  }
95
123
  },
96
124
  prompt() {
97
- return `Get code intelligence from the language server. Actions:
125
+ return `Get code intelligence from the language server. Supports TypeScript, JavaScript, Python, Go, and Rust. Actions:
98
126
  - diagnostics: Get errors and warnings for a file
99
- - definition: Go to definition of a symbol at a given position (requires line, character)
100
- - references: Find all references to a symbol at a given position (requires line, character)
127
+ - definition: Go to definition of a symbol at a given position
128
+ - references: Find all references to a symbol at a given position
129
+ - hover: Get type information and documentation for a symbol
101
130
  Parameters:
102
131
  - file_path (string, required): Absolute path to the file
103
- - action (string): "diagnostics" | "definition" | "references" (default: diagnostics)
104
- - line (number, optional): 0-indexed line for definition/references
105
- - character (number, optional): 0-indexed column for definition/references`;
132
+ - action (string): "diagnostics" | "definition" | "references" | "hover" (default: diagnostics)
133
+ - line (number, optional): 0-indexed line for definition/references/hover
134
+ - character (number, optional): 0-indexed column for definition/references/hover`;
106
135
  },
107
136
  };
108
137
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../../Tool.js";
3
+ declare const inputSchema: z.ZodObject<{
4
+ command: z.ZodString;
5
+ pattern: z.ZodOptional<z.ZodString>;
6
+ timeout: z.ZodOptional<z.ZodNumber>;
7
+ maxLines: z.ZodOptional<z.ZodNumber>;
8
+ }, "strip", z.ZodTypeAny, {
9
+ command: string;
10
+ pattern?: string | undefined;
11
+ timeout?: number | undefined;
12
+ maxLines?: number | undefined;
13
+ }, {
14
+ command: string;
15
+ pattern?: string | undefined;
16
+ timeout?: number | undefined;
17
+ maxLines?: number | undefined;
18
+ }>;
19
+ export declare const MonitorTool: Tool<typeof inputSchema>;
20
+ export {};
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,114 @@
1
+ import { z } from "zod";
2
+ import { spawn } from "node:child_process";
3
+ const inputSchema = z.object({
4
+ command: z.string().describe("Background command to watch"),
5
+ pattern: z.string().optional().describe("Regex pattern to match output lines"),
6
+ timeout: z.number().optional().describe("Max watch time in ms (default 60000)"),
7
+ maxLines: z.number().optional().describe("Max output lines to collect (default 100)"),
8
+ });
9
+ export const MonitorTool = {
10
+ name: "Monitor",
11
+ description: "Watch a background process and collect output. Optionally filter by regex pattern.",
12
+ inputSchema,
13
+ riskLevel: "medium",
14
+ isReadOnly() { return true; },
15
+ isConcurrencySafe() { return true; },
16
+ async call(input, context) {
17
+ const timeout = input.timeout ?? 60_000;
18
+ const maxLines = input.maxLines ?? 100;
19
+ const pattern = input.pattern ? new RegExp(input.pattern) : null;
20
+ return new Promise((resolve) => {
21
+ const lines = [];
22
+ let settled = false;
23
+ const proc = spawn(input.command, {
24
+ shell: true,
25
+ stdio: ['pipe', 'pipe', 'pipe'],
26
+ windowsHide: true,
27
+ });
28
+ const timer = setTimeout(() => {
29
+ if (!settled) {
30
+ settled = true;
31
+ proc.kill();
32
+ resolve({
33
+ output: lines.length > 0
34
+ ? lines.join('\n') + `\n\n[Monitor timed out after ${timeout / 1000}s — ${lines.length} lines collected]`
35
+ : `[Monitor timed out after ${timeout / 1000}s — no output]`,
36
+ isError: false,
37
+ });
38
+ }
39
+ }, timeout);
40
+ const handleLine = (line) => {
41
+ if (settled)
42
+ return;
43
+ if (pattern && !pattern.test(line))
44
+ return;
45
+ lines.push(line.trimEnd());
46
+ // Stream output chunk if callback available
47
+ if (context.onOutputChunk && context.callId) {
48
+ context.onOutputChunk(context.callId, line + '\n');
49
+ }
50
+ if (lines.length >= maxLines) {
51
+ settled = true;
52
+ clearTimeout(timer);
53
+ proc.kill();
54
+ resolve({
55
+ output: lines.join('\n') + `\n\n[Collected ${maxLines} lines — stopped]`,
56
+ isError: false,
57
+ });
58
+ }
59
+ };
60
+ let stdoutBuffer = '';
61
+ proc.stdout?.on('data', (chunk) => {
62
+ stdoutBuffer += chunk.toString();
63
+ const parts = stdoutBuffer.split('\n');
64
+ stdoutBuffer = parts.pop() ?? '';
65
+ for (const line of parts)
66
+ handleLine(line);
67
+ });
68
+ let stderrBuffer = '';
69
+ proc.stderr?.on('data', (chunk) => {
70
+ stderrBuffer += chunk.toString();
71
+ const parts = stderrBuffer.split('\n');
72
+ stderrBuffer = parts.pop() ?? '';
73
+ for (const line of parts)
74
+ handleLine(line);
75
+ });
76
+ proc.on('exit', (code) => {
77
+ if (!settled) {
78
+ settled = true;
79
+ clearTimeout(timer);
80
+ // Flush remaining buffers
81
+ if (stdoutBuffer)
82
+ handleLine(stdoutBuffer);
83
+ if (stderrBuffer)
84
+ handleLine(stderrBuffer);
85
+ resolve({
86
+ output: lines.length > 0
87
+ ? lines.join('\n') + `\n\n[Process exited with code ${code ?? 'unknown'} — ${lines.length} lines]`
88
+ : `[Process exited with code ${code ?? 'unknown'} — no output]`,
89
+ isError: (code ?? 0) !== 0,
90
+ });
91
+ }
92
+ });
93
+ proc.on('error', (err) => {
94
+ if (!settled) {
95
+ settled = true;
96
+ clearTimeout(timer);
97
+ resolve({
98
+ output: `Monitor error: ${err.message}`,
99
+ isError: true,
100
+ });
101
+ }
102
+ });
103
+ });
104
+ },
105
+ prompt() {
106
+ return `Watch a background process and collect its output. Optionally filter lines by regex pattern.
107
+ Parameters:
108
+ - command (string, required): The command to run and watch
109
+ - pattern (string, optional): Regex to filter output lines
110
+ - timeout (number, optional): Max time in ms (default 60000)
111
+ - maxLines (number, optional): Max lines to collect (default 100)`;
112
+ },
113
+ };
114
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../../Tool.js";
3
+ declare const inputSchema: z.ZodObject<{
4
+ command: z.ZodString;
5
+ timeout: z.ZodOptional<z.ZodNumber>;
6
+ }, "strip", z.ZodTypeAny, {
7
+ command: string;
8
+ timeout?: number | undefined;
9
+ }, {
10
+ command: string;
11
+ timeout?: number | undefined;
12
+ }>;
13
+ export declare const PowerShellTool: Tool<typeof inputSchema>;
14
+ export {};
15
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,32 @@
1
+ import { z } from "zod";
2
+ import { execSync } from "node:child_process";
3
+ const inputSchema = z.object({
4
+ command: z.string().describe("PowerShell command to execute"),
5
+ timeout: z.number().optional().describe("Timeout in ms (default 120000)"),
6
+ });
7
+ export const PowerShellTool = {
8
+ name: "PowerShell",
9
+ description: "Execute PowerShell commands (Windows only). Use for Windows-specific tasks like registry access, COM objects, or .NET calls.",
10
+ inputSchema,
11
+ riskLevel: "high",
12
+ isReadOnly() { return false; },
13
+ isConcurrencySafe() { return false; },
14
+ async call(input) {
15
+ if (process.platform !== 'win32') {
16
+ return { output: "PowerShell is only available on Windows. Use Bash instead.", isError: true };
17
+ }
18
+ const timeout = input.timeout ?? 120_000;
19
+ try {
20
+ const output = execSync(`powershell.exe -NoProfile -NonInteractive -Command "${input.command.replace(/"/g, '\\"')}"`, { encoding: 'utf-8', timeout, maxBuffer: 10 * 1024 * 1024, windowsHide: true });
21
+ return { output: output.trim(), isError: false };
22
+ }
23
+ catch (err) {
24
+ const output = String(err.stdout ?? err.stderr ?? err.message ?? 'PowerShell error');
25
+ return { output: output.slice(0, 100_000), isError: true };
26
+ }
27
+ },
28
+ prompt() {
29
+ return "Execute PowerShell commands on Windows. Use for registry, COM, .NET, and Windows-specific operations.";
30
+ },
31
+ };
32
+ //# sourceMappingURL=index.js.map
package/dist/tools.js CHANGED
@@ -42,6 +42,8 @@ import { KillProcessTool } from "./tools/KillProcessTool/index.js";
42
42
  import { RemoteTriggerTool } from "./tools/RemoteTriggerTool/index.js";
43
43
  import { MultiEditTool } from "./tools/MultiEditTool/index.js";
44
44
  import { PipelineTool } from "./tools/PipelineTool/index.js";
45
+ import { PowerShellTool } from "./tools/PowerShellTool/index.js";
46
+ import { MonitorTool } from "./tools/MonitorTool/index.js";
45
47
  /**
46
48
  * Returns all registered tools.
47
49
  *
@@ -96,6 +98,8 @@ export function getAllTools() {
96
98
  KillProcessTool,
97
99
  RemoteTriggerTool,
98
100
  MultiEditTool,
101
+ PowerShellTool,
102
+ MonitorTool,
99
103
  ];
100
104
  return [
101
105
  ...core,
@@ -7,6 +7,13 @@ const EDIT_SAFE_TOOLS = new Set([
7
7
  "FileRead", "FileWrite", "FileEdit", "Glob", "Grep", "LS",
8
8
  "ImageRead", "NotebookEdit",
9
9
  ]);
10
+ /** Parse a tool specifier like "Bash(npm run *)" into tool name + pattern */
11
+ function parseToolSpecifier(specifier) {
12
+ const match = specifier.match(/^(\w+)\((.+)\)$/);
13
+ if (match)
14
+ return { toolName: match[1], argPattern: match[2] };
15
+ return { toolName: specifier };
16
+ }
10
17
  /** Match a tool name against a pattern (supports trailing * for prefix matching) */
11
18
  function matchToolPattern(pattern, toolName) {
12
19
  if (pattern.endsWith("*")) {
@@ -14,14 +21,47 @@ function matchToolPattern(pattern, toolName) {
14
21
  }
15
22
  return pattern === toolName;
16
23
  }
24
+ /**
25
+ * Match an argument pattern against a value using glob-style matching.
26
+ * Supports: * (any chars), ** (any path segments)
27
+ */
28
+ function matchArgGlob(pattern, value) {
29
+ // Convert glob to regex: * → [^/]*, ** → .*, escape other regex chars
30
+ const regexStr = pattern
31
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex chars (except * and ?)
32
+ .replace(/\*\*/g, '{{DOUBLESTAR}}')
33
+ .replace(/\*/g, '[^/]*')
34
+ .replace(/\{\{DOUBLESTAR\}\}/g, '.*');
35
+ try {
36
+ return new RegExp(`^${regexStr}$`).test(value);
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
17
42
  /** Find the first matching tool permission rule */
18
43
  function findToolRule(rules, toolName, toolInput) {
19
44
  if (!rules || rules.length === 0)
20
45
  return undefined;
21
46
  return rules.find(r => {
22
- if (!matchToolPattern(r.tool, toolName))
47
+ const { toolName: specToolName, argPattern } = parseToolSpecifier(r.tool);
48
+ // Check tool name match (with prefix * support)
49
+ if (!matchToolPattern(specToolName, toolName))
23
50
  return false;
24
- // If rule has a pattern, match against Bash command content only
51
+ // If rule has an inline argument pattern (e.g., "Bash(npm run *)")
52
+ if (argPattern && toolInput) {
53
+ const input = toolInput;
54
+ // For Bash: match against command string
55
+ if (toolName === 'Bash' && typeof input.command === 'string') {
56
+ return matchArgGlob(argPattern, input.command);
57
+ }
58
+ // For file tools: match against file_path
59
+ if (['Edit', 'Write', 'Read'].includes(toolName) && typeof input.file_path === 'string') {
60
+ return matchArgGlob(argPattern, input.file_path);
61
+ }
62
+ return false; // Has pattern but no matching field
63
+ }
64
+ // Legacy: separate pattern field (regex) for Bash commands
25
65
  if (r.pattern && toolInput && toolName === "Bash") {
26
66
  const command = toolInput?.command;
27
67
  if (typeof command === "string") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {