@makaio/client-codex 1.0.0-dev-1786014320559 → 1.0.0-dev-1789609124644
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 +9 -4
- package/dist/{codex-client-session-service-epcEX-w5.mjs → codex-client-session-service-CsIoRrhH.mjs} +648 -106
- package/dist/index.mjs +2 -2
- package/dist/runtime/codex-client-session-service.d.ts +11 -0
- package/dist/runtime/fork-sniff.d.ts +101 -0
- package/dist/runtime/hook-normalizer.d.ts +55 -26
- package/dist/runtime/hook-response-contracts.d.ts +15 -1
- package/dist/runtime/package.mjs +1 -1
- package/dist/runtime/schemas.d.ts +21 -1
- package/dist/runtime/wiring.d.ts +9 -0
- package/dist/server.mjs +1 -1
- package/dist/{src-BMTPLcPK.mjs → src-osBCBvw6.mjs} +1 -1
- package/package.json +1 -1
package/dist/{codex-client-session-service-epcEX-w5.mjs → codex-client-session-service-CsIoRrhH.mjs}
RENAMED
|
@@ -4,14 +4,14 @@ import { AbsolutePathSchema, BinaryNotFoundError, ClientSubjects, ClientWiringAp
|
|
|
4
4
|
import { MakaioBus, RequestError } from "@makaio/framework/bus";
|
|
5
5
|
import { BaseService } from "@makaio/framework/service-base";
|
|
6
6
|
import * as fs$1 from "node:fs/promises";
|
|
7
|
-
import fs from "node:fs/promises";
|
|
7
|
+
import fs, { open } from "node:fs/promises";
|
|
8
8
|
import * as path$1 from "node:path";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import * as os$1 from "node:os";
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import { createHash, randomUUID } from "node:crypto";
|
|
13
|
+
import { CLIENT_SESSION_COMPACTION_TRIGGERS, ClientConfigPrimeSchema, SessionConfigSetupRequestSchema, SessionConfigSetupResponseSchema, SessionConfigTeardownRequestSchema, SessionConfigTeardownResponseSchema } from "@makaio/framework/contracts/client";
|
|
13
14
|
import { isDeepStrictEqual } from "node:util";
|
|
14
|
-
import { ClientConfigPrimeSchema, SessionConfigSetupRequestSchema, SessionConfigSetupResponseSchema, SessionConfigTeardownRequestSchema, SessionConfigTeardownResponseSchema } from "@makaio/framework/contracts/client";
|
|
15
15
|
import { parse } from "smol-toml";
|
|
16
16
|
import { lock } from "proper-lockfile";
|
|
17
17
|
|
|
@@ -135,7 +135,22 @@ const clientDefinition = createClientDefinition({
|
|
|
135
135
|
name: "Stop",
|
|
136
136
|
frameworkSubject: "client.session.turn.completed",
|
|
137
137
|
responseCapabilities: [CODEX_HOOK_RESPONSE_CAPABILITIES.block]
|
|
138
|
-
}
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "SubagentStart",
|
|
141
|
+
frameworkSubject: "client.session.subagent.started",
|
|
142
|
+
responseCapabilities: ["context.append"]
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "SubagentStop",
|
|
146
|
+
frameworkSubject: "client.session.subagent.completed"
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "PreCompact",
|
|
150
|
+
frameworkSubject: "client.session.compaction.pre"
|
|
151
|
+
},
|
|
152
|
+
{ name: "PostCompact" },
|
|
153
|
+
{ name: "PermissionRequest" }
|
|
139
154
|
]
|
|
140
155
|
}
|
|
141
156
|
});
|
|
@@ -942,6 +957,191 @@ async function handleCodexConfigPrime(payload) {
|
|
|
942
957
|
return { primed: true };
|
|
943
958
|
}
|
|
944
959
|
|
|
960
|
+
//#endregion
|
|
961
|
+
//#region src/runtime/fork-sniff.ts
|
|
962
|
+
/**
|
|
963
|
+
* Fork lineage sniff for Codex rollout files.
|
|
964
|
+
*
|
|
965
|
+
* Codex records every thread as a JSONL *rollout* file. The first line is the
|
|
966
|
+
* thread's own `session_meta` record; when the thread was created by forking
|
|
967
|
+
* another thread, that record carries `forked_from_id` — the parent thread id.
|
|
968
|
+
* Codex then copies the parent's persisted rollout items verbatim into the
|
|
969
|
+
* child file, so the parent's own `session_meta` line appears *later* in the
|
|
970
|
+
* same file.
|
|
971
|
+
*
|
|
972
|
+
* ## Why a sniff is needed
|
|
973
|
+
*
|
|
974
|
+
* The `SessionStart` hook payload has no lineage field at all: it carries
|
|
975
|
+
* `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `model`,
|
|
976
|
+
* `permission_mode` and `source` — nothing else. A fork also reports
|
|
977
|
+
* `source: 'startup'`, exactly like a brand-new thread, so the hook alone
|
|
978
|
+
* cannot distinguish the two. `transcript_path` points at the rollout file
|
|
979
|
+
* the CLI has just materialized for the starting thread, which makes the
|
|
980
|
+
* rollout head the only lineage source available at session-start time.
|
|
981
|
+
*
|
|
982
|
+
* ## Detection invariant
|
|
983
|
+
*
|
|
984
|
+
* The **first** `session_meta` record in the file is the file's own. Any later
|
|
985
|
+
* `session_meta` record was copied from an ancestor and must be ignored, so the
|
|
986
|
+
* scan stops at the first one it can parse:
|
|
987
|
+
*
|
|
988
|
+
* - `forked_from_id` present and different from the starting thread's own id
|
|
989
|
+
* → fork child; that id is the direct parent.
|
|
990
|
+
* - `forked_from_id` absent (or equal to the own id, which would be a
|
|
991
|
+
* self-reference and is never a usable parent) → no fork signal.
|
|
992
|
+
* - No parseable `session_meta` inside the window → inconclusive; return
|
|
993
|
+
* `undefined`. Fork registration is fill-once on the ingestion side, so a
|
|
994
|
+
* guessed parent would be permanently wrong while deferring is safe.
|
|
995
|
+
*
|
|
996
|
+
* Anchoring the window at the start of the file is what makes the rule sound
|
|
997
|
+
* under a byte cap: the decisive record is the first one, so no earlier record
|
|
998
|
+
* can exist outside the window.
|
|
999
|
+
*
|
|
1000
|
+
* ## Design principles
|
|
1001
|
+
*
|
|
1002
|
+
* - **Pure detection core**: {@link sniffRolloutForkLineage} operates on raw
|
|
1003
|
+
* JSONL lines. No I/O, no bus, no dependencies beyond the language runtime.
|
|
1004
|
+
* - **Bounded I/O wrapper**: {@link sniffRolloutFork} reads at most
|
|
1005
|
+
* {@link SNIFF_MAX_BYTES} from the rollout head, so the hook path never
|
|
1006
|
+
* blocks on a large rollout file.
|
|
1007
|
+
* - **Fail-open**: any I/O or parse error yields `undefined` (no fork signal);
|
|
1008
|
+
* hook processing is never blocked by a sniff failure.
|
|
1009
|
+
* @packageDocumentation
|
|
1010
|
+
*/
|
|
1011
|
+
/**
|
|
1012
|
+
* Maximum number of bytes to read from the rollout head for fork detection.
|
|
1013
|
+
*
|
|
1014
|
+
* The decisive record is the first line, but that line embeds the thread's
|
|
1015
|
+
* `base_instructions`, which can run to several kilobytes. 128 KiB leaves ample
|
|
1016
|
+
* headroom for it while keeping the blocking window small. A `session_meta`
|
|
1017
|
+
* line longer than the window yields a truncated, unparseable JSON fragment and
|
|
1018
|
+
* therefore no signal — the fail-open outcome, not a wrong parent.
|
|
1019
|
+
*/
|
|
1020
|
+
const SNIFF_MAX_BYTES = 128 * 1024;
|
|
1021
|
+
/** Rollout item discriminator for the thread metadata record. */
|
|
1022
|
+
const SESSION_META_TYPE = "session_meta";
|
|
1023
|
+
/**
|
|
1024
|
+
* Read `forked_from_id` from a parsed rollout line when it is the file's own
|
|
1025
|
+
* `session_meta` record.
|
|
1026
|
+
*
|
|
1027
|
+
* Codex serializes its rollout items with an externally tagged representation,
|
|
1028
|
+
* so the record on disk has a `type` discriminator and a `payload` body; the
|
|
1029
|
+
* metadata record flattens the thread metadata into that `payload`.
|
|
1030
|
+
* @param parsed - Parsed JSONL record from the rollout head
|
|
1031
|
+
* @returns The parent thread id, `undefined` when the record names no parent,
|
|
1032
|
+
* or `null` when the record is not a `session_meta` record at all
|
|
1033
|
+
*/
|
|
1034
|
+
function readForkParent(parsed) {
|
|
1035
|
+
if (parsed["type"] !== SESSION_META_TYPE) return null;
|
|
1036
|
+
const payload = parsed["payload"];
|
|
1037
|
+
if (typeof payload !== "object" || payload === null) return void 0;
|
|
1038
|
+
const forkedFromId = payload["forked_from_id"];
|
|
1039
|
+
return typeof forkedFromId === "string" && forkedFromId.length > 0 ? forkedFromId : void 0;
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Detect fork lineage from raw rollout JSONL lines.
|
|
1043
|
+
*
|
|
1044
|
+
* Scans the provided window **forward** from the first line. The window must be
|
|
1045
|
+
* anchored at the start of the rollout file (see {@link sniffRolloutFork});
|
|
1046
|
+
* under that anchoring, the first `session_meta` record found is the file's own
|
|
1047
|
+
* and decides the outcome. Later `session_meta` records belong to ancestors
|
|
1048
|
+
* copied into the fork and are never consulted.
|
|
1049
|
+
* @param lines - Raw JSONL lines from the rollout head (may include empty
|
|
1050
|
+
* strings, partial lines, or non-JSON data)
|
|
1051
|
+
* @param hookSessionId - Session id reported by the hook payload (the starting
|
|
1052
|
+
* thread's own id)
|
|
1053
|
+
* @returns Fork sniff result when the own `session_meta` names a foreign
|
|
1054
|
+
* parent, or `undefined` for a plain start / no signal / inconclusive window
|
|
1055
|
+
*/
|
|
1056
|
+
function sniffRolloutForkLineage(lines, hookSessionId) {
|
|
1057
|
+
for (const raw of lines) {
|
|
1058
|
+
const line = raw.trim();
|
|
1059
|
+
if (line.length === 0) continue;
|
|
1060
|
+
let parsed;
|
|
1061
|
+
try {
|
|
1062
|
+
parsed = JSON.parse(line);
|
|
1063
|
+
} catch {
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
if (typeof parsed !== "object" || parsed === null) continue;
|
|
1067
|
+
const forkParent = readForkParent(parsed);
|
|
1068
|
+
if (forkParent === null) continue;
|
|
1069
|
+
if (forkParent === void 0 || forkParent === hookSessionId) return void 0;
|
|
1070
|
+
return { parentAdapterSessionId: forkParent };
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Read at most {@link SNIFF_MAX_BYTES} from the head of the rollout file.
|
|
1075
|
+
*
|
|
1076
|
+
* The final element is dropped when the file is larger than the window: it is a
|
|
1077
|
+
* partial line whose parse would fail anyway, and dropping it keeps the caller
|
|
1078
|
+
* from treating truncated JSON as data.
|
|
1079
|
+
* @param rolloutPath - Absolute path to the rollout JSONL file
|
|
1080
|
+
* @returns Raw lines from the file head, or `undefined` on any I/O error
|
|
1081
|
+
*/
|
|
1082
|
+
async function readRolloutHead(rolloutPath) {
|
|
1083
|
+
let fh;
|
|
1084
|
+
try {
|
|
1085
|
+
fh = await open(rolloutPath, "r");
|
|
1086
|
+
const { size } = await fh.stat();
|
|
1087
|
+
const readLength = Math.min(size, SNIFF_MAX_BYTES);
|
|
1088
|
+
const buf = Buffer.alloc(readLength);
|
|
1089
|
+
const { bytesRead } = await fh.read(buf, 0, readLength, 0);
|
|
1090
|
+
const lines = buf.toString("utf8", 0, bytesRead).split("\n");
|
|
1091
|
+
if (size > 131072) lines.pop();
|
|
1092
|
+
return lines;
|
|
1093
|
+
} catch {
|
|
1094
|
+
return;
|
|
1095
|
+
} finally {
|
|
1096
|
+
await fh?.close();
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Sniff the rollout file at session-start time to detect fork lineage.
|
|
1101
|
+
*
|
|
1102
|
+
* Combines the bounded head read with the pure forward-scanning detection core.
|
|
1103
|
+
* Because the window is anchored at the start of the file, a byte cap can never
|
|
1104
|
+
* produce a wrong parent: the decisive record — the file's own `session_meta` —
|
|
1105
|
+
* is either inside the window or the sniff returns `undefined`. Returns
|
|
1106
|
+
* `undefined` (no fork signal) on any error; hook processing must never be
|
|
1107
|
+
* blocked by a sniff failure.
|
|
1108
|
+
* @param rolloutPath - Absolute path to the rollout JSONL file, as reported by
|
|
1109
|
+
* the hook payload's `transcript_path`
|
|
1110
|
+
* @param hookSessionId - Session id reported by the hook payload
|
|
1111
|
+
* @returns Fork sniff result, or `undefined` when no fork is detected or the
|
|
1112
|
+
* sniff cannot be performed
|
|
1113
|
+
*/
|
|
1114
|
+
async function sniffRolloutFork(rolloutPath, hookSessionId) {
|
|
1115
|
+
try {
|
|
1116
|
+
const lines = await readRolloutHead(rolloutPath);
|
|
1117
|
+
if (lines === void 0) return void 0;
|
|
1118
|
+
return sniffRolloutForkLineage(lines, hookSessionId);
|
|
1119
|
+
} catch {
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
//#endregion
|
|
1125
|
+
//#region src/runtime/schemas.ts
|
|
1126
|
+
/**
|
|
1127
|
+
* Hook events emitted by Codex that map to the v1 observed-semantics set.
|
|
1128
|
+
*
|
|
1129
|
+
* These are the events the normalizer translates into `client.session.*` bus
|
|
1130
|
+
* emissions. Any event NOT listed here is left as raw `client:codex`
|
|
1131
|
+
* namespace data only.
|
|
1132
|
+
*
|
|
1133
|
+
* Event names are verified against pinned source `rust-v0.144.1`
|
|
1134
|
+
* (`codex-rs/hooks/src/lib.rs`).
|
|
1135
|
+
*/
|
|
1136
|
+
const CODEX_HOOK_SESSION_START = "SessionStart";
|
|
1137
|
+
const CODEX_HOOK_USER_PROMPT_SUBMIT = "UserPromptSubmit";
|
|
1138
|
+
const CODEX_HOOK_PRE_TOOL_USE = "PreToolUse";
|
|
1139
|
+
const CODEX_HOOK_POST_TOOL_USE = "PostToolUse";
|
|
1140
|
+
const CODEX_HOOK_STOP = "Stop";
|
|
1141
|
+
const CODEX_HOOK_SUBAGENT_START = "SubagentStart";
|
|
1142
|
+
const CODEX_HOOK_SUBAGENT_STOP = "SubagentStop";
|
|
1143
|
+
const CODEX_HOOK_PRE_COMPACT = "PreCompact";
|
|
1144
|
+
|
|
945
1145
|
//#endregion
|
|
946
1146
|
//#region src/runtime/hook-normalizer.ts
|
|
947
1147
|
/**
|
|
@@ -951,37 +1151,88 @@ async function handleCodexConfigPrime(payload) {
|
|
|
951
1151
|
* `client:codex.hook.received` to their corresponding
|
|
952
1152
|
* `client.session.*` observed-semantics subjects.
|
|
953
1153
|
*
|
|
954
|
-
* **Mapping table** (Codex event → global subject):
|
|
1154
|
+
* **Mapping table** (Codex event → global subject(s)):
|
|
955
1155
|
*
|
|
956
|
-
* | Codex event name
|
|
957
|
-
*
|
|
958
|
-
* | `SessionStart`
|
|
959
|
-
* | `UserPromptSubmit`
|
|
960
|
-
* | `Stop`
|
|
961
|
-
* | `PreToolUse`
|
|
962
|
-
* | `PostToolUse`
|
|
1156
|
+
* | Codex event name | Global subject(s) |
|
|
1157
|
+
* |------------------|----------------------------------------------------------|
|
|
1158
|
+
* | `SessionStart` | `client.session.started` |
|
|
1159
|
+
* | `UserPromptSubmit` | `client.session.turn.started`, then `client.session.userPrompt.submitted` |
|
|
1160
|
+
* | `Stop` | `client.session.turn.completed` |
|
|
1161
|
+
* | `PreToolUse` | `client.session.tool.pre` |
|
|
1162
|
+
* | `PostToolUse` | `client.session.tool.post` |
|
|
1163
|
+
* | `SubagentStart` | `client.session.subagent.started` |
|
|
1164
|
+
* | `SubagentStop` | `client.session.subagent.completed` |
|
|
1165
|
+
* | `PreCompact` | `client.session.compaction.pre` |
|
|
1166
|
+
* | `PostCompact` | _(raw-only — no global subject)_ |
|
|
963
1167
|
*
|
|
964
|
-
* All other event names
|
|
1168
|
+
* All other event names return an empty array — they are kept raw only and
|
|
965
1169
|
* are never emitted into the global `client.*` namespace.
|
|
966
1170
|
*
|
|
967
|
-
* **Source notes:**
|
|
968
|
-
* Codex
|
|
969
|
-
*
|
|
1171
|
+
* **Source notes:** Event names are verified against the pinned
|
|
1172
|
+
* `rust-v0.144.1` Codex source (`codex-rs/hooks/src/lib.rs`). Update this
|
|
1173
|
+
* normalizer when a new binary version changes or adds hook names.
|
|
1174
|
+
*
|
|
1175
|
+
* **Subagent hooks:** On both `SubagentStart` and `SubagentStop`, the raw
|
|
1176
|
+
* `session_id` is the PARENT session id. `adapterSessionId` on the base carries
|
|
1177
|
+
* it directly (no stripping). `agentId` identifies the subagent. `turnId` is
|
|
1178
|
+
* populated from `turn_id` when the Codex CLI includes it.
|
|
970
1179
|
* @packageDocumentation
|
|
971
1180
|
*/
|
|
1181
|
+
/** Client ID used in all normalized payloads emitted by this normalizer. */
|
|
1182
|
+
const CLIENT_ID$1 = "codex";
|
|
1183
|
+
/** Source tag carried on all normalized observations. */
|
|
1184
|
+
const SOURCE = "native-hook";
|
|
972
1185
|
/**
|
|
973
|
-
*
|
|
1186
|
+
* Known compaction trigger values reported by Codex.
|
|
974
1187
|
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
1188
|
+
* Derived from the contracts constant so it stays in sync without a separate
|
|
1189
|
+
* local enumeration that could drift.
|
|
977
1190
|
*/
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1191
|
+
const COMPACTION_TRIGGERS = new Set(CLIENT_SESSION_COMPACTION_TRIGGERS);
|
|
1192
|
+
/**
|
|
1193
|
+
* Map from the Codex CLI `SessionStart.source` union to the
|
|
1194
|
+
* framework-level {@link ClientSessionStartMode}.
|
|
1195
|
+
*
|
|
1196
|
+
* - `'startup'` → `'fresh'` (brand-new thread — **and a fork child**, see
|
|
1197
|
+
* below; the owning service upgrades the fork case to `'fork'`)
|
|
1198
|
+
* - `'resume'` → `'resume'` (thread continued from its own rollout file)
|
|
1199
|
+
* - `'clear'` → `'clear'` (conversation cleared, new thread id)
|
|
1200
|
+
* - `'compact'` → `'compact'` (context compacted, same thread id)
|
|
1201
|
+
*
|
|
1202
|
+
* The vendor union has exactly these four values in the pinned `rust-v0.144.1`
|
|
1203
|
+
* source (`codex-rs/hooks/src/events/session_start.rs`, `SessionStartSource`);
|
|
1204
|
+
* there is no `'fork'` value. In `codex-rs/core/src/session/session.rs` a fork
|
|
1205
|
+
* is classified next to a brand-new thread — the match arm that maps
|
|
1206
|
+
* `InitialHistory::New` to `SessionStartSource::Startup` also covers
|
|
1207
|
+
* `InitialHistory::Forked` — which is why `'startup'`, not `'resume'`, is the
|
|
1208
|
+
* mode that may still turn out to be a fork.
|
|
1209
|
+
* Lineage is recovered from the rollout file instead; see the fork sniff in
|
|
1210
|
+
* `fork-sniff.ts` and its caller in `codex-client-session-service.ts`.
|
|
1211
|
+
*
|
|
1212
|
+
* Vendor values not in this map yield `undefined`, leaving `startMode`
|
|
1213
|
+
* absent from the normalized payload — safe for forward compatibility when
|
|
1214
|
+
* Codex adds new source values.
|
|
1215
|
+
*/
|
|
1216
|
+
const VENDOR_SOURCE_TO_START_MODE = {
|
|
1217
|
+
startup: "fresh",
|
|
1218
|
+
resume: "resume",
|
|
1219
|
+
clear: "clear",
|
|
1220
|
+
compact: "compact"
|
|
1221
|
+
};
|
|
1222
|
+
/**
|
|
1223
|
+
* Extract the `source` field from a `SessionStart` hook payload and map it
|
|
1224
|
+
* to a {@link ClientSessionStartMode}.
|
|
1225
|
+
*
|
|
1226
|
+
* Returns `undefined` when the field is absent, non-string, or not a
|
|
1227
|
+
* recognized value — keeping the normalizer tolerant of future CLI additions.
|
|
1228
|
+
* @param payload - Raw `SessionStart` hook payload
|
|
1229
|
+
* @returns Mapped start mode, or `undefined` when the source is unknown
|
|
1230
|
+
*/
|
|
1231
|
+
function resolveStartMode(payload) {
|
|
1232
|
+
const source = payload["source"];
|
|
1233
|
+
if (typeof source !== "string") return void 0;
|
|
1234
|
+
return VENDOR_SOURCE_TO_START_MODE[source];
|
|
1235
|
+
}
|
|
985
1236
|
/**
|
|
986
1237
|
* Extract optional session identifier from a raw Codex hook payload.
|
|
987
1238
|
*
|
|
@@ -1021,87 +1272,241 @@ function extractPrompt(payload) {
|
|
|
1021
1272
|
return pickNonEmptyString(payload, "prompt");
|
|
1022
1273
|
}
|
|
1023
1274
|
/**
|
|
1024
|
-
*
|
|
1275
|
+
* Extract the subagent identity from a subagent hook payload.
|
|
1276
|
+
*
|
|
1277
|
+
* Codex reports the agent identity under `agent_id` on both `SubagentStart`
|
|
1278
|
+
* and `SubagentStop` hook payloads.
|
|
1279
|
+
* @param payload - Raw subagent hook payload
|
|
1280
|
+
* @returns Agent ID string, or `undefined` when absent or empty
|
|
1281
|
+
*/
|
|
1282
|
+
function extractAgentId(payload) {
|
|
1283
|
+
return pickNonEmptyString(payload, "agent_id");
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Extract the subagent type label from a subagent hook payload.
|
|
1025
1287
|
*
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
1288
|
+
* Codex reports the agent type under `agent_type` on subagent hooks.
|
|
1289
|
+
* @param payload - Raw subagent hook payload
|
|
1290
|
+
* @returns Agent type string, or `undefined` when absent or empty
|
|
1291
|
+
*/
|
|
1292
|
+
function extractAgentType(payload) {
|
|
1293
|
+
return pickNonEmptyString(payload, "agent_type");
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Extract the subagent transcript path from a `SubagentStop` payload.
|
|
1297
|
+
*
|
|
1298
|
+
* Codex may report the agent's own transcript path under
|
|
1299
|
+
* `agent_transcript_path` at subagent stop time.
|
|
1300
|
+
* @param payload - Raw `SubagentStop` hook payload
|
|
1301
|
+
* @returns Absolute transcript path, or `undefined` when absent or empty
|
|
1302
|
+
*/
|
|
1303
|
+
function extractAgentTranscriptPath(payload) {
|
|
1304
|
+
return pickNonEmptyString(payload, "agent_transcript_path");
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Extract the turn correlation id from a subagent hook payload.
|
|
1308
|
+
*
|
|
1309
|
+
* Codex subagent hooks carry `turn_id` to correlate the hook event with the
|
|
1310
|
+
* parent turn that spawned the subagent.
|
|
1311
|
+
* @param payload - Raw subagent hook payload
|
|
1312
|
+
* @returns Turn id string, or `undefined` when absent or empty
|
|
1313
|
+
*/
|
|
1314
|
+
function extractTurnId(payload) {
|
|
1315
|
+
return pickNonEmptyString(payload, "turn_id");
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Extract and map the compaction trigger from a `PreCompact` payload.
|
|
1319
|
+
*
|
|
1320
|
+
* Codex reports the trigger under `trigger` with values `'manual'` or
|
|
1321
|
+
* `'auto'`. Unknown values are dropped for forward compatibility.
|
|
1322
|
+
* @param payload - Raw `PreCompact` hook payload
|
|
1323
|
+
* @returns Mapped compaction trigger, or `undefined` when absent or unknown
|
|
1324
|
+
*/
|
|
1325
|
+
function extractCompactionTrigger(payload) {
|
|
1326
|
+
const trigger = payload["trigger"];
|
|
1327
|
+
if (typeof trigger !== "string") return void 0;
|
|
1328
|
+
return COMPACTION_TRIGGERS.has(trigger) ? trigger : void 0;
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Extract the transcript path from a raw Codex hook payload.
|
|
1332
|
+
*
|
|
1333
|
+
* Codex includes `transcript_path` on `SessionStart` and on the compaction
|
|
1334
|
+
* hooks; it is the absolute path of the thread's rollout JSONL file and is
|
|
1335
|
+
* serialized as `null` when no rollout has been materialized.
|
|
1336
|
+
* @param payload - Raw hook payload object
|
|
1337
|
+
* @returns Transcript path string, or `undefined` when absent or empty
|
|
1338
|
+
*/
|
|
1339
|
+
function extractTranscriptPath(payload) {
|
|
1340
|
+
return pickNonEmptyString(payload, "transcript_path");
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Normalize a `SubagentStart` hook payload into the subagent-started event.
|
|
1344
|
+
*
|
|
1345
|
+
* SubagentStart/Stop: the raw `session_id` is the PARENT session id.
|
|
1346
|
+
* `adapterSessionId` on the base carries it directly — it is the parent
|
|
1347
|
+
* session id, not a subagent own session id. `agentId` identifies the
|
|
1348
|
+
* subagent. `turnId` is populated from `turn_id` when present.
|
|
1349
|
+
* @param base - Full hook base (includes `adapterSessionId` = parent session id)
|
|
1350
|
+
* @param payload - Raw `SubagentStart` hook payload body
|
|
1351
|
+
* @returns Normalized event array, empty when `agentId` is absent
|
|
1352
|
+
*/
|
|
1353
|
+
function normalizeSubagentStart(base, payload) {
|
|
1354
|
+
const agentId = extractAgentId(payload);
|
|
1355
|
+
if (agentId === void 0) return [];
|
|
1356
|
+
const agentType = extractAgentType(payload);
|
|
1357
|
+
const turnId = extractTurnId(payload);
|
|
1358
|
+
return [{
|
|
1359
|
+
subject: ClientSubjects.session.subagent.started,
|
|
1360
|
+
payload: {
|
|
1361
|
+
...base,
|
|
1362
|
+
agentId,
|
|
1363
|
+
...agentType !== void 0 && { agentType },
|
|
1364
|
+
...turnId !== void 0 && { turnId }
|
|
1365
|
+
}
|
|
1366
|
+
}];
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Normalize a `SubagentStop` hook payload into the subagent-completed event.
|
|
1370
|
+
*
|
|
1371
|
+
* See {@link normalizeSubagentStart} for the `adapterSessionId` = parent
|
|
1372
|
+
* session id convention and `turnId` extraction.
|
|
1373
|
+
* @param base - Full hook base (includes `adapterSessionId` = parent session id)
|
|
1374
|
+
* @param payload - Raw `SubagentStop` hook payload body
|
|
1375
|
+
* @returns Normalized event array, empty when `agentId` is absent
|
|
1376
|
+
*/
|
|
1377
|
+
function normalizeSubagentStop(base, payload) {
|
|
1378
|
+
const agentId = extractAgentId(payload);
|
|
1379
|
+
if (agentId === void 0) return [];
|
|
1380
|
+
const agentType = extractAgentType(payload);
|
|
1381
|
+
const turnId = extractTurnId(payload);
|
|
1382
|
+
const agentTranscriptPath = extractAgentTranscriptPath(payload);
|
|
1383
|
+
return [{
|
|
1384
|
+
subject: ClientSubjects.session.subagent.completed,
|
|
1385
|
+
payload: {
|
|
1386
|
+
...base,
|
|
1387
|
+
agentId,
|
|
1388
|
+
...agentType !== void 0 && { agentType },
|
|
1389
|
+
...turnId !== void 0 && { turnId },
|
|
1390
|
+
...agentTranscriptPath !== void 0 && { agentTranscriptPath }
|
|
1391
|
+
}
|
|
1392
|
+
}];
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Normalize a `PreCompact` hook payload into the compaction-pre event.
|
|
1396
|
+
* @param base - Full hook base (includes `adapterSessionId`)
|
|
1397
|
+
* @param payload - Raw `PreCompact` hook payload body
|
|
1398
|
+
* @returns Single-element normalized event array
|
|
1399
|
+
*/
|
|
1400
|
+
function normalizePreCompact(base, payload) {
|
|
1401
|
+
const trigger = extractCompactionTrigger(payload);
|
|
1402
|
+
const transcriptPath = extractTranscriptPath(payload);
|
|
1403
|
+
return [{
|
|
1404
|
+
subject: ClientSubjects.session.compaction.pre,
|
|
1405
|
+
payload: {
|
|
1406
|
+
...base,
|
|
1407
|
+
...trigger !== void 0 && { trigger },
|
|
1408
|
+
...transcriptPath !== void 0 && { transcriptPath }
|
|
1409
|
+
}
|
|
1410
|
+
}];
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Normalize a raw Codex hook payload into `client.session.*` events.
|
|
1414
|
+
*
|
|
1415
|
+
* Returns an empty array for unknown or not-yet-modeled event names so the
|
|
1416
|
+
* caller skips global emission and keeps the event raw-only in
|
|
1417
|
+
* `client:codex.*`. A single hook may map to more than one normalized event:
|
|
1418
|
+
* `UserPromptSubmit` yields `turn.started` followed by `userPrompt.submitted`.
|
|
1419
|
+
* Emission order within the array is significant and must be preserved by the
|
|
1420
|
+
* caller.
|
|
1421
|
+
*
|
|
1422
|
+
* The `receivedAt` timestamp from the raw hook payload is used as `observedAt`
|
|
1423
|
+
* to preserve the original wall-clock time of the observation.
|
|
1028
1424
|
* @param raw - Raw hook payload delivered on `client:codex.hook.received`
|
|
1029
1425
|
* @param machineId - Stable runtime identity of the observing machine,
|
|
1030
1426
|
* caller-supplied by the owning client runtime. Stamped onto
|
|
1031
1427
|
* `client.session.started` so downstream storage receives the owning
|
|
1032
1428
|
* machine's identity without deriving it from the writer process.
|
|
1033
|
-
* @returns Normalized
|
|
1034
|
-
* the event name is unknown
|
|
1429
|
+
* @returns Normalized events with subject and typed payload, in emission
|
|
1430
|
+
* order; empty when the event name is unknown (raw-only)
|
|
1035
1431
|
*/
|
|
1036
1432
|
function normalizeCodexHook(raw, machineId) {
|
|
1037
|
-
const subject = CODEX_EVENT_MAP.get(raw.eventName);
|
|
1038
|
-
if (subject === void 0) return null;
|
|
1039
1433
|
const base = {
|
|
1040
|
-
clientId:
|
|
1041
|
-
source:
|
|
1434
|
+
clientId: CLIENT_ID$1,
|
|
1435
|
+
source: SOURCE,
|
|
1042
1436
|
observedAt: raw.receivedAt,
|
|
1043
1437
|
adapterSessionId: extractAdapterSessionId(raw.payload),
|
|
1044
1438
|
metadata: raw.metadata
|
|
1045
1439
|
};
|
|
1046
|
-
switch (
|
|
1047
|
-
case
|
|
1048
|
-
|
|
1049
|
-
payload
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1440
|
+
switch (raw.eventName) {
|
|
1441
|
+
case CODEX_HOOK_SESSION_START: {
|
|
1442
|
+
const startMode = resolveStartMode(raw.payload);
|
|
1443
|
+
const transcriptPath = extractTranscriptPath(raw.payload);
|
|
1444
|
+
return [{
|
|
1445
|
+
subject: ClientSubjects.session.started,
|
|
1446
|
+
payload: {
|
|
1447
|
+
...base,
|
|
1448
|
+
...machineId !== void 0 && { machineId },
|
|
1449
|
+
...startMode !== void 0 && { startMode },
|
|
1450
|
+
...transcriptPath !== void 0 && { transcriptPath }
|
|
1451
|
+
}
|
|
1452
|
+
}];
|
|
1453
|
+
}
|
|
1454
|
+
case CODEX_HOOK_USER_PROMPT_SUBMIT: return [{
|
|
1455
|
+
subject: ClientSubjects.session.turn.started,
|
|
1456
|
+
payload: { ...base }
|
|
1457
|
+
}, {
|
|
1458
|
+
subject: ClientSubjects.session.userPrompt.submitted,
|
|
1056
1459
|
payload: {
|
|
1057
1460
|
...base,
|
|
1058
1461
|
prompt: extractPrompt(raw.payload)
|
|
1059
1462
|
}
|
|
1060
|
-
};
|
|
1061
|
-
case
|
|
1062
|
-
subject,
|
|
1063
|
-
payload: { ...base }
|
|
1064
|
-
};
|
|
1065
|
-
case ClientSubjects.session.tool.pre: return {
|
|
1066
|
-
subject,
|
|
1463
|
+
}];
|
|
1464
|
+
case CODEX_HOOK_PRE_TOOL_USE: return [{
|
|
1465
|
+
subject: ClientSubjects.session.tool.pre,
|
|
1067
1466
|
payload: {
|
|
1068
1467
|
...base,
|
|
1069
1468
|
toolName: extractToolName(raw.payload),
|
|
1070
1469
|
toolCallId: extractToolCallId(raw.payload)
|
|
1071
1470
|
}
|
|
1072
|
-
};
|
|
1073
|
-
case
|
|
1074
|
-
subject,
|
|
1471
|
+
}];
|
|
1472
|
+
case CODEX_HOOK_POST_TOOL_USE: return [{
|
|
1473
|
+
subject: ClientSubjects.session.tool.post,
|
|
1075
1474
|
payload: {
|
|
1076
1475
|
...base,
|
|
1077
1476
|
toolName: extractToolName(raw.payload),
|
|
1078
1477
|
toolCallId: extractToolCallId(raw.payload)
|
|
1079
1478
|
}
|
|
1080
|
-
};
|
|
1081
|
-
|
|
1479
|
+
}];
|
|
1480
|
+
case CODEX_HOOK_STOP: return [{
|
|
1481
|
+
subject: ClientSubjects.session.turn.completed,
|
|
1482
|
+
payload: { ...base }
|
|
1483
|
+
}];
|
|
1484
|
+
case CODEX_HOOK_SUBAGENT_START: return normalizeSubagentStart(base, raw.payload);
|
|
1485
|
+
case CODEX_HOOK_SUBAGENT_STOP: return normalizeSubagentStop(base, raw.payload);
|
|
1486
|
+
case CODEX_HOOK_PRE_COMPACT: return normalizePreCompact(base, raw.payload);
|
|
1487
|
+
default: return [];
|
|
1082
1488
|
}
|
|
1083
1489
|
}
|
|
1084
1490
|
|
|
1085
|
-
//#endregion
|
|
1086
|
-
//#region src/runtime/schemas.ts
|
|
1087
|
-
/**
|
|
1088
|
-
* Hook events emitted by Codex that map to the v1 observed-semantics set.
|
|
1089
|
-
*
|
|
1090
|
-
* These are the events the normalizer translates into `client.session.*` bus
|
|
1091
|
-
* emissions. Any event NOT listed here is left as raw `client:codex`
|
|
1092
|
-
* namespace data only.
|
|
1093
|
-
*/
|
|
1094
|
-
const CODEX_HOOK_SESSION_START = "SessionStart";
|
|
1095
|
-
const CODEX_HOOK_USER_PROMPT_SUBMIT = "UserPromptSubmit";
|
|
1096
|
-
const CODEX_HOOK_PRE_TOOL_USE = "PreToolUse";
|
|
1097
|
-
const CODEX_HOOK_POST_TOOL_USE = "PostToolUse";
|
|
1098
|
-
const CODEX_HOOK_STOP = "Stop";
|
|
1099
|
-
|
|
1100
1491
|
//#endregion
|
|
1101
1492
|
//#region src/runtime/hook-response-contracts.ts
|
|
1102
1493
|
const CODEX_CLIENT_ID = "codex";
|
|
1103
1494
|
const CODEX_CONTRACT_ID = "openai.codex-hook-response";
|
|
1104
|
-
|
|
1495
|
+
/**
|
|
1496
|
+
* Semantic version of the Codex hook-response contract.
|
|
1497
|
+
*
|
|
1498
|
+
* Pinned to the proven capabilities of Codex CLI 0.144.1. Bump this version
|
|
1499
|
+
* when a future CLI release expands the native response surface.
|
|
1500
|
+
*
|
|
1501
|
+
* `1.2.0` adds `SubagentStart` as a request-capable, non-blockable interaction
|
|
1502
|
+
* carrying canonical `context.append`. The appended context lands in the
|
|
1503
|
+
* *subagent's* context window, not the parent's — proven live, see
|
|
1504
|
+
* `runtime/__tests__/fixtures/hook-contracts/probe/subagent-start-context-append.json`.
|
|
1505
|
+
* Subagent creation cannot be refused (`continue: false` is parsed for
|
|
1506
|
+
* compatibility but does not stop the subagent), so the interaction is
|
|
1507
|
+
* non-blockable. Purely additive: every `1.1.0` contributor remains valid.
|
|
1508
|
+
*/
|
|
1509
|
+
const CODEX_CONTRACT_VERSION = "1.2.0";
|
|
1105
1510
|
/**
|
|
1106
1511
|
* Build a frozen Codex provider envelope.
|
|
1107
1512
|
* @param effects - Provider-native effect record.
|
|
@@ -1235,6 +1640,7 @@ const CODEX_SUPPORTED_INTERACTIONS = Object.freeze([
|
|
|
1235
1640
|
CODEX_HOOK_PRE_TOOL_USE,
|
|
1236
1641
|
CODEX_HOOK_POST_TOOL_USE,
|
|
1237
1642
|
CODEX_HOOK_STOP,
|
|
1643
|
+
CODEX_HOOK_SUBAGENT_START,
|
|
1238
1644
|
...CODEX_RESPONSE_CAPABILITIES
|
|
1239
1645
|
]);
|
|
1240
1646
|
const CODEX_INTERACTION_BLOCKABILITY = Object.freeze(CODEX_SUPPORTED_INTERACTIONS.map((interaction) => Object.freeze({
|
|
@@ -1267,7 +1673,8 @@ const EVENT_EFFECTS = Object.freeze({
|
|
|
1267
1673
|
"update"
|
|
1268
1674
|
]),
|
|
1269
1675
|
[CODEX_HOOK_POST_TOOL_USE]: new Set(["context", "block"]),
|
|
1270
|
-
[CODEX_HOOK_STOP]: new Set(["block"])
|
|
1676
|
+
[CODEX_HOOK_STOP]: new Set(["block"]),
|
|
1677
|
+
[CODEX_HOOK_SUBAGENT_START]: new Set(["context"])
|
|
1271
1678
|
});
|
|
1272
1679
|
/**
|
|
1273
1680
|
* Classify an exact provider-native Codex effects record.
|
|
@@ -2578,10 +2985,21 @@ const CODEX_HOOK_COMMAND_SENTINEL = "hook received codex";
|
|
|
2578
2985
|
/** Sentinel for synchronous Codex hook responses. */
|
|
2579
2986
|
const CODEX_HOOK_HANDLE_COMMAND_SENTINEL = "hook handle codex";
|
|
2580
2987
|
/**
|
|
2581
|
-
*
|
|
2988
|
+
* Timeout for request-mode hooks on context-only (non-blockable) interactions.
|
|
2989
|
+
*
|
|
2990
|
+
* `hook handle` has no `--debounce-failure`, so a down server would stall every
|
|
2991
|
+
* prompt and subagent spawn for the full timeout; context-only hooks fail fast.
|
|
2992
|
+
* Blockable interactions retain {@link DEFAULT_HOOK_HANDLE_TIMEOUT_MS} because
|
|
2993
|
+
* those must complete before the native client can proceed.
|
|
2994
|
+
*/
|
|
2995
|
+
const CONTEXT_ONLY_HOOK_HANDLE_TIMEOUT_MS = 1e3;
|
|
2996
|
+
/**
|
|
2997
|
+
* Descriptors for all hook events derived from the client definition.
|
|
2582
2998
|
*
|
|
2583
|
-
*
|
|
2584
|
-
*
|
|
2999
|
+
* Includes every event declared in the definition's `hookEvents` array,
|
|
3000
|
+
* regardless of whether it carries a `frameworkSubject`. Events without a
|
|
3001
|
+
* framework mapping (e.g. `PostCompact`) are still wired so that the raw
|
|
3002
|
+
* ingress reaches the bus for Codex-specific consumers.
|
|
2585
3003
|
*/
|
|
2586
3004
|
const SESSION_EVENTS = deriveSessionEventDescriptors(clientDefinition);
|
|
2587
3005
|
/**
|
|
@@ -2684,19 +3102,27 @@ async function removeCodexWiring(settings, scope, projectDir) {
|
|
|
2684
3102
|
}
|
|
2685
3103
|
/**
|
|
2686
3104
|
* Build the managed command for one capability-derived hook mode.
|
|
3105
|
+
*
|
|
3106
|
+
* For request-mode hooks the timeout is derived from the event's blockability:
|
|
3107
|
+
* blockable interactions get {@link DEFAULT_HOOK_HANDLE_TIMEOUT_MS} (5 s);
|
|
3108
|
+
* non-blockable, context-only interactions get
|
|
3109
|
+
* {@link CONTEXT_ONLY_HOOK_HANDLE_TIMEOUT_MS} (1 s) so a down server does not
|
|
3110
|
+
* stall every prompt or subagent spawn for the full duration.
|
|
2687
3111
|
* @param makaioCommand - Makaio CLI executable.
|
|
2688
|
-
* @param eventName - Native Codex event name.
|
|
3112
|
+
* @param eventName - Native Codex event name (used to look up blockability).
|
|
2689
3113
|
* @param mode - Capability-derived transport mode.
|
|
2690
3114
|
* @returns Shell-safe managed hook command.
|
|
2691
3115
|
*/
|
|
2692
3116
|
function buildModeCommand(makaioCommand, eventName, mode) {
|
|
2693
|
-
|
|
3117
|
+
if (mode !== "request") return buildHookCommand(makaioCommand, CODEX_HOOK_COMMAND_SENTINEL, eventName, void 0, ["--debounce-failure"]);
|
|
3118
|
+
const timeoutMs = CODEX_INTERACTION_BLOCKABILITY.some((entry) => entry.interaction === eventName && entry.blockable) ? DEFAULT_HOOK_HANDLE_TIMEOUT_MS : CONTEXT_ONLY_HOOK_HANDLE_TIMEOUT_MS;
|
|
3119
|
+
return buildClientCommand(makaioCommand, [
|
|
2694
3120
|
"--no-launch",
|
|
2695
3121
|
...CODEX_HOOK_HANDLE_COMMAND_SENTINEL.split(" "),
|
|
2696
3122
|
eventName,
|
|
2697
3123
|
"--timeout",
|
|
2698
|
-
String(
|
|
2699
|
-
])
|
|
3124
|
+
String(timeoutMs)
|
|
3125
|
+
]);
|
|
2700
3126
|
}
|
|
2701
3127
|
|
|
2702
3128
|
//#endregion
|
|
@@ -2714,6 +3140,37 @@ const CLIENT_ID = "codex";
|
|
|
2714
3140
|
*/
|
|
2715
3141
|
const MANAGED_SESSION_CAP = 1e4;
|
|
2716
3142
|
/**
|
|
3143
|
+
* Set of `client.session.*` subjects that the `codex-app-server` adapter
|
|
3144
|
+
* emits for adapter-managed sessions.
|
|
3145
|
+
*
|
|
3146
|
+
* Derived from
|
|
3147
|
+
* `adapters/implementations/codex-app-server/src/agent.ts`
|
|
3148
|
+
* (lines 178, 186, 195, 217, 338, 383, 411, 438, 473, 495).
|
|
3149
|
+
*
|
|
3150
|
+
* When both the native-hook ingress and the adapter path are active for the
|
|
3151
|
+
* same session, only these subjects are suppressed by
|
|
3152
|
+
* {@link CodexClientSessionService.handleHookReceived} — the adapter already
|
|
3153
|
+
* owns their canonical emission. Hook-only subjects that have no adapter
|
|
3154
|
+
* equivalent (`subagent.started`, `subagent.completed`, `compaction.pre`)
|
|
3155
|
+
* are NOT in this set and must always be forwarded even for managed sessions.
|
|
3156
|
+
*
|
|
3157
|
+
* `client.session.started` is in this set (the adapter emits it once at thread
|
|
3158
|
+
* start), but the gate exempts it when `startMode` is `'compact'` or `'clear'`.
|
|
3159
|
+
* The reason: the adapter emits `session.started` only once — at thread start,
|
|
3160
|
+
* without a `startMode` — and never again for compaction or clear restarts.
|
|
3161
|
+
* Those transitions happen inside the running thread, so the hook-derived
|
|
3162
|
+
* `session.started{startMode:'compact'|'clear'}` is the sole signal for them
|
|
3163
|
+
* and must always be forwarded even for adapter-managed sessions.
|
|
3164
|
+
*/
|
|
3165
|
+
const ADAPTER_EMITTED_SUBJECTS = new Set([
|
|
3166
|
+
ClientSubjects.session.started,
|
|
3167
|
+
ClientSubjects.session.turn.started,
|
|
3168
|
+
ClientSubjects.session.turn.completed,
|
|
3169
|
+
ClientSubjects.session.userPrompt.submitted,
|
|
3170
|
+
ClientSubjects.session.tool.pre,
|
|
3171
|
+
ClientSubjects.session.tool.post
|
|
3172
|
+
]);
|
|
3173
|
+
/**
|
|
2717
3174
|
* Service that normalizes raw Codex hook events into global
|
|
2718
3175
|
* `client.session.*` observed-semantics events and handles Codex config
|
|
2719
3176
|
* management requests on `client:codex.config.hooks.*`.
|
|
@@ -2963,35 +3420,68 @@ var CodexClientSessionService = class extends BaseService {
|
|
|
2963
3420
|
}
|
|
2964
3421
|
}
|
|
2965
3422
|
/**
|
|
2966
|
-
* Translate a raw Codex hook event into
|
|
3423
|
+
* Translate a raw Codex hook event into normalized `client.session.*` emissions.
|
|
2967
3424
|
*
|
|
2968
3425
|
* Unknown / Codex-specific events produce no emission and are silently
|
|
2969
3426
|
* ignored. The raw event remains observable on `client:codex.*` for
|
|
2970
3427
|
* consumers that need Codex-native detail.
|
|
3428
|
+
*
|
|
3429
|
+
* One raw hook may produce multiple normalized events (e.g. `UserPromptSubmit`
|
|
3430
|
+
* yields `turn.started` then `userPrompt.submitted`). Events are emitted in
|
|
3431
|
+
* the order returned by the normalizer.
|
|
2971
3432
|
* @param raw - Raw hook payload delivered on `client:codex.hook.received`
|
|
2972
3433
|
*/
|
|
2973
3434
|
async handleHookReceived(raw) {
|
|
2974
|
-
const
|
|
2975
|
-
if (
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
3435
|
+
const events = normalizeCodexHook(raw, this.machineId);
|
|
3436
|
+
if (events.length === 0 && (raw.eventName === "SubagentStart" || raw.eventName === "SubagentStop")) console.warn(`[CodexClientSessionService] ${raw.eventName} hook produced no normalized events — agent_id is likely absent. The hook remains raw-only on client:codex.*.`);
|
|
3437
|
+
const sharedAdapterSessionId = events[0]?.payload.adapterSessionId;
|
|
3438
|
+
const isManagedSession = this.isAdapterManagedSession(sharedAdapterSessionId);
|
|
3439
|
+
let firstError;
|
|
3440
|
+
for (const normalized of events) {
|
|
3441
|
+
if (this.shouldSuppressForManagedSession(normalized, isManagedSession)) continue;
|
|
3442
|
+
try {
|
|
3443
|
+
switch (normalized.subject) {
|
|
3444
|
+
case ClientSubjects.session.started:
|
|
3445
|
+
await this.bus.emit(ClientSubjects.session.started, await this.enrichForkLineage(normalized.payload));
|
|
3446
|
+
break;
|
|
3447
|
+
case ClientSubjects.session.userPrompt.submitted:
|
|
3448
|
+
await this.bus.emit(ClientSubjects.session.userPrompt.submitted, normalized.payload);
|
|
3449
|
+
break;
|
|
3450
|
+
case ClientSubjects.session.turn.started:
|
|
3451
|
+
await this.bus.emit(ClientSubjects.session.turn.started, normalized.payload);
|
|
3452
|
+
break;
|
|
3453
|
+
case ClientSubjects.session.turn.completed:
|
|
3454
|
+
await this.bus.emit(ClientSubjects.session.turn.completed, normalized.payload);
|
|
3455
|
+
break;
|
|
3456
|
+
case ClientSubjects.session.tool.pre:
|
|
3457
|
+
await this.bus.emit(ClientSubjects.session.tool.pre, normalized.payload);
|
|
3458
|
+
break;
|
|
3459
|
+
case ClientSubjects.session.tool.post:
|
|
3460
|
+
await this.bus.emit(ClientSubjects.session.tool.post, normalized.payload);
|
|
3461
|
+
break;
|
|
3462
|
+
case ClientSubjects.session.subagent.started: {
|
|
3463
|
+
const { payload: subagentStartedPayload } = normalized;
|
|
3464
|
+
await this.bus.emit(ClientSubjects.session.subagent.started, subagentStartedPayload);
|
|
3465
|
+
break;
|
|
3466
|
+
}
|
|
3467
|
+
case ClientSubjects.session.subagent.completed: {
|
|
3468
|
+
const { payload: subagentCompletedPayload } = normalized;
|
|
3469
|
+
await this.bus.emit(ClientSubjects.session.subagent.completed, subagentCompletedPayload);
|
|
3470
|
+
break;
|
|
3471
|
+
}
|
|
3472
|
+
case ClientSubjects.session.compaction.pre: {
|
|
3473
|
+
const { payload: compactionPrePayload } = normalized;
|
|
3474
|
+
await this.bus.emit(ClientSubjects.session.compaction.pre, compactionPrePayload);
|
|
3475
|
+
break;
|
|
3476
|
+
}
|
|
3477
|
+
default: throwUnhandledNormalizedEvent(normalized);
|
|
3478
|
+
}
|
|
3479
|
+
} catch (error) {
|
|
3480
|
+
console.warn("[CodexClientSessionService] Subscriber threw during emission of", normalized.subject.subject, "— continuing with next event.", error);
|
|
3481
|
+
if (firstError === void 0) firstError = error;
|
|
3482
|
+
}
|
|
2994
3483
|
}
|
|
3484
|
+
if (firstError !== void 0) throw firstError;
|
|
2995
3485
|
}
|
|
2996
3486
|
/**
|
|
2997
3487
|
* Determine whether a normalized native hook belongs to an adapter-managed
|
|
@@ -3003,17 +3493,69 @@ var CodexClientSessionService = class extends BaseService {
|
|
|
3003
3493
|
isAdapterManagedSession(adapterSessionId) {
|
|
3004
3494
|
return adapterSessionId !== void 0 && this.managedAdapterSessionIds.has(adapterSessionId);
|
|
3005
3495
|
}
|
|
3496
|
+
/**
|
|
3497
|
+
* Returns true when a normalized event should be suppressed by the
|
|
3498
|
+
* adapter-managed gate — that is, when the session is adapter-managed AND the
|
|
3499
|
+
* subject is one the adapter emits AND the event is not a compaction or clear
|
|
3500
|
+
* restart start (which have no adapter counterpart and must always be forwarded).
|
|
3501
|
+
* @param normalized - Normalized hook event to evaluate
|
|
3502
|
+
* @param isManagedSession - Whether the originating session is adapter-managed
|
|
3503
|
+
* @returns True when the event should be dropped from the native-hook path
|
|
3504
|
+
*/
|
|
3505
|
+
shouldSuppressForManagedSession(normalized, isManagedSession) {
|
|
3506
|
+
if (!isManagedSession || !ADAPTER_EMITTED_SUBJECTS.has(normalized.subject)) return false;
|
|
3507
|
+
if (normalized.subject !== ClientSubjects.session.started) return true;
|
|
3508
|
+
const { startMode } = normalized.payload;
|
|
3509
|
+
return startMode !== "compact" && startMode !== "clear";
|
|
3510
|
+
}
|
|
3511
|
+
/**
|
|
3512
|
+
* Enrich a `client.session.started` payload with fork lineage when the
|
|
3513
|
+
* normalizer reported `startMode: 'fresh'` and a rollout path is available.
|
|
3514
|
+
*
|
|
3515
|
+
* Codex classifies a fork child next to a brand-new thread: both fire
|
|
3516
|
+
* `SessionStart` with `source: 'startup'`, and the payload carries no
|
|
3517
|
+
* lineage field. The child's rollout file, however, opens with its own
|
|
3518
|
+
* `session_meta` record, and that record names `forked_from_id` — the parent
|
|
3519
|
+
* thread id. This method performs a bounded read of the rollout head to
|
|
3520
|
+
* recover it, upgrading `startMode` from `'fresh'` to `'fork'` and
|
|
3521
|
+
* populating `parentAdapterSessionId`.
|
|
3522
|
+
*
|
|
3523
|
+
* Only `'fresh'` is sniffed. A resume appends to the *existing* rollout file,
|
|
3524
|
+
* so a resumed fork child would still show its original `forked_from_id`;
|
|
3525
|
+
* upgrading it to `'fork'` would re-register an already known session instead
|
|
3526
|
+
* of letting ingestion rebind it by adapter session id.
|
|
3527
|
+
*
|
|
3528
|
+
* Runs **after** the managed-session suppression gate (so adapter-managed
|
|
3529
|
+
* sessions are already filtered out) and **before** bus emission.
|
|
3530
|
+
*
|
|
3531
|
+
* On any sniff error the payload is returned unchanged — hook processing must
|
|
3532
|
+
* never be blocked by a sniff failure.
|
|
3533
|
+
* @param payload - Normalized `client.session.started` payload
|
|
3534
|
+
* @returns The payload, potentially enriched with fork lineage fields
|
|
3535
|
+
*/
|
|
3536
|
+
async enrichForkLineage(payload) {
|
|
3537
|
+
if (payload.startMode !== "fresh" || payload.transcriptPath === void 0 || payload.adapterSessionId === void 0) return payload;
|
|
3538
|
+
const sniffResult = await sniffRolloutFork(payload.transcriptPath, payload.adapterSessionId);
|
|
3539
|
+
if (sniffResult === void 0) return payload;
|
|
3540
|
+
return {
|
|
3541
|
+
...payload,
|
|
3542
|
+
startMode: "fork",
|
|
3543
|
+
parentAdapterSessionId: sniffResult.parentAdapterSessionId
|
|
3544
|
+
};
|
|
3545
|
+
}
|
|
3006
3546
|
};
|
|
3007
3547
|
/**
|
|
3008
3548
|
* Fail fast when the normalizer grows a new subject but service emission has
|
|
3009
3549
|
* not been updated to preserve the normalized-event contract.
|
|
3010
3550
|
*
|
|
3011
3551
|
* The broad parameter type is intentional — the switch operates on
|
|
3012
|
-
* `SubjectDefinition`
|
|
3013
|
-
* TypeScript cannot narrow `normalized` to `never` in the
|
|
3014
|
-
*
|
|
3015
|
-
*
|
|
3016
|
-
* @
|
|
3552
|
+
* `SubjectDefinition` object references rather than a discriminated string
|
|
3553
|
+
* literal union, so TypeScript cannot narrow `normalized` to `never` in the
|
|
3554
|
+
* default branch. Exhaustiveness is therefore enforced at runtime by this
|
|
3555
|
+
* throw, not at compile time. When adding a new arm to the switch in
|
|
3556
|
+
* {@link CodexClientSessionService.handleHookReceived}, verify that the new
|
|
3557
|
+
* subject is also covered here by running the test suite.
|
|
3558
|
+
* @param event - Normalized event whose subject is not handled above
|
|
3017
3559
|
*/
|
|
3018
3560
|
function throwUnhandledNormalizedEvent(event) {
|
|
3019
3561
|
const subject = event.subject.subject;
|