@zhijiewang/openharness 2.20.0 → 2.22.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
@@ -241,6 +241,7 @@ Over 80 commands are registered. The most-used ones are grouped below; see `/hel
241
241
  | `/memory` | View and search memories |
242
242
  | `/doctor` | Run diagnostic health checks |
243
243
  | `/hooks` | List loaded hooks grouped by event |
244
+ | `/reload-plugins` | Hot-reload plugins, skills, hooks, and MCP server connections without restarting the session |
244
245
 
245
246
  **Settings:**
246
247
  | Command | Description |
@@ -298,7 +299,7 @@ hooks:
298
299
  command: "scripts/cleanup.sh"
299
300
  ```
300
301
 
301
- **Event types** (17 total):
302
+ **Event types** (23 total):
302
303
 
303
304
  | Event | When it fires | Can block? |
304
305
  |-------|---------------|------------|
@@ -307,10 +308,13 @@ hooks:
307
308
  | `turnStart` | Top-level agent turn begins (after user prompt accepted) | — |
308
309
  | `turnStop` | Top-level agent turn ends (mirrors Claude Code's `Stop`) | — |
309
310
  | `userPromptSubmit` | Before user prompt reaches the LLM | yes — `decision: deny` |
311
+ | `userPromptExpansion` | Slash command produces an expanded prompt (audit trail) | — |
310
312
  | `preToolUse` | Before each tool call | yes — exit code 1 / `decision: deny` |
311
313
  | `postToolUse` | After successful tool execution | — |
312
314
  | `postToolUseFailure` | After tool throws or returns `isError: true` | — |
315
+ | `postToolBatch` | Once after a turn's full set of tool calls all resolve, before the next model call | — |
313
316
  | `permissionRequest` | When a tool needs approval (between `preToolUse` and the prompt) | yes — `decision: allow\|deny\|ask` |
317
+ | `permissionDenied` | When a tool call is denied (hook / user / headless / policy) | — |
314
318
  | `fileChanged` | After a tool modifies a file | — |
315
319
  | `cwdChanged` | After working directory changes | — |
316
320
  | `subagentStart` | A sub-agent is spawned | — |
@@ -319,6 +323,9 @@ hooks:
319
323
  | `postCompact` | After conversation compaction | — |
320
324
  | `configChange` | `.oh/config.yaml` is modified during the session | — |
321
325
  | `notification` | A notification is dispatched | — |
326
+ | `taskCreated` | `TaskCreate` persists a new task | — |
327
+ | `taskCompleted` | `TaskUpdate` transitions a task to `completed` | — |
328
+ | `instructionsLoaded` | `loadRulesAsPrompt` rebuilt the system prompt with rules in scope | — |
322
329
 
323
330
  Live introspection: run `/hooks` in-session to see exactly which hooks are loaded, grouped by event.
324
331
 
@@ -390,6 +397,15 @@ mcpServers:
390
397
 
391
398
  MCP tools appear alongside built-in tools. `/status` shows connected servers.
392
399
 
400
+ **MCP server prompts as slash commands** — servers that expose `prompts/list` (e.g., GitHub, Sentry, Linear) get their prompts surfaced as `/<server>:<prompt>` slash commands automatically. Arguments use a `key=value` syntax with quoting:
401
+
402
+ ```
403
+ /github:summarize-pr repo=acme/widget pr=42
404
+ /sentry:triage-issue issue=ABC-123 severity="high priority"
405
+ ```
406
+
407
+ Required arguments declared by the prompt template surface a usage error if missing (no model call). Run `/reload-plugins` to re-discover prompts after editing your MCP config.
408
+
393
409
  ### Remote MCP servers (HTTP / SSE)
394
410
 
395
411
  ```yaml
@@ -535,6 +551,10 @@ oh run "add error handling to api.ts" --json # JSON output
535
551
  # Pipe stdin
536
552
  cat error.log | oh run "what's wrong here?"
537
553
  git diff | oh run "review these changes"
554
+
555
+ # Hard cap on session cost — agent halts at the threshold with reason: "budget_exceeded"
556
+ oh run "review the diff" --model claude-sonnet-4-6 --max-budget-usd 0.50
557
+ oh session --model gpt-4o --max-budget-usd 5
538
558
  ```
539
559
 
540
560
  ### Structured output with `--json-schema`
package/README.zh-CN.md CHANGED
@@ -241,6 +241,7 @@ OH 注册了 80+ 个斜杠命令;下表只列出最常用的一部分。在会
241
241
  | `/memory` | 查看并搜索记忆 |
242
242
  | `/doctor` | 运行诊断健康检查 |
243
243
  | `/hooks` | 按事件列出已加载的钩子 |
244
+ | `/reload-plugins` | 不重启会话即可热重载插件、技能、钩子和 MCP 服务器连接 |
244
245
 
245
246
  **设置:**
246
247
  | 命令 | 描述 |
@@ -298,7 +299,7 @@ hooks:
298
299
  command: "scripts/cleanup.sh"
299
300
  ```
300
301
 
301
- **事件类型**(共 17 个):
302
+ **事件类型**(共 23 个):
302
303
 
303
304
  | 事件 | 触发时机 | 是否可阻止 |
304
305
  |-------|---------------|------------|
@@ -307,10 +308,13 @@ hooks:
307
308
  | `turnStart` | 顶层代理回合开始(用户提示词被接受后) | — |
308
309
  | `turnStop` | 顶层代理回合结束(对应 Claude Code 的 `Stop`) | — |
309
310
  | `userPromptSubmit` | 用户提示词到达 LLM 之前 | 是 —— `decision: deny` |
311
+ | `userPromptExpansion` | 斜杠命令展开成模型提示词时(用于审计追踪) | — |
310
312
  | `preToolUse` | 工具调用之前 | 是 —— 退出码 1 / `decision: deny` |
311
313
  | `postToolUse` | 工具成功执行之后 | — |
312
314
  | `postToolUseFailure` | 工具抛错或返回 `isError: true` | — |
315
+ | `postToolBatch` | 一个回合内全部工具调用都完成后、下一次模型调用之前 | — |
313
316
  | `permissionRequest` | 工具需要授权时(`preToolUse` 与询问之间) | 是 —— `decision: allow\|deny\|ask` |
317
+ | `permissionDenied` | 工具调用被拒绝时(钩子 / 用户 / 无头 / 策略) | — |
314
318
  | `fileChanged` | 工具修改文件之后 | — |
315
319
  | `cwdChanged` | 工作目录变更之后 | — |
316
320
  | `subagentStart` | 子代理被派生 | — |
@@ -319,6 +323,9 @@ hooks:
319
323
  | `postCompact` | 对话压缩之后 | — |
320
324
  | `configChange` | 会话过程中 `.oh/config.yaml` 被修改 | — |
321
325
  | `notification` | 通知被派发 | — |
326
+ | `taskCreated` | `TaskCreate` 持久化新任务后 | — |
327
+ | `taskCompleted` | `TaskUpdate` 将任务状态切换为 `completed` 时 | — |
328
+ | `instructionsLoaded` | `loadRulesAsPrompt` 重新构建系统提示并加载规则之后 | — |
322
329
 
323
330
  实时查看:在会话中运行 `/hooks` 可以按事件分组查看当前已加载的钩子。
324
331
 
@@ -390,6 +397,15 @@ mcpServers:
390
397
 
391
398
  MCP 工具会与内置工具并列出现。`/status` 会显示已连接的服务器。
392
399
 
400
+ **MCP 服务器提示词作为斜杠命令** —— 实现了 `prompts/list` 的服务器(如 GitHub、Sentry、Linear)会自动把它们的提示词暴露为 `/<server>:<prompt>` 斜杠命令。参数采用 `key=value` 语法,支持加引号:
401
+
402
+ ```
403
+ /github:summarize-pr repo=acme/widget pr=42
404
+ /sentry:triage-issue issue=ABC-123 severity="high priority"
405
+ ```
406
+
407
+ 提示词模板声明的 required 参数缺失时会直接报用法错误(不会调用模型)。修改 MCP 配置后运行 `/reload-plugins` 即可重新发现提示词。
408
+
393
409
  ### 远程 MCP 服务器(HTTP / SSE)
394
410
 
395
411
  ```yaml
@@ -535,6 +551,10 @@ oh run "add error handling to api.ts" --json # JSON 输出
535
551
  # 通过 stdin 输入
536
552
  cat error.log | oh run "what's wrong here?"
537
553
  git diff | oh run "review these changes"
554
+
555
+ # 会话总成本硬上限 —— 达到阈值时代理会以 reason: "budget_exceeded" 终止
556
+ oh run "review the diff" --model claude-sonnet-4-6 --max-budget-usd 0.50
557
+ oh session --model gpt-4o --max-budget-usd 5
538
558
  ```
539
559
 
540
560
  ### 使用 `--json-schema` 约束结构化输出
@@ -232,6 +232,16 @@ export function registerAICommands(register) {
232
232
  prependToPrompt: `Summarize this conversation concisely. Highlight the main topics discussed, decisions made, and any pending action items. Be brief but thorough.`,
233
233
  };
234
234
  });
235
+ register("recap", "One-sentence recap of the current session (lighter than /summarize)", (_args, ctx) => {
236
+ if (ctx.messages.length === 0) {
237
+ return { output: "No messages to recap.", handled: true };
238
+ }
239
+ return {
240
+ output: `[recap] ${ctx.messages.length} messages`,
241
+ handled: false,
242
+ prependToPrompt: `In ONE sentence (max ~25 words), recap what this session has accomplished so far. No preamble, no bullet list, no markdown — just the sentence. Skip topics still in progress; lead with what's done.`,
243
+ };
244
+ });
235
245
  register("explain", "Explain a file or concept", (args) => {
236
246
  const topic = args.trim();
237
247
  if (!topic) {
@@ -1,6 +1,23 @@
1
1
  /**
2
- * Session commands — /clear, /compact, /export, /history, /browse, /resume, /fork, /pin, /unpin
2
+ * Session commands — /clear, /compact, /copy, /export, /history, /browse, /resume, /fork, /pin, /unpin
3
3
  */
4
4
  import type { CommandHandler } from "./types.js";
5
+ /**
6
+ * Copy text to the system clipboard. Picks a platform-specific external tool
7
+ * — `clip.exe` (Windows / WSL), `pbcopy` (macOS), then `wl-copy` then
8
+ * `xclip -selection clipboard` (Linux). The first one that exits 0 wins.
9
+ *
10
+ * Pure helper exported for tests; everything is synchronous so the slash
11
+ * command response can include success/failure inline.
12
+ *
13
+ * @internal
14
+ */
15
+ export declare function copyToClipboard(text: string): {
16
+ ok: true;
17
+ tool: string;
18
+ } | {
19
+ ok: false;
20
+ reason: string;
21
+ };
5
22
  export declare function registerSessionCommands(register: (name: string, description: string, handler: CommandHandler) => void): void;
6
23
  //# sourceMappingURL=session.d.ts.map
@@ -1,12 +1,53 @@
1
1
  /**
2
- * Session commands — /clear, /compact, /export, /history, /browse, /resume, /fork, /pin, /unpin
2
+ * Session commands — /clear, /compact, /copy, /export, /history, /browse, /resume, /fork, /pin, /unpin
3
3
  */
4
+ import { spawnSync } from "node:child_process";
4
5
  import { existsSync, mkdirSync } from "node:fs";
5
- import { homedir } from "node:os";
6
+ import { homedir, platform } from "node:os";
6
7
  import { dirname, join, resolve } from "node:path";
7
8
  import { getContextWindow } from "../harness/cost.js";
8
9
  import { createSession, listSessions, loadSession, saveSession } from "../harness/session.js";
9
10
  import { compressMessages } from "../query/index.js";
11
+ /**
12
+ * Copy text to the system clipboard. Picks a platform-specific external tool
13
+ * — `clip.exe` (Windows / WSL), `pbcopy` (macOS), then `wl-copy` then
14
+ * `xclip -selection clipboard` (Linux). The first one that exits 0 wins.
15
+ *
16
+ * Pure helper exported for tests; everything is synchronous so the slash
17
+ * command response can include success/failure inline.
18
+ *
19
+ * @internal
20
+ */
21
+ export function copyToClipboard(text) {
22
+ const candidates = platform() === "win32"
23
+ ? [{ cmd: "clip", args: [] }]
24
+ : platform() === "darwin"
25
+ ? [{ cmd: "pbcopy", args: [] }]
26
+ : [
27
+ { cmd: "wl-copy", args: [] },
28
+ { cmd: "xclip", args: ["-selection", "clipboard"] },
29
+ { cmd: "xsel", args: ["--clipboard", "--input"] },
30
+ // WSL / generic-Linux-with-clip.exe-on-PATH fallback
31
+ { cmd: "clip.exe", args: [] },
32
+ ];
33
+ for (const { cmd, args } of candidates) {
34
+ try {
35
+ const result = spawnSync(cmd, args, { input: text, encoding: "utf8", shell: false });
36
+ if (!result.error && result.status === 0) {
37
+ return { ok: true, tool: cmd };
38
+ }
39
+ }
40
+ catch {
41
+ /* try the next candidate */
42
+ }
43
+ }
44
+ return {
45
+ ok: false,
46
+ reason: platform() === "linux"
47
+ ? "no clipboard tool found — install one of: wl-copy, xclip, xsel"
48
+ : `no working clipboard tool found for ${platform()}`,
49
+ };
50
+ }
10
51
  function formatMessagesAsMarkdown(messages) {
11
52
  const blocks = [];
12
53
  for (const m of messages) {
@@ -216,6 +257,45 @@ export function registerSessionCommands(register) {
216
257
  compactedMessages: kept,
217
258
  };
218
259
  });
260
+ register("copy", "Copy the Nth-last assistant response to the clipboard (default: most recent)", (args, ctx) => {
261
+ const raw = args.trim();
262
+ const n = raw ? parseInt(raw, 10) : 1;
263
+ if (raw && (Number.isNaN(n) || n < 1)) {
264
+ return {
265
+ output: `Usage: /copy [n]\n\nCopies the Nth-last assistant response (default: 1 = most recent).`,
266
+ handled: true,
267
+ };
268
+ }
269
+ // Walk messages newest-to-oldest, keeping only assistant messages with text.
270
+ // /export already filters out tool-result messages from rendering — same rule
271
+ // applies here: we want the model's reply text, not its tool plumbing.
272
+ const assistantMessages = ctx.messages
273
+ .filter((m) => m.role === "assistant" && typeof m.content === "string" && m.content.trim().length > 0)
274
+ .reverse();
275
+ if (assistantMessages.length === 0) {
276
+ return { output: "No assistant responses to copy yet.", handled: true };
277
+ }
278
+ if (n > assistantMessages.length) {
279
+ return {
280
+ output: `Only ${assistantMessages.length} assistant response(s) available; can't copy #${n}.`,
281
+ handled: true,
282
+ };
283
+ }
284
+ const target = assistantMessages[n - 1];
285
+ const text = target.content;
286
+ const result = copyToClipboard(text);
287
+ if (!result.ok) {
288
+ return {
289
+ output: `Could not copy to clipboard: ${result.reason}\n\nResponse text (${text.length} chars):\n${text}`,
290
+ handled: true,
291
+ };
292
+ }
293
+ const preview = text.length > 80 ? `${text.slice(0, 80)}…` : text;
294
+ return {
295
+ output: `Copied assistant response #${n} (${text.length} chars) via ${result.tool}: ${preview}`,
296
+ handled: true,
297
+ };
298
+ });
219
299
  register("search", "Search current conversation", (args, ctx) => {
220
300
  const term = args.trim().toLowerCase();
221
301
  if (!term) {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Settings commands — /theme, /companion, /fast, /keys, /effort, /sandbox, /permissions, /allowed-tools
2
+ * Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools
3
3
  */
4
4
  import type { CommandHandler } from "./types.js";
5
5
  export declare function registerSettingsCommands(register: (name: string, description: string, handler: CommandHandler) => void): void;
@@ -1,8 +1,47 @@
1
1
  /**
2
- * Settings commands — /theme, /companion, /fast, /keys, /effort, /sandbox, /permissions, /allowed-tools
2
+ * Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools
3
3
  */
4
+ import { spawn } from "node:child_process";
5
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
6
+ import { homedir, platform } from "node:os";
7
+ import { dirname, join } from "node:path";
4
8
  import { readOhConfig } from "../harness/config.js";
5
9
  import { loadKeybindings } from "../harness/keybindings.js";
10
+ const KEYBINDINGS_TEMPLATE = `[
11
+ { "key": "ctrl+d", "action": "/diff" },
12
+ { "key": "ctrl+l", "action": "/clear" },
13
+ { "key": "ctrl+u", "action": "/undo" },
14
+ { "key": "ctrl+s", "action": "/status" },
15
+ { "key": "ctrl+k ctrl+c", "action": "/cost" },
16
+ { "key": "ctrl+k ctrl+f", "action": "/fast" },
17
+ { "key": "ctrl+k ctrl+l", "action": "/log" }
18
+ ]
19
+ `;
20
+ /**
21
+ * Open a file in the user's editor — `$VISUAL` → `$EDITOR` → `notepad`
22
+ * (Windows) → `vi` (POSIX). The child uses `stdio: "ignore"` + `detached: true`
23
+ * + `unref()` so it is fully decoupled from the REPL and from `node --test`
24
+ * (which would otherwise hang waiting for the inherited stdio handle to
25
+ * close). The trade-off: terminal editors like `vi` / `vim` are not usable
26
+ * here — they need a TTY. That's fine for `/keybindings`, which targets a
27
+ * GUI-editor flow; an interactive in-REPL edit would be its own command.
28
+ */
29
+ function openInEditor(filePath) {
30
+ const editor = process.env.VISUAL || process.env.EDITOR || (platform() === "win32" ? "notepad" : "vi");
31
+ if (process.env.OH_NO_OPEN_EDITOR === "1") {
32
+ // Test / CI escape hatch — pretend the editor launched without actually
33
+ // spawning anything. Used so suite runs don't pop a notepad window.
34
+ return { command: editor, spawned: true };
35
+ }
36
+ try {
37
+ const child = spawn(editor, [filePath], { stdio: "ignore", shell: true, detached: true });
38
+ child.unref();
39
+ return { command: editor, spawned: true };
40
+ }
41
+ catch {
42
+ return { command: editor, spawned: false };
43
+ }
44
+ }
6
45
  export function registerSettingsCommands(register) {
7
46
  register("theme", "Switch theme (dark/light)", (args) => {
8
47
  const theme = args.trim().toLowerCase();
@@ -52,6 +91,37 @@ export function registerSettingsCommands(register) {
52
91
  shortcuts.push("", " Session:", " /vim Toggle Vim mode", " /browse Interactive session browser", " /theme dark|light Switch theme");
53
92
  return { output: shortcuts.join("\n"), handled: true };
54
93
  });
94
+ register("keybindings", "Open ~/.oh/keybindings.json in $EDITOR (creates a starter file if missing)", () => {
95
+ const path = join(homedir(), ".oh", "keybindings.json");
96
+ let createdNew = false;
97
+ if (!existsSync(path)) {
98
+ try {
99
+ mkdirSync(dirname(path), { recursive: true });
100
+ writeFileSync(path, KEYBINDINGS_TEMPLATE);
101
+ createdNew = true;
102
+ }
103
+ catch (err) {
104
+ return {
105
+ output: `Could not create ${path}: ${err instanceof Error ? err.message : String(err)}`,
106
+ handled: true,
107
+ };
108
+ }
109
+ }
110
+ const { command, spawned } = openInEditor(path);
111
+ if (!spawned) {
112
+ return {
113
+ output: `Could not launch ${command}. File path: ${path}\nSet $EDITOR or open it manually.`,
114
+ handled: true,
115
+ };
116
+ }
117
+ const lines = [
118
+ createdNew ? `Created starter file at ${path}` : `Opening ${path}`,
119
+ `Editor: ${command}`,
120
+ "",
121
+ "Edits take effect on the next session start. Reload now with /reload-plugins.",
122
+ ];
123
+ return { output: lines.join("\n"), handled: true };
124
+ });
55
125
  register("effort", "Set reasoning effort level (low/medium/high/max)", (args) => {
56
126
  const level = args.trim().toLowerCase();
57
127
  const valid = ["low", "medium", "high", "max"];
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Run the configured `apiKeyHelper` script and return its trimmed stdout as
3
+ * the API key (audit B8). Mirrors Claude Code's `apiKeyHelper`.
4
+ *
5
+ * Invocation:
6
+ * - shell: true (so `helper-script.sh` and pipelines work without an explicit shell)
7
+ * - 5s timeout (helper should be fast — it's invoked at credential-fetch time)
8
+ * - OH_PROVIDER env var set so a single helper can dispatch by provider
9
+ * - stderr captured and surfaced on failure
10
+ *
11
+ * Failure modes — all return undefined (caller falls through to legacy config):
12
+ * - non-zero exit code
13
+ * - timeout
14
+ * - empty stdout
15
+ * - spawn error (helper not found, permission denied, etc.)
16
+ *
17
+ * Failures are logged via `debug("config", ...)` so users can opt into
18
+ * visibility with `--debug config` without polluting normal output.
19
+ */
20
+ export interface RunApiKeyHelperOptions {
21
+ /** Provider name passed to the helper as `OH_PROVIDER`. */
22
+ provider: string;
23
+ /** Spawn timeout in ms. Defaults to 5_000. */
24
+ timeoutMs?: number;
25
+ }
26
+ /**
27
+ * Execute `command` via the user's shell with `OH_PROVIDER` set, return the
28
+ * trimmed stdout on success, undefined on any failure. Pure side-effect-only —
29
+ * no caching here; resolveApiKey owns lifetime.
30
+ */
31
+ export declare function runApiKeyHelper(command: string, opts: RunApiKeyHelperOptions): string | undefined;
32
+ //# sourceMappingURL=api-key-helper.d.ts.map
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Run the configured `apiKeyHelper` script and return its trimmed stdout as
3
+ * the API key (audit B8). Mirrors Claude Code's `apiKeyHelper`.
4
+ *
5
+ * Invocation:
6
+ * - shell: true (so `helper-script.sh` and pipelines work without an explicit shell)
7
+ * - 5s timeout (helper should be fast — it's invoked at credential-fetch time)
8
+ * - OH_PROVIDER env var set so a single helper can dispatch by provider
9
+ * - stderr captured and surfaced on failure
10
+ *
11
+ * Failure modes — all return undefined (caller falls through to legacy config):
12
+ * - non-zero exit code
13
+ * - timeout
14
+ * - empty stdout
15
+ * - spawn error (helper not found, permission denied, etc.)
16
+ *
17
+ * Failures are logged via `debug("config", ...)` so users can opt into
18
+ * visibility with `--debug config` without polluting normal output.
19
+ */
20
+ import { spawnSync } from "node:child_process";
21
+ import { debug } from "../utils/debug.js";
22
+ /**
23
+ * Execute `command` via the user's shell with `OH_PROVIDER` set, return the
24
+ * trimmed stdout on success, undefined on any failure. Pure side-effect-only —
25
+ * no caching here; resolveApiKey owns lifetime.
26
+ */
27
+ export function runApiKeyHelper(command, opts) {
28
+ const timeout = opts.timeoutMs ?? 5_000;
29
+ try {
30
+ const result = spawnSync(command, {
31
+ shell: true,
32
+ timeout,
33
+ stdio: ["ignore", "pipe", "pipe"],
34
+ env: { ...process.env, OH_PROVIDER: opts.provider },
35
+ encoding: "utf8",
36
+ });
37
+ if (result.error) {
38
+ debug("config", "apiKeyHelper spawn failed", { provider: opts.provider, err: result.error.message });
39
+ return undefined;
40
+ }
41
+ if (result.signal === "SIGTERM") {
42
+ debug("config", "apiKeyHelper timed out", { provider: opts.provider, timeoutMs: timeout });
43
+ return undefined;
44
+ }
45
+ if (result.status !== 0) {
46
+ const stderr = (result.stderr ?? "").toString().trim().slice(0, 500);
47
+ debug("config", "apiKeyHelper non-zero exit", {
48
+ provider: opts.provider,
49
+ exit: result.status,
50
+ stderr,
51
+ });
52
+ return undefined;
53
+ }
54
+ const out = (result.stdout ?? "").toString().trim();
55
+ if (!out) {
56
+ debug("config", "apiKeyHelper produced empty stdout", { provider: opts.provider });
57
+ return undefined;
58
+ }
59
+ debug("config", "apiKeyHelper resolved", { provider: opts.provider, length: out.length });
60
+ return out;
61
+ }
62
+ catch (err) {
63
+ debug("config", "apiKeyHelper threw", {
64
+ provider: opts.provider,
65
+ err: err instanceof Error ? err.message : String(err),
66
+ });
67
+ return undefined;
68
+ }
69
+ }
70
+ //# sourceMappingURL=api-key-helper.js.map
@@ -76,6 +76,23 @@ export type HooksConfig = {
76
76
  taskCreated?: HookDef[];
77
77
  /** Fires when a TaskUpdate tool call transitions a task to status "completed". */
78
78
  taskCompleted?: HookDef[];
79
+ /** Fires after EnterWorktreeTool successfully creates an isolated git worktree. */
80
+ worktreeCreate?: HookDef[];
81
+ /** Fires after ExitWorktreeTool successfully removes a git worktree. */
82
+ worktreeRemove?: HookDef[];
83
+ /**
84
+ * Fires when an MCP server issues an `elicitation/create` request — before
85
+ * any decision is made. Hook can return `permissionDecision: "allow"` to
86
+ * accept (sends `{action: "accept", content: {}}` to the server) or `"deny"`
87
+ * to decline. No decision falls through to the interactive handler (REPL)
88
+ * or, if absent, to a fail-safe `decline`.
89
+ */
90
+ elicitation?: HookDef[];
91
+ /**
92
+ * Fires after the elicitation response has been decided — symmetric to
93
+ * `elicitation`. Useful for audit trails that want the request/response pair.
94
+ */
95
+ elicitationResult?: HookDef[];
79
96
  /** Fires once per system-prompt build after CLAUDE.md / global-rules / project RULES.md / user profile have been concatenated. Useful for audit trails. */
80
97
  instructionsLoaded?: HookDef[];
81
98
  };
@@ -111,6 +128,27 @@ export type OhConfig = {
111
128
  baseUrl?: string;
112
129
  mcpServers?: McpServerConfig[];
113
130
  hooks?: HooksConfig;
131
+ /**
132
+ * Global kill switch for the hook system. When `true`, every `emitHook` /
133
+ * `emitHookAsync` / `emitHookWithOutcome` call short-circuits as if no
134
+ * hooks were configured — useful for one-off CI runs where the configured
135
+ * hooks would interfere. Configured hooks remain in `.oh/config.yaml` and
136
+ * are visible via `/hooks` so the off-state is auditable. Mirrors
137
+ * Claude Code's `disableAllHooks` setting.
138
+ */
139
+ disableAllHooks?: boolean;
140
+ /**
141
+ * Script invoked at credential-fetch time to produce an API key on stdout.
142
+ * Avoids storing keys in plaintext config or the encrypted store. Inserted
143
+ * between the encrypted-store and legacy-config steps in `resolveApiKey`.
144
+ * Mirrors Claude Code's `apiKeyHelper`.
145
+ *
146
+ * The configured command runs through the user's shell with a 5s timeout;
147
+ * stderr is captured and surfaced on failure. The provider name is passed
148
+ * via the `OH_PROVIDER` env var so a single helper can dispatch by provider
149
+ * (`if [ "$OH_PROVIDER" = "anthropic" ]; then ... fi`).
150
+ */
151
+ apiKeyHelper?: string;
114
152
  toolPermissions?: ToolPermissionRule[];
115
153
  statusLineFormat?: string;
116
154
  /** Verification loops — auto-run lint/typecheck after file edits */
@@ -15,10 +15,12 @@ export declare function deleteCredential(key: string): void;
15
15
  /** List credential keys (not values) */
16
16
  export declare function listCredentials(): string[];
17
17
  /**
18
- * Get API key for a provider, checking:
19
- * 1. Environment variable (highest priority)
20
- * 2. Encrypted credential store
21
- * 3. Config file (legacy plaintext, with migration prompt)
18
+ * Get API key for a provider, checking in priority order:
19
+ * 1. Environment variable
20
+ * 2. Encrypted credential store
21
+ * 3. `apiKeyHelper` config script (audit B8) runs the configured command
22
+ * with `OH_PROVIDER` set; trimmed stdout is the key. Failures fall through.
23
+ * 4. Config file (legacy plaintext, with migration into the encrypted store)
22
24
  */
23
25
  export declare function resolveApiKey(provider: string, configApiKey?: string): string | undefined;
24
26
  //# sourceMappingURL=credentials.d.ts.map
@@ -10,6 +10,8 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:
10
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { homedir, hostname, userInfo } from "node:os";
12
12
  import { join } from "node:path";
13
+ import { runApiKeyHelper } from "./api-key-helper.js";
14
+ import { readOhConfig } from "./config.js";
13
15
  const CRED_DIR = join(homedir(), ".oh");
14
16
  const CRED_PATH = join(CRED_DIR, "credentials.enc");
15
17
  const ALGORITHM = "aes-256-gcm";
@@ -74,10 +76,12 @@ export function listCredentials() {
74
76
  return Object.keys(loadStore());
75
77
  }
76
78
  /**
77
- * Get API key for a provider, checking:
78
- * 1. Environment variable (highest priority)
79
- * 2. Encrypted credential store
80
- * 3. Config file (legacy plaintext, with migration prompt)
79
+ * Get API key for a provider, checking in priority order:
80
+ * 1. Environment variable
81
+ * 2. Encrypted credential store
82
+ * 3. `apiKeyHelper` config script (audit B8) runs the configured command
83
+ * with `OH_PROVIDER` set; trimmed stdout is the key. Failures fall through.
84
+ * 4. Config file (legacy plaintext, with migration into the encrypted store)
81
85
  */
82
86
  export function resolveApiKey(provider, configApiKey) {
83
87
  // Environment variable names by provider
@@ -93,6 +97,13 @@ export function resolveApiKey(provider, configApiKey) {
93
97
  const stored = getCredential(`${provider}-api-key`);
94
98
  if (stored)
95
99
  return stored;
100
+ // apiKeyHelper script — let users plug in 1Password / pass / vault / etc.
101
+ const cfg = readOhConfig();
102
+ if (cfg?.apiKeyHelper) {
103
+ const fromHelper = runApiKeyHelper(cfg.apiKeyHelper, { provider });
104
+ if (fromHelper)
105
+ return fromHelper;
106
+ }
96
107
  // Legacy config (migrate on use)
97
108
  if (configApiKey) {
98
109
  // Auto-migrate to encrypted store
@@ -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" | "postToolBatch" | "userPromptSubmit" | "userPromptExpansion" | "permissionRequest" | "permissionDenied" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification" | "turnStart" | "turnStop" | "taskCreated" | "taskCompleted" | "instructionsLoaded";
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" | "worktreeCreate" | "worktreeRemove" | "elicitation" | "elicitationResult" | "instructionsLoaded";
14
14
  export type HookContext = {
15
15
  toolName?: string;
16
16
  toolArgs?: string;
@@ -60,6 +60,22 @@ export type HookContext = {
60
60
  taskSubject?: string;
61
61
  /** For taskCompleted: the previous status before completion (usually "in_progress") */
62
62
  taskPreviousStatus?: string;
63
+ /** For worktreeCreate/worktreeRemove: absolute path to the worktree directory */
64
+ worktreePath?: string;
65
+ /** For worktreeCreate/worktreeRemove: the parent repo directory the worktree was forked from */
66
+ worktreeParent?: string;
67
+ /** For worktreeRemove: whether `force: true` was passed to skip the dirty-state check */
68
+ worktreeForced?: string;
69
+ /** For elicitation/elicitationResult: the MCP server that issued the elicitation request */
70
+ elicitationServer?: string;
71
+ /** For elicitation/elicitationResult: human-readable message the server wants to show (capped at 500 chars) */
72
+ elicitationMessage?: string;
73
+ /** For elicitation: JSON-stringified `requestedSchema` from the server (capped at 2000 chars) */
74
+ elicitationSchema?: string;
75
+ /** For elicitationResult: the final action ("accept" | "decline" | "cancel") */
76
+ elicitationAction?: string;
77
+ /** For elicitationResult: JSON-stringified content payload returned to the server (when action="accept") */
78
+ elicitationContent?: string;
63
79
  /** For instructionsLoaded: count of rules concatenated (as a string for env-var parity) */
64
80
  rulesCount?: string;
65
81
  /** For instructionsLoaded: total character length of the loaded rules */
@@ -68,6 +84,11 @@ export type HookContext = {
68
84
  export declare function getHooks(): HooksConfig | null;
69
85
  /** Clear hook cache (call after config changes) */
70
86
  export declare function invalidateHookCache(): void;
87
+ /**
88
+ * Whether the configured `disableAllHooks` kill switch is set.
89
+ * Cached so the per-emit cost is a single boolean read.
90
+ */
91
+ export declare function areHooksEnabled(): boolean;
71
92
  /**
72
93
  * Evaluate a hook matcher against the current tool name.
73
94
  *