@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.
@@ -12,6 +12,7 @@
12
12
  import { spawn, spawnSync } from "node:child_process";
13
13
  import { debug } from "../utils/debug.js";
14
14
  import { readOhConfig } from "./config.js";
15
+ import { isTrusted, trustSystemActive } from "./trust.js";
15
16
  let cachedHooks;
16
17
  export function getHooks() {
17
18
  if (cachedHooks !== undefined)
@@ -403,6 +404,22 @@ async function runPromptHook(promptText, ctx, timeoutMs = 10_000) {
403
404
  /** Execute a single hook definition. Returns true if allowed. */
404
405
  async function executeHookDef(def, event, ctx) {
405
406
  const timeout = def.timeout ?? 10_000;
407
+ // Workspace-trust gate (audit U-A4). Shell-executing hook types
408
+ // (`command`, `http`) require the cwd to be on the trust list — a fresh
409
+ // clone of a hostile repo can't auto-execute on first launch. Allowed by
410
+ // default for `prompt` hooks (LLM-only, no shell).
411
+ //
412
+ // Soft rollout: the gate is only enforced once the user has interacted
413
+ // with the trust system (i.e., `~/.oh/trusted-dirs.json` exists). Until
414
+ // then, existing behavior is preserved. The first session in a hooked
415
+ // workspace fires a startup prompt that creates the file — from that
416
+ // point on every other dir requires explicit trust.
417
+ if ((def.command || def.http) && trustSystemActive() && !isTrusted(process.cwd())) {
418
+ // Allow as if the hook didn't exist. The REPL surfaces a one-time
419
+ // prompt at session start when hooks are configured but the dir is
420
+ // untrusted; the user can also grant trust via `/trust`.
421
+ return true;
422
+ }
406
423
  if (def.command) {
407
424
  const env = buildEnv(event, ctx);
408
425
  // JSON-mode (Claude Code convention): send `{event, ...ctx}` on stdin,
@@ -439,10 +456,17 @@ export function emitHook(event, ctx = {}) {
439
456
  debug("hooks", "fire", { event, count: defs.length, tool: ctx.toolName });
440
457
  const env = buildEnv(event, ctx);
441
458
  if (event === "preToolUse") {
442
- // preToolUse command hooks must be synchronous — they gate tool execution
459
+ // preToolUse command hooks must be synchronous — they gate tool execution.
460
+ // Workspace-trust gate (audit U-A4): once the trust system is active
461
+ // (file exists), shell-executing hooks in untrusted dirs act as absent.
462
+ // Soft rollout: when no trust file exists at all, treat as legacy mode
463
+ // and run all hooks normally.
464
+ const enforceTrust = trustSystemActive() && !isTrusted(process.cwd());
443
465
  for (const def of defs) {
444
466
  if (!matchesHook(def, ctx))
445
467
  continue;
468
+ if ((def.command || def.http) && enforceTrust)
469
+ continue;
446
470
  if (def.command) {
447
471
  const input = def.jsonIO ? JSON.stringify({ event, ...ctx }) : undefined;
448
472
  const result = spawnSync(def.command, {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * JSON-envelope status line script runner (audit U-B1).
3
+ *
4
+ * Mirrors Claude Code's `statusLine` config. The user configures a shell
5
+ * command in `.oh/config.yaml`:
6
+ *
7
+ * statusLine:
8
+ * command: "~/scripts/oh-status.sh"
9
+ * refreshMs: 2000
10
+ *
11
+ * On each REPL refresh, OH:
12
+ * 1. Builds a JSON envelope of session state (model, tokens, cost, etc.)
13
+ * 2. If the cache window hasn't expired AND the envelope hasn't changed,
14
+ * returns the cached stdout — no spawn cost on every keypress.
15
+ * 3. Otherwise spawns the command through the shell, pipes the envelope
16
+ * on stdin, captures stdout (timeout: 2s), trims to the first line.
17
+ *
18
+ * Caller is responsible for the workspace-trust gate (`isTrusted(cwd)`);
19
+ * this module just runs the command. Failures (non-zero exit, timeout,
20
+ * spawn error) return null so the caller can fall back to template /
21
+ * default rendering.
22
+ *
23
+ * Synchronous spawn used so the renderer doesn't need an async path —
24
+ * keeps the keypress loop hot. Trade-off: a slow script blocks the render
25
+ * up to `timeoutMs`; the cache makes this rare.
26
+ */
27
+ export interface StatusLineEnvelope {
28
+ model: string;
29
+ tokens: {
30
+ input: number;
31
+ output: number;
32
+ };
33
+ cost: number;
34
+ contextPercent: number;
35
+ sessionId: string;
36
+ cwd: string;
37
+ gitBranch?: string;
38
+ }
39
+ export interface StatusLineConfig {
40
+ command: string;
41
+ refreshMs?: number;
42
+ timeoutMs?: number;
43
+ }
44
+ /**
45
+ * Run the status line script with the given envelope. Returns the trimmed
46
+ * first line of stdout, or null on failure / empty output. Caches results
47
+ * for `refreshMs`.
48
+ */
49
+ export declare function runStatusLineScript(env: StatusLineEnvelope, cfg: StatusLineConfig): string | null;
50
+ /** @internal Test-only reset. */
51
+ export declare function _resetStatusLineCacheForTest(): void;
52
+ //# sourceMappingURL=status-line-script.d.ts.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * JSON-envelope status line script runner (audit U-B1).
3
+ *
4
+ * Mirrors Claude Code's `statusLine` config. The user configures a shell
5
+ * command in `.oh/config.yaml`:
6
+ *
7
+ * statusLine:
8
+ * command: "~/scripts/oh-status.sh"
9
+ * refreshMs: 2000
10
+ *
11
+ * On each REPL refresh, OH:
12
+ * 1. Builds a JSON envelope of session state (model, tokens, cost, etc.)
13
+ * 2. If the cache window hasn't expired AND the envelope hasn't changed,
14
+ * returns the cached stdout — no spawn cost on every keypress.
15
+ * 3. Otherwise spawns the command through the shell, pipes the envelope
16
+ * on stdin, captures stdout (timeout: 2s), trims to the first line.
17
+ *
18
+ * Caller is responsible for the workspace-trust gate (`isTrusted(cwd)`);
19
+ * this module just runs the command. Failures (non-zero exit, timeout,
20
+ * spawn error) return null so the caller can fall back to template /
21
+ * default rendering.
22
+ *
23
+ * Synchronous spawn used so the renderer doesn't need an async path —
24
+ * keeps the keypress loop hot. Trade-off: a slow script blocks the render
25
+ * up to `timeoutMs`; the cache makes this rare.
26
+ */
27
+ import { spawnSync } from "node:child_process";
28
+ let cache = null;
29
+ const DEFAULT_REFRESH_MS = 1000;
30
+ const MIN_REFRESH_MS = 100;
31
+ const DEFAULT_TIMEOUT_MS = 2000;
32
+ /**
33
+ * Stable cache key from the envelope. Excludes timestamps / sessionId-y
34
+ * things that don't actually change the script's output (the script reads
35
+ * the envelope it gets — if the input is the same, the output should be
36
+ * too).
37
+ */
38
+ function envelopeKey(env) {
39
+ return JSON.stringify({
40
+ model: env.model,
41
+ tokens: env.tokens,
42
+ cost: env.cost,
43
+ contextPercent: env.contextPercent,
44
+ cwd: env.cwd,
45
+ gitBranch: env.gitBranch,
46
+ });
47
+ }
48
+ /**
49
+ * Run the status line script with the given envelope. Returns the trimmed
50
+ * first line of stdout, or null on failure / empty output. Caches results
51
+ * for `refreshMs`.
52
+ */
53
+ export function runStatusLineScript(env, cfg) {
54
+ const refresh = Math.max(MIN_REFRESH_MS, cfg.refreshMs ?? DEFAULT_REFRESH_MS);
55
+ const timeout = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
56
+ const key = envelopeKey(env);
57
+ const now = Date.now();
58
+ if (cache && cache.envelopeKey === key && now - cache.timestamp < refresh) {
59
+ return cache.output;
60
+ }
61
+ try {
62
+ const result = spawnSync(cfg.command, {
63
+ shell: true,
64
+ timeout,
65
+ stdio: ["pipe", "pipe", "pipe"],
66
+ input: JSON.stringify(env),
67
+ encoding: "utf8",
68
+ });
69
+ if (result.error || result.status !== 0) {
70
+ return null;
71
+ }
72
+ const out = (result.stdout ?? "").toString().trim();
73
+ if (!out)
74
+ return null;
75
+ // Truncate to first line — multi-line output would corrupt the status row.
76
+ const firstLine = out.split(/\r?\n/)[0];
77
+ cache = { envelopeKey: key, output: firstLine, timestamp: now };
78
+ return firstLine;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ /** @internal Test-only reset. */
85
+ export function _resetStatusLineCacheForTest() {
86
+ cache = null;
87
+ }
88
+ //# sourceMappingURL=status-line-script.js.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Workspace-trust store (audit U-A4).
3
+ *
4
+ * OH lets users configure shell hooks (`.oh/config.yaml` `hooks:`) and
5
+ * arbitrary status-line scripts (Tier U-B1) that auto-execute as part of
6
+ * the session loop. That's a footgun for fresh-cloned projects — a hostile
7
+ * `.oh/config.yaml` could run shell on first launch.
8
+ *
9
+ * This module gates user-defined-shell execution on a one-time
10
+ * "trust this directory" prompt, persisted in `~/.oh/trusted-dirs.json`.
11
+ * The first time a hook or status-line script tries to run in an untrusted
12
+ * directory, the REPL pops a question; trusted dirs skip the prompt forever.
13
+ *
14
+ * Mirrors Claude Code's workspace-trust model. Per the prior audit's
15
+ * already-built check, OH had zero `trustedDirectories` matches anywhere —
16
+ * this is genuinely new.
17
+ */
18
+ /** Check whether `dir` is trusted. Pure read — never prompts. */
19
+ export declare function isTrusted(dir: string): boolean;
20
+ /**
21
+ * Whether the user has ever interacted with the trust system. Used by the
22
+ * hook gate as a soft-rollout switch: before the file exists, we treat all
23
+ * dirs as trusted (legacy behavior — existing users not affected). Once
24
+ * the user grants trust to even one workspace, the gate switches on for
25
+ * every other dir. Mirrors the design pattern of "explicit opt-in once,
26
+ * enforce always after."
27
+ *
28
+ * Bypasses the in-memory cache so it picks up writes from a parallel
29
+ * process (e.g., `oh trust` run from another shell while a session is up).
30
+ */
31
+ export declare function trustSystemActive(): boolean;
32
+ /**
33
+ * Mark `dir` as trusted. Idempotent — a second call is a no-op. Persists
34
+ * immediately so a process crash before the next prompt doesn't lose the
35
+ * grant.
36
+ */
37
+ export declare function trust(dir: string): void;
38
+ /** List currently-trusted dirs. For diagnostics / `oh status`. */
39
+ export declare function listTrusted(): readonly string[];
40
+ /** @internal Test-only reset. */
41
+ export declare function _resetTrustForTest(): void;
42
+ //# sourceMappingURL=trust.d.ts.map
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Workspace-trust store (audit U-A4).
3
+ *
4
+ * OH lets users configure shell hooks (`.oh/config.yaml` `hooks:`) and
5
+ * arbitrary status-line scripts (Tier U-B1) that auto-execute as part of
6
+ * the session loop. That's a footgun for fresh-cloned projects — a hostile
7
+ * `.oh/config.yaml` could run shell on first launch.
8
+ *
9
+ * This module gates user-defined-shell execution on a one-time
10
+ * "trust this directory" prompt, persisted in `~/.oh/trusted-dirs.json`.
11
+ * The first time a hook or status-line script tries to run in an untrusted
12
+ * directory, the REPL pops a question; trusted dirs skip the prompt forever.
13
+ *
14
+ * Mirrors Claude Code's workspace-trust model. Per the prior audit's
15
+ * already-built check, OH had zero `trustedDirectories` matches anywhere —
16
+ * this is genuinely new.
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { dirname, join, resolve } from "node:path";
21
+ const TRUST_FILE = join(homedir(), ".oh", "trusted-dirs.json");
22
+ let cached;
23
+ function loadStore() {
24
+ if (cached)
25
+ return cached;
26
+ if (!existsSync(TRUST_FILE)) {
27
+ cached = { trusted: [] };
28
+ return cached;
29
+ }
30
+ try {
31
+ const raw = readFileSync(TRUST_FILE, "utf8");
32
+ const parsed = JSON.parse(raw);
33
+ if (Array.isArray(parsed.trusted)) {
34
+ cached = { trusted: parsed.trusted.filter((p) => typeof p === "string") };
35
+ return cached;
36
+ }
37
+ }
38
+ catch {
39
+ /* malformed file — treat as empty so the user can re-grant */
40
+ }
41
+ cached = { trusted: [] };
42
+ return cached;
43
+ }
44
+ function saveStore(store) {
45
+ cached = store;
46
+ mkdirSync(dirname(TRUST_FILE), { recursive: true });
47
+ writeFileSync(TRUST_FILE, JSON.stringify({ trusted: store.trusted }, null, 2));
48
+ }
49
+ /**
50
+ * Normalize a directory for comparison. Resolves to an absolute path and
51
+ * lowercases on Windows (which is case-insensitive for paths). Other
52
+ * platforms keep case so distinct dirs that differ only in case are treated
53
+ * as distinct.
54
+ */
55
+ function normalize(dir) {
56
+ const abs = resolve(dir);
57
+ return process.platform === "win32" ? abs.toLowerCase() : abs;
58
+ }
59
+ /** Check whether `dir` is trusted. Pure read — never prompts. */
60
+ export function isTrusted(dir) {
61
+ const store = loadStore();
62
+ const target = normalize(dir);
63
+ return store.trusted.some((t) => normalize(t) === target);
64
+ }
65
+ /**
66
+ * Whether the user has ever interacted with the trust system. Used by the
67
+ * hook gate as a soft-rollout switch: before the file exists, we treat all
68
+ * dirs as trusted (legacy behavior — existing users not affected). Once
69
+ * the user grants trust to even one workspace, the gate switches on for
70
+ * every other dir. Mirrors the design pattern of "explicit opt-in once,
71
+ * enforce always after."
72
+ *
73
+ * Bypasses the in-memory cache so it picks up writes from a parallel
74
+ * process (e.g., `oh trust` run from another shell while a session is up).
75
+ */
76
+ export function trustSystemActive() {
77
+ return existsSync(TRUST_FILE);
78
+ }
79
+ /**
80
+ * Mark `dir` as trusted. Idempotent — a second call is a no-op. Persists
81
+ * immediately so a process crash before the next prompt doesn't lose the
82
+ * grant.
83
+ */
84
+ export function trust(dir) {
85
+ const store = loadStore();
86
+ const target = normalize(dir);
87
+ if (store.trusted.some((t) => normalize(t) === target))
88
+ return;
89
+ saveStore({ trusted: [...store.trusted, dir] });
90
+ }
91
+ /** List currently-trusted dirs. For diagnostics / `oh status`. */
92
+ export function listTrusted() {
93
+ return loadStore().trusted;
94
+ }
95
+ /** @internal Test-only reset. */
96
+ export function _resetTrustForTest() {
97
+ cached = undefined;
98
+ }
99
+ //# sourceMappingURL=trust.js.map
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Tool execution — permission checking, batching, output capping.
3
3
  */
4
+ import { previewArgs, recordApproval } from "../harness/approvals.js";
4
5
  import { createCheckpoint, getAffectedFiles } from "../harness/checkpoints.js";
5
6
  import { emitHook, emitHookWithOutcome } from "../harness/hooks.js";
6
7
  import { findToolByName } from "../Tool.js";
@@ -91,6 +92,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
91
92
  permissionMode,
92
93
  permissionAction: "ask",
93
94
  });
95
+ const argsPreview = previewArgs(JSON.stringify(toolCall.arguments));
94
96
  const denyAndEmit = (source, reason, output) => {
95
97
  emitHook("permissionDenied", {
96
98
  toolName: tool.name,
@@ -99,10 +101,31 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
99
101
  denySource: source,
100
102
  denyReason: reason,
101
103
  });
104
+ // Audit U-B5: persist denial to ~/.oh/approvals.log so /permissions log
105
+ // can replay the session's approval history. The cast is safe — the
106
+ // four call sites below pass exact string literals matching ApprovalSource.
107
+ recordApproval({
108
+ tool: tool.name,
109
+ decision: "deny",
110
+ source: source,
111
+ argsPreview,
112
+ reason,
113
+ cwd: process.cwd(),
114
+ });
102
115
  return { output, isError: true };
103
116
  };
117
+ const recordAllow = (source) => {
118
+ recordApproval({
119
+ tool: tool.name,
120
+ decision: "allow",
121
+ source,
122
+ argsPreview,
123
+ cwd: process.cwd(),
124
+ });
125
+ };
104
126
  if (hookOutcome.permissionDecision === "allow") {
105
127
  // Hook granted permission — proceed to execution.
128
+ recordAllow("hook");
106
129
  }
107
130
  else if (hookOutcome.permissionDecision === "deny" || !hookOutcome.allowed) {
108
131
  const reason = hookOutcome.reason ? `: ${hookOutcome.reason}` : "";
@@ -118,6 +141,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
118
141
  const promptDecision = await callPermissionPromptTool(permissionPromptTool, tools, context, tool.name, parsed.data);
119
142
  if (promptDecision.behavior === "allow") {
120
143
  // Permission tool granted — proceed.
144
+ recordAllow("permission-prompt-tool");
121
145
  }
122
146
  else if (promptDecision.behavior === "deny") {
123
147
  return denyAndEmit("permission-prompt-tool", promptDecision.message ?? "denied", `Permission denied by ${permissionPromptTool}${promptDecision.message ? `: ${promptDecision.message}` : ""}`);
@@ -131,6 +155,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
131
155
  if (!allowed) {
132
156
  return denyAndEmit("user", "user declined", "Permission denied by user.");
133
157
  }
158
+ recordAllow("user");
134
159
  }
135
160
  else {
136
161
  return denyAndEmit("headless", "permission-prompt-tool unavailable and no interactive prompt", `Permission denied: ${permissionPromptTool} did not produce a usable decision and no interactive prompt is available.`);
@@ -144,6 +169,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
144
169
  if (!allowed) {
145
170
  return denyAndEmit("user", "user declined", "Permission denied by user.");
146
171
  }
172
+ recordAllow("user");
147
173
  }
148
174
  else {
149
175
  // Headless mode with no hook decision and no interactive prompt:
@@ -161,6 +187,16 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
161
187
  denySource: "policy",
162
188
  denyReason: perm.reason,
163
189
  });
190
+ // Audit U-B5: a `tool-rule-deny` reason came from an explicit
191
+ // `toolPermissions` rule the user wrote; everything else is policy.
192
+ recordApproval({
193
+ tool: tool.name,
194
+ decision: "deny",
195
+ source: perm.reason === "tool-rule-deny" ? "rule" : "policy",
196
+ argsPreview: previewArgs(JSON.stringify(toolCall.arguments)),
197
+ reason: perm.reason,
198
+ cwd: process.cwd(),
199
+ });
164
200
  return { output: `Permission denied: ${perm.reason}`, isError: true };
165
201
  }
166
202
  }
@@ -7,6 +7,7 @@ export type Style = {
7
7
  bold: boolean;
8
8
  dim: boolean;
9
9
  underline: boolean;
10
+ hyperlink?: string | null;
10
11
  };
11
12
  export type Cell = {
12
13
  char: string;
@@ -32,6 +33,11 @@ export declare class CellGrid {
32
33
  * Returns the number of rows consumed.
33
34
  */
34
35
  writeWrapped(row: number, col: number, text: string, style: Style, wrapWidth: number, maxRow?: number): number;
36
+ /**
37
+ * Write text on a single row (no wrapping), tagging http(s):// and file:// runs
38
+ * with the OSC 8 hyperlink attribute (cyan + underline). Stops at maxCol or grid width.
39
+ */
40
+ writeTextWithLinks(row: number, col: number, text: string, baseStyle: Style, maxCol?: number): void;
35
41
  clone(): CellGrid;
36
42
  }
37
43
  //# sourceMappingURL=cells.d.ts.map
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cell grid — 2D array of styled characters for terminal rendering.
3
3
  */
4
- export const EMPTY_STYLE = { fg: null, bg: null, bold: false, dim: false, underline: false };
4
+ export const EMPTY_STYLE = { fg: null, bg: null, bold: false, dim: false, underline: false, hyperlink: null };
5
5
  export const EMPTY_CELL = { char: " ", style: { ...EMPTY_STYLE } };
6
6
  export function cellsEqual(a, b) {
7
7
  return (a.char === b.char &&
@@ -9,7 +9,8 @@ export function cellsEqual(a, b) {
9
9
  a.style.bg === b.style.bg &&
10
10
  a.style.bold === b.style.bold &&
11
11
  a.style.dim === b.style.dim &&
12
- a.style.underline === b.style.underline);
12
+ a.style.underline === b.style.underline &&
13
+ (a.style.hyperlink ?? null) === (b.style.hyperlink ?? null));
13
14
  }
14
15
  export class CellGrid {
15
16
  width;
@@ -37,6 +38,7 @@ export class CellGrid {
37
38
  cell.style.bold = false;
38
39
  cell.style.dim = false;
39
40
  cell.style.underline = false;
41
+ cell.style.hyperlink = null;
40
42
  }
41
43
  }
42
44
  }
@@ -51,6 +53,7 @@ export class CellGrid {
51
53
  cell.style.bold = s.bold;
52
54
  cell.style.dim = s.dim;
53
55
  cell.style.underline = s.underline;
56
+ cell.style.hyperlink = s.hyperlink ?? null;
54
57
  }
55
58
  /**
56
59
  * Write a string into the grid at (row, col). Handles \n for line breaks.
@@ -122,6 +125,49 @@ export class CellGrid {
122
125
  }
123
126
  return r - row;
124
127
  }
128
+ /**
129
+ * Write text on a single row (no wrapping), tagging http(s):// and file:// runs
130
+ * with the OSC 8 hyperlink attribute (cyan + underline). Stops at maxCol or grid width.
131
+ */
132
+ writeTextWithLinks(row, col, text, baseStyle, maxCol) {
133
+ const limit = Math.min(maxCol ?? this.width, this.width);
134
+ if (row < 0 || row >= this.height)
135
+ return;
136
+ const linkRegex = /(https?:\/\/[^\s)\]'"<>]+|file:\/\/[^\s)\]'"<>]+)/g;
137
+ let cursor = 0;
138
+ let c = col;
139
+ while (true) {
140
+ const m = linkRegex.exec(text);
141
+ if (m === null)
142
+ break;
143
+ const before = text.slice(cursor, m.index);
144
+ for (let i = 0; i < before.length && c < limit; i++) {
145
+ this.setCell(row, c, before[i], baseStyle);
146
+ c++;
147
+ }
148
+ if (c >= limit)
149
+ return;
150
+ // Strip trailing punctuation that's almost never part of the URL.
151
+ let url = m[0];
152
+ while (url.length > 0 && /[.,;:!?]/.test(url[url.length - 1])) {
153
+ url = url.slice(0, -1);
154
+ }
155
+ if (url.length === 0) {
156
+ cursor = m.index + m[0].length;
157
+ continue;
158
+ }
159
+ const linkStyle = { ...baseStyle, fg: "cyan", underline: true, hyperlink: url };
160
+ for (let i = 0; i < url.length && c < limit; i++) {
161
+ this.setCell(row, c, url[i], linkStyle);
162
+ c++;
163
+ }
164
+ cursor = m.index + url.length;
165
+ }
166
+ for (let i = cursor; i < text.length && c < limit; i++) {
167
+ this.setCell(row, c, text[i], baseStyle);
168
+ c++;
169
+ }
170
+ }
125
171
  clone() {
126
172
  const g = new CellGrid(this.width, this.height);
127
173
  for (let r = 0; r < this.height; r++) {
@@ -18,6 +18,11 @@ export function styleToSGR(style) {
18
18
  codes.push(BG_CODES[style.bg]);
19
19
  return `\x1b[${codes.join(";")}m`;
20
20
  }
21
+ /** OSC 8 hyperlink open/close sequences (ST = ESC \). */
22
+ function osc8Open(url) {
23
+ return `\x1b]8;;${url}\x1b\\`;
24
+ }
25
+ const OSC8_CLOSE = "\x1b]8;;\x1b\\";
21
26
  /**
22
27
  * Compare two grids and return the ANSI string that transforms prev into next.
23
28
  * Only emits escape sequences for changed cells.
@@ -25,6 +30,7 @@ export function styleToSGR(style) {
25
30
  export function diff(prev, next, rowOffset = 0) {
26
31
  const parts = [];
27
32
  let lastStyle = null;
33
+ let lastHyperlink = null;
28
34
  let expectedRow = -1;
29
35
  let expectedCol = -1;
30
36
  for (let r = 0; r < next.height; r++) {
@@ -43,13 +49,24 @@ export function diff(prev, next, rowOffset = 0) {
43
49
  parts.push(sgr);
44
50
  lastStyle = sgr;
45
51
  }
52
+ // Apply OSC 8 hyperlink transitions independently of SGR
53
+ const nextHyperlink = nextCell.style.hyperlink ?? null;
54
+ if (nextHyperlink !== lastHyperlink) {
55
+ if (lastHyperlink !== null)
56
+ parts.push(OSC8_CLOSE);
57
+ if (nextHyperlink !== null)
58
+ parts.push(osc8Open(nextHyperlink));
59
+ lastHyperlink = nextHyperlink;
60
+ }
46
61
  parts.push(nextCell.char);
47
62
  expectedRow = r;
48
63
  expectedCol = c + 1;
49
64
  }
50
65
  }
51
- // Reset style at end
66
+ // Close any open hyperlink and reset style at end
52
67
  if (parts.length > 0) {
68
+ if (lastHyperlink !== null)
69
+ parts.push(OSC8_CLOSE);
53
70
  parts.push("\x1b[0m");
54
71
  }
55
72
  return parts.join("");
@@ -45,7 +45,7 @@ export declare class TerminalRenderer {
45
45
  setCompanion(lines: string[] | null, color: string): void;
46
46
  setStatusHints(text: string): void;
47
47
  setBannerLines(lines: string[]): void;
48
- setAutocomplete(suggestions: string[], index: number, descriptions?: string[]): void;
48
+ setAutocomplete(suggestions: string[], index: number, descriptions?: string[], categories?: string[]): void;
49
49
  setStatusLine(text: string): void;
50
50
  setContextWarning(warning: {
51
51
  text: string;
@@ -82,7 +82,16 @@ export declare class TerminalRenderer {
82
82
  clearLiveArea(): void;
83
83
  onKeypress(handler: (key: KeyEvent) => void): void;
84
84
  onAnimation(handler: (frame: number) => void): void;
85
- /** Handle permission prompt keys (Y/N/D). Returns true if key was consumed. */
85
+ /**
86
+ * Handle permission prompt keys (Y/N/A/D).
87
+ * - Y / N: approve or deny this single call.
88
+ * - A: approve AND persist a `toolPermissions: { tool, action: "allow" }`
89
+ * rule to `.oh/config.yaml` so future calls to this tool skip the prompt
90
+ * entirely (audit U-A2). Mirrors Claude Code's "yes, don't ask again".
91
+ * - D: toggle inline diff (when available).
92
+ *
93
+ * Returns true if key was consumed.
94
+ */
86
95
  private handlePermissionKey;
87
96
  /** Handle question prompt text input. Returns true if key was consumed. */
88
97
  private handleQuestionKey;