@zhijiewang/openharness 2.19.0 → 2.20.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.d.ts +23 -0
- package/dist/commands/index.js +64 -0
- package/dist/commands/info.js +46 -3
- package/dist/harness/config.d.ts +12 -0
- package/dist/harness/hooks.d.ts +23 -1
- package/dist/harness/rules.js +18 -2
- package/dist/harness/submit-handler.js +14 -1
- package/dist/main.js +15 -1
- package/dist/mcp/client.d.ts +23 -0
- package/dist/mcp/client.js +37 -0
- package/dist/mcp/loader.d.ts +20 -0
- package/dist/mcp/loader.js +27 -0
- package/dist/query/tools.js +34 -6
- package/dist/tools/TaskCreateTool/index.js +5 -0
- package/dist/tools/TaskUpdateTool/index.js +11 -0
- package/package.json +1 -1
package/dist/commands/index.d.ts
CHANGED
|
@@ -26,4 +26,27 @@ export declare function getCommandEntries(): Array<{
|
|
|
26
26
|
name: string;
|
|
27
27
|
description: string;
|
|
28
28
|
}>;
|
|
29
|
+
/**
|
|
30
|
+
* Register MCP-server prompts as `/server:prompt` slash commands. Called from
|
|
31
|
+
* main.tsx after `loadMcpTools()` + `loadMcpPrompts()` so the connections are
|
|
32
|
+
* warm. Each handler invokes the prompt's `render()` and returns the result
|
|
33
|
+
* as a `prependToPrompt` so the next user prompt carries it as context.
|
|
34
|
+
*
|
|
35
|
+
* Argument syntax: `/server:prompt key=value key2=value2 ...`. Quoted values
|
|
36
|
+
* (`key="value with spaces"`) are supported. Args declared as `required` on
|
|
37
|
+
* the prompt template that aren't supplied surface as a usage error.
|
|
38
|
+
*
|
|
39
|
+
* Re-registering replaces any prior MCP prompt commands — safe to call again
|
|
40
|
+
* after `/reload-plugins` triggers a re-discover.
|
|
41
|
+
*/
|
|
42
|
+
import type { McpPromptHandle } from "../mcp/loader.js";
|
|
43
|
+
export declare function registerMcpPromptCommands(prompts: readonly McpPromptHandle[]): void;
|
|
44
|
+
/**
|
|
45
|
+
* Parse `key=value key2="value with spaces"` style args into a map. Bare
|
|
46
|
+
* tokens (no `=`) are dropped — MCP prompt arguments are always named.
|
|
47
|
+
* Exposed for tests.
|
|
48
|
+
*
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseMcpPromptArgs(raw: string): Record<string, string>;
|
|
29
52
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/commands/index.js
CHANGED
|
@@ -67,4 +67,68 @@ export function getCommandNames() {
|
|
|
67
67
|
export function getCommandEntries() {
|
|
68
68
|
return [...commands.entries()].map(([name, { description }]) => ({ name, description }));
|
|
69
69
|
}
|
|
70
|
+
let mcpPromptKeys = [];
|
|
71
|
+
export function registerMcpPromptCommands(prompts) {
|
|
72
|
+
for (const key of mcpPromptKeys)
|
|
73
|
+
commands.delete(key);
|
|
74
|
+
mcpPromptKeys = [];
|
|
75
|
+
for (const handle of prompts) {
|
|
76
|
+
const key = handle.qualifiedName.toLowerCase();
|
|
77
|
+
const required = (handle.arguments ?? []).filter((a) => a.required).map((a) => a.name);
|
|
78
|
+
const optional = (handle.arguments ?? []).filter((a) => !a.required).map((a) => a.name);
|
|
79
|
+
const usageBits = [...required.map((n) => `${n}=<value>`), ...optional.map((n) => `[${n}=<value>]`)].join(" ");
|
|
80
|
+
commands.set(key, {
|
|
81
|
+
description: handle.description,
|
|
82
|
+
handler: async (args) => {
|
|
83
|
+
const parsed = parseMcpPromptArgs(args);
|
|
84
|
+
const missing = required.filter((n) => !(n in parsed));
|
|
85
|
+
if (missing.length > 0) {
|
|
86
|
+
return {
|
|
87
|
+
output: `/${handle.qualifiedName}: missing required argument(s): ${missing.join(", ")}\nUsage: /${handle.qualifiedName}${usageBits ? ` ${usageBits}` : ""}`,
|
|
88
|
+
handled: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const rendered = await handle.render(parsed);
|
|
93
|
+
if (!rendered.trim()) {
|
|
94
|
+
return { output: `/${handle.qualifiedName} returned an empty prompt.`, handled: true };
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
output: `[mcp-prompt] ${handle.qualifiedName}`,
|
|
98
|
+
handled: false,
|
|
99
|
+
prependToPrompt: rendered,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
return {
|
|
104
|
+
output: `/${handle.qualifiedName} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
105
|
+
handled: true,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
mcpPromptKeys.push(key);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Parse `key=value key2="value with spaces"` style args into a map. Bare
|
|
115
|
+
* tokens (no `=`) are dropped — MCP prompt arguments are always named.
|
|
116
|
+
* Exposed for tests.
|
|
117
|
+
*
|
|
118
|
+
* @internal
|
|
119
|
+
*/
|
|
120
|
+
export function parseMcpPromptArgs(raw) {
|
|
121
|
+
const out = {};
|
|
122
|
+
if (!raw.trim())
|
|
123
|
+
return out;
|
|
124
|
+
// Match key=value or key="value with spaces" or key='value'
|
|
125
|
+
const re = /(\w[\w.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g;
|
|
126
|
+
let m;
|
|
127
|
+
while ((m = re.exec(raw)) !== null) {
|
|
128
|
+
const key = m[1];
|
|
129
|
+
const value = m[2] ?? m[3] ?? m[4] ?? "";
|
|
130
|
+
out[key] = value;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
70
134
|
//# sourceMappingURL=index.js.map
|
package/dist/commands/info.js
CHANGED
|
@@ -5,12 +5,15 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { gitBranch, isGitRepo, isInMergeOrRebase } from "../git/index.js";
|
|
8
|
-
import { readOhConfig } from "../harness/config.js";
|
|
8
|
+
import { invalidateConfigCache, readOhConfig } from "../harness/config.js";
|
|
9
9
|
import { estimateMessageTokens } from "../harness/context-warning.js";
|
|
10
10
|
import { getContextWindow } from "../harness/cost.js";
|
|
11
|
-
import { getHooks } from "../harness/hooks.js";
|
|
11
|
+
import { getHooks, invalidateHookCache } from "../harness/hooks.js";
|
|
12
|
+
import { discoverPlugins, discoverSkills } from "../harness/plugins.js";
|
|
13
|
+
import { invalidateSandboxCache } from "../harness/sandbox.js";
|
|
14
|
+
import { invalidateVerificationCache } from "../harness/verification.js";
|
|
12
15
|
import { normalizeMcpConfig } from "../mcp/config-normalize.js";
|
|
13
|
-
import { connectedMcpServers } from "../mcp/loader.js";
|
|
16
|
+
import { connectedMcpServers, disconnectMcpClients, loadMcpTools } from "../mcp/loader.js";
|
|
14
17
|
import { getAuthStatus } from "../mcp/oauth.js";
|
|
15
18
|
import { getRouteSelection } from "../providers/router.js";
|
|
16
19
|
import { formatHooksReport } from "./hooks-report.js";
|
|
@@ -721,6 +724,46 @@ export function registerInfoCommands(register, getCommandMap) {
|
|
|
721
724
|
}
|
|
722
725
|
return { output: lines.join("\n"), handled: true };
|
|
723
726
|
});
|
|
727
|
+
register("reload-plugins", "Hot-reload plugins, skills, hooks, MCP servers and config without restarting the session.", async () => {
|
|
728
|
+
// Invalidate every cached source — config, hooks, sandbox, verification.
|
|
729
|
+
// Skills + plugins aren't cached (each discoverSkills/discoverPlugins call
|
|
730
|
+
// reads fresh) but we still re-run them for the report so the user sees
|
|
731
|
+
// a count consistent with the new on-disk state.
|
|
732
|
+
invalidateConfigCache();
|
|
733
|
+
invalidateHookCache();
|
|
734
|
+
invalidateSandboxCache();
|
|
735
|
+
invalidateVerificationCache();
|
|
736
|
+
// Tear down + reconnect MCP servers (the live connections aren't
|
|
737
|
+
// cache-driven; they're long-lived sockets that need an explicit
|
|
738
|
+
// disconnect/reconnect). Failures don't block the reload — partial
|
|
739
|
+
// success is more useful than nothing.
|
|
740
|
+
disconnectMcpClients();
|
|
741
|
+
let mcpTools = 0;
|
|
742
|
+
let mcpError = null;
|
|
743
|
+
try {
|
|
744
|
+
const tools = await loadMcpTools();
|
|
745
|
+
mcpTools = tools.length;
|
|
746
|
+
}
|
|
747
|
+
catch (err) {
|
|
748
|
+
mcpError = err instanceof Error ? err.message : String(err);
|
|
749
|
+
}
|
|
750
|
+
const skillsCount = discoverSkills().length;
|
|
751
|
+
const pluginsCount = discoverPlugins().length;
|
|
752
|
+
const hookEvents = Object.keys(getHooks() ?? {}).length;
|
|
753
|
+
const mcpServers = connectedMcpServers().length;
|
|
754
|
+
const lines = [
|
|
755
|
+
"Hot reload complete:",
|
|
756
|
+
" - config + hooks + sandbox + verification: caches invalidated",
|
|
757
|
+
` - hook events configured: ${hookEvents}`,
|
|
758
|
+
` - MCP servers connected: ${mcpServers}${mcpError ? ` (error: ${mcpError})` : ""}`,
|
|
759
|
+
` - MCP tools loaded: ${mcpTools}`,
|
|
760
|
+
` - skills discovered: ${skillsCount}`,
|
|
761
|
+
` - plugins discovered: ${pluginsCount}`,
|
|
762
|
+
"",
|
|
763
|
+
"Note: in-flight tool registries (held by the agent loop) refresh on the next prompt.",
|
|
764
|
+
];
|
|
765
|
+
return { output: lines.join("\n"), handled: true };
|
|
766
|
+
});
|
|
724
767
|
register("benchmark", "Run SWE-bench benchmark suite", (args) => {
|
|
725
768
|
const task = args.trim();
|
|
726
769
|
if (!task) {
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -66,6 +66,18 @@ export type HooksConfig = {
|
|
|
66
66
|
turnStart?: HookDef[];
|
|
67
67
|
/** Fires at the end of each top-level agent turn (after the model either completes or errors). Matches Claude Code's Stop hook. */
|
|
68
68
|
turnStop?: HookDef[];
|
|
69
|
+
/** Fires after a slash command expands into a model prompt (`prependToPrompt`), between expansion and userPromptSubmit. Useful for audit trails. */
|
|
70
|
+
userPromptExpansion?: HookDef[];
|
|
71
|
+
/** Fires after a turn's full set of tool calls have all resolved, before the next model call. Sees the batch as a whole; postToolUse fires per-tool. */
|
|
72
|
+
postToolBatch?: HookDef[];
|
|
73
|
+
/** Fires when a tool call is denied (auto-mode policy block, hook-driven deny, headless fail-closed, or user "no"). Symmetric to permissionRequest. */
|
|
74
|
+
permissionDenied?: HookDef[];
|
|
75
|
+
/** Fires when a TaskCreate tool call has just persisted a new task. */
|
|
76
|
+
taskCreated?: HookDef[];
|
|
77
|
+
/** Fires when a TaskUpdate tool call transitions a task to status "completed". */
|
|
78
|
+
taskCompleted?: HookDef[];
|
|
79
|
+
/** Fires once per system-prompt build after CLAUDE.md / global-rules / project RULES.md / user profile have been concatenated. Useful for audit trails. */
|
|
80
|
+
instructionsLoaded?: HookDef[];
|
|
69
81
|
};
|
|
70
82
|
export type ToolPermissionRule = {
|
|
71
83
|
tool: string;
|
package/dist/harness/hooks.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - prompt: LLM yes/no check via provider.complete()
|
|
11
11
|
*/
|
|
12
12
|
import type { HookDef, HooksConfig } from "./config.js";
|
|
13
|
-
export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "postToolUseFailure" | "userPromptSubmit" | "permissionRequest" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification" | "turnStart" | "turnStop";
|
|
13
|
+
export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "postToolUseFailure" | "postToolBatch" | "userPromptSubmit" | "userPromptExpansion" | "permissionRequest" | "permissionDenied" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification" | "turnStart" | "turnStop" | "taskCreated" | "taskCompleted" | "instructionsLoaded";
|
|
14
14
|
export type HookContext = {
|
|
15
15
|
toolName?: string;
|
|
16
16
|
toolArgs?: string;
|
|
@@ -42,6 +42,28 @@ export type HookContext = {
|
|
|
42
42
|
turnNumber?: string;
|
|
43
43
|
/** For turnStop: reason the turn ended ("completed", "max_turns", "error", "interrupted") */
|
|
44
44
|
turnReason?: string;
|
|
45
|
+
/** For userPromptExpansion: the slash command that triggered the expansion (e.g. "/plan") */
|
|
46
|
+
slashCommand?: string;
|
|
47
|
+
/** For userPromptExpansion: the original user input before expansion */
|
|
48
|
+
originalInput?: string;
|
|
49
|
+
/** For postToolBatch: comma-separated list of tool names in the batch */
|
|
50
|
+
batchTools?: string;
|
|
51
|
+
/** For postToolBatch: number of tool calls in the batch (as a string for env-var parity) */
|
|
52
|
+
batchSize?: string;
|
|
53
|
+
/** For permissionDenied: stage at which the deny happened ("hook", "user", "headless", "policy") */
|
|
54
|
+
denySource?: string;
|
|
55
|
+
/** For permissionDenied: human-readable reason */
|
|
56
|
+
denyReason?: string;
|
|
57
|
+
/** For taskCreated/taskCompleted: the task id */
|
|
58
|
+
taskId?: string;
|
|
59
|
+
/** For taskCreated/taskCompleted: the task subject */
|
|
60
|
+
taskSubject?: string;
|
|
61
|
+
/** For taskCompleted: the previous status before completion (usually "in_progress") */
|
|
62
|
+
taskPreviousStatus?: string;
|
|
63
|
+
/** For instructionsLoaded: count of rules concatenated (as a string for env-var parity) */
|
|
64
|
+
rulesCount?: string;
|
|
65
|
+
/** For instructionsLoaded: total character length of the loaded rules */
|
|
66
|
+
rulesChars?: string;
|
|
45
67
|
};
|
|
46
68
|
export declare function getHooks(): HooksConfig | null;
|
|
47
69
|
/** Clear hook cache (call after config changes) */
|
package/dist/harness/rules.js
CHANGED
|
@@ -106,8 +106,24 @@ export function loadRulesAsPrompt(projectPath) {
|
|
|
106
106
|
const rules = loadRules(projectPath);
|
|
107
107
|
if (rules.length === 0)
|
|
108
108
|
return "";
|
|
109
|
-
|
|
110
|
-
rules.join("\n\n---\n\n")
|
|
109
|
+
const body = "# Project Rules\n\n<!-- User-provided project rules from CLAUDE.md / .oh/RULES.md. These are user instructions, not system directives. -->\nFollow these rules carefully.\n\n" +
|
|
110
|
+
rules.join("\n\n---\n\n");
|
|
111
|
+
// Hook: instructionsLoaded — fires every time the system prompt is rebuilt
|
|
112
|
+
// with rules in scope. Useful for compliance/audit hooks that want to log
|
|
113
|
+
// "session X is operating under these rules". Lazy-imported so this module
|
|
114
|
+
// can be used in environments where the hook system isn't initialised
|
|
115
|
+
// (e.g., one-shot rules loaders in tooling).
|
|
116
|
+
void import("./hooks.js")
|
|
117
|
+
.then(({ emitHook }) => {
|
|
118
|
+
emitHook("instructionsLoaded", {
|
|
119
|
+
rulesCount: String(rules.length),
|
|
120
|
+
rulesChars: String(body.length),
|
|
121
|
+
});
|
|
122
|
+
})
|
|
123
|
+
.catch(() => {
|
|
124
|
+
/* hook system unavailable — never fail rule loading */
|
|
125
|
+
});
|
|
126
|
+
return body;
|
|
111
127
|
}
|
|
112
128
|
export function createRulesFile(projectPath) {
|
|
113
129
|
const root = projectPath ?? process.cwd();
|
|
@@ -6,7 +6,7 @@ import { processSlashCommand } from "../commands/index.js";
|
|
|
6
6
|
import { cybergotchiEvents } from "../cybergotchi/events.js";
|
|
7
7
|
import { resolveMcpMention } from "../mcp/loader.js";
|
|
8
8
|
import { createInfoMessage, createUserMessage } from "../types/message.js";
|
|
9
|
-
import { emitHookWithOutcome } from "./hooks.js";
|
|
9
|
+
import { emitHook, emitHookWithOutcome } from "./hooks.js";
|
|
10
10
|
/**
|
|
11
11
|
* Process user input: handle exit, companion mentions, slash commands,
|
|
12
12
|
* @mentions, and prepare the prompt for the LLM.
|
|
@@ -80,6 +80,19 @@ export async function handleUserInput(input, ctx) {
|
|
|
80
80
|
if (result.prependToPrompt) {
|
|
81
81
|
messages = [...messages, createUserMessage(input)];
|
|
82
82
|
const prependPrompt = result.prependToPrompt;
|
|
83
|
+
// Slash command produced an expanded prompt — fire userPromptExpansion
|
|
84
|
+
// before userPromptSubmit so audit hooks can see the (input → expanded)
|
|
85
|
+
// boundary that's otherwise hidden from observers.
|
|
86
|
+
const slashCommand = trimmed.split(/\s/)[0] ?? trimmed;
|
|
87
|
+
emitHook("userPromptExpansion", {
|
|
88
|
+
slashCommand,
|
|
89
|
+
originalInput: input.slice(0, 1000),
|
|
90
|
+
prompt: prependPrompt.slice(0, 1000),
|
|
91
|
+
sessionId: ctx.sessionId,
|
|
92
|
+
model: ctx.currentModel,
|
|
93
|
+
provider: ctx.providerName,
|
|
94
|
+
permissionMode: ctx.permissionMode,
|
|
95
|
+
});
|
|
83
96
|
const prependOutcome = await emitHookWithOutcome("userPromptSubmit", {
|
|
84
97
|
prompt: prependPrompt,
|
|
85
98
|
sessionId: ctx.sessionId,
|
package/dist/main.js
CHANGED
|
@@ -23,7 +23,7 @@ import { detectProject, projectContextToPrompt } from "./harness/onboarding.js";
|
|
|
23
23
|
import { discoverSkills, skillsToPrompt } from "./harness/plugins.js";
|
|
24
24
|
import { createRulesFile, loadRules, loadRulesAsPrompt } from "./harness/rules.js";
|
|
25
25
|
import { listSessions } from "./harness/session.js";
|
|
26
|
-
import { connectedMcpServers, disconnectMcpClients, getMcpInstructions, loadMcpTools } from "./mcp/loader.js";
|
|
26
|
+
import { connectedMcpServers, disconnectMcpClients, getMcpInstructions, loadMcpPrompts, loadMcpTools, } from "./mcp/loader.js";
|
|
27
27
|
import { loadOutputStyle } from "./outputStyles/index.js";
|
|
28
28
|
import { getAllTools } from "./tools.js";
|
|
29
29
|
import { validateAgainstJsonSchema } from "./utils/json-schema.js";
|
|
@@ -653,6 +653,20 @@ program
|
|
|
653
653
|
if (mcpNames.length > 0) {
|
|
654
654
|
console.log(`[mcp] Connected: ${mcpNames.join(", ")}`);
|
|
655
655
|
}
|
|
656
|
+
// Surface MCP-server prompts (`prompts/list`) as `/server:prompt` slash
|
|
657
|
+
// commands. Errors are swallowed inside loadMcpPrompts — servers that
|
|
658
|
+
// don't implement the prompts capability return [] without throwing.
|
|
659
|
+
try {
|
|
660
|
+
const { registerMcpPromptCommands } = await import("./commands/index.js");
|
|
661
|
+
const prompts = await loadMcpPrompts();
|
|
662
|
+
registerMcpPromptCommands(prompts);
|
|
663
|
+
if (prompts.length > 0) {
|
|
664
|
+
console.log(`[mcp] Prompts: ${prompts.map((p) => `/${p.qualifiedName}`).join(", ")}`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
/* prompt registration is best-effort; never block the REPL */
|
|
669
|
+
}
|
|
656
670
|
const tools = [...getAllTools(), ...mcpTools];
|
|
657
671
|
process.on("exit", () => disconnectMcpClients());
|
|
658
672
|
// Compute working directory and git branch
|
package/dist/mcp/client.d.ts
CHANGED
|
@@ -31,6 +31,29 @@ export declare class McpClient {
|
|
|
31
31
|
description?: string;
|
|
32
32
|
}>>;
|
|
33
33
|
readResource(uri: string): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* List the prompts an MCP server exposes. Returns `[]` for servers that
|
|
36
|
+
* don't implement the `prompts/list` capability — this is a normal case
|
|
37
|
+
* (most non-prompt-aware MCP servers throw a method-not-found error).
|
|
38
|
+
*
|
|
39
|
+
* Each prompt may declare named arguments; surfaced via `arguments`.
|
|
40
|
+
*/
|
|
41
|
+
listPrompts(): Promise<Array<{
|
|
42
|
+
name: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
arguments?: Array<{
|
|
45
|
+
name: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
required?: boolean;
|
|
48
|
+
}>;
|
|
49
|
+
}>>;
|
|
50
|
+
/**
|
|
51
|
+
* Get the rendered text of an MCP prompt. Server-side templates are
|
|
52
|
+
* applied with the supplied arguments. Multiple message turns are
|
|
53
|
+
* concatenated with double-newline separators — same shape OH uses for
|
|
54
|
+
* other prepended prompts.
|
|
55
|
+
*/
|
|
56
|
+
getPrompt(name: string, args?: Record<string, string>): Promise<string>;
|
|
34
57
|
callTool(name: string, args: Record<string, unknown>): Promise<string>;
|
|
35
58
|
disconnect(): void;
|
|
36
59
|
}
|
package/dist/mcp/client.js
CHANGED
|
@@ -91,6 +91,43 @@ export class McpClient {
|
|
|
91
91
|
.map((c) => c.text)
|
|
92
92
|
.join("\n");
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* List the prompts an MCP server exposes. Returns `[]` for servers that
|
|
96
|
+
* don't implement the `prompts/list` capability — this is a normal case
|
|
97
|
+
* (most non-prompt-aware MCP servers throw a method-not-found error).
|
|
98
|
+
*
|
|
99
|
+
* Each prompt may declare named arguments; surfaced via `arguments`.
|
|
100
|
+
*/
|
|
101
|
+
async listPrompts() {
|
|
102
|
+
try {
|
|
103
|
+
const res = await this.sdk.listPrompts();
|
|
104
|
+
return (res?.prompts ?? []);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Get the rendered text of an MCP prompt. Server-side templates are
|
|
112
|
+
* applied with the supplied arguments. Multiple message turns are
|
|
113
|
+
* concatenated with double-newline separators — same shape OH uses for
|
|
114
|
+
* other prepended prompts.
|
|
115
|
+
*/
|
|
116
|
+
async getPrompt(name, args = {}) {
|
|
117
|
+
const res = await this.sdk.getPrompt({ name, arguments: args });
|
|
118
|
+
const messages = (res?.messages ?? []);
|
|
119
|
+
const parts = [];
|
|
120
|
+
for (const m of messages) {
|
|
121
|
+
const content = m.content;
|
|
122
|
+
if (typeof content === "string") {
|
|
123
|
+
parts.push(content);
|
|
124
|
+
}
|
|
125
|
+
else if (content && content.type === "text" && typeof content.text === "string") {
|
|
126
|
+
parts.push(content.text);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return parts.join("\n\n");
|
|
130
|
+
}
|
|
94
131
|
async callTool(name, args) {
|
|
95
132
|
// Retry up to 2 times on transport-closed / timeout errors
|
|
96
133
|
let lastErr = null;
|
package/dist/mcp/loader.d.ts
CHANGED
|
@@ -5,6 +5,26 @@ export declare function loadMcpTools(): Promise<Tool[]>;
|
|
|
5
5
|
export declare function disconnectMcpClients(): void;
|
|
6
6
|
/** Names of connected MCP servers */
|
|
7
7
|
export declare function connectedMcpServers(): string[];
|
|
8
|
+
export type McpPromptHandle = {
|
|
9
|
+
/** `<server>:<prompt>` qualified name — the slash command is `/<server>:<prompt>`. */
|
|
10
|
+
qualifiedName: string;
|
|
11
|
+
description: string;
|
|
12
|
+
/** List of named arguments the prompt template expects. */
|
|
13
|
+
arguments?: Array<{
|
|
14
|
+
name: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
}>;
|
|
18
|
+
/** Render the prompt with the supplied named arguments. */
|
|
19
|
+
render(args?: Record<string, string>): Promise<string>;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Enumerate prompts on every already-connected MCP server. Servers that don't
|
|
23
|
+
* implement the `prompts/list` capability return an empty list (handled
|
|
24
|
+
* inside `client.listPrompts`). Call AFTER `loadMcpTools()` so the client
|
|
25
|
+
* connections are warm.
|
|
26
|
+
*/
|
|
27
|
+
export declare function loadMcpPrompts(): Promise<McpPromptHandle[]>;
|
|
8
28
|
/** Get MCP server instructions to inject into system prompt (sandboxed with origin markers) */
|
|
9
29
|
export declare function getMcpInstructions(): string[];
|
|
10
30
|
/** List all available resources across connected MCP servers */
|
package/dist/mcp/loader.js
CHANGED
|
@@ -78,6 +78,33 @@ export function disconnectMcpClients() {
|
|
|
78
78
|
export function connectedMcpServers() {
|
|
79
79
|
return connectedClients.map((c) => c.name);
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Enumerate prompts on every already-connected MCP server. Servers that don't
|
|
83
|
+
* implement the `prompts/list` capability return an empty list (handled
|
|
84
|
+
* inside `client.listPrompts`). Call AFTER `loadMcpTools()` so the client
|
|
85
|
+
* connections are warm.
|
|
86
|
+
*/
|
|
87
|
+
export async function loadMcpPrompts() {
|
|
88
|
+
const handles = [];
|
|
89
|
+
for (const client of connectedClients) {
|
|
90
|
+
let prompts;
|
|
91
|
+
try {
|
|
92
|
+
prompts = await client.listPrompts();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue; // Defensive — listPrompts already swallows method-not-found
|
|
96
|
+
}
|
|
97
|
+
for (const p of prompts) {
|
|
98
|
+
handles.push({
|
|
99
|
+
qualifiedName: `${client.name}:${p.name}`,
|
|
100
|
+
description: p.description ?? `MCP prompt from ${client.name}`,
|
|
101
|
+
...(p.arguments ? { arguments: p.arguments } : {}),
|
|
102
|
+
render: (args = {}) => client.getPrompt(p.name, args),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return handles;
|
|
107
|
+
}
|
|
81
108
|
const MAX_MCP_INSTRUCTION_LENGTH = 2000;
|
|
82
109
|
/** Get MCP server instructions to inject into system prompt (sandboxed with origin markers) */
|
|
83
110
|
export function getMcpInstructions() {
|
package/dist/query/tools.js
CHANGED
|
@@ -55,12 +55,22 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
55
55
|
permissionMode,
|
|
56
56
|
permissionAction: "ask",
|
|
57
57
|
});
|
|
58
|
+
const denyAndEmit = (source, reason, output) => {
|
|
59
|
+
emitHook("permissionDenied", {
|
|
60
|
+
toolName: tool.name,
|
|
61
|
+
toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
|
|
62
|
+
permissionMode,
|
|
63
|
+
denySource: source,
|
|
64
|
+
denyReason: reason,
|
|
65
|
+
});
|
|
66
|
+
return { output, isError: true };
|
|
67
|
+
};
|
|
58
68
|
if (hookOutcome.permissionDecision === "allow") {
|
|
59
69
|
// Hook granted permission — proceed to execution.
|
|
60
70
|
}
|
|
61
71
|
else if (hookOutcome.permissionDecision === "deny" || !hookOutcome.allowed) {
|
|
62
72
|
const reason = hookOutcome.reason ? `: ${hookOutcome.reason}` : "";
|
|
63
|
-
return
|
|
73
|
+
return denyAndEmit("hook", hookOutcome.reason ?? "hook denied", `Permission denied by hook${reason}`);
|
|
64
74
|
}
|
|
65
75
|
else if (askUser) {
|
|
66
76
|
// "ask" or no decision → interactive prompt when available
|
|
@@ -68,20 +78,25 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
68
78
|
const description = formatToolArgs(tool.name, toolCall.arguments);
|
|
69
79
|
const allowed = await askUser(tool.name, description, tool.riskLevel);
|
|
70
80
|
if (!allowed) {
|
|
71
|
-
return
|
|
81
|
+
return denyAndEmit("user", "user declined", "Permission denied by user.");
|
|
72
82
|
}
|
|
73
83
|
}
|
|
74
84
|
else {
|
|
75
85
|
// Headless mode with no hook decision and no interactive prompt:
|
|
76
86
|
// fail-closed deny. SDK consumers should configure a permissionRequest
|
|
77
87
|
// hook (or use canUseTool) to make per-call decisions.
|
|
78
|
-
return
|
|
79
|
-
output: "Permission denied: needs-approval (no interactive prompt available; configure a permissionRequest hook to gate this tool)",
|
|
80
|
-
isError: true,
|
|
81
|
-
};
|
|
88
|
+
return 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)");
|
|
82
89
|
}
|
|
83
90
|
}
|
|
84
91
|
else {
|
|
92
|
+
// Auto-mode policy block (deny / acceptEdits / etc) — symmetric event.
|
|
93
|
+
emitHook("permissionDenied", {
|
|
94
|
+
toolName: tool.name,
|
|
95
|
+
toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
|
|
96
|
+
permissionMode,
|
|
97
|
+
denySource: "policy",
|
|
98
|
+
denyReason: perm.reason,
|
|
99
|
+
});
|
|
85
100
|
return { output: `Permission denied: ${perm.reason}`, isError: true };
|
|
86
101
|
}
|
|
87
102
|
}
|
|
@@ -200,6 +215,7 @@ export async function* executeToolCalls(toolCalls, tools, context, permissionMod
|
|
|
200
215
|
const onOutputChunk = (callId, chunk) => {
|
|
201
216
|
outputChunks.push({ type: "tool_output_delta", callId, chunk });
|
|
202
217
|
};
|
|
218
|
+
const allToolNames = toolCalls.map((tc) => tc.toolName);
|
|
203
219
|
for (const batch of batches) {
|
|
204
220
|
if (batch.concurrent) {
|
|
205
221
|
const results = await Promise.all(batch.calls.map((tc) => executeSingleTool(tc, tools, { ...context, callId: tc.id, onOutputChunk }, permissionMode, askUser)));
|
|
@@ -222,5 +238,17 @@ export async function* executeToolCalls(toolCalls, tools, context, permissionMod
|
|
|
222
238
|
}
|
|
223
239
|
}
|
|
224
240
|
}
|
|
241
|
+
// Hook: postToolBatch — fires once after the model's full set of tool
|
|
242
|
+
// calls for this turn have all resolved (across however many serial /
|
|
243
|
+
// concurrent batches partitionToolCalls produced), before the next model
|
|
244
|
+
// call. Per-tool postToolUse / postToolUseFailure still fire as before;
|
|
245
|
+
// this is the batch-level boundary for hooks that want to act once per
|
|
246
|
+
// turn instead of once per tool.
|
|
247
|
+
if (toolCalls.length > 0) {
|
|
248
|
+
emitHook("postToolBatch", {
|
|
249
|
+
batchSize: String(toolCalls.length),
|
|
250
|
+
batchTools: allToolNames.slice(0, 50).join(","),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
225
253
|
}
|
|
226
254
|
//# sourceMappingURL=tools.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { emitHook } from "../../harness/hooks.js";
|
|
4
5
|
const inputSchema = z.object({
|
|
5
6
|
subject: z.string(),
|
|
6
7
|
description: z.string(),
|
|
@@ -42,6 +43,10 @@ export const TaskCreateTool = {
|
|
|
42
43
|
};
|
|
43
44
|
tasks.push(newTask);
|
|
44
45
|
await fs.writeFile(filePath, JSON.stringify(tasks, null, 2), "utf-8");
|
|
46
|
+
emitHook("taskCreated", {
|
|
47
|
+
taskId: String(newTask.id),
|
|
48
|
+
taskSubject: newTask.subject.slice(0, 200),
|
|
49
|
+
});
|
|
45
50
|
return { output: `Task #${newTask.id} created: ${newTask.subject}`, isError: false };
|
|
46
51
|
}
|
|
47
52
|
catch (err) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { emitHook } from "../../harness/hooks.js";
|
|
4
5
|
const inputSchema = z.object({
|
|
5
6
|
taskId: z.number(),
|
|
6
7
|
status: z.enum(["pending", "in_progress", "completed", "cancelled", "deleted"]).optional(),
|
|
@@ -32,6 +33,7 @@ export const TaskUpdateTool = {
|
|
|
32
33
|
if (!task) {
|
|
33
34
|
return { output: `Error: Task #${input.taskId} not found.`, isError: true };
|
|
34
35
|
}
|
|
36
|
+
const previousStatus = task.status;
|
|
35
37
|
// Handle deletion
|
|
36
38
|
if (input.status === "deleted") {
|
|
37
39
|
const idx = tasks.indexOf(task);
|
|
@@ -69,6 +71,15 @@ export const TaskUpdateTool = {
|
|
|
69
71
|
task.blockedBy = [...new Set([...(task.blockedBy ?? []), ...input.addBlockedBy])];
|
|
70
72
|
}
|
|
71
73
|
await fs.writeFile(filePath, JSON.stringify(tasks, null, 2), "utf-8");
|
|
74
|
+
// Hook: taskCompleted — fires only on the pending|in_progress → completed
|
|
75
|
+
// transition. Re-saving an already-completed task is a no-op for the hook.
|
|
76
|
+
if (input.status === "completed" && previousStatus !== "completed") {
|
|
77
|
+
emitHook("taskCompleted", {
|
|
78
|
+
taskId: String(task.id),
|
|
79
|
+
taskSubject: task.subject.slice(0, 200),
|
|
80
|
+
taskPreviousStatus: previousStatus,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
72
83
|
return { output: `Task #${task.id} updated. Status: ${task.status}`, isError: false };
|
|
73
84
|
}
|
|
74
85
|
catch (err) {
|