@zhijiewang/openharness 2.20.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) {
@@ -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"];
@@ -76,6 +76,10 @@ 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[];
79
83
  /** Fires once per system-prompt build after CLAUDE.md / global-rules / project RULES.md / user profile have been concatenated. Useful for audit trails. */
80
84
  instructionsLoaded?: HookDef[];
81
85
  };
@@ -111,6 +115,15 @@ export type OhConfig = {
111
115
  baseUrl?: string;
112
116
  mcpServers?: McpServerConfig[];
113
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;
114
127
  toolPermissions?: ToolPermissionRule[];
115
128
  statusLineFormat?: string;
116
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" | "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" | "instructionsLoaded";
14
14
  export type HookContext = {
15
15
  toolName?: string;
16
16
  toolArgs?: string;
@@ -60,6 +60,12 @@ 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;
63
69
  /** For instructionsLoaded: count of rules concatenated (as a string for env-var parity) */
64
70
  rulesCount?: string;
65
71
  /** For instructionsLoaded: total character length of the loaded rules */
@@ -68,6 +74,11 @@ export type HookContext = {
68
74
  export declare function getHooks(): HooksConfig | null;
69
75
  /** Clear hook cache (call after config changes) */
70
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;
71
82
  /**
72
83
  * Evaluate a hook matcher against the current tool name.
73
84
  *
@@ -10,6 +10,7 @@
10
10
  * - prompt: LLM yes/no check via provider.complete()
11
11
  */
12
12
  import { spawn, spawnSync } from "node:child_process";
13
+ import { debug } from "../utils/debug.js";
13
14
  import { readOhConfig } from "./config.js";
14
15
  let cachedHooks;
15
16
  export function getHooks() {
@@ -22,6 +23,18 @@ export function getHooks() {
22
23
  /** Clear hook cache (call after config changes) */
23
24
  export function invalidateHookCache() {
24
25
  cachedHooks = undefined;
26
+ cachedDisableAllHooks = undefined;
27
+ }
28
+ let cachedDisableAllHooks;
29
+ /**
30
+ * Whether the configured `disableAllHooks` kill switch is set.
31
+ * Cached so the per-emit cost is a single boolean read.
32
+ */
33
+ export function areHooksEnabled() {
34
+ if (cachedDisableAllHooks === undefined) {
35
+ cachedDisableAllHooks = readOhConfig()?.disableAllHooks === true;
36
+ }
37
+ return !cachedDisableAllHooks;
25
38
  }
26
39
  function buildEnv(event, ctx) {
27
40
  const env = {
@@ -71,6 +84,12 @@ function buildEnv(event, ctx) {
71
84
  env.OH_TURN_NUMBER = ctx.turnNumber;
72
85
  if (ctx.turnReason !== undefined)
73
86
  env.OH_TURN_REASON = ctx.turnReason;
87
+ if (ctx.worktreePath !== undefined)
88
+ env.OH_WORKTREE_PATH = ctx.worktreePath;
89
+ if (ctx.worktreeParent !== undefined)
90
+ env.OH_WORKTREE_PARENT = ctx.worktreeParent;
91
+ if (ctx.worktreeForced !== undefined)
92
+ env.OH_WORKTREE_FORCED = ctx.worktreeForced;
74
93
  return env;
75
94
  }
76
95
  /**
@@ -400,10 +419,14 @@ async function executeHookDef(def, event, ctx) {
400
419
  * All other hooks run asynchronously to avoid blocking the event loop.
401
420
  */
402
421
  export function emitHook(event, ctx = {}) {
422
+ if (!areHooksEnabled())
423
+ return true;
403
424
  const hooks = getHooks();
404
425
  if (!hooks)
405
426
  return true;
406
427
  const defs = hooks[event] ?? [];
428
+ if (defs.length > 0)
429
+ debug("hooks", "fire", { event, count: defs.length, tool: ctx.toolName });
407
430
  const env = buildEnv(event, ctx);
408
431
  if (event === "preToolUse") {
409
432
  // preToolUse command hooks must be synchronous — they gate tool execution
@@ -456,6 +479,8 @@ export function emitHook(event, ctx = {}) {
456
479
  * Supports all hook types (command, HTTP, prompt).
457
480
  */
458
481
  export async function emitHookAsync(event, ctx = {}) {
482
+ if (!areHooksEnabled())
483
+ return true;
459
484
  const hooks = getHooks();
460
485
  if (!hooks)
461
486
  return true;
@@ -570,6 +595,8 @@ async function runHookForOutcome(def, event, ctx) {
570
595
  * from hooks is ignored — outcome.allowed is always true. additionalContext is still collected.
571
596
  */
572
597
  export async function emitHookWithOutcome(event, ctx = {}) {
598
+ if (!areHooksEnabled())
599
+ return { allowed: true };
573
600
  const hooks = getHooks();
574
601
  const list = hooks?.[event];
575
602
  if (!list || list.length === 0)
package/dist/main.js CHANGED
@@ -23,9 +23,10 @@ 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, loadMcpPrompts, loadMcpTools, } from "./mcp/loader.js";
26
+ import { connectedMcpServers, disconnectMcpClients, getMcpInstructions, loadMcpPrompts, loadMcpTools, parseMcpConfigFile, } from "./mcp/loader.js";
27
27
  import { loadOutputStyle } from "./outputStyles/index.js";
28
28
  import { getAllTools } from "./tools.js";
29
+ import { configureDebug, debug } from "./utils/debug.js";
29
30
  import { validateAgainstJsonSchema } from "./utils/json-schema.js";
30
31
  import { parseMaxBudgetUsd } from "./utils/parse-budget.js";
31
32
  const _require = createRequire(import.meta.url);
@@ -75,6 +76,40 @@ You have access to tools for reading, writing, and searching files, running shel
75
76
  - When referencing code, include file_path:line_number.
76
77
  - Do not restate what the user said. Do not add trailing summaries unless asked.
77
78
  - Keep responses short and direct. If you can say it in one sentence, don't use three.`;
79
+ /**
80
+ * Read a system prompt from a file path, or exit 2 with a stderr message.
81
+ * Used by `--system-prompt-file` / `--append-system-prompt-file` so callers
82
+ * can keep prompts as version-controlled files instead of stuffing them on
83
+ * the command line. Trailing newline is stripped (most editors add one).
84
+ */
85
+ function readSystemPromptFile(path, label) {
86
+ try {
87
+ return readFileSync(path, "utf8").replace(/\n$/, "");
88
+ }
89
+ catch (err) {
90
+ const message = err instanceof Error ? err.message : String(err);
91
+ process.stderr.write(`Error: ${label} '${path}' could not be read: ${message}\n`);
92
+ process.exit(2);
93
+ }
94
+ }
95
+ /**
96
+ * Parse `--mcp-config <path>` (and the optional `--strict-mcp-config` flag)
97
+ * into a `LoadMcpOptions` shape ready to pass to `loadMcpTools`. Returns
98
+ * undefined when the user didn't pass `--mcp-config`. Exits 2 with a stderr
99
+ * message on parse / shape errors.
100
+ */
101
+ function buildMcpLoadOpts(opts) {
102
+ if (!opts.mcpConfig)
103
+ return undefined;
104
+ try {
105
+ const extraServers = parseMcpConfigFile(opts.mcpConfig);
106
+ return { extraServers, strict: opts.strictMcpConfig === true };
107
+ }
108
+ catch (err) {
109
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
110
+ process.exit(2);
111
+ }
112
+ }
78
113
  /**
79
114
  * Parse the `--max-budget-usd` CLI argument into a positive USD amount, or
80
115
  * exit 2 with an error message. The pure parser lives in
@@ -89,7 +124,19 @@ function parseMaxBudgetUsdOrExit(raw) {
89
124
  }
90
125
  return result.value;
91
126
  }
92
- function buildSystemPrompt(model) {
127
+ /**
128
+ * Build the assembled system prompt for a session.
129
+ *
130
+ * In `bare` mode (audit A4 — `--bare`) every optional contributor is skipped:
131
+ * no project context, no rules, no user profile, no remembered memories, no
132
+ * skill catalog, no MCP server instructions, no language directive, no output
133
+ * style. The result is exactly `DEFAULT_SYSTEM_PROMPT`. Used for fast SDK /
134
+ * CI invocations where the model just needs the tool-use baseline and the
135
+ * caller will supply its own context.
136
+ */
137
+ function buildSystemPrompt(model, opts = {}) {
138
+ if (opts.bare)
139
+ return DEFAULT_SYSTEM_PROMPT;
93
140
  const cfg = readOhConfig();
94
141
  // Output-style preface (first — sets personality for everything that follows).
95
142
  // Skipped silently for the "default" style (empty prompt).
@@ -146,13 +193,27 @@ program
146
193
  .addOption(new Option("--output-format <format>", "Output format").choices(["json", "text", "stream-json"]).default("text"))
147
194
  .option("--max-turns <n>", "Maximum turns", "20")
148
195
  .option("--system-prompt <prompt>", "Override the system prompt")
196
+ .option("--system-prompt-file <path>", "Read the system prompt from a file (overrides --system-prompt)")
149
197
  .option("--append-system-prompt <text>", "Append text to the system prompt")
198
+ .option("--append-system-prompt-file <path>", "Append the contents of a file to the system prompt")
150
199
  .option("--allowed-tools <tools>", "Comma-separated list of allowed tools")
151
200
  .option("--disallowed-tools <tools>", "Comma-separated list of disallowed tools")
152
201
  .option("--resume <id>", "Resume a saved session (replays its message history before this prompt)")
153
202
  .option("--setting-sources <sources>", "Comma-separated list of setting sources to merge (e.g. 'user,project,local'). Mirrors Claude Code's setting_sources.")
154
203
  .option("--max-budget-usd <amount>", "Hard cap on session cost in USD. The agent halts with reason 'budget_exceeded' once totalCost reaches this amount. Mirrors Claude Code's --max-budget-usd.")
204
+ .option("--no-session-persistence", "Skip writing the session to disk under ~/.oh/sessions/. Useful for ephemeral CI runs that don't need resume.")
205
+ .option("--mcp-config <path>", 'Load MCP servers from a JSON file (in addition to .oh/config.yaml). File format: {"mcpServers": [...]} or a bare array.')
206
+ .option("--strict-mcp-config", "With --mcp-config, ignore .oh/config.yaml mcpServers — use only the file's servers.")
207
+ .option("--bare", "Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline. Useful for fast CI / SDK invocations.")
208
+ .option("--debug [categories]", "Enable categorized debug logs to stderr. Pass comma-separated categories (e.g. 'mcp,hooks') or no value for all. Also reads OH_DEBUG.")
209
+ .option("--debug-file <path>", "When --debug is set, append debug lines to this file instead of stderr.")
155
210
  .action(async (promptArg, opts) => {
211
+ configureDebug({
212
+ categories: opts.debug,
213
+ ...(opts.debugFile ? { file: opts.debugFile } : {}),
214
+ });
215
+ const bare = opts.bare === true;
216
+ debug("startup", "oh run", { bare, model: opts.model });
156
217
  // Read from stdin if prompt is "-" or omitted and stdin is not a TTY
157
218
  let prompt;
158
219
  if (!promptArg || promptArg === "-" || !process.stdin.isTTY) {
@@ -189,8 +250,14 @@ program
189
250
  overrides.baseUrl = savedConfig.baseUrl;
190
251
  const { provider, model } = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined);
191
252
  const { query } = await import("./query.js");
192
- // Tool filtering
193
- let tools = getAllTools();
253
+ // Tool list = built-ins + MCP server tools (project config + --mcp-config).
254
+ // Previously oh run skipped MCP entirely, which silently broke the SDK
255
+ // `tools=[...]` feature (the SDK injects mcpServers into a temp config but
256
+ // the CLI never read it back). `--bare` opts back out — built-ins only.
257
+ const mcpLoadOpts = buildMcpLoadOpts(opts);
258
+ const mcpTools = bare ? [] : await loadMcpTools(mcpLoadOpts);
259
+ debug("mcp", "loaded", { count: mcpTools.length, bare });
260
+ let tools = [...getAllTools(), ...mcpTools];
194
261
  if (opts.allowedTools) {
195
262
  const allowed = new Set(opts.allowedTools.split(",").map((s) => s.trim()));
196
263
  tools = tools.filter((t) => allowed.has(t.name));
@@ -199,13 +266,22 @@ program
199
266
  const disallowed = new Set(opts.disallowedTools.split(",").map((s) => s.trim()));
200
267
  tools = tools.filter((t) => !disallowed.has(t.name));
201
268
  }
202
- // System prompt
269
+ process.on("exit", () => disconnectMcpClients());
270
+ // System prompt — file variants take precedence over inline string variants
271
+ // so callers can override-from-file without removing a stale --system-prompt
272
+ // they were previously passing.
203
273
  let systemPrompt;
204
- if (opts.systemPrompt) {
274
+ if (opts.systemPromptFile) {
275
+ systemPrompt = readSystemPromptFile(opts.systemPromptFile, "--system-prompt-file");
276
+ }
277
+ else if (opts.systemPrompt) {
205
278
  systemPrompt = opts.systemPrompt;
206
279
  }
207
280
  else {
208
- systemPrompt = buildSystemPrompt(model);
281
+ systemPrompt = buildSystemPrompt(model, { bare });
282
+ }
283
+ if (opts.appendSystemPromptFile) {
284
+ systemPrompt += `\n\n${readSystemPromptFile(opts.appendSystemPromptFile, "--append-system-prompt-file")}`;
209
285
  }
210
286
  if (opts.appendSystemPrompt) {
211
287
  systemPrompt += `\n\n${opts.appendSystemPrompt}`;
@@ -233,6 +309,8 @@ program
233
309
  // --resume <id> on a later run. Without this, every fresh `oh run` was
234
310
  // a programmatic dead-end for resumption (issue #60).
235
311
  const { createSession, loadSession, saveSession } = await import("./harness/session.js");
312
+ // Commander rewrites --no-session-persistence to opts.sessionPersistence === false.
313
+ const persistSession = opts.sessionPersistence !== false;
236
314
  let priorMessages;
237
315
  let sessionId;
238
316
  let sessionRecord;
@@ -250,7 +328,8 @@ program
250
328
  else {
251
329
  sessionRecord = createSession(provider.name, model);
252
330
  sessionId = sessionRecord.id;
253
- saveSession(sessionRecord);
331
+ if (persistSession)
332
+ saveSession(sessionRecord);
254
333
  }
255
334
  if (outputFormat === "stream-json") {
256
335
  // Emit a session_start event so SDK callers can capture the id for
@@ -352,16 +431,18 @@ program
352
431
  // they're per-tool ephemerals; the assistant's final text is what
353
432
  // matters for context resumption. Mirrors the REPL's save-on-exit pattern
354
433
  // (src/components/REPL.tsx:120) but at one-shot scope.
355
- try {
356
- const { createUserMessage, createAssistantMessage } = await import("./types/message.js");
357
- const newMessages = [...(priorMessages ?? []), createUserMessage(prompt)];
358
- if (fullOutput)
359
- newMessages.push(createAssistantMessage(fullOutput));
360
- sessionRecord.messages = newMessages;
361
- saveSession(sessionRecord);
362
- }
363
- catch {
364
- /* persistence is best-effort — never fail the user's run on a save error */
434
+ if (persistSession) {
435
+ try {
436
+ const { createUserMessage, createAssistantMessage } = await import("./types/message.js");
437
+ const newMessages = [...(priorMessages ?? []), createUserMessage(prompt)];
438
+ if (fullOutput)
439
+ newMessages.push(createAssistantMessage(fullOutput));
440
+ sessionRecord.messages = newMessages;
441
+ saveSession(sessionRecord);
442
+ }
443
+ catch {
444
+ /* persistence is best-effort — never fail the user's run on a save error */
445
+ }
365
446
  }
366
447
  });
367
448
  // ── `oh session`: long-lived stateful session for the Python SDK ──
@@ -376,10 +457,25 @@ program
376
457
  .option("--disallowed-tools <tools>", "Comma-separated disallowed tool names")
377
458
  .option("--max-turns <n>", "Maximum turns per prompt", "20")
378
459
  .option("--system-prompt <prompt>", "Override the system prompt")
460
+ .option("--system-prompt-file <path>", "Read the system prompt from a file (overrides --system-prompt)")
461
+ .option("--append-system-prompt <text>", "Append text to the system prompt")
462
+ .option("--append-system-prompt-file <path>", "Append the contents of a file to the system prompt")
379
463
  .option("--resume <id>", "Resume a saved session (seeds the conversation with its prior message history)")
380
464
  .option("--setting-sources <sources>", "Comma-separated list of setting sources to merge (mirrors Claude Code's setting_sources).")
381
465
  .option("--max-budget-usd <amount>", "Hard cap on session cost in USD. Each prompt's cost accumulates; the agent halts with reason 'budget_exceeded' once totalCost reaches this amount.")
466
+ .option("--no-session-persistence", "Skip writing the session to disk under ~/.oh/sessions/. Useful for ephemeral SDK clients that don't need resume.")
467
+ .option("--mcp-config <path>", 'Load MCP servers from a JSON file (in addition to .oh/config.yaml). File format: {"mcpServers": [...]} or a bare array.')
468
+ .option("--strict-mcp-config", "With --mcp-config, ignore .oh/config.yaml mcpServers — use only the file's servers.")
469
+ .option("--bare", "Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline.")
470
+ .option("--debug [categories]", "Enable categorized debug logs to stderr. Pass comma-separated categories (e.g. 'mcp,hooks') or no value for all. Also reads OH_DEBUG.")
471
+ .option("--debug-file <path>", "When --debug is set, append debug lines to this file instead of stderr.")
382
472
  .action(async (opts) => {
473
+ configureDebug({
474
+ categories: opts.debug,
475
+ ...(opts.debugFile ? { file: opts.debugFile } : {}),
476
+ });
477
+ const bare = opts.bare === true;
478
+ debug("startup", "oh session", { bare, model: opts.model });
383
479
  const settingSources = parseSettingSources(opts.settingSources);
384
480
  const savedConfig = readOhConfig(undefined, settingSources);
385
481
  const permissionMode = (opts.permissionMode ??
@@ -395,7 +491,14 @@ program
395
491
  const { provider, model } = await createProvider(effectiveModel, Object.keys(overrides).length ? overrides : undefined);
396
492
  const { query } = await import("./query.js");
397
493
  const { createAssistantMessage, createToolResultMessage, createUserMessage } = await import("./types/message.js");
398
- let tools = getAllTools();
494
+ // Tool list = built-ins + MCP server tools (project config + --mcp-config).
495
+ // Same fix as `oh run` — `oh session` previously skipped MCP entirely,
496
+ // which silently broke the SDK `tools=[...]` feature for stateful clients.
497
+ // `--bare` opts back out — built-ins only.
498
+ const mcpLoadOpts = buildMcpLoadOpts(opts);
499
+ const mcpTools = bare ? [] : await loadMcpTools(mcpLoadOpts);
500
+ debug("mcp", "loaded", { count: mcpTools.length, bare });
501
+ let tools = [...getAllTools(), ...mcpTools];
399
502
  if (opts.allowedTools) {
400
503
  const allowed = new Set(opts.allowedTools.split(",").map((s) => s.trim()));
401
504
  tools = tools.filter((t) => allowed.has(t.name));
@@ -404,7 +507,23 @@ program
404
507
  const disallowed = new Set(opts.disallowedTools.split(",").map((s) => s.trim()));
405
508
  tools = tools.filter((t) => !disallowed.has(t.name));
406
509
  }
407
- const systemPrompt = opts.systemPrompt ?? buildSystemPrompt(model);
510
+ process.on("exit", () => disconnectMcpClients());
511
+ let systemPrompt;
512
+ if (opts.systemPromptFile) {
513
+ systemPrompt = readSystemPromptFile(opts.systemPromptFile, "--system-prompt-file");
514
+ }
515
+ else if (opts.systemPrompt) {
516
+ systemPrompt = opts.systemPrompt;
517
+ }
518
+ else {
519
+ systemPrompt = buildSystemPrompt(model, { bare });
520
+ }
521
+ if (opts.appendSystemPromptFile) {
522
+ systemPrompt += `\n\n${readSystemPromptFile(opts.appendSystemPromptFile, "--append-system-prompt-file")}`;
523
+ }
524
+ if (opts.appendSystemPrompt) {
525
+ systemPrompt += `\n\n${opts.appendSystemPrompt}`;
526
+ }
408
527
  const config = {
409
528
  provider,
410
529
  tools,
@@ -420,6 +539,8 @@ program
420
539
  // event for later resume (issue #60).
421
540
  const conversation = [];
422
541
  const { createSession, loadSession, saveSession } = await import("./harness/session.js");
542
+ // Commander rewrites --no-session-persistence to opts.sessionPersistence === false.
543
+ const persistSession = opts.sessionPersistence !== false;
423
544
  let sessionId;
424
545
  let sessionRecord;
425
546
  if (opts.resume) {
@@ -436,7 +557,8 @@ program
436
557
  else {
437
558
  sessionRecord = createSession(provider.name, model);
438
559
  sessionId = sessionRecord.id;
439
- saveSession(sessionRecord);
560
+ if (persistSession)
561
+ saveSession(sessionRecord);
440
562
  }
441
563
  let turnCounter = 0;
442
564
  // Will be set to the current prompt id before each turn so hook_decision
@@ -549,12 +671,15 @@ program
549
671
  }
550
672
  // Persist after every completed turn so a later --resume picks up the
551
673
  // history. Best-effort — a save failure shouldn't break the live session.
552
- try {
553
- sessionRecord.messages = conversation.slice();
554
- saveSession(sessionRecord);
555
- }
556
- catch {
557
- /* save errors don't propagate to the client */
674
+ // Skipped entirely when --no-session-persistence was passed.
675
+ if (persistSession) {
676
+ try {
677
+ sessionRecord.messages = conversation.slice();
678
+ saveSession(sessionRecord);
679
+ }
680
+ catch {
681
+ /* save errors don't propagate to the client */
682
+ }
558
683
  }
559
684
  }
560
685
  });
@@ -578,7 +703,16 @@ program
578
703
  .option("--json-schema <schema>", "Constrain output to match a JSON schema (headless mode)")
579
704
  .option("--input-format <format>", "Input format: text (default) or stream-json (NDJSON on stdin)")
580
705
  .option("--replay-user-messages", "Re-emit user messages on stdout (requires stream-json output)")
706
+ .option("--bare", "Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline.")
707
+ .option("--debug [categories]", "Enable categorized debug logs to stderr. Pass comma-separated categories (e.g. 'mcp,hooks') or no value for all. Also reads OH_DEBUG.")
708
+ .option("--debug-file <path>", "When --debug is set, append debug lines to this file instead of stderr.")
581
709
  .action(async (opts) => {
710
+ configureDebug({
711
+ categories: opts.debug,
712
+ ...(opts.debugFile ? { file: opts.debugFile } : {}),
713
+ });
714
+ const bare = opts.bare === true;
715
+ debug("startup", "oh chat", { bare, model: opts.model, print: !!opts.print });
582
716
  // Load saved config as defaults (env vars + CLI flags override)
583
717
  const savedConfig = readOhConfig();
584
718
  const effectiveModel = opts.model ?? savedConfig?.model;
@@ -648,25 +782,31 @@ program
648
782
  process.exit(0);
649
783
  }
650
784
  }
651
- const mcpTools = await loadMcpTools();
652
- const mcpNames = connectedMcpServers();
653
- if (mcpNames.length > 0) {
654
- console.log(`[mcp] Connected: ${mcpNames.join(", ")}`);
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(", ")}`);
785
+ // `--bare` skips MCP entirely (servers, prompts, instructions). The
786
+ // built-in tool set is still loaded — bare is about reducing optional
787
+ // startup work, not stripping the agent's tool surface.
788
+ const mcpTools = bare ? [] : await loadMcpTools();
789
+ if (!bare) {
790
+ const mcpNames = connectedMcpServers();
791
+ if (mcpNames.length > 0) {
792
+ console.log(`[mcp] Connected: ${mcpNames.join(", ")}`);
793
+ }
794
+ // Surface MCP-server prompts (`prompts/list`) as `/server:prompt` slash
795
+ // commands. Errors are swallowed inside loadMcpPrompts — servers that
796
+ // don't implement the prompts capability return [] without throwing.
797
+ try {
798
+ const { registerMcpPromptCommands } = await import("./commands/index.js");
799
+ const prompts = await loadMcpPrompts();
800
+ registerMcpPromptCommands(prompts);
801
+ if (prompts.length > 0) {
802
+ console.log(`[mcp] Prompts: ${prompts.map((p) => `/${p.qualifiedName}`).join(", ")}`);
803
+ }
804
+ }
805
+ catch {
806
+ /* prompt registration is best-effort; never block the REPL */
665
807
  }
666
808
  }
667
- catch {
668
- /* prompt registration is best-effort; never block the REPL */
669
- }
809
+ debug("mcp", "loaded", { count: mcpTools.length, bare });
670
810
  const tools = [...getAllTools(), ...mcpTools];
671
811
  process.on("exit", () => disconnectMcpClients());
672
812
  // Compute working directory and git branch
@@ -728,7 +868,7 @@ program
728
868
  const qConfig = {
729
869
  provider,
730
870
  tools,
731
- systemPrompt: buildSystemPrompt(resolvedModel),
871
+ systemPrompt: buildSystemPrompt(resolvedModel, { bare }),
732
872
  permissionMode: effectivePermMode,
733
873
  maxTurns: 20,
734
874
  model: resolvedModel,
@@ -1,6 +1,33 @@
1
+ import type { McpServerConfig } from "../harness/config.js";
1
2
  import type { Tool } from "../Tool.js";
2
- /** Load MCP tools from .oh/config.yaml mcpServers list. Returns empty array if none configured. */
3
- export declare function loadMcpTools(): Promise<Tool[]>;
3
+ /**
4
+ * Parse a `--mcp-config <path>` file. Format:
5
+ * - `{ "mcpServers": [...] }` — Claude Code convention (preferred)
6
+ * - `[ ... ]` — bare array of server configs (also accepted)
7
+ * - `{ "name": ..., ... }` — single-server object (also accepted)
8
+ *
9
+ * Validation is shape-only: each entry must be an object with a `name`.
10
+ * Connection-time validation happens in `McpClient.connect`. Throws on
11
+ * malformed JSON or unrecognised top-level shape.
12
+ */
13
+ export declare function parseMcpConfigFile(path: string): McpServerConfig[];
14
+ export interface LoadMcpOptions {
15
+ /**
16
+ * MCP servers loaded from sources outside `.oh/config.yaml` — typically
17
+ * a `--mcp-config <path>` file. Merged with the config-file servers
18
+ * unless `strict` is set, in which case these REPLACE the config-file
19
+ * servers entirely.
20
+ */
21
+ extraServers?: import("../harness/config.js").McpServerConfig[];
22
+ /**
23
+ * When `true`, ignore `cfg.mcpServers` and use only `extraServers`.
24
+ * No-op when `extraServers` is undefined (the config-file servers
25
+ * still load). Mirrors Claude Code's `--strict-mcp-config`.
26
+ */
27
+ strict?: boolean;
28
+ }
29
+ /** Load MCP tools from .oh/config.yaml mcpServers list (and/or `--mcp-config` overrides). Returns empty array if none configured. */
30
+ export declare function loadMcpTools(opts?: LoadMcpOptions): Promise<Tool[]>;
4
31
  /** Disconnect all MCP clients (call on exit) */
5
32
  export declare function disconnectMcpClients(): void;
6
33
  /** Names of connected MCP servers */
@@ -1,7 +1,52 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { readOhConfig } from "../harness/config.js";
3
+ import { debug } from "../utils/debug.js";
2
4
  import { McpClient } from "./client.js";
3
5
  import { DeferredMcpTool } from "./DeferredMcpTool.js";
4
6
  import { McpTool } from "./McpTool.js";
7
+ /**
8
+ * Parse a `--mcp-config <path>` file. Format:
9
+ * - `{ "mcpServers": [...] }` — Claude Code convention (preferred)
10
+ * - `[ ... ]` — bare array of server configs (also accepted)
11
+ * - `{ "name": ..., ... }` — single-server object (also accepted)
12
+ *
13
+ * Validation is shape-only: each entry must be an object with a `name`.
14
+ * Connection-time validation happens in `McpClient.connect`. Throws on
15
+ * malformed JSON or unrecognised top-level shape.
16
+ */
17
+ export function parseMcpConfigFile(path) {
18
+ const raw = readFileSync(path, "utf8");
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(raw);
22
+ }
23
+ catch (err) {
24
+ throw new Error(`--mcp-config '${path}' is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
25
+ }
26
+ let servers;
27
+ if (Array.isArray(parsed)) {
28
+ servers = parsed;
29
+ }
30
+ else if (parsed && typeof parsed === "object" && "mcpServers" in parsed) {
31
+ const list = parsed.mcpServers;
32
+ if (!Array.isArray(list)) {
33
+ throw new Error(`--mcp-config '${path}': mcpServers must be an array`);
34
+ }
35
+ servers = list;
36
+ }
37
+ else if (parsed && typeof parsed === "object" && "name" in parsed) {
38
+ servers = [parsed];
39
+ }
40
+ else {
41
+ throw new Error(`--mcp-config '${path}': expected an mcpServers array, a bare array, or a single server object`);
42
+ }
43
+ for (const s of servers) {
44
+ if (!s || typeof s !== "object" || typeof s.name !== "string") {
45
+ throw new Error(`--mcp-config '${path}': every server entry must be an object with a 'name' string`);
46
+ }
47
+ }
48
+ return servers;
49
+ }
5
50
  const connectedClients = [];
6
51
  let exitHandlerInstalled = false;
7
52
  function installExitHandler() {
@@ -28,11 +73,20 @@ function installExitHandler() {
28
73
  }
29
74
  /** Threshold: servers with more tools than this use deferred loading */
30
75
  const DEFERRED_THRESHOLD = 10;
31
- /** Load MCP tools from .oh/config.yaml mcpServers list. Returns empty array if none configured. */
32
- export async function loadMcpTools() {
76
+ /** Load MCP tools from .oh/config.yaml mcpServers list (and/or `--mcp-config` overrides). Returns empty array if none configured. */
77
+ export async function loadMcpTools(opts = {}) {
33
78
  installExitHandler();
34
79
  const cfg = readOhConfig();
35
- const servers = cfg?.mcpServers ?? [];
80
+ const fromConfig = opts.strict ? [] : (cfg?.mcpServers ?? []);
81
+ const fromExtra = opts.extraServers ?? [];
82
+ // Dedup by name — extras win on conflict so --mcp-config can override a
83
+ // project-config entry without --strict.
84
+ const byName = new Map();
85
+ for (const s of fromConfig)
86
+ byName.set(s.name, s);
87
+ for (const s of fromExtra)
88
+ byName.set(s.name, s);
89
+ const servers = Array.from(byName.values());
36
90
  if (servers.length === 0)
37
91
  return [];
38
92
  const tools = [];
@@ -45,10 +99,12 @@ export async function loadMcpTools() {
45
99
  for (const result of results) {
46
100
  if (result.status === "rejected") {
47
101
  console.warn(`[mcp] Failed to connect: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
102
+ debug("mcp", "connect failed", result.reason);
48
103
  continue;
49
104
  }
50
105
  const { client, defs, server } = result.value;
51
106
  connectedClients.push(client);
107
+ debug("mcp", "connected", { server: server.name, tools: defs.length, deferred: defs.length > DEFERRED_THRESHOLD });
52
108
  if (defs.length > DEFERRED_THRESHOLD) {
53
109
  for (const def of defs) {
54
110
  tools.push(new DeferredMcpTool(client, def.name, def.description ?? "", server.riskLevel));
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { createWorktree, isGitRepo } from "../../git/index.js";
3
+ import { emitHook } from "../../harness/hooks.js";
3
4
  const inputSchema = z.object({
4
5
  branch: z.string().optional().describe("Branch name for the worktree (auto-generated if omitted)"),
5
6
  });
@@ -22,6 +23,9 @@ export const EnterWorktreeTool = {
22
23
  if (!path) {
23
24
  return { output: "Failed to create worktree.", isError: true };
24
25
  }
26
+ // Symmetric to taskCreated — fire only on the success path so audit hooks
27
+ // can react to the new worktree (e.g. set up a per-worktree scratch dir).
28
+ emitHook("worktreeCreate", { worktreePath: path, worktreeParent: context.workingDir });
25
29
  return { output: `Worktree created at: ${path}\nUse ExitWorktree to clean up when done.`, isError: false };
26
30
  },
27
31
  prompt() {
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { hasWorktreeChanges, removeWorktree } from "../../git/index.js";
3
+ import { emitHook } from "../../harness/hooks.js";
3
4
  const inputSchema = z.object({
4
5
  path: z.string().describe("Path to the worktree to remove"),
5
6
  force: z.boolean().optional().describe("Force removal even with uncommitted changes"),
@@ -24,6 +25,12 @@ export const ExitWorktreeTool = {
24
25
  }
25
26
  try {
26
27
  removeWorktree(input.path);
28
+ // Fire after removeWorktree resolves so the hook only sees confirmed
29
+ // removals — symmetric to worktreeCreate firing on success.
30
+ emitHook("worktreeRemove", {
31
+ worktreePath: input.path,
32
+ worktreeForced: input.force ? "true" : "false",
33
+ });
27
34
  return { output: `Worktree removed: ${input.path}`, isError: false };
28
35
  }
29
36
  catch (err) {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Categorized debug logger — gates verbose internal traces behind a runtime
3
+ * switch so they're silent by default but easy to flip on for support / CI.
4
+ *
5
+ * Activation precedence (highest first):
6
+ * 1. `configureDebug({ categories })` from a CLI flag (`--debug [cats]`)
7
+ * 2. `OH_DEBUG` env var
8
+ *
9
+ * Sink precedence:
10
+ * 1. `configureDebug({ file })` from `--debug-file <path>`
11
+ * 2. `OH_DEBUG_FILE` env var
12
+ * 3. `process.stderr` (default)
13
+ *
14
+ * Categories are arbitrary strings — call sites pick them. The CLI accepts a
15
+ * comma-separated list (`--debug mcp,hooks`) or `--debug` alone for "all".
16
+ *
17
+ * Wire pattern:
18
+ * import { configureDebug, debug } from "./utils/debug.js";
19
+ * configureDebug({ categories: opts.debug, file: opts.debugFile });
20
+ * debug("mcp", "connected", server.name);
21
+ */
22
+ /**
23
+ * Parse the raw flag value into a Set of enabled categories.
24
+ *
25
+ * Accepted values:
26
+ * - `undefined` / empty / `false` → no debug
27
+ * - `true` / `"*"` / `"all"` / `"1"` → all categories
28
+ * - `"mcp,hooks,provider"` → comma-separated explicit list
29
+ *
30
+ * Whitespace is trimmed and empty entries dropped, so `"mcp, ,hooks"` is
31
+ * equivalent to `"mcp,hooks"`. Pure function — exposed for testability.
32
+ */
33
+ export declare function parseDebugCategories(raw: string | boolean | undefined): Set<string>;
34
+ export interface ConfigureDebugOptions {
35
+ /** CLI flag value: `--debug` → true, `--debug mcp` → "mcp", absent → undefined. */
36
+ categories?: string | boolean | undefined;
37
+ /** CLI flag value: `--debug-file <path>` — appended to, never truncated. */
38
+ file?: string;
39
+ /** Test injection — overrides the file/stderr sink. Not used at runtime. */
40
+ sink?: NodeJS.WritableStream;
41
+ }
42
+ /**
43
+ * Apply debug configuration. Safe to call multiple times — later calls fully
44
+ * replace earlier state. When `categories` is undefined, falls back to
45
+ * `OH_DEBUG`; when `file` is undefined, falls back to `OH_DEBUG_FILE`.
46
+ *
47
+ * File output uses `appendFileSync` rather than a `WriteStream` so each
48
+ * `debug()` line lands on disk before the function returns. That trades a
49
+ * little throughput for ordering guarantees that matter when debugging
50
+ * crashes — a streamed sink could lose its tail buffer on `process.exit`.
51
+ */
52
+ export declare function configureDebug(opts?: ConfigureDebugOptions): void;
53
+ /** Whether the given category is currently emitting. Cheap — a Set lookup. */
54
+ export declare function isDebugEnabled(category: string): boolean;
55
+ /**
56
+ * Emit a debug line for the given category. Cheap no-op when the category is
57
+ * disabled — argument formatting is skipped entirely. Each line is prefixed
58
+ * with `[debug:<cat>] +<elapsed_ms>ms` so categories interleave readably.
59
+ */
60
+ export declare function debug(category: string, ...args: unknown[]): void;
61
+ /** @internal Test-only: reset module-level state between cases. */
62
+ export declare function _resetDebugForTest(): void;
63
+ //# sourceMappingURL=debug.d.ts.map
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Categorized debug logger — gates verbose internal traces behind a runtime
3
+ * switch so they're silent by default but easy to flip on for support / CI.
4
+ *
5
+ * Activation precedence (highest first):
6
+ * 1. `configureDebug({ categories })` from a CLI flag (`--debug [cats]`)
7
+ * 2. `OH_DEBUG` env var
8
+ *
9
+ * Sink precedence:
10
+ * 1. `configureDebug({ file })` from `--debug-file <path>`
11
+ * 2. `OH_DEBUG_FILE` env var
12
+ * 3. `process.stderr` (default)
13
+ *
14
+ * Categories are arbitrary strings — call sites pick them. The CLI accepts a
15
+ * comma-separated list (`--debug mcp,hooks`) or `--debug` alone for "all".
16
+ *
17
+ * Wire pattern:
18
+ * import { configureDebug, debug } from "./utils/debug.js";
19
+ * configureDebug({ categories: opts.debug, file: opts.debugFile });
20
+ * debug("mcp", "connected", server.name);
21
+ */
22
+ import { appendFileSync } from "node:fs";
23
+ const ALL = "*";
24
+ let enabledCategories = new Set();
25
+ let debugFilePath;
26
+ let sinkOverride;
27
+ let started = Date.now();
28
+ /**
29
+ * Parse the raw flag value into a Set of enabled categories.
30
+ *
31
+ * Accepted values:
32
+ * - `undefined` / empty / `false` → no debug
33
+ * - `true` / `"*"` / `"all"` / `"1"` → all categories
34
+ * - `"mcp,hooks,provider"` → comma-separated explicit list
35
+ *
36
+ * Whitespace is trimmed and empty entries dropped, so `"mcp, ,hooks"` is
37
+ * equivalent to `"mcp,hooks"`. Pure function — exposed for testability.
38
+ */
39
+ export function parseDebugCategories(raw) {
40
+ if (raw === undefined || raw === false || raw === "")
41
+ return new Set();
42
+ if (raw === true)
43
+ return new Set([ALL]);
44
+ const lower = raw.toLowerCase();
45
+ if (lower === "*" || lower === "all" || lower === "true" || lower === "1")
46
+ return new Set([ALL]);
47
+ return new Set(raw
48
+ .split(",")
49
+ .map((s) => s.trim())
50
+ .filter(Boolean));
51
+ }
52
+ /**
53
+ * Apply debug configuration. Safe to call multiple times — later calls fully
54
+ * replace earlier state. When `categories` is undefined, falls back to
55
+ * `OH_DEBUG`; when `file` is undefined, falls back to `OH_DEBUG_FILE`.
56
+ *
57
+ * File output uses `appendFileSync` rather than a `WriteStream` so each
58
+ * `debug()` line lands on disk before the function returns. That trades a
59
+ * little throughput for ordering guarantees that matter when debugging
60
+ * crashes — a streamed sink could lose its tail buffer on `process.exit`.
61
+ */
62
+ export function configureDebug(opts = {}) {
63
+ const rawCats = opts.categories !== undefined ? opts.categories : process.env.OH_DEBUG;
64
+ enabledCategories = parseDebugCategories(rawCats);
65
+ sinkOverride = opts.sink;
66
+ debugFilePath = opts.sink ? undefined : (opts.file ?? process.env.OH_DEBUG_FILE);
67
+ started = Date.now();
68
+ }
69
+ /** Whether the given category is currently emitting. Cheap — a Set lookup. */
70
+ export function isDebugEnabled(category) {
71
+ return enabledCategories.has(ALL) || enabledCategories.has(category);
72
+ }
73
+ /**
74
+ * Emit a debug line for the given category. Cheap no-op when the category is
75
+ * disabled — argument formatting is skipped entirely. Each line is prefixed
76
+ * with `[debug:<cat>] +<elapsed_ms>ms` so categories interleave readably.
77
+ */
78
+ export function debug(category, ...args) {
79
+ if (!isDebugEnabled(category))
80
+ return;
81
+ const elapsed = Date.now() - started;
82
+ const formatted = args
83
+ .map((a) => {
84
+ if (typeof a === "string")
85
+ return a;
86
+ if (a instanceof Error)
87
+ return a.stack ?? a.message;
88
+ try {
89
+ return JSON.stringify(a);
90
+ }
91
+ catch {
92
+ return String(a);
93
+ }
94
+ })
95
+ .join(" ");
96
+ const line = `[debug:${category}] +${elapsed}ms ${formatted}\n`;
97
+ if (sinkOverride) {
98
+ sinkOverride.write(line);
99
+ }
100
+ else if (debugFilePath) {
101
+ try {
102
+ appendFileSync(debugFilePath, line);
103
+ }
104
+ catch (err) {
105
+ // Fall back to stderr so a broken --debug-file doesn't swallow output.
106
+ process.stderr.write(`[debug] could not append to '${debugFilePath}': ${err instanceof Error ? err.message : String(err)}\n`);
107
+ process.stderr.write(line);
108
+ debugFilePath = undefined;
109
+ }
110
+ }
111
+ else {
112
+ process.stderr.write(line);
113
+ }
114
+ }
115
+ /** @internal Test-only: reset module-level state between cases. */
116
+ export function _resetDebugForTest() {
117
+ enabledCategories = new Set();
118
+ debugFilePath = undefined;
119
+ sinkOverride = undefined;
120
+ started = Date.now();
121
+ }
122
+ //# sourceMappingURL=debug.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.20.0",
3
+ "version": "2.21.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {