@makaio/client-codex 1.0.0-dev-1789404058608 → 1.0.0-dev-1789642522820

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.
@@ -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 { CANONICAL_HOOK_RESPONSE_CAPABILITIES, 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 | Global subject |
957
- * |--------------------------|------------------------------------------|
958
- * | `SessionStart` | `client.session.started` |
959
- * | `UserPromptSubmit` | `client.session.userPrompt.submitted` |
960
- * | `Stop` | `client.session.turn.completed` |
961
- * | `PreToolUse` | `client.session.tool.pre` |
962
- * | `PostToolUse` | `client.session.tool.post` |
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 are returned as `null` — they are kept raw only and
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:** The Codex CLI hook event names above reflect the OpenAI
968
- * Codex CLI hook system as documented at the time of authoring. If the binary
969
- * changes its hook names, update {@link CODEX_EVENT_MAP} accordingly.
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
- * Static map from Codex-native hook event name to the matching global subject.
1186
+ * Known compaction trigger values reported by Codex.
974
1187
  *
975
- * Update this map when the Codex CLI exposes new hook names that correspond
976
- * to global session lifecycle events.
1188
+ * Derived from the contracts constant so it stays in sync without a separate
1189
+ * local enumeration that could drift.
977
1190
  */
978
- const CODEX_EVENT_MAP = new Map([
979
- ["SessionStart", ClientSubjects.session.started],
980
- ["UserPromptSubmit", ClientSubjects.session.userPrompt.submitted],
981
- ["Stop", ClientSubjects.session.turn.completed],
982
- ["PreToolUse", ClientSubjects.session.tool.pre],
983
- ["PostToolUse", ClientSubjects.session.tool.post]
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,249 @@ function extractPrompt(payload) {
1021
1272
  return pickNonEmptyString(payload, "prompt");
1022
1273
  }
1023
1274
  /**
1024
- * Normalize a raw Codex hook payload into a `client.session.*` event.
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.
1287
+ *
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.
1025
1344
  *
1026
- * Returns `null` for unknown or not-yet-modeled event names so the caller
1027
- * skips global emission and keeps the event raw-only in `client:codex.*`.
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 event with subject and typed payload, or `null` when
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: "codex",
1041
- source: "native-hook",
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 (subject) {
1047
- case ClientSubjects.session.started: return {
1048
- subject,
1049
- payload: {
1050
- ...base,
1051
- ...machineId !== void 0 && { machineId }
1052
- }
1053
- };
1054
- case ClientSubjects.session.userPrompt.submitted: return {
1055
- subject,
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 ClientSubjects.session.turn.completed: return {
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 ClientSubjects.session.tool.post: return {
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
- default: return null;
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
1493
+ /** Codex 0.144.1 synchronous hook-response contract. @packageDocumentation */
1102
1494
  const CODEX_CLIENT_ID = "codex";
1103
1495
  const CODEX_CONTRACT_ID = "openai.codex-hook-response";
1104
- const CODEX_CONTRACT_VERSION = "1.1.0";
1496
+ /**
1497
+ * Semantic version of the Codex hook-response contract.
1498
+ *
1499
+ * Pinned to the proven capabilities of Codex CLI 0.144.1. Bump this version
1500
+ * when a future CLI release expands the native response surface.
1501
+ *
1502
+ * `1.2.0` adds `SubagentStart` as a request-capable, non-blockable interaction
1503
+ * carrying canonical `context.append`. The appended context lands in the
1504
+ * *subagent's* context window, not the parent's — proven live, see
1505
+ * `runtime/__tests__/fixtures/hook-contracts/probe/subagent-start-context-append.json`.
1506
+ * Subagent creation cannot be refused (`continue: false` is parsed for
1507
+ * compatibility but does not stop the subagent), so the interaction is
1508
+ * non-blockable. Purely additive: every `1.1.0` contributor remains valid.
1509
+ *
1510
+ * `1.3.0` adds the canonical `session.token` interaction to the catalog. The
1511
+ * definition does not declare it on any Codex event yet (Codex hands its MCP
1512
+ * subprocesses no session id). When declared, the composer collects the token
1513
+ * and hands it to the in-process token sink of the client runtime; it is never
1514
+ * rendered to the client binary's stdout. Purely additive: every `1.2.0`
1515
+ * contributor remains valid.
1516
+ */
1517
+ const CODEX_CONTRACT_VERSION = "1.3.0";
1105
1518
  /**
1106
1519
  * Build a frozen Codex provider envelope.
1107
1520
  * @param effects - Provider-native effect record.
@@ -1225,6 +1638,7 @@ function createCodexStopBlockEffect(reason) {
1225
1638
  }
1226
1639
  const CODEX_RESPONSE_CAPABILITIES = Object.freeze([
1227
1640
  "context.append",
1641
+ CANONICAL_HOOK_RESPONSE_CAPABILITIES.sessionToken,
1228
1642
  CODEX_HOOK_RESPONSE_CAPABILITIES.block,
1229
1643
  CODEX_HOOK_RESPONSE_CAPABILITIES.permissionDeny,
1230
1644
  CODEX_HOOK_RESPONSE_CAPABILITIES.inputUpdate
@@ -1235,6 +1649,7 @@ const CODEX_SUPPORTED_INTERACTIONS = Object.freeze([
1235
1649
  CODEX_HOOK_PRE_TOOL_USE,
1236
1650
  CODEX_HOOK_POST_TOOL_USE,
1237
1651
  CODEX_HOOK_STOP,
1652
+ CODEX_HOOK_SUBAGENT_START,
1238
1653
  ...CODEX_RESPONSE_CAPABILITIES
1239
1654
  ]);
1240
1655
  const CODEX_INTERACTION_BLOCKABILITY = Object.freeze(CODEX_SUPPORTED_INTERACTIONS.map((interaction) => Object.freeze({
@@ -1267,7 +1682,8 @@ const EVENT_EFFECTS = Object.freeze({
1267
1682
  "update"
1268
1683
  ]),
1269
1684
  [CODEX_HOOK_POST_TOOL_USE]: new Set(["context", "block"]),
1270
- [CODEX_HOOK_STOP]: new Set(["block"])
1685
+ [CODEX_HOOK_STOP]: new Set(["block"]),
1686
+ [CODEX_HOOK_SUBAGENT_START]: new Set(["context"])
1271
1687
  });
1272
1688
  /**
1273
1689
  * Classify an exact provider-native Codex effects record.
@@ -1335,6 +1751,22 @@ const codexProviderContractCatalog = Object.freeze({
1335
1751
  function capabilities(eventName) {
1336
1752
  return clientDefinition.runtimeCapabilities.hookEvents.find((event) => event.name === eventName)?.responseCapabilities ?? [];
1337
1753
  }
1754
+ /**
1755
+ * Extract the winning `session.token` value from the ordered effects array.
1756
+ *
1757
+ * Effects arrive in priority-descending order (highest-priority contributor
1758
+ * first), so the first matching `session.token` effect is the winner. When the
1759
+ * event does not declare the `session.token` capability the effect is dropped
1760
+ * and `undefined` is returned — this ensures the token is only collected when
1761
+ * the contributor explicitly targeted an event that supports it.
1762
+ * @param eventName - Native Codex hook event name.
1763
+ * @param effects - Deterministically ordered effects (priority-desc).
1764
+ * @returns The winning token value, or `undefined` when absent or not supported.
1765
+ */
1766
+ function extractSessionToken(eventName, effects) {
1767
+ if (!capabilities(eventName).includes(CANONICAL_HOOK_RESPONSE_CAPABILITIES.sessionToken)) return void 0;
1768
+ for (const effect of effects) if ("kind" in effect && effect.kind === "session.token") return effect.value;
1769
+ }
1338
1770
  const FIRST_BLOCK_EVENTS = new Set([
1339
1771
  CODEX_HOOK_SESSION_START,
1340
1772
  CODEX_HOOK_USER_PROMPT_SUBMIT,
@@ -1462,10 +1894,44 @@ function renderCodexNativeResponse(eventName, effects) {
1462
1894
  return serialize({ hookSpecificOutput });
1463
1895
  }
1464
1896
  /**
1897
+ * Derive the session-token storage scope for a Codex hook event.
1898
+ *
1899
+ * `SubagentStart` requires a non-empty `agent_id` in the payload. The hook
1900
+ * normalizers that feed this composer reject subagent events without an id,
1901
+ * so a missing id signals a malformed event. Forwarding without an `agentId`
1902
+ * would store the token under the parent session key and overwrite it; the
1903
+ * sink is skipped instead (returns `undefined`).
1904
+ *
1905
+ * `SessionStart` and every other event are always session-scoped. Even when
1906
+ * a stray `agent_id` appears in the payload it is never adopted — doing so
1907
+ * would create a spurious subagent-scoped entry.
1908
+ * @param eventName - Codex hook event name.
1909
+ * @param rawPayload - The inner `payload` object from the hook envelope.
1910
+ * @returns Scope to forward to the token sink, or `undefined` to skip it.
1911
+ */
1912
+ function resolveSessionTokenScope(eventName, rawPayload) {
1913
+ const adapterSessionId = pickNonEmptyString(rawPayload, "session_id") ?? pickNonEmptyString(rawPayload, "thread_id");
1914
+ if (adapterSessionId === void 0) return void 0;
1915
+ if (eventName === "SubagentStart") {
1916
+ const agentId = pickNonEmptyString(rawPayload, "agent_id");
1917
+ if (agentId === void 0) return void 0;
1918
+ return {
1919
+ clientId: "codex",
1920
+ adapterSessionId,
1921
+ agentId
1922
+ };
1923
+ }
1924
+ return {
1925
+ clientId: "codex",
1926
+ adapterSessionId
1927
+ };
1928
+ }
1929
+ /**
1465
1930
  * Compose one terminal Codex native hook response.
1466
1931
  * @param registry - Active response contributor registry.
1467
1932
  * @param payload - Normalized native hook payload.
1468
- * @param options - Request deadline, cancellation, and diagnostics hooks.
1933
+ * @param options - Request deadline, cancellation, diagnostics hooks, and
1934
+ * optional session-token sink.
1469
1935
  * @returns The composed native response envelope.
1470
1936
  */
1471
1937
  async function composeCodexHookResponse(registry, payload, options) {
@@ -1481,7 +1947,13 @@ async function composeCodexHookResponse(registry, payload, options) {
1481
1947
  reason: result.closedFailure.detail
1482
1948
  }
1483
1949
  }]);
1484
- return renderCodexNativeResponse(payload.eventName, result.outcomes.flatMap((outcome) => outcome.effects ?? []));
1950
+ const effects = result.outcomes.flatMap((outcome) => outcome.effects ?? []);
1951
+ const token = extractSessionToken(payload.eventName, effects);
1952
+ if (token !== void 0 && options?.onSessionToken !== void 0) {
1953
+ const scope = resolveSessionTokenScope(payload.eventName, payload.payload);
1954
+ if (scope !== void 0) await options.onSessionToken(token, scope);
1955
+ }
1956
+ return renderCodexNativeResponse(payload.eventName, effects);
1485
1957
  }
1486
1958
 
1487
1959
  //#endregion
@@ -2578,10 +3050,23 @@ const CODEX_HOOK_COMMAND_SENTINEL = "hook received codex";
2578
3050
  /** Sentinel for synchronous Codex hook responses. */
2579
3051
  const CODEX_HOOK_HANDLE_COMMAND_SENTINEL = "hook handle codex";
2580
3052
  /**
2581
- * Descriptors for all session-events hooks derived from the client definition.
3053
+ * Timeout for request-mode hooks on context-only (non-blockable) interactions.
2582
3054
  *
2583
- * Only events with a defined `frameworkSubject` are included — events without
2584
- * one are Codex-internal and do not need framework wiring.
3055
+ * Request-mode hooks carry `--debounce-failure`, so a down server is detected
3056
+ * quickly and cool-down suppression kicks in for subsequent invocations.
3057
+ * Context-only hooks still fail fast (1 s) to bound the overhead on each prompt
3058
+ * or subagent spawn when the bus is genuinely unreachable.
3059
+ * Blockable interactions retain {@link DEFAULT_HOOK_HANDLE_TIMEOUT_MS} because
3060
+ * those must complete before the native client can proceed.
3061
+ */
3062
+ const CONTEXT_ONLY_HOOK_HANDLE_TIMEOUT_MS = 1e3;
3063
+ /**
3064
+ * Descriptors for all hook events derived from the client definition.
3065
+ *
3066
+ * Includes every event declared in the definition's `hookEvents` array,
3067
+ * regardless of whether it carries a `frameworkSubject`. Events without a
3068
+ * framework mapping (e.g. `PostCompact`) are still wired so that the raw
3069
+ * ingress reaches the bus for Codex-specific consumers.
2585
3070
  */
2586
3071
  const SESSION_EVENTS = deriveSessionEventDescriptors(clientDefinition);
2587
3072
  /**
@@ -2684,19 +3169,28 @@ async function removeCodexWiring(settings, scope, projectDir) {
2684
3169
  }
2685
3170
  /**
2686
3171
  * Build the managed command for one capability-derived hook mode.
3172
+ *
3173
+ * For request-mode hooks the timeout is derived from the event's blockability:
3174
+ * blockable interactions get {@link DEFAULT_HOOK_HANDLE_TIMEOUT_MS} (5 s);
3175
+ * non-blockable, context-only interactions get
3176
+ * {@link CONTEXT_ONLY_HOOK_HANDLE_TIMEOUT_MS} (1 s) so a down server does not
3177
+ * stall every prompt or subagent spawn for the full duration.
2687
3178
  * @param makaioCommand - Makaio CLI executable.
2688
- * @param eventName - Native Codex event name.
3179
+ * @param eventName - Native Codex event name (used to look up blockability).
2689
3180
  * @param mode - Capability-derived transport mode.
2690
3181
  * @returns Shell-safe managed hook command.
2691
3182
  */
2692
3183
  function buildModeCommand(makaioCommand, eventName, mode) {
2693
- return mode === "request" ? buildClientCommand(makaioCommand, [
3184
+ if (mode !== "request") return buildHookCommand(makaioCommand, CODEX_HOOK_COMMAND_SENTINEL, eventName, void 0, ["--debounce-failure"]);
3185
+ const timeoutMs = CODEX_INTERACTION_BLOCKABILITY.some((entry) => entry.interaction === eventName && entry.blockable) ? DEFAULT_HOOK_HANDLE_TIMEOUT_MS : CONTEXT_ONLY_HOOK_HANDLE_TIMEOUT_MS;
3186
+ return buildClientCommand(makaioCommand, [
2694
3187
  "--no-launch",
3188
+ "--debounce-failure",
2695
3189
  ...CODEX_HOOK_HANDLE_COMMAND_SENTINEL.split(" "),
2696
3190
  eventName,
2697
3191
  "--timeout",
2698
- String(DEFAULT_HOOK_HANDLE_TIMEOUT_MS)
2699
- ]) : buildHookCommand(makaioCommand, CODEX_HOOK_COMMAND_SENTINEL, eventName, void 0, ["--debounce-failure"]);
3192
+ String(timeoutMs)
3193
+ ]);
2700
3194
  }
2701
3195
 
2702
3196
  //#endregion
@@ -2714,6 +3208,37 @@ const CLIENT_ID = "codex";
2714
3208
  */
2715
3209
  const MANAGED_SESSION_CAP = 1e4;
2716
3210
  /**
3211
+ * Set of `client.session.*` subjects that the `codex-app-server` adapter
3212
+ * emits for adapter-managed sessions.
3213
+ *
3214
+ * Derived from
3215
+ * `adapters/implementations/codex-app-server/src/agent.ts`
3216
+ * (lines 178, 186, 195, 217, 338, 383, 411, 438, 473, 495).
3217
+ *
3218
+ * When both the native-hook ingress and the adapter path are active for the
3219
+ * same session, only these subjects are suppressed by
3220
+ * {@link CodexClientSessionService.handleHookReceived} — the adapter already
3221
+ * owns their canonical emission. Hook-only subjects that have no adapter
3222
+ * equivalent (`subagent.started`, `subagent.completed`, `compaction.pre`)
3223
+ * are NOT in this set and must always be forwarded even for managed sessions.
3224
+ *
3225
+ * `client.session.started` is in this set (the adapter emits it once at thread
3226
+ * start), but the gate exempts it when `startMode` is `'compact'` or `'clear'`.
3227
+ * The reason: the adapter emits `session.started` only once — at thread start,
3228
+ * without a `startMode` — and never again for compaction or clear restarts.
3229
+ * Those transitions happen inside the running thread, so the hook-derived
3230
+ * `session.started{startMode:'compact'|'clear'}` is the sole signal for them
3231
+ * and must always be forwarded even for adapter-managed sessions.
3232
+ */
3233
+ const ADAPTER_EMITTED_SUBJECTS = new Set([
3234
+ ClientSubjects.session.started,
3235
+ ClientSubjects.session.turn.started,
3236
+ ClientSubjects.session.turn.completed,
3237
+ ClientSubjects.session.userPrompt.submitted,
3238
+ ClientSubjects.session.tool.pre,
3239
+ ClientSubjects.session.tool.post
3240
+ ]);
3241
+ /**
2717
3242
  * Service that normalizes raw Codex hook events into global
2718
3243
  * `client.session.*` observed-semantics events and handles Codex config
2719
3244
  * management requests on `client:codex.config.hooks.*`.
@@ -2769,6 +3294,15 @@ var CodexClientSessionService = class extends BaseService {
2769
3294
  providerContractRegistry;
2770
3295
  hookResponseRegistry;
2771
3296
  /**
3297
+ * In-process sink for session correlation tokens.
3298
+ *
3299
+ * When non-undefined, the {@link onSessionToken} callback hands the token
3300
+ * directly to this sink so no bus payload (and no `MAKAIO_DEBUG` bus logger)
3301
+ * ever sees the token value. Codex declares no session-token capability, so
3302
+ * this will typically remain `undefined`.
3303
+ */
3304
+ sessionTokens;
3305
+ /**
2772
3306
  * Creates a new Codex client session service.
2773
3307
  * @param bus - Bus instance used for subscribing and emitting events
2774
3308
  * @param settings - Optional {@link CodexClientSettings} instance for tests
@@ -2782,14 +3316,20 @@ var CodexClientSessionService = class extends BaseService {
2782
3316
  * from clients-core for registering the Codex hook response contract.
2783
3317
  * Omit in tests that do not exercise the response pipeline.
2784
3318
  * @param hookResponseRegistry - Optional contributor registry used by the terminal composer.
3319
+ * @param sessionTokens - In-process token sink from clients-core. When
3320
+ * supplied, the {@link onSessionToken} callback hands the token directly
3321
+ * to the sink — no bus payload (and no `MAKAIO_DEBUG` bus logger) ever
3322
+ * sees the token value. Codex declares no session-token capability, so
3323
+ * this will typically be `undefined`.
2785
3324
  */
2786
- constructor(bus = MakaioBus, settings, machineId, sessionConfigHandler = new CodexSessionConfigHandler(), providerContractRegistry, hookResponseRegistry) {
3325
+ constructor(bus = MakaioBus, settings, machineId, sessionConfigHandler = new CodexSessionConfigHandler(), providerContractRegistry, hookResponseRegistry, sessionTokens) {
2787
3326
  super(bus);
2788
3327
  this.settingsOverride = settings;
2789
3328
  this.machineId = machineId;
2790
3329
  this.sessionConfigHandler = sessionConfigHandler;
2791
3330
  this.providerContractRegistry = providerContractRegistry;
2792
3331
  this.hookResponseRegistry = hookResponseRegistry;
3332
+ this.sessionTokens = sessionTokens;
2793
3333
  }
2794
3334
  /**
2795
3335
  * Register the raw hook ingress handler, the `hook.handle` request handler,
@@ -2886,7 +3426,10 @@ var CodexClientSessionService = class extends BaseService {
2886
3426
  return composeCodexHookResponse(this.hookResponseRegistry, ctx.payload, {
2887
3427
  deadline: ctx.deadline,
2888
3428
  signal: ctx.signal,
2889
- onDiagnostics: (diagnostics) => diagnostics.forEach((diagnostic) => console.warn(`[CodexClientSessionService] Hook contributor '${diagnostic.contributorId}': ${diagnostic.message}`))
3429
+ onDiagnostics: (diagnostics) => diagnostics.forEach((diagnostic) => console.warn(`[CodexClientSessionService] Hook contributor '${diagnostic.contributorId}': ${diagnostic.message}`)),
3430
+ onSessionToken: (token, scope) => {
3431
+ this.sessionTokens?.record(scope, token);
3432
+ }
2890
3433
  }).then((response) => ctx.setResult(response));
2891
3434
  });
2892
3435
  }
@@ -2963,35 +3506,68 @@ var CodexClientSessionService = class extends BaseService {
2963
3506
  }
2964
3507
  }
2965
3508
  /**
2966
- * Translate a raw Codex hook event into a normalized `client.session.*` emission.
3509
+ * Translate a raw Codex hook event into normalized `client.session.*` emissions.
2967
3510
  *
2968
3511
  * Unknown / Codex-specific events produce no emission and are silently
2969
3512
  * ignored. The raw event remains observable on `client:codex.*` for
2970
3513
  * consumers that need Codex-native detail.
3514
+ *
3515
+ * One raw hook may produce multiple normalized events (e.g. `UserPromptSubmit`
3516
+ * yields `turn.started` then `userPrompt.submitted`). Events are emitted in
3517
+ * the order returned by the normalizer.
2971
3518
  * @param raw - Raw hook payload delivered on `client:codex.hook.received`
2972
3519
  */
2973
3520
  async handleHookReceived(raw) {
2974
- const normalized = normalizeCodexHook(raw, this.machineId);
2975
- if (normalized === null) return;
2976
- if (this.isAdapterManagedSession(normalized.payload.adapterSessionId)) return;
2977
- switch (normalized.subject) {
2978
- case ClientSubjects.session.started:
2979
- await this.bus.emit(ClientSubjects.session.started, normalized.payload);
2980
- break;
2981
- case ClientSubjects.session.userPrompt.submitted:
2982
- await this.bus.emit(ClientSubjects.session.userPrompt.submitted, normalized.payload);
2983
- break;
2984
- case ClientSubjects.session.turn.completed:
2985
- await this.bus.emit(ClientSubjects.session.turn.completed, normalized.payload);
2986
- break;
2987
- case ClientSubjects.session.tool.pre:
2988
- await this.bus.emit(ClientSubjects.session.tool.pre, normalized.payload);
2989
- break;
2990
- case ClientSubjects.session.tool.post:
2991
- await this.bus.emit(ClientSubjects.session.tool.post, normalized.payload);
2992
- break;
2993
- default: throwUnhandledNormalizedEvent(normalized);
3521
+ const events = normalizeCodexHook(raw, this.machineId);
3522
+ 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.*.`);
3523
+ const sharedAdapterSessionId = events[0]?.payload.adapterSessionId;
3524
+ const isManagedSession = this.isAdapterManagedSession(sharedAdapterSessionId);
3525
+ let firstError;
3526
+ for (const normalized of events) {
3527
+ if (this.shouldSuppressForManagedSession(normalized, isManagedSession)) continue;
3528
+ try {
3529
+ switch (normalized.subject) {
3530
+ case ClientSubjects.session.started:
3531
+ await this.bus.emit(ClientSubjects.session.started, await this.enrichForkLineage(normalized.payload));
3532
+ break;
3533
+ case ClientSubjects.session.userPrompt.submitted:
3534
+ await this.bus.emit(ClientSubjects.session.userPrompt.submitted, normalized.payload);
3535
+ break;
3536
+ case ClientSubjects.session.turn.started:
3537
+ await this.bus.emit(ClientSubjects.session.turn.started, normalized.payload);
3538
+ break;
3539
+ case ClientSubjects.session.turn.completed:
3540
+ await this.bus.emit(ClientSubjects.session.turn.completed, normalized.payload);
3541
+ break;
3542
+ case ClientSubjects.session.tool.pre:
3543
+ await this.bus.emit(ClientSubjects.session.tool.pre, normalized.payload);
3544
+ break;
3545
+ case ClientSubjects.session.tool.post:
3546
+ await this.bus.emit(ClientSubjects.session.tool.post, normalized.payload);
3547
+ break;
3548
+ case ClientSubjects.session.subagent.started: {
3549
+ const { payload: subagentStartedPayload } = normalized;
3550
+ await this.bus.emit(ClientSubjects.session.subagent.started, subagentStartedPayload);
3551
+ break;
3552
+ }
3553
+ case ClientSubjects.session.subagent.completed: {
3554
+ const { payload: subagentCompletedPayload } = normalized;
3555
+ await this.bus.emit(ClientSubjects.session.subagent.completed, subagentCompletedPayload);
3556
+ break;
3557
+ }
3558
+ case ClientSubjects.session.compaction.pre: {
3559
+ const { payload: compactionPrePayload } = normalized;
3560
+ await this.bus.emit(ClientSubjects.session.compaction.pre, compactionPrePayload);
3561
+ break;
3562
+ }
3563
+ default: throwUnhandledNormalizedEvent(normalized);
3564
+ }
3565
+ } catch (error) {
3566
+ console.warn("[CodexClientSessionService] Subscriber threw during emission of", normalized.subject.subject, "— continuing with next event.", error);
3567
+ if (firstError === void 0) firstError = error;
3568
+ }
2994
3569
  }
3570
+ if (firstError !== void 0) throw firstError;
2995
3571
  }
2996
3572
  /**
2997
3573
  * Determine whether a normalized native hook belongs to an adapter-managed
@@ -3003,17 +3579,69 @@ var CodexClientSessionService = class extends BaseService {
3003
3579
  isAdapterManagedSession(adapterSessionId) {
3004
3580
  return adapterSessionId !== void 0 && this.managedAdapterSessionIds.has(adapterSessionId);
3005
3581
  }
3582
+ /**
3583
+ * Returns true when a normalized event should be suppressed by the
3584
+ * adapter-managed gate — that is, when the session is adapter-managed AND the
3585
+ * subject is one the adapter emits AND the event is not a compaction or clear
3586
+ * restart start (which have no adapter counterpart and must always be forwarded).
3587
+ * @param normalized - Normalized hook event to evaluate
3588
+ * @param isManagedSession - Whether the originating session is adapter-managed
3589
+ * @returns True when the event should be dropped from the native-hook path
3590
+ */
3591
+ shouldSuppressForManagedSession(normalized, isManagedSession) {
3592
+ if (!isManagedSession || !ADAPTER_EMITTED_SUBJECTS.has(normalized.subject)) return false;
3593
+ if (normalized.subject !== ClientSubjects.session.started) return true;
3594
+ const { startMode } = normalized.payload;
3595
+ return startMode !== "compact" && startMode !== "clear";
3596
+ }
3597
+ /**
3598
+ * Enrich a `client.session.started` payload with fork lineage when the
3599
+ * normalizer reported `startMode: 'fresh'` and a rollout path is available.
3600
+ *
3601
+ * Codex classifies a fork child next to a brand-new thread: both fire
3602
+ * `SessionStart` with `source: 'startup'`, and the payload carries no
3603
+ * lineage field. The child's rollout file, however, opens with its own
3604
+ * `session_meta` record, and that record names `forked_from_id` — the parent
3605
+ * thread id. This method performs a bounded read of the rollout head to
3606
+ * recover it, upgrading `startMode` from `'fresh'` to `'fork'` and
3607
+ * populating `parentAdapterSessionId`.
3608
+ *
3609
+ * Only `'fresh'` is sniffed. A resume appends to the *existing* rollout file,
3610
+ * so a resumed fork child would still show its original `forked_from_id`;
3611
+ * upgrading it to `'fork'` would re-register an already known session instead
3612
+ * of letting ingestion rebind it by adapter session id.
3613
+ *
3614
+ * Runs **after** the managed-session suppression gate (so adapter-managed
3615
+ * sessions are already filtered out) and **before** bus emission.
3616
+ *
3617
+ * On any sniff error the payload is returned unchanged — hook processing must
3618
+ * never be blocked by a sniff failure.
3619
+ * @param payload - Normalized `client.session.started` payload
3620
+ * @returns The payload, potentially enriched with fork lineage fields
3621
+ */
3622
+ async enrichForkLineage(payload) {
3623
+ if (payload.startMode !== "fresh" || payload.transcriptPath === void 0 || payload.adapterSessionId === void 0) return payload;
3624
+ const sniffResult = await sniffRolloutFork(payload.transcriptPath, payload.adapterSessionId);
3625
+ if (sniffResult === void 0) return payload;
3626
+ return {
3627
+ ...payload,
3628
+ startMode: "fork",
3629
+ parentAdapterSessionId: sniffResult.parentAdapterSessionId
3630
+ };
3631
+ }
3006
3632
  };
3007
3633
  /**
3008
3634
  * Fail fast when the normalizer grows a new subject but service emission has
3009
3635
  * not been updated to preserve the normalized-event contract.
3010
3636
  *
3011
3637
  * The broad parameter type is intentional — the switch operates on
3012
- * `SubjectDefinition` subject strings rather than a discriminated union, so
3013
- * TypeScript cannot narrow `normalized` to `never` in the default branch.
3014
- * Compile-time exhaustiveness is enforced by the normalizer's return type
3015
- * and the matching set of case branches above.
3016
- * @param event - Normalized event whose subject is not emitted above
3638
+ * `SubjectDefinition` object references rather than a discriminated string
3639
+ * literal union, so TypeScript cannot narrow `normalized` to `never` in the
3640
+ * default branch. Exhaustiveness is therefore enforced at runtime by this
3641
+ * throw, not at compile time. When adding a new arm to the switch in
3642
+ * {@link CodexClientSessionService.handleHookReceived}, verify that the new
3643
+ * subject is also covered here by running the test suite.
3644
+ * @param event - Normalized event whose subject is not handled above
3017
3645
  */
3018
3646
  function throwUnhandledNormalizedEvent(event) {
3019
3647
  const subject = event.subject.subject;