@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.
@@ -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 | 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,241 @@ 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.
1025
1287
  *
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.*`.
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 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
1102
1493
  const CODEX_CLIENT_ID = "codex";
1103
1494
  const CODEX_CONTRACT_ID = "openai.codex-hook-response";
1104
- const CODEX_CONTRACT_VERSION = "1.1.0";
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
- * Descriptors for all session-events hooks derived from the client definition.
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
- * Only events with a defined `frameworkSubject` are included — events without
2584
- * one are Codex-internal and do not need framework wiring.
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
- return mode === "request" ? buildClientCommand(makaioCommand, [
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(DEFAULT_HOOK_HANDLE_TIMEOUT_MS)
2699
- ]) : buildHookCommand(makaioCommand, CODEX_HOOK_COMMAND_SENTINEL, eventName, void 0, ["--debounce-failure"]);
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 a normalized `client.session.*` emission.
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 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);
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` 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
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;