diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index f376540f412..e520b3f8e28 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -536,6 +536,10 @@ Until then, OpenClaw's `before_compaction`, `after_compaction`, `llm_input`, and `llm_output` events are adapter-level observations, not byte-for-byte captures of Codex's internal request or compaction payloads. +Codex native `hook/started` and `hook/completed` app-server notifications are +projected as `codex_app_server.hook` agent events for trajectory and debugging. +They do not invoke OpenClaw plugin hooks. + ## Tools, media, and compaction The Codex harness changes the low-level embedded agent executor only. diff --git a/extensions/codex/src/app-server/event-projector.test.ts b/extensions/codex/src/app-server/event-projector.test.ts index e0a1ad050b9..f2d3fd2f37f 100644 --- a/extensions/codex/src/app-server/event-projector.test.ts +++ b/extensions/codex/src/app-server/event-projector.test.ts @@ -663,4 +663,105 @@ describe("CodexAppServerEventProjector", () => { }), ); }); + + it("projects codex hook started and completed notifications into agent events", async () => { + const onAgentEvent = vi.fn(); + const params = await createParams(); + const projector = await createProjector({ ...params, onAgentEvent }); + + await projector.handleNotification( + forCurrentTurn("hook/started", { + run: { + id: "hook-1", + eventName: "preToolUse", + handlerType: "command", + executionMode: "sync", + scope: "turn", + source: "project", + sourcePath: "/repo/.codex/hooks.json", + status: "running", + statusMessage: null, + entries: [], + }, + }), + ); + await projector.handleNotification( + forCurrentTurn("hook/completed", { + run: { + id: "hook-1", + eventName: "preToolUse", + handlerType: "command", + executionMode: "sync", + scope: "turn", + source: "project", + sourcePath: "/repo/.codex/hooks.json", + status: "blocked", + statusMessage: "blocked by hook", + durationMs: 42, + entries: [{ kind: "stderr", text: "blocked" }], + }, + }), + ); + + expect(onAgentEvent).toHaveBeenCalledWith({ + stream: "codex_app_server.hook", + data: expect.objectContaining({ + phase: "started", + threadId: THREAD_ID, + turnId: TURN_ID, + hookRunId: "hook-1", + eventName: "preToolUse", + status: "running", + }), + }); + expect(onAgentEvent).toHaveBeenCalledWith({ + stream: "codex_app_server.hook", + data: expect.objectContaining({ + phase: "completed", + hookRunId: "hook-1", + status: "blocked", + statusMessage: "blocked by hook", + durationMs: 42, + entries: [{ kind: "stderr", text: "blocked" }], + }), + }); + }); + + it("projects thread-scoped codex hook notifications that omit a turn id", async () => { + const onAgentEvent = vi.fn(); + const params = await createParams(); + const projector = await createProjector({ ...params, onAgentEvent }); + + await projector.handleNotification({ + method: "hook/started", + params: { + threadId: THREAD_ID, + turnId: null, + run: { + id: "hook-thread-1", + eventName: "sessionStart", + handlerType: "command", + executionMode: "sync", + scope: "thread", + source: "project", + sourcePath: "/repo/.codex/hooks.json", + status: "running", + statusMessage: null, + entries: [], + }, + }, + }); + + expect(onAgentEvent).toHaveBeenCalledWith({ + stream: "codex_app_server.hook", + data: expect.objectContaining({ + phase: "started", + threadId: THREAD_ID, + turnId: null, + hookRunId: "hook-thread-1", + eventName: "sessionStart", + scope: "thread", + }), + }); + }); }); diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 126e5b43594..cd5dc490264 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -86,7 +86,14 @@ export class CodexAppServerEventProjector { async handleNotification(notification: CodexServerNotification): Promise { const params = isJsonObject(notification.params) ? notification.params : undefined; - if (!params || !this.isNotificationForTurn(params)) { + if (!params) { + return; + } + if (isHookNotificationMethod(notification.method)) { + if (!this.isHookNotificationForCurrentThread(params)) { + return; + } + } else if (!this.isNotificationForTurn(params)) { return; } @@ -120,6 +127,10 @@ export class CodexAppServerEventProjector { case "item/autoApprovalReview/completed": this.handleGuardianReviewNotification(notification.method, params); break; + case "hook/started": + case "hook/completed": + this.handleHookNotification(notification.method, params); + break; case "thread/tokenUsage/updated": this.handleTokenUsage(params); break; @@ -413,6 +424,35 @@ export class CodexAppServerEventProjector { }); } + private handleHookNotification(method: string, params: JsonObject): void { + const run = isJsonObject(params.run) ? params.run : undefined; + if (!run) { + return; + } + const durationMs = readNumber(run, "durationMs"); + const entries = readHookOutputEntries(run.entries); + const hookTurnId = readNullableString(params, "turnId"); + this.emitAgentEvent({ + stream: "codex_app_server.hook", + data: { + phase: method === "hook/started" ? "started" : "completed", + threadId: this.threadId, + turnId: hookTurnId === undefined ? this.turnId : hookTurnId, + hookRunId: readString(run, "id"), + eventName: readString(run, "eventName"), + handlerType: readString(run, "handlerType"), + executionMode: readString(run, "executionMode"), + scope: readString(run, "scope"), + source: readString(run, "source"), + sourcePath: readString(run, "sourcePath"), + status: readString(run, "status"), + statusMessage: readNullableString(run, "statusMessage"), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(entries.length > 0 ? { entries } : {}), + }, + }); + } + private async handleTurnCompleted(params: JsonObject): Promise { const turn = readTurn(params.turn); if (!turn || turn.id !== this.turnId) { @@ -690,6 +730,16 @@ export class CodexAppServerEventProjector { const turnId = readNotificationTurnId(params); return threadId === this.threadId && turnId === this.turnId; } + + private isHookNotificationForCurrentThread(params: JsonObject): boolean { + const threadId = readString(params, "threadId"); + const turnId = params.turnId; + return threadId === this.threadId && (turnId === this.turnId || turnId === null); + } +} + +function isHookNotificationMethod(method: string): method is "hook/started" | "hook/completed" { + return method === "hook/started" || method === "hook/completed"; } function readNotificationTurnId(record: JsonObject): string | undefined { @@ -719,6 +769,25 @@ function readNumber(record: JsonObject, key: string): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function readHookOutputEntries( + value: JsonValue | undefined, +): Array<{ kind?: string; text: string }> { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((entry) => { + if (!isJsonObject(entry)) { + return []; + } + const text = readString(entry, "text"); + if (!text) { + return []; + } + const kind = readString(entry, "kind"); + return [{ ...(kind ? { kind } : {}), text }]; + }); +} + function readFirstJsonObject(record: JsonObject, keys: readonly string[]): JsonObject | undefined { for (const key of keys) { const value = record[key];