@zhijiewang/openharness 2.19.0 → 2.21.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) {
@@ -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) {
@@ -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"];
@@ -66,6 +66,22 @@ 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 after EnterWorktreeTool successfully creates an isolated git worktree. */
80
+ worktreeCreate?: HookDef[];
81
+ /** Fires after ExitWorktreeTool successfully removes a git worktree. */
82
+ worktreeRemove?: HookDef[];
83
+ /** Fires once per system-prompt build after CLAUDE.md / global-rules / project RULES.md / user profile have been concatenated. Useful for audit trails. */
84
+ instructionsLoaded?: HookDef[];
69
85
  };
70
86
  export type ToolPermissionRule = {
71
87
  tool: string;
@@ -99,6 +115,15 @@ export type OhConfig = {
99
115
  baseUrl?: string;
100
116
  mcpServers?: McpServerConfig[];
101
117
  hooks?: HooksConfig;
118
+ /**
119
+ * Global kill switch for the hook system. When `true`, every `emitHook` /
120
+ * `emitHookAsync` / `emitHookWithOutcome` call short-circuits as if no
121
+ * hooks were configured — useful for one-off CI runs where the configured
122
+ * hooks would interfere. Configured hooks remain in `.oh/config.yaml` and
123
+ * are visible via `/hooks` so the off-state is auditable. Mirrors
124
+ * Claude Code's `disableAllHooks` setting.
125
+ */
126
+ disableAllHooks?: boolean;
102
127
  toolPermissions?: ToolPermissionRule[];
103
128
  statusLineFormat?: string;
104
129
  /** Verification loops — auto-run lint/typecheck after file edits */
@@ -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" | "worktreeCreate" | "worktreeRemove" | "instructionsLoaded";
14
14
  export type HookContext = {
15
15
  toolName?: string;
16
16
  toolArgs?: string;
@@ -42,10 +42,43 @@ 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 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 instructionsLoaded: count of rules concatenated (as a string for env-var parity) */
70
+ rulesCount?: string;
71
+ /** For instructionsLoaded: total character length of the loaded rules */
72
+ rulesChars?: string;
45
73
  };
46
74
  export declare function getHooks(): HooksConfig | null;
47
75
  /** Clear hook cache (call after config changes) */
48
76
  export declare function invalidateHookCache(): void;
77
+ /**
78
+ * Whether the configured `disableAllHooks` kill switch is set.
79
+ * Cached so the per-emit cost is a single boolean read.
80
+ */
81
+ export declare function areHooksEnabled(): boolean;
49
82
  /**
50
83
  * Evaluate a hook matcher against the current tool name.
51
84
  *