@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.
- package/dist/commands/index.js +45 -6
- package/dist/harness/config.d.ts +12 -1
- package/dist/harness/config.js +5 -0
- package/dist/harness/hooks.d.ts +19 -4
- package/dist/harness/hooks.js +82 -23
- package/dist/harness/rules.js +32 -4
- package/dist/harness/submit-handler.js +18 -2
- package/dist/main.js +1 -0
- package/dist/query/compress.js +5 -1
- package/dist/query/tools.js +7 -0
- package/dist/tools/AgentTool/index.js +5 -1
- package/dist/tools/DiagnosticsTool/index.d.ts +3 -3
- package/dist/tools/DiagnosticsTool/index.js +37 -8
- package/dist/tools/MonitorTool/index.d.ts +21 -0
- package/dist/tools/MonitorTool/index.js +114 -0
- package/dist/tools/PowerShellTool/index.d.ts +15 -0
- package/dist/tools/PowerShellTool/index.js +32 -0
- package/dist/tools.js +4 -0
- package/dist/types/permissions.js +42 -2
- package/package.json +1 -1
package/dist/commands/index.js
CHANGED
|
@@ -100,15 +100,54 @@ register("undo", "Undo last AI commit", () => {
|
|
|
100
100
|
handled: true,
|
|
101
101
|
};
|
|
102
102
|
});
|
|
103
|
-
register("rewind", "Restore files from
|
|
104
|
-
const { rewindLastCheckpoint, checkpointCount } = require("../harness/checkpoints.js");
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
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
|
|
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
|
|
150
|
+
output: `Rewound ${restored} checkpoint(s) to point #${num}.\n${checkpointCount()} checkpoint(s) remaining.`,
|
|
112
151
|
handled: true,
|
|
113
152
|
};
|
|
114
153
|
});
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -11,14 +11,25 @@ export type McpServerConfig = {
|
|
|
11
11
|
timeout?: number;
|
|
12
12
|
};
|
|
13
13
|
export type HookDef = {
|
|
14
|
-
command
|
|
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;
|
package/dist/harness/config.js
CHANGED
|
@@ -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") {
|
package/dist/harness/hooks.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hooks system — run
|
|
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
|
|
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
|
-
*
|
|
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
|
package/dist/harness/hooks.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hooks system — run
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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);
|
|
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 —
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
137
|
-
if (event === "preToolUse" &&
|
|
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
|
}
|
package/dist/harness/rules.js
CHANGED
|
@@ -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
|
|
73
|
-
if (
|
|
74
|
-
|
|
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
|
|
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();
|
package/dist/query/compress.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/dist/query/tools.js
CHANGED
|
@@ -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 =
|
|
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
|
|
100
|
-
- references: Find all references to a symbol at a given position
|
|
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
|
-
|
|
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
|
|
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") {
|