@zhijiewang/openharness 2.11.0 → 2.13.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/README.md CHANGED
@@ -317,6 +317,8 @@ hooks:
317
317
 
318
318
  Use `match` to restrict a hook to a specific tool name (e.g., `match: Bash` only triggers for the Bash tool).
319
319
 
320
+ See [docs/hooks.md](docs/hooks.md) for the full event reference including the new `userPromptSubmit`, `permissionRequest`, and `postToolUseFailure` events.
321
+
320
322
  ## Cybergotchi
321
323
 
322
324
  OpenHarness ships with a Tamagotchi-style companion that lives in the side panel. It reacts to your session in real time — celebrating streaks, complaining when tools fail, and getting hungry if you ignore it.
@@ -378,6 +380,7 @@ mcpServers:
378
380
  ```
379
381
 
380
382
  See [docs/mcp-servers.md](docs/mcp-servers.md) for the full reference.
383
+ See [docs/mcp-servers.md](docs/mcp-servers.md#authentication) for OAuth 2.1 setup (auto-triggered on 401; `/mcp-login` and `/mcp-logout` commands available).
381
384
 
382
385
  **MCP Server Registry** — browse and install from a curated catalog:
383
386
 
@@ -17,7 +17,7 @@ import type { CommandContext, CommandResult } from "./types.js";
17
17
  /**
18
18
  * Check if input is a slash command. If so, execute it.
19
19
  */
20
- export declare function processSlashCommand(input: string, context: CommandContext): CommandResult | null;
20
+ export declare function processSlashCommand(input: string, context: CommandContext): Promise<CommandResult | null>;
21
21
  /**
22
22
  * Get all registered command names (for autocomplete/display).
23
23
  */
@@ -34,7 +34,7 @@ registerSkillCommands(register);
34
34
  /**
35
35
  * Check if input is a slash command. If so, execute it.
36
36
  */
37
- export function processSlashCommand(input, context) {
37
+ export async function processSlashCommand(input, context) {
38
38
  const trimmed = input.trim();
39
39
  if (!trimmed.startsWith("/"))
40
40
  return null;
@@ -8,7 +8,11 @@ import { gitBranch, isGitRepo, isInMergeOrRebase } from "../git/index.js";
8
8
  import { readOhConfig } from "../harness/config.js";
9
9
  import { estimateMessageTokens } from "../harness/context-warning.js";
10
10
  import { getContextWindow } from "../harness/cost.js";
11
+ import { normalizeMcpConfig } from "../mcp/config-normalize.js";
11
12
  import { connectedMcpServers } from "../mcp/loader.js";
13
+ import { getAuthStatus } from "../mcp/oauth.js";
14
+ import { getRouteSelection } from "../providers/router.js";
15
+ import { mcpLoginHandler, mcpLogoutHandler } from "./mcp-auth.js";
12
16
  export function registerInfoCommands(register, getCommandMap) {
13
17
  register("help", "Show available commands", () => {
14
18
  const categories = {
@@ -39,7 +43,10 @@ export function registerInfoCommands(register, getCommandMap) {
39
43
  "doctor",
40
44
  "context",
41
45
  "mcp",
46
+ "mcp-login",
47
+ "mcp-logout",
42
48
  "mcp-registry",
49
+ "router",
43
50
  "init",
44
51
  "bug",
45
52
  "feedback",
@@ -387,19 +394,50 @@ export function registerInfoCommands(register, getCommandMap) {
387
394
  ];
388
395
  return { output: lines.join("\n"), handled: true };
389
396
  });
390
- register("mcp", "Show MCP server status", () => {
391
- const mcp = connectedMcpServers();
392
- if (mcp.length === 0) {
397
+ register("mcp", "Show MCP server status", async () => {
398
+ const connected = connectedMcpServers();
399
+ if (connected.length === 0) {
393
400
  return {
394
401
  output: "No MCP servers connected.\nConfigure in .oh/config.yaml under mcpServers.\nRun /mcp-registry to browse available servers.",
395
402
  handled: true,
396
403
  };
397
404
  }
398
- const lines = [`MCP Servers (${mcp.length} connected):\n`];
399
- for (const name of mcp) {
400
- lines.push(` ✓ ${name}`);
405
+ const cfg = readOhConfig();
406
+ const servers = cfg?.mcpServers ?? [];
407
+ const storageDir = join(homedir(), ".oh", "credentials", "mcp");
408
+ const lines = [`MCP Servers (${connected.length} connected):`, ""];
409
+ for (const name of connected) {
410
+ const entry = servers.find((s) => s.name === name);
411
+ if (!entry) {
412
+ lines.push(` ${name.padEnd(20)} unknown —`);
413
+ continue;
414
+ }
415
+ const normalized = normalizeMcpConfig(entry, process.env);
416
+ if (normalized.kind === "error") {
417
+ lines.push(` ${name.padEnd(20)} error ${normalized.message}`);
418
+ continue;
419
+ }
420
+ const kind = normalized.cfg.type;
421
+ const status = await getAuthStatus(normalized.cfg, storageDir);
422
+ let statusText;
423
+ switch (status) {
424
+ case "n/a":
425
+ statusText = "—";
426
+ break;
427
+ case "none":
428
+ statusText = "not authenticated";
429
+ break;
430
+ case "authenticated":
431
+ statusText = "authenticated";
432
+ break;
433
+ case "expired":
434
+ statusText = "expired (re-authenticate with /mcp-login)";
435
+ break;
436
+ }
437
+ lines.push(` ${name.padEnd(20)} ${kind.padEnd(6)} ${statusText}`);
401
438
  }
402
- lines.push("\nRun /mcp-registry to browse and add more servers.");
439
+ lines.push("");
440
+ lines.push("Run /mcp-registry to browse and add more servers.");
403
441
  return { output: lines.join("\n"), handled: true };
404
442
  });
405
443
  register("mcp-registry", "Browse and add MCP servers from the curated registry", (args) => {
@@ -426,6 +464,30 @@ export function registerInfoCommands(register, getCommandMap) {
426
464
  }
427
465
  return { output: `Found ${results.length} servers:\n\n${formatRegistry(results)}`, handled: true };
428
466
  });
467
+ register("mcp-login", "Authenticate to a remote MCP server via OAuth", async (args) => {
468
+ return mcpLoginHandler(args);
469
+ });
470
+ register("mcp-logout", "Wipe local OAuth tokens for an MCP server", async (args) => {
471
+ return mcpLogoutHandler(args);
472
+ });
473
+ register("router", "Show the model router state", (_args, ctx) => {
474
+ const cfg = readOhConfig()?.modelRouter;
475
+ const defaultModel = ctx.model ?? "unknown";
476
+ if (!cfg || (!cfg.fast && !cfg.balanced && !cfg.powerful)) {
477
+ return { output: `Router: off (single model: ${defaultModel})`, handled: true };
478
+ }
479
+ const last = ctx.sessionId ? getRouteSelection(ctx.sessionId) : undefined;
480
+ const lines = [
481
+ "Model router:",
482
+ ` fast ${cfg.fast ?? `(default: ${defaultModel})`}`,
483
+ ` balanced ${cfg.balanced ?? `(default: ${defaultModel})`}`,
484
+ ` powerful ${cfg.powerful ?? `(default: ${defaultModel})`}`,
485
+ ];
486
+ if (last) {
487
+ lines.push("", `Last selection: ${last.tier} — "${last.reason}"`);
488
+ }
489
+ return { output: lines.join("\n"), handled: true };
490
+ });
429
491
  register("init", "Initialize project with .oh/ config", () => {
430
492
  const ohDir = join(process.cwd(), ".oh");
431
493
  if (existsSync(ohDir)) {
@@ -0,0 +1,11 @@
1
+ export type CommandResult = {
2
+ output: string;
3
+ handled: true;
4
+ };
5
+ export declare function mcpLogoutHandler(name: string, opts?: {
6
+ storageDir?: string;
7
+ }): Promise<CommandResult>;
8
+ export declare function mcpLoginHandler(name: string, opts?: {
9
+ storageDir?: string;
10
+ }): Promise<CommandResult>;
11
+ //# sourceMappingURL=mcp-auth.d.ts.map
@@ -0,0 +1,57 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { readOhConfig } from "../harness/config.js";
4
+ import { McpClient } from "../mcp/client.js";
5
+ import { normalizeMcpConfig } from "../mcp/config-normalize.js";
6
+ import { clearTokens } from "../mcp/oauth.js";
7
+ import { loadCredentials } from "../mcp/oauth-storage.js";
8
+ function defaultStorageDir() {
9
+ return join(homedir(), ".oh", "credentials", "mcp");
10
+ }
11
+ export async function mcpLogoutHandler(name, opts = {}) {
12
+ const storageDir = opts.storageDir ?? defaultStorageDir();
13
+ const trimmed = name.trim();
14
+ if (!trimmed) {
15
+ return { output: "Usage: /mcp-logout <server-name>", handled: true };
16
+ }
17
+ const existing = await loadCredentials(storageDir, trimmed);
18
+ if (!existing) {
19
+ return { output: `No credentials stored for '${trimmed}'.`, handled: true };
20
+ }
21
+ await clearTokens(storageDir, trimmed);
22
+ return {
23
+ output: `Local token for '${trimmed}' wiped. Server-side session may remain valid until expiry.`,
24
+ handled: true,
25
+ };
26
+ }
27
+ export async function mcpLoginHandler(name, opts = {}) {
28
+ const storageDir = opts.storageDir ?? defaultStorageDir();
29
+ const trimmed = name.trim();
30
+ if (!trimmed) {
31
+ return { output: "Usage: /mcp-login <server-name>", handled: true };
32
+ }
33
+ const cfg = readOhConfig();
34
+ const servers = cfg?.mcpServers ?? [];
35
+ const entry = servers.find((s) => s.name === trimmed);
36
+ if (!entry) {
37
+ return { output: `No MCP server named '${trimmed}' in .oh/config.yaml.`, handled: true };
38
+ }
39
+ const normalized = normalizeMcpConfig(entry, process.env);
40
+ if (normalized.kind === "error") {
41
+ return { output: `Invalid config for '${trimmed}': ${normalized.message}`, handled: true };
42
+ }
43
+ if (normalized.cfg.type === "stdio") {
44
+ return { output: `Server '${trimmed}' is stdio; OAuth is not applicable.`, handled: true };
45
+ }
46
+ await clearTokens(storageDir, trimmed);
47
+ try {
48
+ const client = await McpClient.connect(entry, { storageDir });
49
+ client.disconnect();
50
+ return { output: `\u2713 Authenticated to '${trimmed}'.`, handled: true };
51
+ }
52
+ catch (err) {
53
+ const msg = err instanceof Error ? err.message : String(err);
54
+ return { output: `Authentication failed for '${trimmed}': ${msg}`, handled: true };
55
+ }
56
+ }
57
+ //# sourceMappingURL=mcp-auth.js.map
@@ -22,7 +22,7 @@ export type CommandResult = {
22
22
  /** If set, toggle fast mode */
23
23
  toggleFastMode?: boolean;
24
24
  };
25
- export type CommandHandler = (args: string, context: CommandContext) => CommandResult;
25
+ export type CommandHandler = (args: string, context: CommandContext) => CommandResult | Promise<CommandResult>;
26
26
  export type CommandContext = {
27
27
  messages: Message[];
28
28
  model: string;
@@ -405,8 +405,14 @@ export default function REPL({ provider, tools, permissionMode, systemPrompt, mo
405
405
  totalOutputTokens: costRef.current.totalOutputTokens,
406
406
  sessionId,
407
407
  };
408
- const result = processSlashCommand(trimmed, ctx);
409
- if (result) {
408
+ void processSlashCommand(trimmed, ctx).then((result) => {
409
+ if (!result) {
410
+ const userMsg = createUserMessage(input);
411
+ setMessages((prev) => [...prev, userMsg]);
412
+ pendingPromptRef.current = input;
413
+ setSubmitCount((c) => c + 1);
414
+ return;
415
+ }
410
416
  if (result.openCybergotchiSetup) {
411
417
  setShowCybergotchiSetup(true);
412
418
  return;
@@ -446,7 +452,8 @@ export default function REPL({ provider, tools, permissionMode, systemPrompt, mo
446
452
  setSubmitCount((c) => c + 1);
447
453
  return;
448
454
  }
449
- }
455
+ });
456
+ return;
450
457
  }
451
458
  const userMsg = createUserMessage(input);
452
459
  setMessages((prev) => [...prev, userMsg]);
@@ -17,11 +17,13 @@ export type McpHttpConfig = McpCommonConfig & {
17
17
  type: "http";
18
18
  url: string;
19
19
  headers?: Record<string, string>;
20
+ auth?: "oauth" | "none";
20
21
  };
21
22
  export type McpSseConfig = McpCommonConfig & {
22
23
  type: "sse";
23
24
  url: string;
24
25
  headers?: Record<string, string>;
26
+ auth?: "oauth" | "none";
25
27
  };
26
28
  export type McpServerConfig = McpStdioConfig | McpHttpConfig | McpSseConfig;
27
29
  export type HookDef = {
@@ -49,6 +51,9 @@ export type HooksConfig = {
49
51
  sessionEnd?: HookDef[];
50
52
  preToolUse?: HookDef[];
51
53
  postToolUse?: HookDef[];
54
+ postToolUseFailure?: HookDef[];
55
+ userPromptSubmit?: HookDef[];
56
+ permissionRequest?: HookDef[];
52
57
  fileChanged?: HookDef[];
53
58
  cwdChanged?: HookDef[];
54
59
  subagentStart?: HookDef[];
@@ -10,7 +10,7 @@
10
10
  * - prompt: LLM yes/no check via provider.complete()
11
11
  */
12
12
  import type { HookDef } from "./config.js";
13
- export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification";
13
+ export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "postToolUseFailure" | "userPromptSubmit" | "permissionRequest" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification";
14
14
  export type HookContext = {
15
15
  toolName?: string;
16
16
  toolArgs?: string;
@@ -30,6 +30,14 @@ export type HookContext = {
30
30
  agentId?: string;
31
31
  /** For notification: the message */
32
32
  message?: string;
33
+ /** For userPromptSubmit: the raw prompt text the user is about to submit */
34
+ prompt?: string;
35
+ /** For postToolUseFailure: short error label ("TimeoutError", "ExecutionError", "ReportedError") */
36
+ toolError?: string;
37
+ /** For postToolUseFailure: full error message */
38
+ errorMessage?: string;
39
+ /** For permissionRequest: the decision OH would take absent the hook ("ask", "allow", "deny") — informational */
40
+ permissionAction?: "ask" | "allow" | "deny";
33
41
  };
34
42
  /** Clear hook cache (call after config changes) */
35
43
  export declare function invalidateHookCache(): void;
@@ -58,4 +66,30 @@ export declare function emitHook(event: HookEvent, ctx?: HookContext): boolean;
58
66
  * Supports all hook types (command, HTTP, prompt).
59
67
  */
60
68
  export declare function emitHookAsync(event: HookEvent, ctx?: HookContext): Promise<boolean>;
69
+ /** Parsed shape of a jsonIO hook's stdout JSON response. */
70
+ export type ParsedJsonIoResponse = {
71
+ decision?: "allow" | "deny";
72
+ reason?: string;
73
+ additionalContext?: string;
74
+ permissionDecision?: "allow" | "deny" | "ask";
75
+ };
76
+ /** Parse a hook's stdout as a jsonIO envelope. Returns an empty object on malformed input. */
77
+ export declare function parseJsonIoResponse(raw: string): ParsedJsonIoResponse;
78
+ export type HookOutcome = {
79
+ allowed: boolean;
80
+ additionalContext?: string;
81
+ permissionDecision?: "allow" | "deny" | "ask";
82
+ reason?: string;
83
+ };
84
+ /**
85
+ * Emit a hook event and return a structured HookOutcome parsed from jsonIO responses.
86
+ *
87
+ * Merge semantics:
88
+ * - First `deny` (or `permissionDecision: "deny"`) short-circuits: {allowed: false, ...}.
89
+ * - `permissionDecision: "allow"` short-circuits: {allowed: true, permissionDecision: "allow"}.
90
+ * - `additionalContext` from multiple hooks is concatenated in order, "\n\n" separated.
91
+ * - For NOTIFY_ONLY_OUTCOME_EVENTS (postToolUseFailure), decision/permissionDecision
92
+ * from hooks is ignored — outcome.allowed is always true. additionalContext is still collected.
93
+ */
94
+ export declare function emitHookWithOutcome(event: HookEvent, ctx?: HookContext): Promise<HookOutcome>;
61
95
  //# sourceMappingURL=hooks.d.ts.map
@@ -56,6 +56,17 @@ function buildEnv(event, ctx) {
56
56
  env.OH_AGENT_ID = ctx.agentId;
57
57
  if (ctx.message)
58
58
  env.OH_MESSAGE = ctx.message;
59
+ if (ctx.prompt !== undefined) {
60
+ // Cap at 8KB to avoid Windows env-var length limits.
61
+ const PROMPT_MAX = 8 * 1024;
62
+ env.OH_PROMPT = ctx.prompt.length > PROMPT_MAX ? ctx.prompt.slice(0, PROMPT_MAX) : ctx.prompt;
63
+ }
64
+ if (ctx.toolError !== undefined)
65
+ env.OH_TOOL_ERROR = ctx.toolError;
66
+ if (ctx.errorMessage !== undefined)
67
+ env.OH_ERROR_MESSAGE = ctx.errorMessage;
68
+ if (ctx.permissionAction !== undefined)
69
+ env.OH_PERMISSION_ACTION = ctx.permissionAction;
59
70
  return env;
60
71
  }
61
72
  /**
@@ -142,20 +153,15 @@ function runCommandHookAsync(command, env, timeoutMs = 10_000) {
142
153
  });
143
154
  }
144
155
  /**
145
- * Run a JSON-mode command hook (Claude Code convention).
146
- *
147
- * Sends `{event, ...context}` as JSON on stdin. Parses stdout as JSON
148
- * `{ decision: "allow" | "deny", reason?: string, hookSpecificOutput?: any }`.
156
+ * Run a JSON-mode command hook and return the raw stdout string.
149
157
  *
150
- * Gating logic:
151
- * - `decision: "deny"` blocks (returns false).
152
- * - `decision: "allow"` or omitted decision allow (returns true).
153
- * - Non-zero exit code → block.
154
- * - Invalid/empty JSON on stdout → fall back to exit code (0 = allow).
155
- * - Timeout or spawn error → block.
158
+ * Rejects (throws) on timeout or spawn error so callers can decide how to
159
+ * interpret the failure. Returns an empty string when stdout is empty.
160
+ * Rejects when the process exits with a non-zero code (callers treat this as
161
+ * a block).
156
162
  */
157
- function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
158
- return new Promise((resolve) => {
163
+ function runJsonIoHookCaptureStdout(command, env, event, ctx, timeoutMs = 10_000) {
164
+ return new Promise((resolve, reject) => {
159
165
  const proc = spawn(command, {
160
166
  shell: true,
161
167
  timeout: timeoutMs,
@@ -168,7 +174,7 @@ function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
168
174
  if (!settled) {
169
175
  settled = true;
170
176
  proc.kill();
171
- resolve(false);
177
+ reject(new Error("hook timed out"));
172
178
  }
173
179
  }, timeoutMs);
174
180
  proc.stdout?.on("data", (chunk) => {
@@ -188,39 +194,56 @@ function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
188
194
  return;
189
195
  settled = true;
190
196
  clearTimeout(timer);
191
- // Non-zero exit is always a block, regardless of stdout.
192
197
  if ((code ?? 1) !== 0) {
193
- resolve(false);
194
- return;
195
- }
196
- // Empty stdout → treat exit code as the signal (allow for exit 0).
197
- if (!stdoutBuf.trim()) {
198
- resolve(true);
198
+ reject(new Error(`hook exited with code ${code ?? 1}`));
199
199
  return;
200
200
  }
201
- try {
202
- const parsed = JSON.parse(stdoutBuf);
203
- if (parsed.decision === "deny") {
204
- resolve(false);
205
- }
206
- else {
207
- resolve(true); // "allow" or omitted → allow
208
- }
209
- }
210
- catch {
211
- // Malformed JSON with a zero exit — fail closed conservatively.
212
- resolve(false);
213
- }
201
+ resolve(stdoutBuf);
214
202
  });
215
- proc.on("error", () => {
203
+ proc.on("error", (err) => {
216
204
  if (!settled) {
217
205
  settled = true;
218
206
  clearTimeout(timer);
219
- resolve(false);
207
+ reject(err);
220
208
  }
221
209
  });
222
210
  });
223
211
  }
212
+ /**
213
+ * Run a JSON-mode command hook (Claude Code convention).
214
+ *
215
+ * Sends `{event, ...context}` as JSON on stdin. Parses stdout as JSON
216
+ * `{ decision: "allow" | "deny", reason?: string, hookSpecificOutput?: any }`.
217
+ *
218
+ * Gating logic:
219
+ * - `decision: "deny"` → blocks (returns false).
220
+ * - `decision: "allow"` or omitted decision → allow (returns true).
221
+ * - Non-zero exit code → block.
222
+ * - Invalid/empty JSON on stdout → fall back to exit code (0 = allow).
223
+ * - Timeout or spawn error → block.
224
+ */
225
+ async function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
226
+ let stdout;
227
+ try {
228
+ stdout = await runJsonIoHookCaptureStdout(command, env, event, ctx, timeoutMs);
229
+ }
230
+ catch {
231
+ // timeout, spawn error, or non-zero exit — block
232
+ return false;
233
+ }
234
+ // Empty stdout → treat exit code as the signal (allow for exit 0).
235
+ if (!stdout.trim()) {
236
+ return true;
237
+ }
238
+ try {
239
+ const parsed = JSON.parse(stdout);
240
+ return parsed.decision !== "deny";
241
+ }
242
+ catch {
243
+ // Malformed JSON with a zero exit — fail closed conservatively.
244
+ return false;
245
+ }
246
+ }
224
247
  /** Run an HTTP hook. POSTs context as JSON, expects { allowed: true/false }. */
225
248
  async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
226
249
  try {
@@ -398,4 +421,150 @@ export async function emitHookAsync(event, ctx = {}) {
398
421
  }
399
422
  return true;
400
423
  }
424
+ /** Parse a hook's stdout as a jsonIO envelope. Returns an empty object on malformed input. */
425
+ export function parseJsonIoResponse(raw) {
426
+ let obj;
427
+ try {
428
+ obj = JSON.parse(raw);
429
+ }
430
+ catch {
431
+ return {};
432
+ }
433
+ if (!obj || typeof obj !== "object" || Array.isArray(obj))
434
+ return {};
435
+ const rec = obj;
436
+ const out = {};
437
+ if (rec.decision === "allow" || rec.decision === "deny")
438
+ out.decision = rec.decision;
439
+ if (typeof rec.reason === "string")
440
+ out.reason = rec.reason;
441
+ const hso = rec.hookSpecificOutput;
442
+ if (hso && typeof hso === "object" && !Array.isArray(hso)) {
443
+ const hsoRec = hso;
444
+ if (typeof hsoRec.additionalContext === "string")
445
+ out.additionalContext = hsoRec.additionalContext;
446
+ if (hsoRec.decision === "allow" || hsoRec.decision === "deny" || hsoRec.decision === "ask") {
447
+ out.permissionDecision = hsoRec.decision;
448
+ }
449
+ if (typeof hsoRec.reason === "string" && !out.reason)
450
+ out.reason = hsoRec.reason;
451
+ }
452
+ return out;
453
+ }
454
+ /** Events for which "notify-only" semantics apply — outcome.allowed is always true. */
455
+ const NOTIFY_ONLY_OUTCOME_EVENTS = new Set(["postToolUseFailure"]);
456
+ /**
457
+ * Map a command-hook's boolean (exit 0 / nonzero) result to a ParsedJsonIoResponse
458
+ * for the given event, applying per-event semantics:
459
+ *
460
+ * - userPromptSubmit: exit 0 → allow ({}); nonzero → deny.
461
+ * - permissionRequest: exit 0 → "ask" (fall through to user); nonzero → deny.
462
+ * - postToolUseFailure: notify-only — exit code is irrelevant, always return {}.
463
+ * - All other events: same as userPromptSubmit (exit 0 allow, nonzero deny).
464
+ */
465
+ function mapEnvExitToOutcome(event, allowed) {
466
+ switch (event) {
467
+ case "permissionRequest":
468
+ return allowed
469
+ ? { permissionDecision: "ask" }
470
+ : { permissionDecision: "deny", decision: "deny", reason: "hook denied (exit code)" };
471
+ case "postToolUseFailure":
472
+ // notify-only; exit code is irrelevant
473
+ return {};
474
+ default:
475
+ return allowed ? {} : { decision: "deny", reason: "hook denied (exit code)" };
476
+ }
477
+ }
478
+ /**
479
+ * Execute a single hook definition and return a ParsedJsonIoResponse for outcome merging.
480
+ * Private to this module — not exported.
481
+ */
482
+ async function runHookForOutcome(def, event, ctx) {
483
+ if (def.jsonIO && def.command) {
484
+ const env = buildEnv(event, ctx);
485
+ let raw;
486
+ try {
487
+ raw = await runJsonIoHookCaptureStdout(def.command, env, event, ctx, def.timeout ?? 10_000);
488
+ }
489
+ catch {
490
+ // timeout, spawn error, non-zero exit — treat as deny for gating events
491
+ return { decision: "deny", reason: "hook failed (timeout or non-zero exit)" };
492
+ }
493
+ if (!raw.trim()) {
494
+ // empty stdout with exit 0 — treat as allow (no decision)
495
+ return {};
496
+ }
497
+ return parseJsonIoResponse(raw);
498
+ }
499
+ if (def.command) {
500
+ // env-var mode — apply per-event exit-code semantics
501
+ const env = buildEnv(event, ctx);
502
+ const code = await runCommandHookAsync(def.command, env, def.timeout ?? 10_000);
503
+ return mapEnvExitToOutcome(event, code === 0);
504
+ }
505
+ if (def.http) {
506
+ const allowed = await runHttpHook(def.http, event, ctx, def.timeout ?? 10_000);
507
+ return mapEnvExitToOutcome(event, allowed);
508
+ }
509
+ if (def.prompt) {
510
+ const allowed = await runPromptHook(def.prompt, ctx, def.timeout ?? 10_000);
511
+ return allowed ? {} : { decision: "deny", reason: "prompt hook denied" };
512
+ }
513
+ return {};
514
+ }
515
+ /**
516
+ * Emit a hook event and return a structured HookOutcome parsed from jsonIO responses.
517
+ *
518
+ * Merge semantics:
519
+ * - First `deny` (or `permissionDecision: "deny"`) short-circuits: {allowed: false, ...}.
520
+ * - `permissionDecision: "allow"` short-circuits: {allowed: true, permissionDecision: "allow"}.
521
+ * - `additionalContext` from multiple hooks is concatenated in order, "\n\n" separated.
522
+ * - For NOTIFY_ONLY_OUTCOME_EVENTS (postToolUseFailure), decision/permissionDecision
523
+ * from hooks is ignored — outcome.allowed is always true. additionalContext is still collected.
524
+ */
525
+ export async function emitHookWithOutcome(event, ctx = {}) {
526
+ const hooks = getHooks();
527
+ const list = hooks?.[event];
528
+ if (!list || list.length === 0)
529
+ return { allowed: true };
530
+ const notifyOnly = NOTIFY_ONLY_OUTCOME_EVENTS.has(event);
531
+ const additionalContexts = [];
532
+ let reason;
533
+ let askSeen = false;
534
+ for (const def of list) {
535
+ if (def.match && !matchesHook(def, ctx))
536
+ continue;
537
+ const parsed = await runHookForOutcome(def, event, ctx);
538
+ if (!notifyOnly) {
539
+ if (parsed.decision === "deny" || parsed.permissionDecision === "deny") {
540
+ return {
541
+ allowed: false,
542
+ reason: parsed.reason ?? reason,
543
+ permissionDecision: parsed.permissionDecision,
544
+ };
545
+ }
546
+ if (parsed.permissionDecision === "allow") {
547
+ if (parsed.additionalContext)
548
+ additionalContexts.push(parsed.additionalContext);
549
+ return {
550
+ allowed: true,
551
+ permissionDecision: "allow",
552
+ additionalContext: additionalContexts.length ? additionalContexts.join("\n\n") : undefined,
553
+ };
554
+ }
555
+ if (parsed.permissionDecision === "ask")
556
+ askSeen = true;
557
+ }
558
+ if (parsed.additionalContext)
559
+ additionalContexts.push(parsed.additionalContext);
560
+ if (!reason && parsed.reason)
561
+ reason = parsed.reason;
562
+ }
563
+ return {
564
+ allowed: true,
565
+ additionalContext: additionalContexts.length ? additionalContexts.join("\n\n") : undefined,
566
+ permissionDecision: askSeen ? "ask" : undefined,
567
+ reason,
568
+ };
569
+ }
401
570
  //# sourceMappingURL=hooks.js.map