@darkhunt-security/endpoint-codex 0.9.10 → 0.9.12

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "darkhunt-guard",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "hooks": "./hooks/hooks.json",
5
5
  "description": "Darkhunt endpoint plugin for Codex CLI \u2014 full session trace capture and PreToolUse guardrails.",
6
6
  "author": {
@@ -18308,6 +18308,7 @@ function attachmentLabel(record) {
18308
18308
  return `[${record.mediaType ?? "attachment"}${size}]`;
18309
18309
  }
18310
18310
  var RESUMED_USER_TEXT_LIMIT = 4e3;
18311
+ var USAGE_WIRE_NAMES = { thinking_tokens: "reasoning" };
18311
18312
  var SessionEmitter = class {
18312
18313
  client;
18313
18314
  options;
@@ -18331,6 +18332,8 @@ var SessionEmitter = class {
18331
18332
  /** Attachments seen before the trace could be opened knowing the prompt. */
18332
18333
  deferred = [];
18333
18334
  pendingUsage;
18335
+ /** Set when the model call this generation stands for failed. Cleared with it. */
18336
+ generationError;
18334
18337
  outputText = [];
18335
18338
  /** The last thing the assistant said in this pass — the trace's result. */
18336
18339
  lastAssistantText;
@@ -18386,9 +18389,10 @@ var SessionEmitter = class {
18386
18389
  this.count++;
18387
18390
  if (record.kind === "attachment")
18388
18391
  this.pendingAttachments.push(attachmentLabel(record));
18389
- if (record.kind === "user_message")
18392
+ if (record.kind === "user_message" && !record.isError && !record.synthetic) {
18390
18393
  this.absorbUserTurn(record);
18391
- if (!this.trace && record.kind === "attachment") {
18394
+ }
18395
+ if (!this.trace && (record.kind === "attachment" || record.synthetic === true)) {
18392
18396
  this.deferred.push(record);
18393
18397
  return;
18394
18398
  }
@@ -18426,6 +18430,15 @@ var SessionEmitter = class {
18426
18430
  this.owners.set(record.uuid, owner);
18427
18431
  switch (record.kind) {
18428
18432
  case "user_message":
18433
+ if (record.synthetic) {
18434
+ this.seeded(["injected", record.uuid], () => owner.span("context_injection", {
18435
+ startTime: record.ts,
18436
+ ...record.text !== void 0 ? { output: summarize(record.text) } : {}
18437
+ })).end({ endTime: record.ts });
18438
+ break;
18439
+ }
18440
+ if (record.isError)
18441
+ this.generationError = record.errorKind ?? "error";
18429
18442
  this.endGeneration();
18430
18443
  break;
18431
18444
  case "attachment": {
@@ -18442,7 +18455,9 @@ var SessionEmitter = class {
18442
18455
  }
18443
18456
  case "assistant_message":
18444
18457
  this.openGeneration(record, owner);
18445
- if (record.text)
18458
+ if (record.isError)
18459
+ this.generationError = record.errorKind ?? "error";
18460
+ else if (record.text)
18446
18461
  this.outputText.push(record.text);
18447
18462
  break;
18448
18463
  case "thinking": {
@@ -18471,15 +18486,44 @@ var SessionEmitter = class {
18471
18486
  case "tool_result": {
18472
18487
  const span = record.toolCallId ? this.toolSpans.get(record.toolCallId) : void 0;
18473
18488
  if (span) {
18474
- span.end({ output: record.output, endTime: record.ts });
18489
+ span.end({
18490
+ output: record.output,
18491
+ endTime: record.ts,
18492
+ ...record.isError ? { level: "ERROR" } : {},
18493
+ ...record.errorKind !== void 0 ? { statusMessage: record.errorKind } : {}
18494
+ });
18475
18495
  this.toolSpans.delete(record.toolCallId);
18476
18496
  }
18477
18497
  this.pendingToolResults.push(summarize(record.output));
18478
18498
  break;
18479
18499
  }
18500
+ case "compaction":
18501
+ this.endGeneration();
18502
+ this.seeded(["compaction", record.uuid], () => owner.span("compaction", {
18503
+ startTime: record.ts,
18504
+ ...record.text ? { output: record.text } : {}
18505
+ })).end({ endTime: record.ts });
18506
+ break;
18507
+ case "permission_grant": {
18508
+ const snapshot = record.input && typeof record.input === "object" ? record.input : void 0;
18509
+ const steps = Array.isArray(snapshot?.steps) ? snapshot.steps : [];
18510
+ this.seeded(["permission", record.uuid], () => owner.span("permission_grant", {
18511
+ startTime: record.ts,
18512
+ // `input`, not `output`: the list is what this record IS, not a result it
18513
+ // produced.
18514
+ input: steps,
18515
+ metadata: {
18516
+ // The true count, which is larger than `steps.length` when the mapper
18517
+ // truncated the snapshot to its byte budget.
18518
+ count: String(snapshot?.count ?? steps.length),
18519
+ full: String(snapshot?.full === true)
18520
+ }
18521
+ })).end({ endTime: record.ts });
18522
+ break;
18523
+ }
18480
18524
  case "usage":
18481
18525
  if (record.usage) {
18482
- this.pendingUsage = Object.fromEntries(Object.entries(record.usage).filter(([, v]) => typeof v === "number"));
18526
+ this.pendingUsage = Object.fromEntries(Object.entries(record.usage).filter(([, v]) => typeof v === "number").map(([key, value]) => [USAGE_WIRE_NAMES[key] ?? key, value]));
18483
18527
  }
18484
18528
  if (record.model)
18485
18529
  this.model = record.model;
@@ -18636,7 +18680,18 @@ var SessionEmitter = class {
18636
18680
  this.model = record.model;
18637
18681
  this.generation = this.seeded(["generation", id], () => owner.generation("assistant", {
18638
18682
  startTime: record.ts,
18639
- ...this.model !== void 0 ? { model: this.model } : {}
18683
+ ...this.model !== void 0 ? { model: this.model } : {},
18684
+ // What drove this model call — an installed skill, an MCP server's output, a
18685
+ // plugin. A flat scalar like every other metadata value emitted here, because
18686
+ // trace-hub lifts each key onto the span unchanged and a nested value would
18687
+ // arrive as a blob nothing can group or filter on. One lowercase word, so unlike
18688
+ // `agent_id` there is no camelCase to convert.
18689
+ //
18690
+ // Set once, at open, and never through update(): this method early-returns when
18691
+ // the generation is already open, so the value has to be constant across a
18692
+ // message group. Measured over the claude-code corpus, 0 of 1,381 attributed
18693
+ // groups carry two different tokens.
18694
+ ...record.attribution !== void 0 ? { metadata: { attribution: record.attribution } } : {}
18640
18695
  }));
18641
18696
  const input = this.buildInputMessages();
18642
18697
  if (input.inputMessages)
@@ -18676,12 +18731,14 @@ var SessionEmitter = class {
18676
18731
  this.generation.end({
18677
18732
  ...output ? { outputMessages: [{ role: "assistant", content: output }] } : {},
18678
18733
  ...this.model !== void 0 ? { model: this.model } : {},
18679
- ...this.pendingUsage !== void 0 ? { usage: this.pendingUsage } : {}
18734
+ ...this.pendingUsage !== void 0 ? { usage: this.pendingUsage } : {},
18735
+ ...this.generationError !== void 0 ? { level: "ERROR", statusMessage: this.generationError } : {}
18680
18736
  });
18681
18737
  this.generation = void 0;
18682
18738
  this.generationId = void 0;
18683
18739
  this.outputText = [];
18684
18740
  this.pendingUsage = void 0;
18741
+ this.generationError = void 0;
18685
18742
  }
18686
18743
  };
18687
18744
 
@@ -21939,6 +21996,62 @@ function textOf(content) {
21939
21996
  const parts = content.map((b) => b && typeof b === "object" ? str(b["text"]) : void 0).filter((t) => !!t);
21940
21997
  return parts.length > 0 ? parts.join("\n") : void 0;
21941
21998
  }
21999
+ var CODEX_APPROVAL_MODES = {
22000
+ never: "auto",
22001
+ "on-request": "ask"
22002
+ };
22003
+ var CODEX_SANDBOX_MODES = {
22004
+ "workspace-write": "workspace-write",
22005
+ "read-only": "read-only",
22006
+ "danger-full-access": "danger-full-access"
22007
+ };
22008
+ var PERMISSION_SNAPSHOT_LIMIT = 32e3;
22009
+ function budgeted(steps) {
22010
+ const kept = [];
22011
+ let bytes = 0;
22012
+ for (const step of steps) {
22013
+ bytes += JSON.stringify(step ?? null).length + 1;
22014
+ if (bytes > PERMISSION_SNAPSHOT_LIMIT)
22015
+ break;
22016
+ kept.push(step);
22017
+ }
22018
+ return kept;
22019
+ }
22020
+ function execEnvelope(text) {
22021
+ let parsed;
22022
+ try {
22023
+ parsed = JSON.parse(text);
22024
+ } catch {
22025
+ return void 0;
22026
+ }
22027
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
22028
+ return void 0;
22029
+ const bag = parsed;
22030
+ if (!Object.prototype.hasOwnProperty.call(bag, "output"))
22031
+ return void 0;
22032
+ const metadata = bag["metadata"];
22033
+ const exitCode = num(bag["exit_code"]) ?? (metadata && typeof metadata === "object" ? num(metadata["exit_code"]) : void 0);
22034
+ if (exitCode === void 0)
22035
+ return void 0;
22036
+ return { output: bag["output"], exitCode };
22037
+ }
22038
+ function execResult(value) {
22039
+ if (typeof value === "string")
22040
+ return execEnvelope(value) ?? { output: value };
22041
+ if (Array.isArray(value)) {
22042
+ for (const block of value) {
22043
+ if (!block || typeof block !== "object")
22044
+ continue;
22045
+ const text = block["text"];
22046
+ if (typeof text !== "string")
22047
+ continue;
22048
+ const envelope = execEnvelope(text);
22049
+ if (envelope)
22050
+ return envelope;
22051
+ }
22052
+ }
22053
+ return { output: textOf(value) ?? value };
22054
+ }
21942
22055
  function jwtClaims(token) {
21943
22056
  const parts = token.split(".");
21944
22057
  if (parts.length !== 3 || !parts[1])
@@ -21965,6 +22078,29 @@ function emailFromAuth(raw) {
21965
22078
  const email = jwtClaims(token)?.["email"];
21966
22079
  return typeof email === "string" && email !== "" ? email : void 0;
21967
22080
  }
22081
+ function repositoryIdentity(url) {
22082
+ if (url === void 0)
22083
+ return void 0;
22084
+ const trimmed = url.trim();
22085
+ if (trimmed === "")
22086
+ return void 0;
22087
+ const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/)(\S+)$/.exec(trimmed);
22088
+ if (scp)
22089
+ return `${scp[1]}/${stripSuffix(scp[2])}`;
22090
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `ssh://${trimmed}`;
22091
+ try {
22092
+ const parsed = new URL(withScheme);
22093
+ if (!parsed.hostname)
22094
+ return trimmed;
22095
+ const path = stripSuffix(parsed.pathname.replace(/^\/+/, ""));
22096
+ return path === "" ? parsed.hostname : `${parsed.hostname}/${path}`;
22097
+ } catch {
22098
+ return trimmed;
22099
+ }
22100
+ }
22101
+ function stripSuffix(path) {
22102
+ return path.replace(/\/+$/, "").replace(/\.git$/i, "");
22103
+ }
21968
22104
  var codexTranscript = {
21969
22105
  vendor: "codex",
21970
22106
  sessionRoots() {
@@ -22017,12 +22153,20 @@ var codexTranscript = {
22017
22153
  const uuid = `codex:${offset}`;
22018
22154
  const base = { vendor: "codex", ts, uuid };
22019
22155
  if (raw.type === "session_meta") {
22156
+ const rawGit = payload["git"];
22157
+ const git = rawGit && typeof rawGit === "object" ? rawGit : void 0;
22158
+ const gitBranch = str(git?.["branch"]);
22159
+ const gitRepositoryUrl = repositoryIdentity(str(git?.["repository_url"]));
22020
22160
  return [
22021
22161
  {
22022
22162
  ...base,
22023
22163
  kind: "session_meta",
22024
22164
  meta: {
22025
22165
  ...str(payload["cwd"]) !== void 0 ? { cwd: str(payload["cwd"]) } : {},
22166
+ // Conditional spread rather than a plain assignment: the 7 `{}` blocks and
22167
+ // the 92 absent ones must leave the key absent, not present-and-undefined.
22168
+ ...gitBranch !== void 0 ? { gitBranch } : {},
22169
+ ...gitRepositoryUrl !== void 0 ? { gitRepositoryUrl } : {},
22026
22170
  ...str(payload["cli_version"]) !== void 0 ? { version: str(payload["cli_version"]) } : {},
22027
22171
  ...str(payload["originator"]) !== void 0 ? { entrypoint: str(payload["originator"]) } : {},
22028
22172
  // A Codex subagent is a rollout of its own, a peer of its parent in the
@@ -22038,15 +22182,61 @@ var codexTranscript = {
22038
22182
  }
22039
22183
  if (raw.type === "turn_context") {
22040
22184
  const model = str(payload["model"]);
22041
- if (!model)
22185
+ const approval = str(payload["approval_policy"]);
22186
+ const sandbox = payload["sandbox_policy"];
22187
+ const sandboxMode = (() => {
22188
+ if (!sandbox || typeof sandbox !== "object")
22189
+ return void 0;
22190
+ const bag = sandbox;
22191
+ return str(bag["mode"]) ?? str(bag["type"]);
22192
+ })();
22193
+ const meta = {
22194
+ ...model !== void 0 ? { model } : {},
22195
+ ...approval !== void 0 ? { approvalMode: CODEX_APPROVAL_MODES[approval] ?? approval } : {},
22196
+ ...sandboxMode !== void 0 ? { sandboxMode: CODEX_SANDBOX_MODES[sandboxMode] ?? sandboxMode } : {}
22197
+ };
22198
+ if (Object.keys(meta).length === 0)
22042
22199
  return [];
22043
- return [{ ...base, kind: "session_meta", meta: { model } }];
22200
+ return [{ ...base, kind: "session_meta", meta }];
22044
22201
  }
22045
22202
  const kind = str(payload["type"]);
22046
22203
  if (raw.type === "event_msg" && kind === "token_count") {
22047
22204
  const usage = mapTokenCount(payload["info"]);
22048
22205
  return usage ? [{ ...base, kind: "usage", usage }] : [];
22049
22206
  }
22207
+ if (raw.type === "compacted") {
22208
+ const note = str(payload["message"]);
22209
+ return [{ ...base, kind: "compaction", ...note ? { text: note } : {} }];
22210
+ }
22211
+ if (raw.type === "world_state") {
22212
+ const state = payload["state"];
22213
+ const perms = state && typeof state === "object" ? state["permissions"] : void 0;
22214
+ if (!perms || typeof perms !== "object")
22215
+ return [];
22216
+ const list = perms["approved_command_prefixes"];
22217
+ const steps = Array.isArray(list) ? list : [];
22218
+ return [
22219
+ {
22220
+ ...base,
22221
+ kind: "permission_grant",
22222
+ input: {
22223
+ steps: budgeted(steps),
22224
+ count: steps.length,
22225
+ ...payload["full"] === true ? { full: true } : {}
22226
+ }
22227
+ }
22228
+ ];
22229
+ }
22230
+ if (raw.type === "event_msg" && kind === "turn_aborted") {
22231
+ return [
22232
+ {
22233
+ ...base,
22234
+ kind: "user_message",
22235
+ isError: true,
22236
+ errorKind: `aborted:${str(payload["reason"]) ?? "unknown"}`
22237
+ }
22238
+ ];
22239
+ }
22050
22240
  if (raw.type !== "response_item")
22051
22241
  return [];
22052
22242
  switch (kind) {
@@ -22060,18 +22250,72 @@ var codexTranscript = {
22060
22250
  ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
22061
22251
  }
22062
22252
  ];
22063
- case "function_call_output":
22253
+ case "function_call_output": {
22254
+ const { output, exitCode } = execResult(payload["output"]);
22064
22255
  return [
22065
22256
  {
22066
22257
  ...base,
22067
22258
  kind: "tool_result",
22068
- output: payload["output"],
22259
+ output,
22260
+ // Only where an exit status was actually found. On the 993 string outputs
22261
+ // that are not envelopes and the 144 array ones the key stays ABSENT rather
22262
+ // than `false`: the emitter tests `record.isError`, and "this tool reported
22263
+ // success" is a different claim from "nothing here reported an outcome".
22264
+ ...exitCode !== void 0 ? { isError: exitCode !== 0 } : {},
22069
22265
  ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
22070
22266
  }
22071
22267
  ];
22268
+ }
22269
+ // Codex's OTHER tool-call shape, and the one it actually uses: `exec` and
22270
+ // `apply_patch` arrive as `custom_tool_call`, never as `function_call`. Measured
22271
+ // across 251 rollouts, 2179 of 5382 tool calls — 40% — took this shape, so
22272
+ // handling only `function_call` left every shell command and every file write
22273
+ // this endpoint made invisible to both the trace and anything reading it.
22274
+ case "custom_tool_call":
22275
+ return [
22276
+ {
22277
+ ...base,
22278
+ kind: "tool_call",
22279
+ toolName: str(payload["name"]) ?? "tool",
22280
+ input: payload["input"],
22281
+ ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
22282
+ }
22283
+ ];
22284
+ case "custom_tool_call_output": {
22285
+ const { output, exitCode } = execResult(payload["output"]);
22286
+ return [
22287
+ {
22288
+ ...base,
22289
+ kind: "tool_result",
22290
+ output,
22291
+ ...exitCode !== void 0 ? { isError: exitCode !== 0 } : {},
22292
+ ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
22293
+ }
22294
+ ];
22295
+ }
22296
+ // The model's own web reads. 49 across the corpus in 9 rollouts — `search` 31,
22297
+ // `open_page` 16, `find_in_page` 2 — every one of which mapped to nothing, so a
22298
+ // session where the agent searched and opened pages rendered as reasoning
22299
+ // followed by an answer with no source anywhere in it. The queries and the URLs
22300
+ // are the whole point of the record: they are the evidence of what the agent
22301
+ // read from outside the machine.
22302
+ //
22303
+ // `action` is passed through whole. It is already a vendor-neutral argument bag
22304
+ // ({type, query?, queries?, url?, pattern?}) and it is small — 322 bytes at the
22305
+ // worst, 121 on average across all 49.
22306
+ //
22307
+ // Deliberately unpaired: no `toolCallId`. Only 1 of the 49 lines carries an
22308
+ // `id`, `call_id` is the pairing key everywhere else in this stream, and no
22309
+ // response_item output line ever pairs with a search — so setting one would
22310
+ // register a span in the emitter's pairing table that nothing ever closes, and
22311
+ // `finish()` would end it at pass end and stamp a multi-minute duration on a
22312
+ // search that took seconds. Left unpaired, the emitter closes it at the call
22313
+ // timestamp, which is the truthful reading.
22314
+ case "web_search_call":
22315
+ return [{ ...base, kind: "tool_call", toolName: "web_search", input: payload["action"] }];
22072
22316
  case "reasoning": {
22073
22317
  const text = textOf(payload["summary"]) ?? textOf(payload["content"]);
22074
- return text ? [{ ...base, kind: "thinking", text }] : [];
22318
+ return [{ ...base, kind: "thinking", ...text !== void 0 ? { text } : {} }];
22075
22319
  }
22076
22320
  case "message": {
22077
22321
  const text = textOf(payload["content"]);
@@ -18308,6 +18308,7 @@ function attachmentLabel(record) {
18308
18308
  return `[${record.mediaType ?? "attachment"}${size}]`;
18309
18309
  }
18310
18310
  var RESUMED_USER_TEXT_LIMIT = 4e3;
18311
+ var USAGE_WIRE_NAMES = { thinking_tokens: "reasoning" };
18311
18312
  var SessionEmitter = class {
18312
18313
  client;
18313
18314
  options;
@@ -18331,6 +18332,8 @@ var SessionEmitter = class {
18331
18332
  /** Attachments seen before the trace could be opened knowing the prompt. */
18332
18333
  deferred = [];
18333
18334
  pendingUsage;
18335
+ /** Set when the model call this generation stands for failed. Cleared with it. */
18336
+ generationError;
18334
18337
  outputText = [];
18335
18338
  /** The last thing the assistant said in this pass — the trace's result. */
18336
18339
  lastAssistantText;
@@ -18386,9 +18389,10 @@ var SessionEmitter = class {
18386
18389
  this.count++;
18387
18390
  if (record.kind === "attachment")
18388
18391
  this.pendingAttachments.push(attachmentLabel(record));
18389
- if (record.kind === "user_message")
18392
+ if (record.kind === "user_message" && !record.isError && !record.synthetic) {
18390
18393
  this.absorbUserTurn(record);
18391
- if (!this.trace && record.kind === "attachment") {
18394
+ }
18395
+ if (!this.trace && (record.kind === "attachment" || record.synthetic === true)) {
18392
18396
  this.deferred.push(record);
18393
18397
  return;
18394
18398
  }
@@ -18426,6 +18430,15 @@ var SessionEmitter = class {
18426
18430
  this.owners.set(record.uuid, owner);
18427
18431
  switch (record.kind) {
18428
18432
  case "user_message":
18433
+ if (record.synthetic) {
18434
+ this.seeded(["injected", record.uuid], () => owner.span("context_injection", {
18435
+ startTime: record.ts,
18436
+ ...record.text !== void 0 ? { output: summarize(record.text) } : {}
18437
+ })).end({ endTime: record.ts });
18438
+ break;
18439
+ }
18440
+ if (record.isError)
18441
+ this.generationError = record.errorKind ?? "error";
18429
18442
  this.endGeneration();
18430
18443
  break;
18431
18444
  case "attachment": {
@@ -18442,7 +18455,9 @@ var SessionEmitter = class {
18442
18455
  }
18443
18456
  case "assistant_message":
18444
18457
  this.openGeneration(record, owner);
18445
- if (record.text)
18458
+ if (record.isError)
18459
+ this.generationError = record.errorKind ?? "error";
18460
+ else if (record.text)
18446
18461
  this.outputText.push(record.text);
18447
18462
  break;
18448
18463
  case "thinking": {
@@ -18471,15 +18486,44 @@ var SessionEmitter = class {
18471
18486
  case "tool_result": {
18472
18487
  const span = record.toolCallId ? this.toolSpans.get(record.toolCallId) : void 0;
18473
18488
  if (span) {
18474
- span.end({ output: record.output, endTime: record.ts });
18489
+ span.end({
18490
+ output: record.output,
18491
+ endTime: record.ts,
18492
+ ...record.isError ? { level: "ERROR" } : {},
18493
+ ...record.errorKind !== void 0 ? { statusMessage: record.errorKind } : {}
18494
+ });
18475
18495
  this.toolSpans.delete(record.toolCallId);
18476
18496
  }
18477
18497
  this.pendingToolResults.push(summarize(record.output));
18478
18498
  break;
18479
18499
  }
18500
+ case "compaction":
18501
+ this.endGeneration();
18502
+ this.seeded(["compaction", record.uuid], () => owner.span("compaction", {
18503
+ startTime: record.ts,
18504
+ ...record.text ? { output: record.text } : {}
18505
+ })).end({ endTime: record.ts });
18506
+ break;
18507
+ case "permission_grant": {
18508
+ const snapshot = record.input && typeof record.input === "object" ? record.input : void 0;
18509
+ const steps = Array.isArray(snapshot?.steps) ? snapshot.steps : [];
18510
+ this.seeded(["permission", record.uuid], () => owner.span("permission_grant", {
18511
+ startTime: record.ts,
18512
+ // `input`, not `output`: the list is what this record IS, not a result it
18513
+ // produced.
18514
+ input: steps,
18515
+ metadata: {
18516
+ // The true count, which is larger than `steps.length` when the mapper
18517
+ // truncated the snapshot to its byte budget.
18518
+ count: String(snapshot?.count ?? steps.length),
18519
+ full: String(snapshot?.full === true)
18520
+ }
18521
+ })).end({ endTime: record.ts });
18522
+ break;
18523
+ }
18480
18524
  case "usage":
18481
18525
  if (record.usage) {
18482
- this.pendingUsage = Object.fromEntries(Object.entries(record.usage).filter(([, v]) => typeof v === "number"));
18526
+ this.pendingUsage = Object.fromEntries(Object.entries(record.usage).filter(([, v]) => typeof v === "number").map(([key, value]) => [USAGE_WIRE_NAMES[key] ?? key, value]));
18483
18527
  }
18484
18528
  if (record.model)
18485
18529
  this.model = record.model;
@@ -18636,7 +18680,18 @@ var SessionEmitter = class {
18636
18680
  this.model = record.model;
18637
18681
  this.generation = this.seeded(["generation", id], () => owner.generation("assistant", {
18638
18682
  startTime: record.ts,
18639
- ...this.model !== void 0 ? { model: this.model } : {}
18683
+ ...this.model !== void 0 ? { model: this.model } : {},
18684
+ // What drove this model call — an installed skill, an MCP server's output, a
18685
+ // plugin. A flat scalar like every other metadata value emitted here, because
18686
+ // trace-hub lifts each key onto the span unchanged and a nested value would
18687
+ // arrive as a blob nothing can group or filter on. One lowercase word, so unlike
18688
+ // `agent_id` there is no camelCase to convert.
18689
+ //
18690
+ // Set once, at open, and never through update(): this method early-returns when
18691
+ // the generation is already open, so the value has to be constant across a
18692
+ // message group. Measured over the claude-code corpus, 0 of 1,381 attributed
18693
+ // groups carry two different tokens.
18694
+ ...record.attribution !== void 0 ? { metadata: { attribution: record.attribution } } : {}
18640
18695
  }));
18641
18696
  const input = this.buildInputMessages();
18642
18697
  if (input.inputMessages)
@@ -18676,12 +18731,14 @@ var SessionEmitter = class {
18676
18731
  this.generation.end({
18677
18732
  ...output ? { outputMessages: [{ role: "assistant", content: output }] } : {},
18678
18733
  ...this.model !== void 0 ? { model: this.model } : {},
18679
- ...this.pendingUsage !== void 0 ? { usage: this.pendingUsage } : {}
18734
+ ...this.pendingUsage !== void 0 ? { usage: this.pendingUsage } : {},
18735
+ ...this.generationError !== void 0 ? { level: "ERROR", statusMessage: this.generationError } : {}
18680
18736
  });
18681
18737
  this.generation = void 0;
18682
18738
  this.generationId = void 0;
18683
18739
  this.outputText = [];
18684
18740
  this.pendingUsage = void 0;
18741
+ this.generationError = void 0;
18685
18742
  }
18686
18743
  };
18687
18744
 
@@ -21926,6 +21983,62 @@ function textOf(content) {
21926
21983
  const parts = content.map((b) => b && typeof b === "object" ? str(b["text"]) : void 0).filter((t) => !!t);
21927
21984
  return parts.length > 0 ? parts.join("\n") : void 0;
21928
21985
  }
21986
+ var CODEX_APPROVAL_MODES = {
21987
+ never: "auto",
21988
+ "on-request": "ask"
21989
+ };
21990
+ var CODEX_SANDBOX_MODES = {
21991
+ "workspace-write": "workspace-write",
21992
+ "read-only": "read-only",
21993
+ "danger-full-access": "danger-full-access"
21994
+ };
21995
+ var PERMISSION_SNAPSHOT_LIMIT = 32e3;
21996
+ function budgeted(steps) {
21997
+ const kept = [];
21998
+ let bytes = 0;
21999
+ for (const step of steps) {
22000
+ bytes += JSON.stringify(step ?? null).length + 1;
22001
+ if (bytes > PERMISSION_SNAPSHOT_LIMIT)
22002
+ break;
22003
+ kept.push(step);
22004
+ }
22005
+ return kept;
22006
+ }
22007
+ function execEnvelope(text) {
22008
+ let parsed;
22009
+ try {
22010
+ parsed = JSON.parse(text);
22011
+ } catch {
22012
+ return void 0;
22013
+ }
22014
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
22015
+ return void 0;
22016
+ const bag = parsed;
22017
+ if (!Object.prototype.hasOwnProperty.call(bag, "output"))
22018
+ return void 0;
22019
+ const metadata = bag["metadata"];
22020
+ const exitCode = num(bag["exit_code"]) ?? (metadata && typeof metadata === "object" ? num(metadata["exit_code"]) : void 0);
22021
+ if (exitCode === void 0)
22022
+ return void 0;
22023
+ return { output: bag["output"], exitCode };
22024
+ }
22025
+ function execResult(value) {
22026
+ if (typeof value === "string")
22027
+ return execEnvelope(value) ?? { output: value };
22028
+ if (Array.isArray(value)) {
22029
+ for (const block of value) {
22030
+ if (!block || typeof block !== "object")
22031
+ continue;
22032
+ const text = block["text"];
22033
+ if (typeof text !== "string")
22034
+ continue;
22035
+ const envelope = execEnvelope(text);
22036
+ if (envelope)
22037
+ return envelope;
22038
+ }
22039
+ }
22040
+ return { output: textOf(value) ?? value };
22041
+ }
21929
22042
  function jwtClaims(token) {
21930
22043
  const parts = token.split(".");
21931
22044
  if (parts.length !== 3 || !parts[1])
@@ -21952,6 +22065,29 @@ function emailFromAuth(raw) {
21952
22065
  const email = jwtClaims(token)?.["email"];
21953
22066
  return typeof email === "string" && email !== "" ? email : void 0;
21954
22067
  }
22068
+ function repositoryIdentity(url) {
22069
+ if (url === void 0)
22070
+ return void 0;
22071
+ const trimmed = url.trim();
22072
+ if (trimmed === "")
22073
+ return void 0;
22074
+ const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/)(\S+)$/.exec(trimmed);
22075
+ if (scp)
22076
+ return `${scp[1]}/${stripSuffix(scp[2])}`;
22077
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `ssh://${trimmed}`;
22078
+ try {
22079
+ const parsed = new URL(withScheme);
22080
+ if (!parsed.hostname)
22081
+ return trimmed;
22082
+ const path = stripSuffix(parsed.pathname.replace(/^\/+/, ""));
22083
+ return path === "" ? parsed.hostname : `${parsed.hostname}/${path}`;
22084
+ } catch {
22085
+ return trimmed;
22086
+ }
22087
+ }
22088
+ function stripSuffix(path) {
22089
+ return path.replace(/\/+$/, "").replace(/\.git$/i, "");
22090
+ }
21955
22091
  var codexTranscript = {
21956
22092
  vendor: "codex",
21957
22093
  sessionRoots() {
@@ -22004,12 +22140,20 @@ var codexTranscript = {
22004
22140
  const uuid = `codex:${offset}`;
22005
22141
  const base = { vendor: "codex", ts, uuid };
22006
22142
  if (raw.type === "session_meta") {
22143
+ const rawGit = payload["git"];
22144
+ const git = rawGit && typeof rawGit === "object" ? rawGit : void 0;
22145
+ const gitBranch = str(git?.["branch"]);
22146
+ const gitRepositoryUrl = repositoryIdentity(str(git?.["repository_url"]));
22007
22147
  return [
22008
22148
  {
22009
22149
  ...base,
22010
22150
  kind: "session_meta",
22011
22151
  meta: {
22012
22152
  ...str(payload["cwd"]) !== void 0 ? { cwd: str(payload["cwd"]) } : {},
22153
+ // Conditional spread rather than a plain assignment: the 7 `{}` blocks and
22154
+ // the 92 absent ones must leave the key absent, not present-and-undefined.
22155
+ ...gitBranch !== void 0 ? { gitBranch } : {},
22156
+ ...gitRepositoryUrl !== void 0 ? { gitRepositoryUrl } : {},
22013
22157
  ...str(payload["cli_version"]) !== void 0 ? { version: str(payload["cli_version"]) } : {},
22014
22158
  ...str(payload["originator"]) !== void 0 ? { entrypoint: str(payload["originator"]) } : {},
22015
22159
  // A Codex subagent is a rollout of its own, a peer of its parent in the
@@ -22025,15 +22169,61 @@ var codexTranscript = {
22025
22169
  }
22026
22170
  if (raw.type === "turn_context") {
22027
22171
  const model = str(payload["model"]);
22028
- if (!model)
22172
+ const approval = str(payload["approval_policy"]);
22173
+ const sandbox = payload["sandbox_policy"];
22174
+ const sandboxMode = (() => {
22175
+ if (!sandbox || typeof sandbox !== "object")
22176
+ return void 0;
22177
+ const bag = sandbox;
22178
+ return str(bag["mode"]) ?? str(bag["type"]);
22179
+ })();
22180
+ const meta = {
22181
+ ...model !== void 0 ? { model } : {},
22182
+ ...approval !== void 0 ? { approvalMode: CODEX_APPROVAL_MODES[approval] ?? approval } : {},
22183
+ ...sandboxMode !== void 0 ? { sandboxMode: CODEX_SANDBOX_MODES[sandboxMode] ?? sandboxMode } : {}
22184
+ };
22185
+ if (Object.keys(meta).length === 0)
22029
22186
  return [];
22030
- return [{ ...base, kind: "session_meta", meta: { model } }];
22187
+ return [{ ...base, kind: "session_meta", meta }];
22031
22188
  }
22032
22189
  const kind = str(payload["type"]);
22033
22190
  if (raw.type === "event_msg" && kind === "token_count") {
22034
22191
  const usage = mapTokenCount(payload["info"]);
22035
22192
  return usage ? [{ ...base, kind: "usage", usage }] : [];
22036
22193
  }
22194
+ if (raw.type === "compacted") {
22195
+ const note = str(payload["message"]);
22196
+ return [{ ...base, kind: "compaction", ...note ? { text: note } : {} }];
22197
+ }
22198
+ if (raw.type === "world_state") {
22199
+ const state = payload["state"];
22200
+ const perms = state && typeof state === "object" ? state["permissions"] : void 0;
22201
+ if (!perms || typeof perms !== "object")
22202
+ return [];
22203
+ const list = perms["approved_command_prefixes"];
22204
+ const steps = Array.isArray(list) ? list : [];
22205
+ return [
22206
+ {
22207
+ ...base,
22208
+ kind: "permission_grant",
22209
+ input: {
22210
+ steps: budgeted(steps),
22211
+ count: steps.length,
22212
+ ...payload["full"] === true ? { full: true } : {}
22213
+ }
22214
+ }
22215
+ ];
22216
+ }
22217
+ if (raw.type === "event_msg" && kind === "turn_aborted") {
22218
+ return [
22219
+ {
22220
+ ...base,
22221
+ kind: "user_message",
22222
+ isError: true,
22223
+ errorKind: `aborted:${str(payload["reason"]) ?? "unknown"}`
22224
+ }
22225
+ ];
22226
+ }
22037
22227
  if (raw.type !== "response_item")
22038
22228
  return [];
22039
22229
  switch (kind) {
@@ -22047,18 +22237,72 @@ var codexTranscript = {
22047
22237
  ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
22048
22238
  }
22049
22239
  ];
22050
- case "function_call_output":
22240
+ case "function_call_output": {
22241
+ const { output, exitCode } = execResult(payload["output"]);
22051
22242
  return [
22052
22243
  {
22053
22244
  ...base,
22054
22245
  kind: "tool_result",
22055
- output: payload["output"],
22246
+ output,
22247
+ // Only where an exit status was actually found. On the 993 string outputs
22248
+ // that are not envelopes and the 144 array ones the key stays ABSENT rather
22249
+ // than `false`: the emitter tests `record.isError`, and "this tool reported
22250
+ // success" is a different claim from "nothing here reported an outcome".
22251
+ ...exitCode !== void 0 ? { isError: exitCode !== 0 } : {},
22056
22252
  ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
22057
22253
  }
22058
22254
  ];
22255
+ }
22256
+ // Codex's OTHER tool-call shape, and the one it actually uses: `exec` and
22257
+ // `apply_patch` arrive as `custom_tool_call`, never as `function_call`. Measured
22258
+ // across 251 rollouts, 2179 of 5382 tool calls — 40% — took this shape, so
22259
+ // handling only `function_call` left every shell command and every file write
22260
+ // this endpoint made invisible to both the trace and anything reading it.
22261
+ case "custom_tool_call":
22262
+ return [
22263
+ {
22264
+ ...base,
22265
+ kind: "tool_call",
22266
+ toolName: str(payload["name"]) ?? "tool",
22267
+ input: payload["input"],
22268
+ ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
22269
+ }
22270
+ ];
22271
+ case "custom_tool_call_output": {
22272
+ const { output, exitCode } = execResult(payload["output"]);
22273
+ return [
22274
+ {
22275
+ ...base,
22276
+ kind: "tool_result",
22277
+ output,
22278
+ ...exitCode !== void 0 ? { isError: exitCode !== 0 } : {},
22279
+ ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
22280
+ }
22281
+ ];
22282
+ }
22283
+ // The model's own web reads. 49 across the corpus in 9 rollouts — `search` 31,
22284
+ // `open_page` 16, `find_in_page` 2 — every one of which mapped to nothing, so a
22285
+ // session where the agent searched and opened pages rendered as reasoning
22286
+ // followed by an answer with no source anywhere in it. The queries and the URLs
22287
+ // are the whole point of the record: they are the evidence of what the agent
22288
+ // read from outside the machine.
22289
+ //
22290
+ // `action` is passed through whole. It is already a vendor-neutral argument bag
22291
+ // ({type, query?, queries?, url?, pattern?}) and it is small — 322 bytes at the
22292
+ // worst, 121 on average across all 49.
22293
+ //
22294
+ // Deliberately unpaired: no `toolCallId`. Only 1 of the 49 lines carries an
22295
+ // `id`, `call_id` is the pairing key everywhere else in this stream, and no
22296
+ // response_item output line ever pairs with a search — so setting one would
22297
+ // register a span in the emitter's pairing table that nothing ever closes, and
22298
+ // `finish()` would end it at pass end and stamp a multi-minute duration on a
22299
+ // search that took seconds. Left unpaired, the emitter closes it at the call
22300
+ // timestamp, which is the truthful reading.
22301
+ case "web_search_call":
22302
+ return [{ ...base, kind: "tool_call", toolName: "web_search", input: payload["action"] }];
22059
22303
  case "reasoning": {
22060
22304
  const text = textOf(payload["summary"]) ?? textOf(payload["content"]);
22061
- return text ? [{ ...base, kind: "thinking", text }] : [];
22305
+ return [{ ...base, kind: "thinking", ...text !== void 0 ? { text } : {} }];
22062
22306
  }
22063
22307
  case "message": {
22064
22308
  const text = textOf(payload["content"]);
@@ -1378,6 +1378,14 @@ var codexCodec = {
1378
1378
  };
1379
1379
  },
1380
1380
  encodeDecision(decision) {
1381
+ const noOpinion = decision.decision === "allow" && decision.updatedInput === void 0 && (decision.ruleId === null || decision.enforcement === "advisory");
1382
+ if (noOpinion) {
1383
+ const message = [decision.reason, decision.remediation].filter(Boolean).join(" ");
1384
+ return {
1385
+ stdout: message ? JSON.stringify({ systemMessage: message }) : "",
1386
+ exitCode: 0
1387
+ };
1388
+ }
1381
1389
  const hookSpecificOutput = {
1382
1390
  hookEventName: "PreToolUse",
1383
1391
  permissionDecision: decision.decision
@@ -1180,6 +1180,14 @@ var codexCodec = {
1180
1180
  };
1181
1181
  },
1182
1182
  encodeDecision(decision) {
1183
+ const noOpinion = decision.decision === "allow" && decision.updatedInput === void 0 && (decision.ruleId === null || decision.enforcement === "advisory");
1184
+ if (noOpinion) {
1185
+ const message = [decision.reason, decision.remediation].filter(Boolean).join(" ");
1186
+ return {
1187
+ stdout: message ? JSON.stringify({ systemMessage: message }) : "",
1188
+ exitCode: 0
1189
+ };
1190
+ }
1183
1191
  const hookSpecificOutput = {
1184
1192
  hookEventName: "PreToolUse",
1185
1193
  permissionDecision: decision.decision
@@ -1265,6 +1265,62 @@ function textOf(content) {
1265
1265
  const parts = content.map((b) => b && typeof b === "object" ? str(b["text"]) : void 0).filter((t) => !!t);
1266
1266
  return parts.length > 0 ? parts.join("\n") : void 0;
1267
1267
  }
1268
+ var CODEX_APPROVAL_MODES = {
1269
+ never: "auto",
1270
+ "on-request": "ask"
1271
+ };
1272
+ var CODEX_SANDBOX_MODES = {
1273
+ "workspace-write": "workspace-write",
1274
+ "read-only": "read-only",
1275
+ "danger-full-access": "danger-full-access"
1276
+ };
1277
+ var PERMISSION_SNAPSHOT_LIMIT = 32e3;
1278
+ function budgeted(steps) {
1279
+ const kept = [];
1280
+ let bytes = 0;
1281
+ for (const step of steps) {
1282
+ bytes += JSON.stringify(step ?? null).length + 1;
1283
+ if (bytes > PERMISSION_SNAPSHOT_LIMIT)
1284
+ break;
1285
+ kept.push(step);
1286
+ }
1287
+ return kept;
1288
+ }
1289
+ function execEnvelope(text) {
1290
+ let parsed;
1291
+ try {
1292
+ parsed = JSON.parse(text);
1293
+ } catch {
1294
+ return void 0;
1295
+ }
1296
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1297
+ return void 0;
1298
+ const bag = parsed;
1299
+ if (!Object.prototype.hasOwnProperty.call(bag, "output"))
1300
+ return void 0;
1301
+ const metadata = bag["metadata"];
1302
+ const exitCode = num(bag["exit_code"]) ?? (metadata && typeof metadata === "object" ? num(metadata["exit_code"]) : void 0);
1303
+ if (exitCode === void 0)
1304
+ return void 0;
1305
+ return { output: bag["output"], exitCode };
1306
+ }
1307
+ function execResult(value) {
1308
+ if (typeof value === "string")
1309
+ return execEnvelope(value) ?? { output: value };
1310
+ if (Array.isArray(value)) {
1311
+ for (const block of value) {
1312
+ if (!block || typeof block !== "object")
1313
+ continue;
1314
+ const text = block["text"];
1315
+ if (typeof text !== "string")
1316
+ continue;
1317
+ const envelope = execEnvelope(text);
1318
+ if (envelope)
1319
+ return envelope;
1320
+ }
1321
+ }
1322
+ return { output: textOf(value) ?? value };
1323
+ }
1268
1324
  function jwtClaims(token) {
1269
1325
  const parts = token.split(".");
1270
1326
  if (parts.length !== 3 || !parts[1])
@@ -1291,6 +1347,29 @@ function emailFromAuth(raw) {
1291
1347
  const email = jwtClaims(token)?.["email"];
1292
1348
  return typeof email === "string" && email !== "" ? email : void 0;
1293
1349
  }
1350
+ function repositoryIdentity(url) {
1351
+ if (url === void 0)
1352
+ return void 0;
1353
+ const trimmed = url.trim();
1354
+ if (trimmed === "")
1355
+ return void 0;
1356
+ const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/)(\S+)$/.exec(trimmed);
1357
+ if (scp)
1358
+ return `${scp[1]}/${stripSuffix(scp[2])}`;
1359
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `ssh://${trimmed}`;
1360
+ try {
1361
+ const parsed = new URL(withScheme);
1362
+ if (!parsed.hostname)
1363
+ return trimmed;
1364
+ const path = stripSuffix(parsed.pathname.replace(/^\/+/, ""));
1365
+ return path === "" ? parsed.hostname : `${parsed.hostname}/${path}`;
1366
+ } catch {
1367
+ return trimmed;
1368
+ }
1369
+ }
1370
+ function stripSuffix(path) {
1371
+ return path.replace(/\/+$/, "").replace(/\.git$/i, "");
1372
+ }
1294
1373
  var codexTranscript = {
1295
1374
  vendor: "codex",
1296
1375
  sessionRoots() {
@@ -1343,12 +1422,20 @@ var codexTranscript = {
1343
1422
  const uuid = `codex:${offset}`;
1344
1423
  const base = { vendor: "codex", ts, uuid };
1345
1424
  if (raw.type === "session_meta") {
1425
+ const rawGit = payload["git"];
1426
+ const git = rawGit && typeof rawGit === "object" ? rawGit : void 0;
1427
+ const gitBranch = str(git?.["branch"]);
1428
+ const gitRepositoryUrl = repositoryIdentity(str(git?.["repository_url"]));
1346
1429
  return [
1347
1430
  {
1348
1431
  ...base,
1349
1432
  kind: "session_meta",
1350
1433
  meta: {
1351
1434
  ...str(payload["cwd"]) !== void 0 ? { cwd: str(payload["cwd"]) } : {},
1435
+ // Conditional spread rather than a plain assignment: the 7 `{}` blocks and
1436
+ // the 92 absent ones must leave the key absent, not present-and-undefined.
1437
+ ...gitBranch !== void 0 ? { gitBranch } : {},
1438
+ ...gitRepositoryUrl !== void 0 ? { gitRepositoryUrl } : {},
1352
1439
  ...str(payload["cli_version"]) !== void 0 ? { version: str(payload["cli_version"]) } : {},
1353
1440
  ...str(payload["originator"]) !== void 0 ? { entrypoint: str(payload["originator"]) } : {},
1354
1441
  // A Codex subagent is a rollout of its own, a peer of its parent in the
@@ -1364,15 +1451,61 @@ var codexTranscript = {
1364
1451
  }
1365
1452
  if (raw.type === "turn_context") {
1366
1453
  const model = str(payload["model"]);
1367
- if (!model)
1454
+ const approval = str(payload["approval_policy"]);
1455
+ const sandbox = payload["sandbox_policy"];
1456
+ const sandboxMode = (() => {
1457
+ if (!sandbox || typeof sandbox !== "object")
1458
+ return void 0;
1459
+ const bag = sandbox;
1460
+ return str(bag["mode"]) ?? str(bag["type"]);
1461
+ })();
1462
+ const meta = {
1463
+ ...model !== void 0 ? { model } : {},
1464
+ ...approval !== void 0 ? { approvalMode: CODEX_APPROVAL_MODES[approval] ?? approval } : {},
1465
+ ...sandboxMode !== void 0 ? { sandboxMode: CODEX_SANDBOX_MODES[sandboxMode] ?? sandboxMode } : {}
1466
+ };
1467
+ if (Object.keys(meta).length === 0)
1368
1468
  return [];
1369
- return [{ ...base, kind: "session_meta", meta: { model } }];
1469
+ return [{ ...base, kind: "session_meta", meta }];
1370
1470
  }
1371
1471
  const kind = str(payload["type"]);
1372
1472
  if (raw.type === "event_msg" && kind === "token_count") {
1373
1473
  const usage = mapTokenCount(payload["info"]);
1374
1474
  return usage ? [{ ...base, kind: "usage", usage }] : [];
1375
1475
  }
1476
+ if (raw.type === "compacted") {
1477
+ const note = str(payload["message"]);
1478
+ return [{ ...base, kind: "compaction", ...note ? { text: note } : {} }];
1479
+ }
1480
+ if (raw.type === "world_state") {
1481
+ const state = payload["state"];
1482
+ const perms = state && typeof state === "object" ? state["permissions"] : void 0;
1483
+ if (!perms || typeof perms !== "object")
1484
+ return [];
1485
+ const list = perms["approved_command_prefixes"];
1486
+ const steps = Array.isArray(list) ? list : [];
1487
+ return [
1488
+ {
1489
+ ...base,
1490
+ kind: "permission_grant",
1491
+ input: {
1492
+ steps: budgeted(steps),
1493
+ count: steps.length,
1494
+ ...payload["full"] === true ? { full: true } : {}
1495
+ }
1496
+ }
1497
+ ];
1498
+ }
1499
+ if (raw.type === "event_msg" && kind === "turn_aborted") {
1500
+ return [
1501
+ {
1502
+ ...base,
1503
+ kind: "user_message",
1504
+ isError: true,
1505
+ errorKind: `aborted:${str(payload["reason"]) ?? "unknown"}`
1506
+ }
1507
+ ];
1508
+ }
1376
1509
  if (raw.type !== "response_item")
1377
1510
  return [];
1378
1511
  switch (kind) {
@@ -1386,18 +1519,72 @@ var codexTranscript = {
1386
1519
  ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
1387
1520
  }
1388
1521
  ];
1389
- case "function_call_output":
1522
+ case "function_call_output": {
1523
+ const { output, exitCode } = execResult(payload["output"]);
1390
1524
  return [
1391
1525
  {
1392
1526
  ...base,
1393
1527
  kind: "tool_result",
1394
- output: payload["output"],
1528
+ output,
1529
+ // Only where an exit status was actually found. On the 993 string outputs
1530
+ // that are not envelopes and the 144 array ones the key stays ABSENT rather
1531
+ // than `false`: the emitter tests `record.isError`, and "this tool reported
1532
+ // success" is a different claim from "nothing here reported an outcome".
1533
+ ...exitCode !== void 0 ? { isError: exitCode !== 0 } : {},
1395
1534
  ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
1396
1535
  }
1397
1536
  ];
1537
+ }
1538
+ // Codex's OTHER tool-call shape, and the one it actually uses: `exec` and
1539
+ // `apply_patch` arrive as `custom_tool_call`, never as `function_call`. Measured
1540
+ // across 251 rollouts, 2179 of 5382 tool calls — 40% — took this shape, so
1541
+ // handling only `function_call` left every shell command and every file write
1542
+ // this endpoint made invisible to both the trace and anything reading it.
1543
+ case "custom_tool_call":
1544
+ return [
1545
+ {
1546
+ ...base,
1547
+ kind: "tool_call",
1548
+ toolName: str(payload["name"]) ?? "tool",
1549
+ input: payload["input"],
1550
+ ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
1551
+ }
1552
+ ];
1553
+ case "custom_tool_call_output": {
1554
+ const { output, exitCode } = execResult(payload["output"]);
1555
+ return [
1556
+ {
1557
+ ...base,
1558
+ kind: "tool_result",
1559
+ output,
1560
+ ...exitCode !== void 0 ? { isError: exitCode !== 0 } : {},
1561
+ ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
1562
+ }
1563
+ ];
1564
+ }
1565
+ // The model's own web reads. 49 across the corpus in 9 rollouts — `search` 31,
1566
+ // `open_page` 16, `find_in_page` 2 — every one of which mapped to nothing, so a
1567
+ // session where the agent searched and opened pages rendered as reasoning
1568
+ // followed by an answer with no source anywhere in it. The queries and the URLs
1569
+ // are the whole point of the record: they are the evidence of what the agent
1570
+ // read from outside the machine.
1571
+ //
1572
+ // `action` is passed through whole. It is already a vendor-neutral argument bag
1573
+ // ({type, query?, queries?, url?, pattern?}) and it is small — 322 bytes at the
1574
+ // worst, 121 on average across all 49.
1575
+ //
1576
+ // Deliberately unpaired: no `toolCallId`. Only 1 of the 49 lines carries an
1577
+ // `id`, `call_id` is the pairing key everywhere else in this stream, and no
1578
+ // response_item output line ever pairs with a search — so setting one would
1579
+ // register a span in the emitter's pairing table that nothing ever closes, and
1580
+ // `finish()` would end it at pass end and stamp a multi-minute duration on a
1581
+ // search that took seconds. Left unpaired, the emitter closes it at the call
1582
+ // timestamp, which is the truthful reading.
1583
+ case "web_search_call":
1584
+ return [{ ...base, kind: "tool_call", toolName: "web_search", input: payload["action"] }];
1398
1585
  case "reasoning": {
1399
1586
  const text = textOf(payload["summary"]) ?? textOf(payload["content"]);
1400
- return text ? [{ ...base, kind: "thinking", text }] : [];
1587
+ return [{ ...base, kind: "thinking", ...text !== void 0 ? { text } : {} }];
1401
1588
  }
1402
1589
  case "message": {
1403
1590
  const text = textOf(payload["content"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@darkhunt-security/endpoint-codex",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "type": "module",
5
5
  "description": "Darkhunt endpoint adapter for Codex CLI: hook codec, rollout transcript mapper, plugin manifest.",
6
6
  "bin": {
@@ -13,8 +13,8 @@
13
13
  ".codex-plugin"
14
14
  ],
15
15
  "devDependencies": {
16
- "@darkhunt-security/endpoint-contracts": "0.9.2",
17
- "@darkhunt-security/endpoint-core": "0.9.2"
16
+ "@darkhunt-security/endpoint-contracts": "0.9.3",
17
+ "@darkhunt-security/endpoint-core": "0.9.3"
18
18
  },
19
19
  "publishConfig": {
20
20
  "access": "public",