context-mode 1.0.161 → 1.0.162

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.
@@ -267,15 +267,273 @@ function extractGit(input) {
267
267
  if (input.tool_name !== "Bash")
268
268
  return [];
269
269
  const cmd = String(input.tool_input["command"] ?? "");
270
- const match = GIT_PATTERNS.find(p => p.pattern.test(cmd));
270
+ // Bug 8 (v1.0.162) — parse the git invocation algorithmically so flags
271
+ // between `git` and the operation token are tolerated (`git -C /path
272
+ // status`, `git --no-pager log`, etc.). Falls back to the legacy regex
273
+ // pattern scan when the algorithmic parse cannot locate a `git` token —
274
+ // preserves backward compat for commands like `cd /repo && git status`
275
+ // where the algorithmic parse sees `cd` as the first token instead.
276
+ const parsed = parseGitInvocation(cmd);
277
+ let match;
278
+ if (parsed && parsed.operation) {
279
+ match = GIT_PATTERNS.find(p => p.operation === parsed.operation);
280
+ }
281
+ if (!match) {
282
+ match = GIT_PATTERNS.find(p => p.pattern.test(cmd));
283
+ }
271
284
  if (!match)
272
285
  return [];
273
- return [{
274
- type: "git",
275
- category: "git",
276
- data: safeString(match.operation),
286
+ // Bug 1 (v1.0.161) — for `git commit` operations, parse -m / -am / --message=
287
+ // from the Bash command via shell-like argv tokenization so downstream
288
+ // consumers receive the actual commit subject in `data`. Falls back to the
289
+ // operation name when no message argument is present (--amend / --no-edit /
290
+ // -F file / interactive editor flow). Tokenizer is hand-rolled char-by-char
291
+ // (no regex) to mirror real shell quoting/cluster-flag semantics.
292
+ //
293
+ // When a message is captured, the event surfaces as type='git_commit' so the
294
+ // rollup aggregator can distinguish ACTUAL commits from other git operations
295
+ // (status/diff/log were inflating has_commit on every event — see
296
+ // session-loaders.mjs rollup stamp + Bug 2).
297
+ // Bug 8 cwd hint — when `-C <dir>` is present in the git invocation, emit
298
+ // a leading cwd event so the attribution carry-forward (LAST_SEEN source)
299
+ // routes downstream events in the same batch to the scoped directory's
300
+ // project. Without the hint, `git -C /projB status` while cwd=/projA
301
+ // misattributes to /projA.
302
+ const out = [];
303
+ if (parsed?.scopedDir) {
304
+ out.push({
305
+ type: "cwd",
306
+ category: "cwd",
307
+ data: safeString(parsed.scopedDir),
277
308
  priority: 2,
278
- }];
309
+ });
310
+ }
311
+ if (match.operation === "commit") {
312
+ const msg = extractCommitMessageFromCommand(cmd);
313
+ if (msg) {
314
+ out.push({
315
+ type: "git_commit",
316
+ category: "git",
317
+ data: safeString(msg),
318
+ priority: 2,
319
+ });
320
+ return out;
321
+ }
322
+ }
323
+ out.push({
324
+ type: "git",
325
+ category: "git",
326
+ data: safeString(match.operation),
327
+ priority: 2,
328
+ });
329
+ return out;
330
+ }
331
+ /**
332
+ * Gap #2 (16-oss-verify-gap-prd) — expand leading `~` / `~/` to homedir.
333
+ * Does NOT support `~user/path` (no current-user resolution at bridge
334
+ * layer; that requires a passwd lookup). Returns input unchanged when
335
+ * there is no tilde or the path starts with `~<otheruser>`.
336
+ */
337
+ function expandHomeTilde(path) {
338
+ if (typeof path !== "string" || path.length === 0)
339
+ return path;
340
+ if (path === "~")
341
+ return getHomedirSafe();
342
+ if (path.startsWith("~/"))
343
+ return getHomedirSafe() + path.slice(1);
344
+ return path;
345
+ }
346
+ /**
347
+ * Lazily-resolved homedir — avoids a require/import at module init time.
348
+ * Falls back to "~" (no-op expansion) when the environment is sandboxed
349
+ * without HOME / USERPROFILE.
350
+ */
351
+ function getHomedirSafe() {
352
+ try {
353
+ const home = process.env.HOME
354
+ || process.env.USERPROFILE
355
+ || (process.env.HOMEDRIVE && process.env.HOMEPATH
356
+ ? process.env.HOMEDRIVE + process.env.HOMEPATH
357
+ : "");
358
+ return home || "~";
359
+ }
360
+ catch {
361
+ return "~";
362
+ }
363
+ }
364
+ function parseGitInvocation(cmd) {
365
+ const tokens = tokenizeCommand(cmd);
366
+ let i = 0;
367
+ // Skip env-style assignments at the head (FOO=bar git ...)
368
+ while (i < tokens.length && isEnvAssignment(tokens[i]))
369
+ i++;
370
+ // Locate the `git` token (allow common runners like `sudo git ...`)
371
+ while (i < tokens.length && tokens[i] !== "git" && !tokens[i].endsWith("/git")) {
372
+ // Stop runner-skipping at the first non-assignment, non-runner token
373
+ if (!isCommonRunner(tokens[i]))
374
+ break;
375
+ i++;
376
+ }
377
+ if (i >= tokens.length)
378
+ return null;
379
+ if (tokens[i] !== "git" && !tokens[i].endsWith("/git"))
380
+ return null;
381
+ i++; // consume `git`
382
+ let scopedDir = null;
383
+ let operation = null;
384
+ while (i < tokens.length) {
385
+ const t = tokens[i];
386
+ if (t === "-C" || t === "--directory") {
387
+ scopedDir = tokens[i + 1] ?? null;
388
+ i += 2;
389
+ continue;
390
+ }
391
+ // Gap #2 — `--directory=/path` equals-form (tokenizer keeps it as one)
392
+ if (t.startsWith("--directory=")) {
393
+ scopedDir = t.slice("--directory=".length);
394
+ i++;
395
+ continue;
396
+ }
397
+ if (t.length > 0 && t[0] === "-") {
398
+ // Generic flag — skip the flag itself. We do NOT consume the next
399
+ // token as its value generically because git's per-flag arg shape
400
+ // varies; the dedicated extractCommitMessageFromCommand handles -m
401
+ // separately.
402
+ i++;
403
+ continue;
404
+ }
405
+ // First bare (non-flag) token after `git` = operation
406
+ operation = t;
407
+ break;
408
+ }
409
+ if (scopedDir)
410
+ scopedDir = expandHomeTilde(scopedDir);
411
+ return { scopedDir, operation };
412
+ }
413
+ function isEnvAssignment(token) {
414
+ if (token.length === 0)
415
+ return false;
416
+ // FOO=bar shape: starts with an uppercase letter, contains an `=`
417
+ let sawEq = false;
418
+ for (let j = 0; j < token.length; j++) {
419
+ const c = token.charCodeAt(j);
420
+ if (j === 0) {
421
+ // First char must be A-Z or underscore
422
+ if (!((c >= 65 && c <= 90) || c === 95))
423
+ return false;
424
+ }
425
+ else if (c === 61 /* = */) {
426
+ sawEq = true;
427
+ break;
428
+ }
429
+ else if (!((c >= 65 && c <= 90) || (c >= 48 && c <= 57) || c === 95)) {
430
+ // Body chars must be A-Z, 0-9, or _
431
+ return false;
432
+ }
433
+ }
434
+ return sawEq;
435
+ }
436
+ function isCommonRunner(token) {
437
+ // Runners that wrap real commands. We skip them when locating `git`
438
+ // so `sudo git status` works the same as `git status`.
439
+ switch (token) {
440
+ case "sudo":
441
+ case "doas":
442
+ case "env":
443
+ case "exec":
444
+ case "time":
445
+ return true;
446
+ default:
447
+ return false;
448
+ }
449
+ }
450
+ // Shell-like argv tokenizer — handles single/double quotes, backslash escapes,
451
+ // and merges adjacent quoted/unquoted segments per POSIX shell behavior
452
+ // (`echo a"b c"d` → ["ab cd"]). Pure char loop; no regex.
453
+ function tokenizeCommand(cmd) {
454
+ const tokens = [];
455
+ const n = cmd.length;
456
+ let i = 0;
457
+ while (i < n) {
458
+ while (i < n && (cmd[i] === " " || cmd[i] === "\t"))
459
+ i++;
460
+ if (i >= n)
461
+ break;
462
+ let buf = "";
463
+ while (i < n && cmd[i] !== " " && cmd[i] !== "\t") {
464
+ const ch = cmd[i];
465
+ if (ch === '"' || ch === "'") {
466
+ const quote = ch;
467
+ i++;
468
+ while (i < n && cmd[i] !== quote) {
469
+ if (cmd[i] === "\\" && i + 1 < n) {
470
+ buf += cmd[i + 1];
471
+ i += 2;
472
+ }
473
+ else {
474
+ buf += cmd[i];
475
+ i++;
476
+ }
477
+ }
478
+ if (i < n)
479
+ i++; // consume closing quote
480
+ }
481
+ else if (ch === "\\" && i + 1 < n) {
482
+ buf += cmd[i + 1];
483
+ i += 2;
484
+ }
485
+ else {
486
+ buf += ch;
487
+ i++;
488
+ }
489
+ }
490
+ tokens.push(buf);
491
+ }
492
+ return tokens;
493
+ }
494
+ // Linear scan over argv looking for a commit-message-bearing flag:
495
+ // --message=<value> long form, attached value
496
+ // --message <value> long form, separate token
497
+ // -m / -am / -cm ... short cluster ending in 'm', value in next token
498
+ // Returns null when no message arg is present — caller falls back to
499
+ // operation name. Pure char checks; no regex.
500
+ function extractCommitMessageFromCommand(cmd) {
501
+ const argv = tokenizeCommand(cmd);
502
+ const longPrefix = "--message=";
503
+ for (let i = 0; i < argv.length; i++) {
504
+ const arg = argv[i];
505
+ // Long form: --message=VALUE
506
+ if (arg.length > longPrefix.length && arg.startsWith(longPrefix)) {
507
+ const v = arg.slice(longPrefix.length);
508
+ return v.length > 0 ? v : null;
509
+ }
510
+ // Long form: --message VALUE
511
+ if (arg === "--message") {
512
+ const v = argv[i + 1];
513
+ return v && v.length > 0 ? v : null;
514
+ }
515
+ // Short cluster ending in 'm' (e.g. -m, -am, -cm). Cluster must be
516
+ // single-dash followed by only lowercase letters, last letter 'm'.
517
+ if (arg.length >= 2 &&
518
+ arg[0] === "-" &&
519
+ arg[1] !== "-" &&
520
+ arg[arg.length - 1] === "m" &&
521
+ isLowerAlphaRun(arg, 1)) {
522
+ const v = argv[i + 1];
523
+ return v && v.length > 0 ? v : null;
524
+ }
525
+ }
526
+ return null;
527
+ }
528
+ function isLowerAlphaRun(s, start) {
529
+ if (start >= s.length)
530
+ return false;
531
+ for (let i = start; i < s.length; i++) {
532
+ const c = s.charCodeAt(i);
533
+ if (c < 97 || c > 122)
534
+ return false; // not a-z
535
+ }
536
+ return true;
279
537
  }
280
538
  /**
281
539
  * Category 3: task
@@ -307,6 +565,39 @@ function extractTask(input) {
307
565
  * Note: Shift+Tab and /plan command do NOT fire PostToolUse hooks
308
566
  * (Claude Code bug #15660). Only programmatic EnterPlanMode is tracked.
309
567
  */
568
+ /**
569
+ * FNV-1a 32-bit hash → 8-char lowercase hex. Stable across runs/platforms.
570
+ * Used for plan_hash so identical plans dedupe at the platform side.
571
+ */
572
+ function fnv1a32Hex(s) {
573
+ let hash = 0x811c9dc5;
574
+ for (let i = 0; i < s.length; i++) {
575
+ hash ^= s.charCodeAt(i);
576
+ hash = Math.imul(hash, 0x01000193);
577
+ }
578
+ return (hash >>> 0).toString(16).padStart(8, "0");
579
+ }
580
+ /**
581
+ * Read the plan text from the ExitPlanMode envelope. SDK carries it on
582
+ * the OUTPUT (ExitPlanModeOutput @ :2222), but the PRD body cites input.
583
+ * Try both so we are spec-flexible.
584
+ */
585
+ function extractExitPlanText(input) {
586
+ const inputPlan = input.tool_input["plan"];
587
+ if (typeof inputPlan === "string" && inputPlan.length > 0)
588
+ return inputPlan;
589
+ const resp = input.tool_response;
590
+ if (typeof resp === "string" && resp.length > 0) {
591
+ try {
592
+ const parsed = JSON.parse(resp);
593
+ if (parsed && typeof parsed === "object" && typeof parsed.plan === "string") {
594
+ return parsed.plan;
595
+ }
596
+ }
597
+ catch { /* fall through */ }
598
+ }
599
+ return null;
600
+ }
310
601
  function extractPlan(input) {
311
602
  if (input.tool_name === "EnterPlanMode") {
312
603
  return [{
@@ -320,13 +611,22 @@ function extractPlan(input) {
320
611
  const events = [];
321
612
  // Plan exit event with allowedPrompts detail
322
613
  const prompts = input.tool_input["allowedPrompts"];
323
- const detail = Array.isArray(prompts) && prompts.length > 0
614
+ let detail = Array.isArray(prompts) && prompts.length > 0
324
615
  ? `exited plan mode (allowed: ${safeStringAny(prompts.map((p) => {
325
616
  if (typeof p === "object" && p !== null && "prompt" in p)
326
617
  return String(p.prompt);
327
618
  return String(p);
328
619
  }).join(", "))})`
329
620
  : "exited plan mode";
621
+ // §11 / PRD #6 — append plan_bytes + plan_hash so the platform can
622
+ // dedupe identical plans across sessions and JOIN plan_mode_authorized
623
+ // writes against a stable plan id. Plan source: tool_input.plan first
624
+ // (per PRD), fall back to tool_response.plan (SDK actually carries it
625
+ // there per ExitPlanModeOutput @ sdk-tools.d.ts:2222).
626
+ const plan = extractExitPlanText(input);
627
+ if (typeof plan === "string" && plan.length > 0) {
628
+ detail += ` plan_bytes:${plan.length} plan_hash:${fnv1a32Hex(plan)}`;
629
+ }
330
630
  events.push({
331
631
  type: "plan_exit",
332
632
  category: "plan",
@@ -757,16 +1057,310 @@ function extractExternalRef(input) {
757
1057
  }
758
1058
  /**
759
1059
  * Category 8: env (worktree)
760
- * EnterWorktree tool — tracks worktree creation.
1060
+ * EnterWorktree + ExitWorktree tools — tracks worktree lifecycle.
761
1061
  */
762
1062
  function extractWorktree(input) {
763
- if (input.tool_name !== "EnterWorktree")
1063
+ if (input.tool_name === "EnterWorktree") {
1064
+ const name = String(input.tool_input["name"] ?? "unnamed");
1065
+ return [{
1066
+ type: "worktree",
1067
+ category: "env",
1068
+ data: safeString(`entered worktree: ${name}`),
1069
+ priority: 2,
1070
+ }];
1071
+ }
1072
+ if (input.tool_name === "ExitWorktree") {
1073
+ const discard = Boolean(input.tool_input["discard_changes"]);
1074
+ return [{
1075
+ type: "worktree_exit",
1076
+ category: "env",
1077
+ data: safeString(`exited worktree (discard_changes:${discard})`),
1078
+ priority: 2,
1079
+ }];
1080
+ }
1081
+ return [];
1082
+ }
1083
+ /**
1084
+ * Algorithmic URL host extraction — no regex.
1085
+ * Skips scheme, returns everything up to the first path/query/fragment marker.
1086
+ * Port is preserved as part of the host signature.
1087
+ */
1088
+ function extractHostFromUrl(url) {
1089
+ if (typeof url !== "string" || url.length === 0)
1090
+ return null;
1091
+ const protoEnd = url.indexOf("://");
1092
+ if (protoEnd < 0)
1093
+ return null;
1094
+ const start = protoEnd + 3;
1095
+ if (start >= url.length)
1096
+ return null;
1097
+ let end = url.length;
1098
+ for (let i = start; i < url.length; i++) {
1099
+ const c = url.charCodeAt(i);
1100
+ if (c === 47 || c === 63 || c === 35) {
1101
+ end = i;
1102
+ break;
1103
+ }
1104
+ }
1105
+ const host = url.slice(start, end);
1106
+ return host.length > 0 ? host : null;
1107
+ }
1108
+ /**
1109
+ * WebFetch response metadata — captures bytes/code/durationMs and host
1110
+ * (privacy: never the full URL or query string). Redirect-loop detection
1111
+ * is temporal, not single-field — SDK has no redirect_url.
1112
+ */
1113
+ function extractWebFetchMetadata(input) {
1114
+ if (input.tool_name !== "WebFetch")
1115
+ return [];
1116
+ const resp = input.tool_response;
1117
+ if (typeof resp !== "string" || resp.length === 0)
1118
+ return [];
1119
+ let parsed;
1120
+ try {
1121
+ parsed = JSON.parse(resp);
1122
+ }
1123
+ catch {
1124
+ return [];
1125
+ }
1126
+ if (!parsed || typeof parsed !== "object")
1127
+ return [];
1128
+ const obj = parsed;
1129
+ const parts = [];
1130
+ if (typeof obj.code === "number")
1131
+ parts.push(`code:${obj.code}`);
1132
+ if (typeof obj.bytes === "number")
1133
+ parts.push(`bytes:${obj.bytes}`);
1134
+ if (typeof obj.durationMs === "number")
1135
+ parts.push(`durMs:${obj.durationMs}`);
1136
+ if (typeof obj.url === "string") {
1137
+ const host = extractHostFromUrl(obj.url);
1138
+ if (host)
1139
+ parts.push(`host:${host}`);
1140
+ }
1141
+ if (parts.length === 0)
764
1142
  return [];
765
- const name = String(input.tool_input["name"] ?? "unnamed");
766
1143
  return [{
767
- type: "worktree",
768
- category: "env",
769
- data: safeString(`entered worktree: ${name}`),
1144
+ type: "webfetch_metadata",
1145
+ category: "data",
1146
+ data: safeString(parts.join(" ")),
1147
+ priority: 3,
1148
+ }];
1149
+ }
1150
+ /**
1151
+ * Bash outcome signals — captures the three fields that DO exist on
1152
+ * BashOutput (SDK :2160-2200): interrupted (boolean), stderr (length-only
1153
+ * for privacy), returnCodeInterpretation (semantic non-zero exit hint).
1154
+ * NO exit_code field exists in the SDK.
1155
+ */
1156
+ function extractBashOutcome(input) {
1157
+ if (input.tool_name !== "Bash")
1158
+ return [];
1159
+ const resp = input.tool_response;
1160
+ if (typeof resp !== "string" || resp.length === 0)
1161
+ return [];
1162
+ let parsed;
1163
+ try {
1164
+ parsed = JSON.parse(resp);
1165
+ }
1166
+ catch {
1167
+ return [];
1168
+ }
1169
+ if (!parsed || typeof parsed !== "object")
1170
+ return [];
1171
+ const obj = parsed;
1172
+ const hasSignal = typeof obj.interrupted === "boolean" ||
1173
+ typeof obj.stderr === "string" ||
1174
+ typeof obj.returnCodeInterpretation === "string";
1175
+ if (!hasSignal)
1176
+ return [];
1177
+ const parts = [];
1178
+ if (typeof obj.interrupted === "boolean") {
1179
+ parts.push(`interrupted:${obj.interrupted}`);
1180
+ }
1181
+ if (typeof obj.returnCodeInterpretation === "string") {
1182
+ parts.push(`rcInterp:${obj.returnCodeInterpretation.slice(0, 80)}`);
1183
+ }
1184
+ if (typeof obj.stderr === "string") {
1185
+ parts.push(`stderrBytes:${obj.stderr.length}`);
1186
+ }
1187
+ return [{
1188
+ type: "bash_outcome",
1189
+ category: "data",
1190
+ data: safeString(parts.join(" ")),
1191
+ priority: 3,
1192
+ }];
1193
+ }
1194
+ /**
1195
+ * FileReadOutput size metadata — branches on the text/image variant.
1196
+ * Captures sizes/line counts only; never file content. Image dimensions
1197
+ * are formatted as "WxH" when both width/height are numeric.
1198
+ */
1199
+ function extractFileReadMetadata(input) {
1200
+ if (input.tool_name !== "Read")
1201
+ return [];
1202
+ const resp = input.tool_response;
1203
+ if (typeof resp !== "string" || resp.length === 0)
1204
+ return [];
1205
+ let parsed;
1206
+ try {
1207
+ parsed = JSON.parse(resp);
1208
+ }
1209
+ catch {
1210
+ return [];
1211
+ }
1212
+ if (!parsed || typeof parsed !== "object")
1213
+ return [];
1214
+ const obj = parsed;
1215
+ const variant = obj.type;
1216
+ if (variant !== "text" && variant !== "image")
1217
+ return [];
1218
+ const parts = [`type:${variant}`];
1219
+ if (variant === "text") {
1220
+ if (typeof obj.numLines === "number")
1221
+ parts.push(`lines:${obj.numLines}`);
1222
+ if (typeof obj.totalLines === "number")
1223
+ parts.push(`totalLines:${obj.totalLines}`);
1224
+ if (typeof obj.startLine === "number")
1225
+ parts.push(`start:${obj.startLine}`);
1226
+ }
1227
+ else {
1228
+ if (typeof obj.originalSize === "number")
1229
+ parts.push(`origSize:${obj.originalSize}`);
1230
+ const dims = obj.dimensions;
1231
+ if (dims && typeof dims === "object") {
1232
+ const d = dims;
1233
+ if (typeof d.width === "number" && typeof d.height === "number") {
1234
+ parts.push(`dims:${d.width}x${d.height}`);
1235
+ }
1236
+ }
1237
+ }
1238
+ return [{
1239
+ type: "file_read_metadata",
1240
+ category: "data",
1241
+ data: safeString(parts.join(" ")),
1242
+ priority: 3,
1243
+ }];
1244
+ }
1245
+ /**
1246
+ * Per-model USD price table — Anthropic public list pricing, $/MTok.
1247
+ * Verified against platform.claude.com/docs/en/about-claude/pricing,
1248
+ * cloudzero.com, finout.io 2026-06 (cache: 5-min cache_write = 1.25× input,
1249
+ * cache_read = 0.10× input). Fast-mode variants (e.g. opus-4-8-fast at
1250
+ * $10/$50) are intentionally NOT mapped — they ship as separate model
1251
+ * ids and would dilute the standard-tier dashboards if blended here.
1252
+ *
1253
+ * NOTE: 16-oss-verify-gap-prd Gap #1 quoted Opus at $15/$75 — that is
1254
+ * the prior Opus 4 (non-4.7) rate. Opus 4.7 and 4.8 ship at $5/$25.
1255
+ */
1256
+ const MODEL_PRICING_USD_PER_MTOK = {
1257
+ "claude-opus-4-8": { input: 5.00, output: 25.00, cache_write: 6.25, cache_read: 0.50 },
1258
+ "claude-opus-4-7": { input: 5.00, output: 25.00, cache_write: 6.25, cache_read: 0.50 },
1259
+ "claude-sonnet-4-6": { input: 3.00, output: 15.00, cache_write: 3.75, cache_read: 0.30 },
1260
+ "claude-haiku-4-5": { input: 1.00, output: 5.00, cache_write: 1.25, cache_read: 0.10 },
1261
+ default: { input: 3.00, output: 15.00, cache_write: 3.75, cache_read: 0.30 },
1262
+ };
1263
+ function resolveModelKey(input, parsedResp) {
1264
+ const candidates = [
1265
+ input.tool_input?.model,
1266
+ input.model,
1267
+ parsedResp.model,
1268
+ ];
1269
+ const keys = Object.keys(MODEL_PRICING_USD_PER_MTOK).filter((k) => k !== "default");
1270
+ for (const c of candidates) {
1271
+ if (typeof c !== "string" || c.length === 0)
1272
+ continue;
1273
+ if (c in MODEL_PRICING_USD_PER_MTOK)
1274
+ return c;
1275
+ // Prefix match for date-suffixed model ids
1276
+ // (e.g. claude-haiku-4-5-20251001 → claude-haiku-4-5)
1277
+ for (const key of keys) {
1278
+ if (c.startsWith(key))
1279
+ return key;
1280
+ }
1281
+ }
1282
+ return "default";
1283
+ }
1284
+ function computeCostUsd(modelKey, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens) {
1285
+ const price = MODEL_PRICING_USD_PER_MTOK[modelKey] ?? MODEL_PRICING_USD_PER_MTOK.default;
1286
+ const totalMicroDollars = inputTokens * price.input +
1287
+ outputTokens * price.output +
1288
+ cacheCreationTokens * price.cache_write +
1289
+ cacheReadTokens * price.cache_read;
1290
+ return totalMicroDollars / 1_000_000;
1291
+ }
1292
+ /**
1293
+ * AgentOutput.usage capture — fires on the Task sub-agent dispatcher.
1294
+ * Captures the 7 cost/perf fields from sdk-tools.d.ts:64-75. Derives
1295
+ * cost_usd from per-model pricing (Gap #1 fix). The platform persists
1296
+ * these as typed columns post-release; the bridge emits them as
1297
+ * structured tokens in event.data for forward-compatible ingestion.
1298
+ */
1299
+ function extractAgentUsage(input) {
1300
+ if (input.tool_name !== "Task")
1301
+ return [];
1302
+ const resp = input.tool_response;
1303
+ if (typeof resp !== "string" || resp.length === 0)
1304
+ return [];
1305
+ let parsed;
1306
+ try {
1307
+ parsed = JSON.parse(resp);
1308
+ }
1309
+ catch {
1310
+ return [];
1311
+ }
1312
+ if (!parsed || typeof parsed !== "object")
1313
+ return [];
1314
+ const out = parsed;
1315
+ const usage = (out.usage && typeof out.usage === "object")
1316
+ ? out.usage
1317
+ : {};
1318
+ const hasSignal = typeof out.totalTokens === "number" ||
1319
+ typeof out.totalDurationMs === "number" ||
1320
+ typeof usage.input_tokens === "number" ||
1321
+ typeof usage.output_tokens === "number" ||
1322
+ typeof usage.service_tier === "string";
1323
+ if (!hasSignal)
1324
+ return [];
1325
+ const parts = [];
1326
+ if (typeof out.totalTokens === "number")
1327
+ parts.push(`totalTokens:${out.totalTokens}`);
1328
+ if (typeof out.totalDurationMs === "number")
1329
+ parts.push(`totalDurMs:${out.totalDurationMs}`);
1330
+ if (typeof usage.input_tokens === "number")
1331
+ parts.push(`tokens_in:${usage.input_tokens}`);
1332
+ if (typeof usage.output_tokens === "number")
1333
+ parts.push(`tokens_out:${usage.output_tokens}`);
1334
+ if (typeof usage.cache_creation_input_tokens === "number") {
1335
+ parts.push(`cache_create:${usage.cache_creation_input_tokens}`);
1336
+ }
1337
+ if (typeof usage.cache_read_input_tokens === "number") {
1338
+ parts.push(`cache_read:${usage.cache_read_input_tokens}`);
1339
+ }
1340
+ if (typeof usage.service_tier === "string") {
1341
+ parts.push(`tier:${usage.service_tier.slice(0, 32)}`);
1342
+ }
1343
+ // Gap #1 (16-oss-verify-gap-prd) — derive cost_usd from per-model pricing
1344
+ // when at least one token count is present. Zero-token case skips cost
1345
+ // so dashboard never shows misleading "$0.00 for nothing" rows.
1346
+ const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
1347
+ const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
1348
+ const cacheCreate = typeof usage.cache_creation_input_tokens === "number"
1349
+ ? usage.cache_creation_input_tokens
1350
+ : 0;
1351
+ const cacheRead = typeof usage.cache_read_input_tokens === "number"
1352
+ ? usage.cache_read_input_tokens
1353
+ : 0;
1354
+ const anyTokens = inputTokens > 0 || outputTokens > 0 || cacheCreate > 0 || cacheRead > 0;
1355
+ if (anyTokens) {
1356
+ const modelKey = resolveModelKey(input, out);
1357
+ const cost = computeCostUsd(modelKey, inputTokens, outputTokens, cacheCreate, cacheRead);
1358
+ parts.push(`cost_usd:${cost.toFixed(6).replace(/0+$/, "").replace(/\.$/, ".0")}`);
1359
+ }
1360
+ return [{
1361
+ type: "agent_usage",
1362
+ category: "cost",
1363
+ data: safeString(parts.join(" ")),
770
1364
  priority: 2,
771
1365
  }];
772
1366
  }
@@ -1189,6 +1783,10 @@ export function extractEvents(rawInput) {
1189
1783
  events.push(...extractDecision(input));
1190
1784
  events.push(...extractConstraint(input));
1191
1785
  events.push(...extractWorktree(input));
1786
+ events.push(...extractWebFetchMetadata(input));
1787
+ events.push(...extractBashOutcome(input));
1788
+ events.push(...extractFileReadMetadata(input));
1789
+ events.push(...extractAgentUsage(input));
1192
1790
  events.push(...extractAgentFinding(input));
1193
1791
  events.push(...extractExternalRef(input));
1194
1792
  // Cross-event stateful extractors
@@ -1210,6 +1808,7 @@ export function extractEvents(rawInput) {
1210
1808
  export function extractUserEvents(message) {
1211
1809
  try {
1212
1810
  const events = [];
1811
+ events.push(...extractUserPlan(message));
1213
1812
  events.push(...extractUserDecision(message));
1214
1813
  events.push(...extractRole(message));
1215
1814
  events.push(...extractIntent(message));
@@ -1222,3 +1821,147 @@ export function extractUserEvents(message) {
1222
1821
  return [];
1223
1822
  }
1224
1823
  }
1824
+ /**
1825
+ * Issue #4 (new PRD) — SessionStart settings + MCP servers snapshot.
1826
+ *
1827
+ * Emits ONE session_settings_snapshot event when ≥1 setting is available
1828
+ * on the SessionStart input. The data field carries key:value tokens
1829
+ * (mcp_count, mcp_servers, model, permission_mode) so the platform can
1830
+ * compute MCP integration counts and primary-model adoption per org.
1831
+ * mcp_servers list is truncated to first 8 names.
1832
+ */
1833
+ export function extractSessionSettings(input) {
1834
+ if (!input || typeof input !== "object")
1835
+ return [];
1836
+ const obj = input;
1837
+ const parts = [];
1838
+ const mcpServers = obj.mcp_servers;
1839
+ let mcpKeys = null;
1840
+ if (mcpServers && typeof mcpServers === "object" && !Array.isArray(mcpServers)) {
1841
+ mcpKeys = Object.keys(mcpServers);
1842
+ parts.push(`mcp_count:${mcpKeys.length}`);
1843
+ if (mcpKeys.length > 0) {
1844
+ parts.push(`mcp_servers:${mcpKeys.slice(0, 8).join(",")}`);
1845
+ }
1846
+ }
1847
+ if (typeof obj.model === "string") {
1848
+ parts.push(`model:${obj.model.slice(0, 64)}`);
1849
+ }
1850
+ if (typeof obj.permission_mode === "string") {
1851
+ parts.push(`permission_mode:${obj.permission_mode.slice(0, 32)}`);
1852
+ }
1853
+ if (parts.length === 0)
1854
+ return [];
1855
+ return [{
1856
+ type: "session_settings_snapshot",
1857
+ category: "env",
1858
+ data: safeString(parts.join(" ")),
1859
+ priority: 2,
1860
+ }];
1861
+ }
1862
+ const PROMPT_SCRIPT_NAMES = [
1863
+ "Latin", "Cyrillic", "Arabic", "Han", "Hangul",
1864
+ "Hiragana", "Katakana", "Devanagari", "Hebrew", "Thai", "Greek",
1865
+ ];
1866
+ const EMPTY_PROMPT_FEATURES = {
1867
+ prompt_length: 0,
1868
+ prompt_word_count: 0,
1869
+ prompt_uppercase_ratio: 0,
1870
+ prompt_file_ref_count: 0,
1871
+ prompt_path_ref_count: 0,
1872
+ prompt_script_primary: null,
1873
+ prompt_script_count: 0,
1874
+ prompt_question_glyph_count: 0,
1875
+ prompt_code_block_count: 0,
1876
+ prompt_url_count: 0,
1877
+ prompt_word_tokens: [],
1878
+ };
1879
+ /**
1880
+ * Verbatim mirror of §11 Layer 1 reference implementation + Layer 3
1881
+ * token extraction. Uses Unicode property regex per the spec — the
1882
+ * "no regex" project default does NOT apply here because the spec
1883
+ * explicitly mandates `\p{Script=X}` for script-agnostic classification.
1884
+ */
1885
+ export function extractUserPromptFeatures(prompt) {
1886
+ if (typeof prompt !== "string" || prompt.length === 0) {
1887
+ return { ...EMPTY_PROMPT_FEATURES, prompt_word_tokens: [] };
1888
+ }
1889
+ const letters = prompt.match(/\p{L}+/gu) ?? [];
1890
+ const upperCount = (prompt.match(/\p{Lu}/gu) ?? []).length;
1891
+ const totalLetters = letters.join("").length;
1892
+ const fences = (prompt.match(/```/g) ?? []).length;
1893
+ const scripts = {};
1894
+ for (const name of PROMPT_SCRIPT_NAMES) {
1895
+ const re = new RegExp(`\\p{Script=${name}}`, "gu");
1896
+ const n = (prompt.match(re) ?? []).length;
1897
+ if (n > 0)
1898
+ scripts[name] = n;
1899
+ }
1900
+ const primary = Object.entries(scripts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
1901
+ const seen = new Set();
1902
+ const tokens = [];
1903
+ for (const word of letters) {
1904
+ if (word.length < 3)
1905
+ continue;
1906
+ const lower = word.toLowerCase();
1907
+ if (seen.has(lower))
1908
+ continue;
1909
+ seen.add(lower);
1910
+ tokens.push(lower);
1911
+ }
1912
+ return {
1913
+ prompt_length: prompt.length,
1914
+ prompt_word_count: letters.length,
1915
+ prompt_uppercase_ratio: totalLetters === 0 ? 0 : upperCount / totalLetters,
1916
+ prompt_file_ref_count: (prompt.match(/(\w+\/)+\w+\.\w+/g) ?? []).length,
1917
+ prompt_path_ref_count: (prompt.match(/\.{0,2}\/[\w\/.-]+/g) ?? []).length,
1918
+ prompt_script_primary: primary,
1919
+ prompt_script_count: Object.keys(scripts).length,
1920
+ prompt_question_glyph_count: (prompt.match(/[??؟]/gu) ?? []).length,
1921
+ prompt_code_block_count: Math.floor(fences / 2),
1922
+ prompt_url_count: (prompt.match(/https?:\/\/[^\s]+/gu) ?? []).length,
1923
+ prompt_word_tokens: tokens,
1924
+ };
1925
+ }
1926
+ /**
1927
+ * UserPromptSubmit-driven `/plan` slash detector.
1928
+ *
1929
+ * Compensates for Claude Code Bug #15660: programmatic EnterPlanMode tool
1930
+ * calls fire PostToolUse, but the `/plan` slash command and Shift+Tab do
1931
+ * NOT. Shift+Tab is unrecoverable from the OSS bridge without an upstream
1932
+ * SDK change; this detector handles the slash case.
1933
+ *
1934
+ * Algorithmic (no regex): tolerate leading whitespace, require lowercase
1935
+ * "/plan", reject longer slashes like "/plans" via the next-char check.
1936
+ */
1937
+ function extractUserPlan(message) {
1938
+ if (typeof message !== "string" || message.length === 0)
1939
+ return [];
1940
+ let i = 0;
1941
+ while (i < message.length) {
1942
+ const c = message.charCodeAt(i);
1943
+ if (c !== 32 && c !== 9)
1944
+ break;
1945
+ i++;
1946
+ }
1947
+ if (i + 5 > message.length)
1948
+ return [];
1949
+ if (message.slice(i, i + 5) !== "/plan")
1950
+ return [];
1951
+ if (i + 5 < message.length) {
1952
+ const next = message.charCodeAt(i + 5);
1953
+ const isWordBoundary = next === 32 || next === 9 || next === 10 || next === 13;
1954
+ if (!isWordBoundary)
1955
+ return [];
1956
+ }
1957
+ const arg = message.slice(i + 5).trim();
1958
+ const detail = arg.length > 0
1959
+ ? `plan via /plan slash: ${arg.slice(0, 120)}`
1960
+ : "plan via /plan slash";
1961
+ return [{
1962
+ type: "plan_enter",
1963
+ category: "plan",
1964
+ data: safeString(detail),
1965
+ priority: 2,
1966
+ }];
1967
+ }