fix(omp): report tool approvals to Codeman so an omp pane alerts

An omp pane asking for tool approval raised no tab alert, no desktop
notification and no Approvals Inbox item. omp inherited hooks:'none' from
agentDefaults(), so hooksAvailableForMode() answered false and
hook-event-routes never opened an item; there was nothing to resolve either.

omp is one of the few non-claude CLIs with real approval events
(tool_approval_requested / tool_approval_resolved), so Codeman can install a
hook module into <workspace>/.claude/hooks/pre/ - the directory omp scans -
on session create. It forwards them as permission_prompt and
elicitation_complete, the names the server already routes on, so the existing
alert, notification and inbox pipeline works unchanged.

The file name comes from the new capabilities.approvalHookFile field rather
than a branch on the CLI id (cli-registry-no-id-branching). stop/idle_prompt
are deliberately NOT reported: omp's idle stays screen-read via workDetect,
which the agent skill's verbs.md now says explicitly.
This commit is contained in:
timkjr committed 2026-11-09 18:51:42 -06:00
1 parent f62c1f7e2f
commit 0bbf483a35
9 files changed
+325 -4

No files matched your search

+8
View File
@@ -325,6 +325,14 @@ const capabilitiesSchema = z
// A closed list on purpose: it decides where the server WRITES, and this file is
// user-editable, so it can never be a free path (`..`, absolute).
agentSkillDir: z.enum(AGENT_SKILL_DIRS).optional(),
// The hook module Codeman installs for a CLI whose hooks are files, not a settings
// block (omp). Same closed-list reasoning as `agentSkillDir`, stricter still: this
// name is written into the user's workspace, so a value like `../../evil.ts` must be
// refused by the schema rather than by whatever calls the writer.
approvalHookFile: z
.string()
.regex(/^[A-Za-z0-9._-]+\.ts$/)
.optional(),
statusLineTelemetry: z.boolean(),
// How many columns this CLI indents its transcript body by, so a copy can take
// that much off the clipboard. Bounded, because it is the whole strip: a copy
+15
View File
@@ -1742,6 +1742,21 @@ const OMP: CliEntry = {
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
// omp emits REAL approval events (`tool_approval_requested` / `tool_approval_resolved`),
// so an omp pane can report a pending dialog first-hand instead of by inference.
// Codeman installs the hook module named by `approvalHookFile` into
// `<workspace>/.claude/hooks/pre/` when a session is created; it forwards those two
// events to /api/hook-event as `permission_prompt` / `elicitation_complete`, the same
// names claude's settings block sends, so the tab alert, the desktop notification and
// the Approvals Inbox work unchanged.
// ⚠️ 'always' rather than 'supervised': the report comes from a hook Codeman owns and
// installs (like claude's), not from a supervised-CLI status bridge.
// ⚠️ Only approvals are reported. `stop`/`idle_prompt` still come from `workDetect`
// below, which stays the source of truth for whether an omp pane is working or idle.
hooks: 'always',
// The hook module Codeman writes for this CLI. Declared as data so the session-create
// path asks "does this CLI declare a hook file?" instead of branching on an id.
approvalHookFile: 'codeman-approval.ts',
// Fullscreen alt-screen TUI, same shape as antigravity/grok: only the
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
altScreen: 'strip-mux-only',
+17 -1
View File
@@ -509,7 +509,9 @@ export interface CliCapabilities {
*
* ⚠️ A TRI-STATE, not a boolean, because for one CLI this is a per-SESSION question:
* 'none' — no hook signals, ever (every external CLI, and `shell`).
* 'always' — the CLI installs Codeman's hooks (claude).
* 'always' — the CLI's signals are installed by Codeman and are unconditional:
* claude via a `.claude/settings.local.json` block, omp via the hook
* MODULE named by `approvalHookFile` below.
* 'supervised' — the CLI REPORTS its own idle/working/blocked state to a supervisor
* over a generic env-gated contract, and Codeman is that supervisor
* (deepseek, via deepseek-status-shim.ts). Definitive rather than
@@ -523,6 +525,20 @@ export interface CliCapabilities {
* timeout the predicate exists to prevent.
*/
hooks: 'none' | 'always' | 'supervised';
/**
* A CLI whose hooks are MODULES rather than a settings-file block names the file
* Codeman installs into its project hook directory.
*
* omp is the case that exists today: it loads `<cwd>/.claude/hooks/pre/*.ts`, and the
* installed module forwards `tool_approval_requested`/`tool_approval_resolved` to
* `/api/hook-event` as `permission_prompt`/`elicitation_complete`, so an omp pane asking
* for approval raises the same tab alert, notification and Approvals Inbox item a claude
* pane does. Absent with `hooks: 'always'` means Claude's settings-file block.
*
* Declared as data, not a branch on the id, so the session-create path can ask
* "does this CLI need a hook file installed?" (see cli-registry-no-id-branching).
*/
approvalHookFile?: string;
/**
* Which transcript reader, if any, understands this CLI's on-disk history.
*
+121
View File
@@ -1669,6 +1669,127 @@ export async function removeAgentSkillFrom(skillDir: string): Promise<AgentSkill
});
}
// ============================================================================
// omp approval hooks
// ============================================================================
/** Marks the hook file this module owns inside a workspace. */
const OMP_APPROVAL_HOOK_MARKER = 'CODEMAN_OMP_APPROVAL_HOOK_V1';
/**
* omp's own hook directory, workspace-relative. omp scans `<cwd>/.claude/hooks/pre`
* and `<cwd>/.claude/hooks/post` (`projectDir` for the `claude` persona) plus a
* user-level `<agentDir>/hooks`, and loads each `.ts`/`.js` file's default export as
* `(pi: HookAPI) => void`. A project-level `pre` hook is deliberately the target: it
* travels with the workspace the session actually runs in, so no per-machine or
* per-user install is needed and removing the workspace removes the hook.
*/
export const OMP_APPROVAL_HOOK_DIR = '.claude/hooks/pre';
export const OMP_APPROVAL_HOOK_FILE = 'codeman-approval.ts';
/**
* Source of the hook that turns omp's own approval events into Codeman hook events.
*
* WHY this exists: omp declares `capabilities.hooks: 'always'` (stock registry) but
* installs no Claude-style `settings.local.json` hook block — its hooks are modules.
* Without this file `hooksAvailableForMode()` is true while nothing ever POSTs, so
* `/api/approvals` stays empty, the tab never goes red and no notification fires: an
* omp pane asking for tool approval is silent. Claude solves the same problem by
* writing a `Notification: permission_prompt` block into `settings.local.json`.
*
* The mapping mirrors Claude's names exactly, because the server routes on them
* (`APPROVAL_KIND_BY_EVENT` in web/routes/hook-event-routes.ts):
* - `tool_approval_requested` -> `permission_prompt` (opens the inbox item)
* - `tool_approval_resolved` -> `elicitation_complete` (resolves it, whether the user
* approved or denied, so a denied dialog does not leave a red tab forever)
*
* ⚠️ Fails OPEN and SILENT, like the memory-gate hook: a hook that throws would break
* the approval flow it exists to report. Every path catches, and nothing is awaited
* unbounded — the fetch is fire-and-forget with a short AbortSignal.
*/
export function generateOmpApprovalHook(): string {
return `// ${OMP_APPROVAL_HOOK_MARKER} — auto-generated by Codeman; safe to delete, rewritten when a session is created.
//
// Reports omp's tool-approval dialogs to Codeman's /api/hook-event so an omp pane
// raises a tab alert and a desktop notification exactly like a claude pane does.
// Codeman owns this file: a foreign file of the same name is never overwritten.
import { readFile } from "node:fs/promises";
import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
const API_URL = process.env.CODEMAN_API_URL;
const SESSION_ID = process.env.CODEMAN_SESSION_ID;
const SECRET_FILE = process.env.CODEMAN_HOOK_SECRET_FILE;
/** POST one Codeman hook event. Never throws, never awaited past its timeout. */
async function report(event: string, data: Record<string, unknown>): Promise<void> {
if (!API_URL || !SESSION_ID) return;
try {
const secret = SECRET_FILE ? (await readFile(SECRET_FILE, "utf8")).trim() : "";
await fetch(\`\${API_URL}/api/hook-event\`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Codeman-Hook-Secret": secret },
body: JSON.stringify({ event, sessionId: SESSION_ID, data }),
signal: AbortSignal.timeout(5000),
});
} catch {
// Best effort by contract: a failed report must never disturb the dialog itself.
}
}
export default function codemanApprovalHook(pi: HookAPI): void {
pi.on("tool_approval_requested", (event) => {
void report("permission_prompt", {
tool_name: event.toolName,
tool_input: event.reason ? { description: event.reason } : undefined,
});
});
pi.on("tool_approval_resolved", (event) => {
void report("elicitation_complete", {
tool_name: event.toolName,
approved: event.approved,
});
});
}
`;
}
export type ApprovalHookApplyResult = 'installed' | 'refreshed' | 'unchanged' | 'foreign' | 'symlink';
/**
* Install or refresh the omp approval hook in `<workspace>/.claude/hooks/pre/`.
*
* Ownership: the file carries `OMP_APPROVAL_HOOK_MARKER` in its first line. A file
* WITHOUT the marker is somebody else's hook and is left untouched (`foreign`) — the
* same posture `installAgentSkillInto` takes, so Codeman never clobbers user work.
* A symlinked directory is refused rather than written through.
*
* Idempotent: an unchanged file is not rewritten (`unchanged`), so this is safe to call
* on every omp session create.
*/
export async function applyApprovalHookFile(workspace: string, fileName: string): Promise<ApprovalHookApplyResult> {
const hookDir = join(workspace, OMP_APPROVAL_HOOK_DIR);
const target = join(hookDir, fileName);
return withSettingsLock(target, async () => {
if ((await isSymlink(hookDir)) || (await isSymlink(join(workspace, '.claude')))) return 'symlink';
let existing: string | null = null;
try {
existing = await readFile(target, 'utf-8');
} catch {
// absent: fresh install
}
if (existing !== null && !existing.includes(OMP_APPROVAL_HOOK_MARKER)) return 'foreign';
const content = generateOmpApprovalHook();
if (existing === content) return 'unchanged';
await mkdir(hookDir, { recursive: true });
await writeSkillFileAtomic(target, content);
return existing === null ? 'installed' : 'refreshed';
});
}
/**
* Claude Code's project skills directory, and the meaning `agentSkillInjection: true`
* had before `capabilities.agentSkillDir` existed.
+13
View File
@@ -156,6 +156,7 @@ import {
seedAgentSessionPreamble,
applyWorkspaceHooks,
refreshStaleCodemanHooks,
applyApprovalHookFile,
} from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { imageWatcher } from '../../image-watcher.js';
@@ -1331,6 +1332,18 @@ export function registerSessionRoutes(
await applyWorkspaceHooks(workingDir, await ctx.getWorkspaceHooksEnabled());
}
// A CLI whose hooks are MODULES rather than a settings.local.json block (omp) needs
// its hook file installed into the workspace, so its approval dialogs raise the same
// alert a claude pane does. Registry-driven: `approvalHookFile` names the file, so no
// call site branches on a CLI id. Same local-workspace and `workspaceHooksEnabled`
// gate as claude's block; best-effort, because a failure here must not block a create.
const approvalHookFile = getCli(body.mode ?? 'claude')?.capabilities.approvalHookFile;
if (!remote && body.workingDir && approvalHookFile) {
if (await ctx.getWorkspaceHooksEnabled()) {
await applyApprovalHookFile(workingDir, approvalHookFile).catch(() => {});
}
}
// Refuse up front if the requested CLI cannot start, rather than spawning a pane that
// dies on `command not found`. The message comes from the resolver, so it names where
// resolution actually looked (server PATH, login shell, the entry's search dirs); a