@zhijiewang/openharness 2.22.1 → 2.24.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
@@ -42,6 +42,8 @@ AI coding agent in your terminal. Works with any LLM -- free local models or clo
42
42
  - [Cybergotchi](#cybergotchi)
43
43
  - [MCP Servers](#mcp-servers)
44
44
  - [Providers](#providers)
45
+ - [Auth](#auth)
46
+ - [Update](#update)
45
47
  - [FAQ](#faq)
46
48
  - [Install](#install)
47
49
  - [Development](#development)
@@ -162,6 +164,7 @@ Available variables: `{model}`, `{tokens}` (input↑ output↓), `{cost}` ($X.XX
162
164
  | **Web** | | |
163
165
  | WebFetch | medium | Fetch URL content (SSRF-protected) |
164
166
  | WebSearch | medium | Search the web |
167
+ | ExaSearch | medium | Neural web search via Exa (requires `EXA_API_KEY`) |
165
168
  | RemoteTrigger | high | HTTP requests to webhooks/APIs |
166
169
  | **Tasks** | | |
167
170
  | TaskCreate | low | Create structured tasks |
@@ -216,6 +219,7 @@ Over 80 commands are registered. The most-used ones are grouped below; see `/hel
216
219
  | `/clear` | Clear conversation history |
217
220
  | `/compact` | Compress conversation to free context |
218
221
  | `/export` | Export conversation to markdown |
222
+ | `/copy [n]` | Copy the Nth-last assistant response to the system clipboard |
219
223
  | `/history [n]` | List recent sessions; `/history search <term>` to search |
220
224
  | `/browse` | Interactive session browser with preview |
221
225
  | `/resume <id>` | Resume a saved session |
@@ -249,12 +253,16 @@ Over 80 commands are registered. The most-used ones are grouped below; see `/hel
249
253
  | `/theme dark\|light` | Switch theme (saved to config) |
250
254
  | `/vim` | Toggle Vim mode |
251
255
  | `/companion off\|on` | Toggle companion visibility |
256
+ | `/keys` | Show keyboard shortcuts |
257
+ | `/keybindings` | Open `~/.oh/keybindings.json` in `$EDITOR` (creates a starter file if missing) |
252
258
 
253
259
  **AI:**
254
260
  | Command | Description |
255
261
  |---------|-------------|
256
262
  | `/plan <task>` | Enter plan mode |
257
263
  | `/review` | Review recent code changes |
264
+ | `/summarize` | Summarize the current conversation |
265
+ | `/recap` | One-sentence recap of the session (lighter than `/summarize`) |
258
266
 
259
267
  **Pet:**
260
268
  | Command | Description |
@@ -299,7 +307,7 @@ hooks:
299
307
  command: "scripts/cleanup.sh"
300
308
  ```
301
309
 
302
- **Event types** (23 total):
310
+ **Event types** (27 total — matches Claude Code's stable surface):
303
311
 
304
312
  | Event | When it fires | Can block? |
305
313
  |-------|---------------|------------|
@@ -325,8 +333,14 @@ hooks:
325
333
  | `notification` | A notification is dispatched | — |
326
334
  | `taskCreated` | `TaskCreate` persists a new task | — |
327
335
  | `taskCompleted` | `TaskUpdate` transitions a task to `completed` | — |
336
+ | `worktreeCreate` | `EnterWorktreeTool` creates an isolated git worktree | — |
337
+ | `worktreeRemove` | `ExitWorktreeTool` removes a git worktree | — |
338
+ | `elicitation` | An MCP server requests user input via `elicitation/create` | yes — `decision: allow\|deny` |
339
+ | `elicitationResult` | After the elicitation response has been decided (audit trail) | — |
328
340
  | `instructionsLoaded` | `loadRulesAsPrompt` rebuilt the system prompt with rules in scope | — |
329
341
 
342
+ Set `disableAllHooks: true` in `.oh/config.yaml` to globally disable hook execution while keeping definitions on disk for auditability.
343
+
330
344
  Live introspection: run `/hooks` in-session to see exactly which hooks are loaded, grouped by event.
331
345
 
332
346
  **Environment variables** available to hook scripts:
@@ -557,6 +571,23 @@ oh run "review the diff" --model claude-sonnet-4-6 --max-budget-usd 0.50
557
571
  oh session --model gpt-4o --max-budget-usd 5
558
572
  ```
559
573
 
574
+ ### CLI flags for CI / SDK use
575
+
576
+ | Flag | Effect |
577
+ |------|--------|
578
+ | `--bare` | Skip optional startup work (project detection, plugins, memory, skills, MCP). System prompt is just the tool-use baseline. Faster startup on repos with many CLAUDE.md / RULES.md files. |
579
+ | `--debug [categories]` | Enable categorized debug logs. `--debug` alone enables all; `--debug mcp,hooks` filters. Falls back to `OH_DEBUG` env var. |
580
+ | `--debug-file <path>` | Append debug lines to a file instead of stderr. Falls back to `OH_DEBUG_FILE`. |
581
+ | `--mcp-config <path>` | Load MCP servers from an external JSON file (in addition to `.oh/config.yaml`). |
582
+ | `--strict-mcp-config` | With `--mcp-config`, ignore `.oh/config.yaml` MCP servers entirely. |
583
+ | `--system-prompt-file <path>` / `--append-system-prompt-file <path>` | File-path variants of `--system-prompt` / `--append-system-prompt`. |
584
+ | `--no-session-persistence` | Skip writing the session record to `~/.oh/sessions/` for ephemeral CI runs. |
585
+ | `--fallback-model <model>` | Fallback used when the primary fails with a retriable error. REPLACES `.oh/config.yaml` `fallbackProviders` for this run. |
586
+ | `--permission-prompt-tool <mcp_tool>` | Delegate tool-permission decisions to a configured MCP tool (e.g. `mcp__myperm__check`). |
587
+ | `--init` / `--init-only` | Run the interactive setup wizard before / instead of the command. |
588
+
589
+ All flags work on both `oh run` and `oh session`. See `oh run --help` and `oh session --help` for the full surface.
590
+
560
591
  ### Structured output with `--json-schema`
561
592
 
562
593
  Constrain the model's output to a JSON Schema. Useful for CI scripts that
@@ -650,6 +681,35 @@ oh --model llamacpp/my-model
650
681
  oh models # list available models
651
682
  ```
652
683
 
684
+ ## Auth
685
+
686
+ Provider-agnostic credential management. Local LLMs (Ollama / llama.cpp / LM Studio) need no auth — configure them via `oh init`.
687
+
688
+ ```bash
689
+ oh auth login [provider] [--key <value>] # store API key for a provider
690
+ oh auth logout [provider] # clear stored API key
691
+ oh auth status # show stored providers + env-var overrides
692
+ ```
693
+
694
+ `[provider]` defaults to your configured default. `--key` supplies the value inline; otherwise OH prompts (TTY) or reads from stdin (piped).
695
+
696
+ ### Script-based key resolution (`apiKeyHelper`)
697
+
698
+ Avoid storing keys in plaintext / the encrypted store by plugging in a helper script (1Password, `pass`, vault, cloud secret manager). The configured command runs at credential-fetch time with `OH_PROVIDER` set, and its trimmed stdout becomes the key.
699
+
700
+ ```yaml
701
+ # .oh/config.yaml
702
+ apiKeyHelper: 'op read "op://Personal/Anthropic/key"'
703
+ ```
704
+
705
+ Resolution priority: env var → encrypted store → `apiKeyHelper` → legacy plaintext config.
706
+
707
+ ## Update
708
+
709
+ ```bash
710
+ oh update # detects how OH was installed (npm-global / npx / local clone) and prints the right upgrade command
711
+ ```
712
+
653
713
  ## Configuration Hierarchy
654
714
 
655
715
  Config is loaded in layers (later overrides earlier):
package/README.zh-CN.md CHANGED
@@ -42,6 +42,8 @@
42
42
  - [电子宠物 Cybergotchi](#电子宠物-cybergotchi)
43
43
  - [MCP 服务器](#mcp-服务器)
44
44
  - [模型提供商](#模型提供商)
45
+ - [鉴权(Auth)](#鉴权auth)
46
+ - [自动更新(Update)](#自动更新update)
45
47
  - [常见问题](#常见问题)
46
48
  - [安装](#安装)
47
49
  - [开发](#开发)
@@ -162,6 +164,7 @@ statusLineFormat: '{model} │ {tokens} │ {cost} │ {ctx}'
162
164
  | **Web** | | |
163
165
  | WebFetch | 中 | 获取 URL 内容(防 SSRF) |
164
166
  | WebSearch | 中 | 网络搜索 |
167
+ | ExaSearch | 中 | 通过 Exa 进行神经网络搜索(需要 `EXA_API_KEY`) |
165
168
  | RemoteTrigger | 高 | 向 webhook/API 发送 HTTP 请求 |
166
169
  | **任务** | | |
167
170
  | TaskCreate | 低 | 创建结构化任务 |
@@ -216,6 +219,7 @@ OH 注册了 80+ 个斜杠命令;下表只列出最常用的一部分。在会
216
219
  | `/clear` | 清空对话历史 |
217
220
  | `/compact` | 压缩对话以释放上下文 |
218
221
  | `/export` | 将对话导出为 markdown |
222
+ | `/copy [n]` | 复制倒数第 N 条助手回复到系统剪贴板 |
219
223
  | `/history [n]` | 列出最近的会话;`/history search <term>` 搜索 |
220
224
  | `/browse` | 带预览的交互式会话浏览器 |
221
225
  | `/resume <id>` | 恢复已保存的会话 |
@@ -249,12 +253,16 @@ OH 注册了 80+ 个斜杠命令;下表只列出最常用的一部分。在会
249
253
  | `/theme dark\|light` | 切换主题(自动保存到配置) |
250
254
  | `/vim` | 切换 Vim 模式 |
251
255
  | `/companion off\|on` | 切换电子宠物可见性 |
256
+ | `/keys` | 显示键盘快捷键 |
257
+ | `/keybindings` | 在 `$EDITOR` 中打开 `~/.oh/keybindings.json`(首次运行会创建初始文件) |
252
258
 
253
259
  **AI:**
254
260
  | 命令 | 描述 |
255
261
  |---------|-------------|
256
262
  | `/plan <task>` | 进入规划模式 |
257
263
  | `/review` | 审查最近的代码变更 |
264
+ | `/summarize` | 总结当前对话 |
265
+ | `/recap` | 一句话回顾本次会话(比 `/summarize` 更轻量) |
258
266
 
259
267
  **宠物:**
260
268
  | 命令 | 描述 |
@@ -299,7 +307,7 @@ hooks:
299
307
  command: "scripts/cleanup.sh"
300
308
  ```
301
309
 
302
- **事件类型**(共 23 个):
310
+ **事件类型**(共 27 个 —— 与 Claude Code 稳定版一致):
303
311
 
304
312
  | 事件 | 触发时机 | 是否可阻止 |
305
313
  |-------|---------------|------------|
@@ -325,8 +333,14 @@ hooks:
325
333
  | `notification` | 通知被派发 | — |
326
334
  | `taskCreated` | `TaskCreate` 持久化新任务后 | — |
327
335
  | `taskCompleted` | `TaskUpdate` 将任务状态切换为 `completed` 时 | — |
336
+ | `worktreeCreate` | `EnterWorktreeTool` 创建隔离的 git worktree 时 | — |
337
+ | `worktreeRemove` | `ExitWorktreeTool` 移除 git worktree 时 | — |
338
+ | `elicitation` | MCP 服务器通过 `elicitation/create` 请求用户输入 | 是 —— `decision: allow\|deny` |
339
+ | `elicitationResult` | elicitation 响应决定之后(用于审计追踪) | — |
328
340
  | `instructionsLoaded` | `loadRulesAsPrompt` 重新构建系统提示并加载规则之后 | — |
329
341
 
342
+ 在 `.oh/config.yaml` 中设置 `disableAllHooks: true` 可全局禁用钩子执行,同时保留磁盘上的定义以便审计。
343
+
330
344
  实时查看:在会话中运行 `/hooks` 可以按事件分组查看当前已加载的钩子。
331
345
 
332
346
  **环境变量**(钩子脚本可用):
@@ -557,6 +571,23 @@ oh run "review the diff" --model claude-sonnet-4-6 --max-budget-usd 0.50
557
571
  oh session --model gpt-4o --max-budget-usd 5
558
572
  ```
559
573
 
574
+ ### CI / SDK 常用 CLI 标志
575
+
576
+ | 标志 | 作用 |
577
+ |------|------|
578
+ | `--bare` | 跳过启动时的可选工作(项目检测、插件、记忆、技能、MCP)。系统提示仅保留工具使用基线,对包含大量 CLAUDE.md / RULES.md 的仓库启动更快。 |
579
+ | `--debug [类别]` | 启用分类调试日志。`--debug` 启用全部;`--debug mcp,hooks` 仅启用指定类别。也读取 `OH_DEBUG` 环境变量。 |
580
+ | `--debug-file <path>` | 把调试日志追加到文件而非 stderr。也读取 `OH_DEBUG_FILE`。 |
581
+ | `--mcp-config <path>` | 从外部 JSON 文件加载 MCP 服务器(叠加在 `.oh/config.yaml` 之上)。 |
582
+ | `--strict-mcp-config` | 配合 `--mcp-config`,完全忽略 `.oh/config.yaml` 中的 MCP 服务器。 |
583
+ | `--system-prompt-file <path>` / `--append-system-prompt-file <path>` | `--system-prompt` / `--append-system-prompt` 的文件路径变体。 |
584
+ | `--no-session-persistence` | 跳过会话写入 `~/.oh/sessions/`,适合一次性 CI 运行。 |
585
+ | `--fallback-model <model>` | 主模型遇到可重试错误时使用的回退模型。本次运行内会替代 `.oh/config.yaml` 的 `fallbackProviders`。 |
586
+ | `--permission-prompt-tool <mcp_tool>` | 把工具授权决策委托给指定的 MCP 工具(例如 `mcp__myperm__check`)。 |
587
+ | `--init` / `--init-only` | 在执行命令前 / 替代执行命令运行交互式安装向导。 |
588
+
589
+ 所有标志在 `oh run` 与 `oh session` 上都可用。完整列表见 `oh run --help` 与 `oh session --help`。
590
+
560
591
  ### 使用 `--json-schema` 约束结构化输出
561
592
 
562
593
  按 JSON Schema 约束模型输出。适用于需要以编程方式解析模型输出、避免正则启发式的 CI 脚本:
@@ -649,6 +680,35 @@ oh --model llamacpp/my-model
649
680
  oh models # 列出可用模型
650
681
  ```
651
682
 
683
+ ## 鉴权(Auth)
684
+
685
+ 提供商无关的凭据管理。本地 LLM(Ollama / llama.cpp / LM Studio)无需鉴权 —— 通过 `oh init` 配置即可。
686
+
687
+ ```bash
688
+ oh auth login [provider] [--key <value>] # 存储某个提供商的 API key
689
+ oh auth logout [provider] # 清除已存储的 API key
690
+ oh auth status # 显示已存储的提供商及环境变量覆盖情况
691
+ ```
692
+
693
+ `[provider]` 默认使用配置好的默认提供商。`--key` 可直接传入;否则 OH 会在 TTY 下交互询问,在管道输入下读到 EOF。
694
+
695
+ ### 脚本化 key 解析(`apiKeyHelper`)
696
+
697
+ 通过插入辅助脚本(1Password、`pass`、vault、云端密钥管理器等)避免把 key 写入纯文本或加密存储。配置好的命令在取 key 时执行,环境变量带 `OH_PROVIDER`,去掉首尾空白的 stdout 即为 key。
698
+
699
+ ```yaml
700
+ # .oh/config.yaml
701
+ apiKeyHelper: 'op read "op://Personal/Anthropic/key"'
702
+ ```
703
+
704
+ 解析优先级:环境变量 → 加密存储 → `apiKeyHelper` → 旧版纯文本配置。
705
+
706
+ ## 自动更新(Update)
707
+
708
+ ```bash
709
+ oh update # 检测安装方式(npm 全局 / npx / 本地克隆),打印对应的升级命令
710
+ ```
711
+
652
712
  ## 配置层级
653
713
 
654
714
  配置按层加载(后者覆盖前者):
@@ -14,6 +14,12 @@
14
14
  */
15
15
  export type { CommandContext, CommandHandler, CommandResult } from "./types.js";
16
16
  import type { CommandContext, CommandResult } from "./types.js";
17
+ /**
18
+ * Slash command categories shown in the autocomplete picker (audit U-A3).
19
+ * Names are user-facing — keep them short. Order here is the grouping
20
+ * order in the picker: most-frequently-used first.
21
+ */
22
+ export type CommandCategory = "Session" | "Git" | "Info" | "Settings" | "AI" | "Skills" | "MCP" | "Other";
17
23
  /**
18
24
  * Check if input is a slash command. If so, execute it.
19
25
  */
@@ -25,6 +31,7 @@ export declare function getCommandNames(): string[];
25
31
  export declare function getCommandEntries(): Array<{
26
32
  name: string;
27
33
  description: string;
34
+ category: CommandCategory;
28
35
  }>;
29
36
  /**
30
37
  * Register MCP-server prompts as `/server:prompt` slash commands. Called from
@@ -18,18 +18,25 @@ import { registerInfoCommands } from "./info.js";
18
18
  import { registerSessionCommands } from "./session.js";
19
19
  import { registerSettingsCommands } from "./settings.js";
20
20
  import { registerSkillCommands } from "./skills.js";
21
- // ── Command Registry ──
22
21
  const commands = new Map();
23
- function register(name, description, handler) {
24
- commands.set(name, { description, handler });
22
+ /**
23
+ * Adapter: each `registerXxx(register)` call is wrapped with a category-bound
24
+ * register so per-domain command files don't need a 4th argument. Adding a
25
+ * new category is a one-line change here, not a sweep across the command
26
+ * files.
27
+ */
28
+ function registerFor(category) {
29
+ return (name, description, handler) => {
30
+ commands.set(name, { description, handler, category });
31
+ };
25
32
  }
26
- // Register all command groups
27
- registerSessionCommands(register);
28
- registerGitCommands(register);
29
- registerInfoCommands(register, () => commands);
30
- registerSettingsCommands(register);
31
- registerAICommands(register);
32
- registerSkillCommands(register);
33
+ // Register all command groups — category derived from the source file split.
34
+ registerSessionCommands(registerFor("Session"));
35
+ registerGitCommands(registerFor("Git"));
36
+ registerInfoCommands(registerFor("Info"), () => commands);
37
+ registerSettingsCommands(registerFor("Settings"));
38
+ registerAICommands(registerFor("AI"));
39
+ registerSkillCommands(registerFor("Skills"));
33
40
  // ── Command Parser ──
34
41
  /**
35
42
  * Check if input is a slash command. If so, execute it.
@@ -65,7 +72,7 @@ export function getCommandNames() {
65
72
  return [...commands.keys()];
66
73
  }
67
74
  export function getCommandEntries() {
68
- return [...commands.entries()].map(([name, { description }]) => ({ name, description }));
75
+ return [...commands.entries()].map(([name, { description, category }]) => ({ name, description, category }));
69
76
  }
70
77
  let mcpPromptKeys = [];
71
78
  export function registerMcpPromptCommands(prompts) {
@@ -79,6 +86,7 @@ export function registerMcpPromptCommands(prompts) {
79
86
  const usageBits = [...required.map((n) => `${n}=<value>`), ...optional.map((n) => `[${n}=<value>]`)].join(" ");
80
87
  commands.set(key, {
81
88
  description: handle.description,
89
+ category: "MCP",
82
90
  handler: async (args) => {
83
91
  const parsed = parseMcpPromptArgs(args);
84
92
  const missing = required.filter((n) => !(n in parsed));
@@ -181,10 +181,13 @@ export function registerSessionCommands(register) {
181
181
  register("browse", "Open interactive session browser", () => {
182
182
  return { output: "__OPEN_SESSION_BROWSER__", handled: true };
183
183
  });
184
- register("resume", "Resume a saved session by ID", (args) => {
184
+ register("resume", "Resume a saved session opens a picker if no ID given", (args) => {
185
185
  const id = args.trim();
186
+ // Audit U-A6: with no id, open the interactive session browser instead
187
+ // of erroring with a usage hint. Mirrors Claude Code's `/resume`
188
+ // picker. The browser already supports Enter-to-resume.
186
189
  if (!id)
187
- return { output: "Usage: /resume <session-id>", handled: true };
190
+ return { output: "__OPEN_SESSION_BROWSER__", handled: true };
188
191
  const sessionDir = join(homedir(), ".oh", "sessions");
189
192
  try {
190
193
  loadSession(id, sessionDir);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools
2
+ * Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools, /trust
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,12 +1,14 @@
1
1
  /**
2
- * Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools
2
+ * Settings commands — /theme, /companion, /fast, /keys, /keybindings, /effort, /sandbox, /permissions, /allowed-tools, /trust
3
3
  */
4
4
  import { spawn } from "node:child_process";
5
5
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
6
6
  import { homedir, platform } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
+ import { readApprovalLog } from "../harness/approvals.js";
8
9
  import { readOhConfig } from "../harness/config.js";
9
10
  import { loadKeybindings } from "../harness/keybindings.js";
11
+ import { isTrusted, listTrusted, trust } from "../harness/trust.js";
10
12
  const KEYBINDINGS_TEMPLATE = `[
11
13
  { "key": "ctrl+d", "action": "/diff" },
12
14
  { "key": "ctrl+l", "action": "/clear" },
@@ -137,17 +139,37 @@ export function registerSettingsCommands(register) {
137
139
  const { sandboxStatus } = require("../harness/sandbox.js");
138
140
  return { output: `${sandboxStatus()}\n\nConfigure in .oh/config.yaml under sandbox:`, handled: true };
139
141
  });
140
- register("permissions", "View or change permission mode", (args, ctx) => {
141
- const mode = args.trim().toLowerCase();
142
- if (!mode) {
142
+ register("permissions", "View or change permission mode (or 'log' for approval history)", (args, ctx) => {
143
+ const trimmed = args.trim();
144
+ if (!trimmed) {
143
145
  return {
144
- output: `Current permission mode: ${ctx.permissionMode}\n\nAvailable modes:\n ask Prompt for medium/high risk (default)\n trust Auto-approve everything\n deny Only low-risk read-only\n acceptEdits Auto-approve file edits\n plan Read-only mode\n auto Auto-approve, block dangerous bash\n bypassPermissions CI/CD only`,
146
+ output: `Current permission mode: ${ctx.permissionMode}\n\nAvailable modes:\n ask Prompt for medium/high risk (default)\n trust Auto-approve everything\n deny Only low-risk read-only\n acceptEdits Auto-approve file edits\n plan Read-only mode\n auto Auto-approve, block dangerous bash\n bypassPermissions CI/CD only\n\nApproval history:\n /permissions log [n] Show last n approval decisions (default 50)`,
145
147
  handled: true,
146
148
  };
147
149
  }
150
+ // Audit U-B5: /permissions log [n] — show approval history from
151
+ // ~/.oh/approvals.log. Subcommand check happens before the mode-name
152
+ // validation so "log" doesn't collide with the mode list.
153
+ const [head, ...tail] = trimmed.split(/\s+/);
154
+ if (head?.toLowerCase() === "log") {
155
+ const n = Math.max(1, Math.min(500, Number.parseInt(tail[0] ?? "50", 10) || 50));
156
+ const records = readApprovalLog(n);
157
+ if (records.length === 0) {
158
+ return { output: "No approval decisions logged yet.", handled: true };
159
+ }
160
+ const lines = records.map((r) => {
161
+ const time = r.ts.slice(11, 19); // HH:MM:SS from ISO
162
+ const date = r.ts.slice(0, 10);
163
+ const decision = r.decision === "allow" ? "✓" : r.decision === "always" ? "★" : "✗";
164
+ const reason = r.reason ? ` (${r.reason})` : "";
165
+ return `${date} ${time} ${decision} ${r.decision.padEnd(7)} ${r.tool.padEnd(14)} ${r.source}${reason}`;
166
+ });
167
+ return { output: `Last ${records.length} approval decisions:\n${lines.join("\n")}`, handled: true };
168
+ }
169
+ const mode = trimmed.toLowerCase();
148
170
  const valid = ["ask", "trust", "deny", "acceptedits", "plan", "auto", "bypasspermissions"];
149
171
  if (!valid.includes(mode)) {
150
- return { output: `Unknown mode: ${mode}. Valid: ${valid.join(", ")}`, handled: true };
172
+ return { output: `Unknown mode: ${mode}. Valid: ${valid.join(", ")} | log`, handled: true };
151
173
  }
152
174
  return {
153
175
  output: `Permission mode set to: ${mode}\n(Note: takes effect for new tool calls in this session)`,
@@ -174,6 +196,25 @@ export function registerSettingsCommands(register) {
174
196
  register("vim", "Toggle Vim mode", () => {
175
197
  return { output: "__TOGGLE_VIM__", handled: true };
176
198
  });
199
+ register("trust", "Trust this workspace for shell hooks / status-line scripts (or list / add a path)", (args) => {
200
+ const arg = args.trim();
201
+ if (arg === "list") {
202
+ const trusted = listTrusted();
203
+ if (trusted.length === 0) {
204
+ return { output: "No trusted workspaces yet.\nRun `/trust` to add the current directory.", handled: true };
205
+ }
206
+ return { output: `Trusted workspaces:\n${trusted.map((d) => ` ${d}`).join("\n")}`, handled: true };
207
+ }
208
+ const target = arg || process.cwd();
209
+ if (isTrusted(target)) {
210
+ return { output: `Already trusted: ${target}`, handled: true };
211
+ }
212
+ trust(target);
213
+ return {
214
+ output: `Trusted: ${target}\nShell hooks and status-line scripts will now execute in this directory.`,
215
+ handled: true,
216
+ };
217
+ });
177
218
  register("login", "Set API key for current provider", (args, ctx) => {
178
219
  const key = args.trim();
179
220
  if (!key) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Approval log (audit U-B5).
3
+ *
4
+ * Append-only JSONL at `~/.oh/approvals.log` recording every permission
5
+ * resolution OH makes during a session — the source (user / hook / rule /
6
+ * permission-prompt-tool / headless / policy), the tool name, the decision
7
+ * (allow / deny / always), a redacted args preview, and a timestamp.
8
+ *
9
+ * Rotated to a `.1` sibling once the file exceeds ~2 MiB so the log doesn't
10
+ * grow unbounded. The slash command `/permissions log` reads the tail.
11
+ *
12
+ * Mirrors Claude Code's session approval log. Genuinely new — no prior art
13
+ * in OH (grep-verified during the audit refresh).
14
+ */
15
+ export type ApprovalSource = "user" | "hook" | "rule" | "permission-prompt-tool" | "policy" | "headless";
16
+ export type ApprovalDecision = "allow" | "deny" | "always";
17
+ export interface ApprovalRecord {
18
+ ts: string;
19
+ tool: string;
20
+ decision: ApprovalDecision;
21
+ source: ApprovalSource;
22
+ /** Tool args as JSON, truncated to ~500 chars to keep the log compact. */
23
+ argsPreview?: string;
24
+ /** Optional human-readable reason (hook reason, headless reason, etc.). */
25
+ reason?: string;
26
+ cwd?: string;
27
+ }
28
+ /**
29
+ * Override the log file path for tests, or `null` to silence the writer.
30
+ * Calling without arguments resets to the real `~/.oh/approvals.log`.
31
+ */
32
+ export declare function setApprovalLogPathForTests(path: string | null | undefined): void;
33
+ /**
34
+ * Append a single approval decision to the log. Errors are swallowed: a
35
+ * disk-full or permission error must not block the agent loop.
36
+ */
37
+ export declare function recordApproval(rec: Omit<ApprovalRecord, "ts">): void;
38
+ /**
39
+ * Read the most recent `n` records from the log. Skips malformed lines.
40
+ * Used by the `/permissions log` slash command.
41
+ */
42
+ export declare function readApprovalLog(n?: number): ApprovalRecord[];
43
+ /** Truncate an args string to roughly N chars without breaking JSON brackets. */
44
+ export declare function previewArgs(argsJson: string, max?: number): string;
45
+ //# sourceMappingURL=approvals.d.ts.map
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Approval log (audit U-B5).
3
+ *
4
+ * Append-only JSONL at `~/.oh/approvals.log` recording every permission
5
+ * resolution OH makes during a session — the source (user / hook / rule /
6
+ * permission-prompt-tool / headless / policy), the tool name, the decision
7
+ * (allow / deny / always), a redacted args preview, and a timestamp.
8
+ *
9
+ * Rotated to a `.1` sibling once the file exceeds ~2 MiB so the log doesn't
10
+ * grow unbounded. The slash command `/permissions log` reads the tail.
11
+ *
12
+ * Mirrors Claude Code's session approval log. Genuinely new — no prior art
13
+ * in OH (grep-verified during the audit refresh).
14
+ */
15
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ const LOG_FILE = join(homedir(), ".oh", "approvals.log");
19
+ const ROTATE_BYTES = 2 * 1024 * 1024;
20
+ /** Test seam — flip to `null` to disable logging in test runs. */
21
+ let _logFileOverride;
22
+ /**
23
+ * Override the log file path for tests, or `null` to silence the writer.
24
+ * Calling without arguments resets to the real `~/.oh/approvals.log`.
25
+ */
26
+ export function setApprovalLogPathForTests(path) {
27
+ _logFileOverride = path;
28
+ }
29
+ function logPath() {
30
+ if (_logFileOverride === null)
31
+ return null;
32
+ return _logFileOverride ?? LOG_FILE;
33
+ }
34
+ function rotateIfNeeded(file) {
35
+ try {
36
+ const st = statSync(file);
37
+ if (st.size >= ROTATE_BYTES) {
38
+ renameSync(file, `${file}.1`);
39
+ }
40
+ }
41
+ catch {
42
+ /* file does not exist yet — no rotate needed */
43
+ }
44
+ }
45
+ /**
46
+ * Append a single approval decision to the log. Errors are swallowed: a
47
+ * disk-full or permission error must not block the agent loop.
48
+ */
49
+ export function recordApproval(rec) {
50
+ const file = logPath();
51
+ if (!file)
52
+ return;
53
+ try {
54
+ mkdirSync(dirname(file), { recursive: true });
55
+ rotateIfNeeded(file);
56
+ const line = `${JSON.stringify({ ts: new Date().toISOString(), ...rec })}\n`;
57
+ appendFileSync(file, line, "utf8");
58
+ }
59
+ catch {
60
+ /* logging must not throw into caller */
61
+ }
62
+ }
63
+ /**
64
+ * Read the most recent `n` records from the log. Skips malformed lines.
65
+ * Used by the `/permissions log` slash command.
66
+ */
67
+ export function readApprovalLog(n = 50) {
68
+ const file = logPath();
69
+ if (!file || !existsSync(file))
70
+ return [];
71
+ let raw;
72
+ try {
73
+ raw = readFileSync(file, "utf8");
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ const lines = raw.split("\n").filter((l) => l.length > 0);
79
+ const tail = lines.slice(Math.max(0, lines.length - n));
80
+ const out = [];
81
+ for (const line of tail) {
82
+ try {
83
+ const obj = JSON.parse(line);
84
+ if (obj && typeof obj.tool === "string" && typeof obj.decision === "string") {
85
+ out.push(obj);
86
+ }
87
+ }
88
+ catch {
89
+ /* skip malformed line */
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+ /** Truncate an args string to roughly N chars without breaking JSON brackets. */
95
+ export function previewArgs(argsJson, max = 500) {
96
+ if (argsJson.length <= max)
97
+ return argsJson;
98
+ return `${argsJson.slice(0, max)}…`;
99
+ }
100
+ //# sourceMappingURL=approvals.js.map
@@ -151,6 +151,27 @@ export type OhConfig = {
151
151
  apiKeyHelper?: string;
152
152
  toolPermissions?: ToolPermissionRule[];
153
153
  statusLineFormat?: string;
154
+ /**
155
+ * JSON-envelope status line script (audit U-B1). When set, OH spawns
156
+ * `command` through the user's shell on each refresh, pipes a JSON
157
+ * envelope `{ model, tokens, cost, ctx, sessionId, cwd, gitBranch }` to
158
+ * stdin, and uses the trimmed stdout as the status line. Mirrors Claude
159
+ * Code's `statusLine` config. Gated through the workspace-trust system —
160
+ * scripts only run in trusted dirs.
161
+ *
162
+ * Output is cached for `refreshMs` (default 1000) so the script doesn't
163
+ * re-spawn on every keypress. Multi-line output is truncated to the
164
+ * first line.
165
+ *
166
+ * Coexists with `statusLineFormat` — when both are set, the script wins.
167
+ */
168
+ statusLine?: {
169
+ command: string;
170
+ /** Cache window in ms. Default: 1000. Min: 100. */
171
+ refreshMs?: number;
172
+ /** Spawn timeout in ms. Default: 2000. */
173
+ timeoutMs?: number;
174
+ };
154
175
  /** Verification loops — auto-run lint/typecheck after file edits */
155
176
  verification?: {
156
177
  enabled?: boolean;
@@ -223,5 +244,18 @@ export declare function readOhConfig(root?: string, sources?: readonly SettingSo
223
244
  * Unknown names are silently dropped.
224
245
  */
225
246
  export declare function parseSettingSources(raw: string | undefined): SettingSource[] | undefined;
247
+ /**
248
+ * Persist a single tool-allow rule (audit U-A2). Used by the "[A]lways"
249
+ * keypress in the permission prompt — the user has just approved a tool
250
+ * call and wants future calls to that tool to skip the prompt.
251
+ *
252
+ * No-ops when no project config exists (user is running on auto-detected
253
+ * settings; we don't auto-create `.oh/config.yaml` just to add a rule).
254
+ * De-dupes against an exact-tool rule with no pattern.
255
+ *
256
+ * Returns `true` if a rule was written, `false` if already present or
257
+ * skipped because no config exists.
258
+ */
259
+ export declare function appendToolPermission(toolName: string, action?: "allow" | "deny", root?: string): boolean;
226
260
  export declare function writeOhConfig(cfg: OhConfig, root?: string): void;
227
261
  //# sourceMappingURL=config.d.ts.map
@@ -101,6 +101,30 @@ export function parseSettingSources(raw) {
101
101
  .filter((s) => valid.has(s));
102
102
  return out.length > 0 ? out : undefined;
103
103
  }
104
+ /**
105
+ * Persist a single tool-allow rule (audit U-A2). Used by the "[A]lways"
106
+ * keypress in the permission prompt — the user has just approved a tool
107
+ * call and wants future calls to that tool to skip the prompt.
108
+ *
109
+ * No-ops when no project config exists (user is running on auto-detected
110
+ * settings; we don't auto-create `.oh/config.yaml` just to add a rule).
111
+ * De-dupes against an exact-tool rule with no pattern.
112
+ *
113
+ * Returns `true` if a rule was written, `false` if already present or
114
+ * skipped because no config exists.
115
+ */
116
+ export function appendToolPermission(toolName, action = "allow", root) {
117
+ const cfg = readOhConfig(root);
118
+ if (!cfg)
119
+ return false;
120
+ const existing = cfg.toolPermissions ?? [];
121
+ if (existing.some((r) => r.tool === toolName && !r.pattern && r.action === action)) {
122
+ return false;
123
+ }
124
+ cfg.toolPermissions = [...existing, { tool: toolName, action }];
125
+ writeOhConfig(cfg, root);
126
+ return true;
127
+ }
104
128
  export function writeOhConfig(cfg, root) {
105
129
  invalidateConfigCache();
106
130
  // Emit configChange hook (lazy import to avoid circular dependency)