@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.
@@ -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
@@ -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
@@ -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) {
@@ -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;
@@ -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) */
@@ -106,8 +106,24 @@ export function loadRulesAsPrompt(projectPath) {
106
106
  const rules = loadRules(projectPath);
107
107
  if (rules.length === 0)
108
108
  return "";
109
- return ("# 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"));
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
@@ -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
  }
@@ -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;
@@ -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 */
@@ -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() {
@@ -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 { output: `Permission denied by hook${reason}`, isError: true };
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 { output: "Permission denied by user.", isError: true };
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.19.0",
3
+ "version": "2.20.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {