social-autoposter 1.3.10 → 1.4.0

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.
Files changed (74) hide show
  1. package/bin/server.js +1308 -135
  2. package/package.json +1 -1
  3. package/scripts/_insert_post_023.py +127 -0
  4. package/scripts/_scan_aggregate.py +108 -0
  5. package/scripts/_scan_timeline.py +85 -0
  6. package/scripts/_test_since_hook.py +117 -0
  7. package/scripts/audience_pages.py +243 -0
  8. package/scripts/classify_run_error.py +145 -0
  9. package/scripts/dm_db_update.py +69 -0
  10. package/scripts/dm_outreach_twitter_helper.py +129 -0
  11. package/scripts/engage_github.py +6 -5
  12. package/scripts/engage_reddit.py +44 -6
  13. package/scripts/engage_twitter_helper.py +197 -0
  14. package/scripts/engagement_styles.py +119 -18
  15. package/scripts/fetch_twitter_t1.py +41 -29
  16. package/scripts/github_tools.py +139 -10
  17. package/scripts/ig_post_type_picker.py +113 -37
  18. package/scripts/insert_post029.py +86 -0
  19. package/scripts/insert_post_024.py +110 -0
  20. package/scripts/insert_post_026.py +103 -0
  21. package/scripts/insert_post_039.py +80 -0
  22. package/scripts/install_lane_digest.py +5 -1
  23. package/scripts/install_lane_monitor.py +1 -1
  24. package/scripts/log_run.py +47 -6
  25. package/scripts/log_twitter_search_attempts.py +21 -12
  26. package/scripts/log_twitter_skips.py +30 -55
  27. package/scripts/octolens_twitter_batch.py +34 -33
  28. package/scripts/octolens_twitter_cdp.py +29 -29
  29. package/scripts/pick_ig_account.py +192 -0
  30. package/scripts/pick_project.py +108 -43
  31. package/scripts/pick_twitter_thread_target.py +37 -80
  32. package/scripts/post_github.py +185 -68
  33. package/scripts/post_reddit.py +136 -35
  34. package/scripts/precompute_dashboard_stats.py +13 -1
  35. package/scripts/project_stats_json.py +23 -0
  36. package/scripts/reconcile_twitter_search_topic.py +125 -0
  37. package/scripts/reddit_browser.py +49 -2
  38. package/scripts/reddit_tools.py +12 -0
  39. package/scripts/regenerate_ig_plists.py +262 -0
  40. package/scripts/scan_twitter_mentions_browser.py +150 -63
  41. package/scripts/scan_twitter_thread_followups.py +78 -52
  42. package/scripts/score_twitter_candidates.py +102 -103
  43. package/scripts/scrape_reddit_views.py +66 -2
  44. package/scripts/snapshot_style_targets.py +85 -0
  45. package/scripts/strike_alert.py +479 -18
  46. package/scripts/top_dud_twitter_queries.py +20 -34
  47. package/scripts/top_performers.py +223 -14
  48. package/scripts/top_twitter_queries.py +30 -124
  49. package/scripts/twitter_batch_phase.py +26 -44
  50. package/scripts/twitter_browser.py +31 -20
  51. package/scripts/twitter_cycle_helper.py +268 -0
  52. package/scripts/twitter_gen_links.py +35 -2
  53. package/scripts/twitter_post_plan.py +67 -40
  54. package/scripts/twitter_supply_signal.py +8 -53
  55. package/scripts/twitter_threads_helper.py +152 -0
  56. package/scripts/update_stats.py +218 -24
  57. package/skill/dm-outreach-linkedin.sh +1 -1
  58. package/skill/dm-outreach-reddit.sh +1 -1
  59. package/skill/dm-outreach-twitter.sh +23 -49
  60. package/skill/engage-dm-replies.sh +1 -1
  61. package/skill/engage-linkedin.sh +2 -2
  62. package/skill/engage-reddit.sh +15 -0
  63. package/skill/engage-twitter.sh +37 -63
  64. package/skill/lib/twitter-backend.sh +14 -2
  65. package/skill/link-edit-github.sh +1 -1
  66. package/skill/link-edit-reddit.sh +1 -1
  67. package/skill/run-github.sh +13 -1
  68. package/skill/run-instagram-daily.sh +24 -12
  69. package/skill/run-instagram-render.sh +332 -51
  70. package/skill/run-linkedin.sh +14 -3
  71. package/skill/run-reddit-search.sh +13 -0
  72. package/skill/run-twitter-cycle.sh +351 -221
  73. package/skill/run-twitter-threads.sh +19 -27
  74. package/skill/stats.sh +38 -3
package/bin/server.js CHANGED
@@ -477,7 +477,11 @@ const RUN_MONITOR_PATH = path.join(LOG_DIR, 'run_monitor.log');
477
477
  // queries+candidates+above_floor only. Each sub-key is omitted when zero, so
478
478
  // `discover=` itself is absent on lines from pipelines that don't emit it.
479
479
  // Old log lines without the segment still parse cleanly via the optional `?`.
480
- const RUN_LINE_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\s*\|\s*(\S+)\s*\|\s*posted=(\d+)\s+skipped=(\d+)\s+failed=(\d+)(?:\s+replies_refreshed=(\d+))?(?:\s+checked=(\d+)\s+updated=(\d+)\s+removed=(\d+))?(?:\s+unavailable=(\d+))?(?:\s+not_found=(\d+))?(?:\s+salvaged=(\d+))?(?:\s+discover=([^\s|]+))?(?:\s+scan=([^\s|]+))?\s+cost=\$([\d.]+)\s+elapsed=(\d+)s(?:\s+failure_reasons=([^\s|]+))?(?:\s+skip_reasons=([^\s|]+))?/;
480
+ // 2026-05-18 stats-pill relabel: three new optional groups (scanned,
481
+ // changed, views_refreshed) tail the unavailable/not_found block so old log
482
+ // lines still parse via the existing positional regex. Each is independently
483
+ // optional so a partial roll-out (just scanned, just changed) also parses.
484
+ const RUN_LINE_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\s*\|\s*(\S+)\s*\|\s*posted=(\d+)\s+skipped=(\d+)\s+failed=(\d+)(?:\s+replies_refreshed=(\d+))?(?:\s+checked=(\d+)\s+updated=(\d+)\s+removed=(\d+))?(?:\s+unavailable=(\d+))?(?:\s+not_found=(\d+))?(?:\s+scanned=(\d+))?(?:\s+changed=(\d+))?(?:\s+views_refreshed=(\d+))?(?:\s+salvaged=(\d+))?(?:\s+discover=([^\s|]+))?(?:\s+scan=([^\s|]+))?\s+cost=\$([\d.]+)\s+elapsed=(\d+)s(?:\s+failure_reasons=([^\s|]+))?(?:\s+skip_reasons=([^\s|]+))?/;
481
485
 
482
486
  // posts.platform is lowercase; UI labels are capitalized.
483
487
  const PLATFORM_LABELS = {
@@ -586,7 +590,7 @@ function parseRunMonitorLog(maxLines) {
586
590
  for (const line of tail) {
587
591
  const m = line.match(RUN_LINE_RE);
588
592
  if (!m) continue;
589
- const [, ts, script, posted, skipped, failed, repliesRefreshed, checked, updated, removed, unavailable, notFound, salvaged, discoverStr, scanStr, cost, elapsed, failureReasonsStr, skipReasonsStr] = m;
593
+ const [, ts, script, posted, skipped, failed, repliesRefreshed, checked, updated, removed, unavailable, notFound, scannedRaw, changedRaw, viewsRefreshedRaw, salvaged, discoverStr, scanStr, cost, elapsed, failureReasonsStr, skipReasonsStr] = m;
590
594
  if (JOB_HISTORY_HIDDEN_SCRIPTS.has(script)) continue;
591
595
  // log_run.py writes naive local-wallclock time (strftime without tz), so
592
596
  // `new Date(ts)` in node interprets it as local on the server. That is
@@ -670,6 +674,13 @@ function parseRunMonitorLog(maxLines) {
670
674
  removed: removed ? parseInt(removed, 10) : 0,
671
675
  unavailable: unavailable ? parseInt(unavailable, 10) : 0,
672
676
  not_found: notFound ? parseInt(notFound, 10) : 0,
677
+ // 2026-05-18 stats-pill relabel: three new fields surface the split
678
+ // between Step 1 view-scrape leg and Step 2 metric-changed leg.
679
+ // Absent on pre-relabel log lines (defaults to 0); renderResultPills
680
+ // falls back to `checked`/`updated` when these are 0.
681
+ scanned: scannedRaw ? parseInt(scannedRaw, 10) : 0,
682
+ changed: changedRaw ? parseInt(changedRaw, 10) : 0,
683
+ views_refreshed: viewsRefreshedRaw ? parseInt(viewsRefreshedRaw, 10) : 0,
673
684
  salvaged: salvaged ? parseInt(salvaged, 10) : 0,
674
685
  discover, // {} when no `discover=` segment was present on the line
675
686
  scan, // {} when no `scan=` segment was present on the line
@@ -1130,7 +1141,8 @@ async function enrichPostCommentsTwitterRuns(runs) {
1130
1141
  // otherwise undercount the per-run queue snapshot at older runs.
1131
1142
  const candidateRows = await pq(
1132
1143
  "SELECT discovered_at, posted_at, t1_checked_at, drafted_at, " +
1133
- " (draft_reply_text IS NOT NULL) AS has_draft, status, batch_id " +
1144
+ " (draft_reply_text IS NOT NULL) AS has_draft, status, batch_id, " +
1145
+ " matched_project " +
1134
1146
  "FROM twitter_candidates " +
1135
1147
  "WHERE discovered_at >= $1::timestamp OR posted_at >= $1::timestamp OR t1_checked_at >= $1::timestamp OR status='pending'",
1136
1148
  [since]
@@ -1194,6 +1206,7 @@ async function enrichPostCommentsTwitterRuns(runs) {
1194
1206
  exitMs,
1195
1207
  status: r.status,
1196
1208
  batch_id: r.batch_id || '',
1209
+ matched_project: r.matched_project || '',
1197
1210
  };
1198
1211
  });
1199
1212
 
@@ -1235,9 +1248,25 @@ async function enrichPostCommentsTwitterRuns(runs) {
1235
1248
  }
1236
1249
  let candidatesPassed = 0;
1237
1250
  let salvagePosted = 0;
1251
+ // Project labels this cycle actually worked on, surfaced at the end of the
1252
+ // pill row (mirrors enrichPostCommentsRedditRuns). Source is the
1253
+ // twitter_candidates.matched_project values for rows tied to this run's
1254
+ // own batch_id — i.e. tweets Phase 1 scraped + scored on behalf of these
1255
+ // projects. Insertion-order Set preserves first-seen order while deduping.
1256
+ const projectsSeen = new Set();
1257
+ const projectsList = [];
1258
+ const recordProject = (raw) => {
1259
+ if (!raw) return;
1260
+ const proj = raw.trim();
1261
+ if (proj && !projectsSeen.has(proj)) {
1262
+ projectsSeen.add(proj);
1263
+ projectsList.push(proj);
1264
+ }
1265
+ };
1238
1266
  for (const c of candNorm) {
1239
1267
  if (!ownBatchId || c.batch_id !== ownBatchId) continue;
1240
1268
  candidatesPassed++;
1269
+ recordProject(c.matched_project);
1241
1270
  if (c.status === 'posted') {
1242
1271
  posted++;
1243
1272
  // Salvage signature: candidate's discovered_at predates this cycle's
@@ -1270,6 +1299,19 @@ async function enrichPostCommentsTwitterRuns(runs) {
1270
1299
  const body = fs.readFileSync(path.join(LOG_DIR, chosenLog), 'utf8');
1271
1300
  const m = body.match(phase0SalvageRe);
1272
1301
  if (m) salvageAttempted = parseInt(m[1], 10);
1302
+ // Fallback for scan-only cycles where 0 candidates upserted (so
1303
+ // matched_project never landed in twitter_candidates for this batch):
1304
+ // pull project names from the cycle log's "Selected projects:" header.
1305
+ // This surfaces the projects the cycle SCANNED for, mirroring Reddit's
1306
+ // behaviour of capturing project labels even when nothing posted.
1307
+ if (!projectsList.length) {
1308
+ const selM = body.match(/Selected projects:\s*([^\n]+)/);
1309
+ if (selM) {
1310
+ for (const tok of selM[1].split(',')) {
1311
+ recordProject(tok);
1312
+ }
1313
+ }
1314
+ }
1273
1315
  } catch { /* empty */ }
1274
1316
  }
1275
1317
  // Per-run queue delta. ADD = candidates whose discovered_at fell in this
@@ -1373,6 +1415,12 @@ async function enrichPostCommentsTwitterRuns(runs) {
1373
1415
  salvage_attempted: salvageAttempted,
1374
1416
  salvage_posted: salvagePosted,
1375
1417
  own_batch_id: ownBatchId,
1418
+ // Project(s) this cycle actually worked on (Phase 1 scraped + scored
1419
+ // candidates for), parsed from twitter_candidates.matched_project for
1420
+ // rows tied to this run's batch_id. Surfaces at the end of the dashboard
1421
+ // pill row so the operator can see at a glance which projects consumed
1422
+ // the cycle, even when posted=0. Mirrors enrichPostCommentsRedditRuns.
1423
+ projects_worked: projectsList,
1376
1424
  cost_usd: prior.cost_usd || 0,
1377
1425
  failed: prior.failed || 0,
1378
1426
  failure_reasons: Array.isArray(prior.failure_reasons) ? prior.failure_reasons : [],
@@ -2224,7 +2272,7 @@ async function enrichSeoRuns(runs) {
2224
2272
  }
2225
2273
  if (sessionIds.length) {
2226
2274
  const rows = await pq(
2227
- 'SELECT session_id, total_cost_usd, orchestrator_cost_usd FROM claude_sessions WHERE session_id = ANY($1::uuid[])',
2275
+ 'SELECT session_id, total_cost_usd, orchestrator_cost_usd, subagent_cost_usd FROM claude_sessions WHERE session_id = ANY($1::uuid[])',
2228
2276
  [sessionIds]
2229
2277
  );
2230
2278
  if (rows && rows.length) {
@@ -2234,6 +2282,9 @@ async function enrichSeoRuns(runs) {
2234
2282
  orchestrator: r.orchestrator_cost_usd != null
2235
2283
  ? Number(r.orchestrator_cost_usd)
2236
2284
  : null,
2285
+ subagent: r.subagent_cost_usd != null
2286
+ ? Number(r.subagent_cost_usd)
2287
+ : null,
2237
2288
  }])
2238
2289
  );
2239
2290
  for (const run of seoRuns) {
@@ -2246,16 +2297,20 @@ async function enrichSeoRuns(runs) {
2246
2297
  // sessions, locked callers that don't pass --orchestrator-cost-usd),
2247
2298
  // keep whatever streamRes value _collectSeoDetails parsed from the
2248
2299
  // .log file.
2300
+ // SDK-only: d.cost_usd is orchestrator_cost_usd alone. Estimate
2301
+ // and subagent are kept on the row for diagnostic tooltips but
2302
+ // not folded into the displayed total.
2249
2303
  if (Number.isFinite(row.orchestrator)) {
2250
2304
  d.cost_usd = row.orchestrator;
2251
2305
  d.cost_usd_orchestrator = row.orchestrator;
2252
- } else if (Number.isFinite(d.cost_usd)) {
2253
- d.cost_usd_orchestrator = d.cost_usd;
2306
+ } else {
2307
+ d.cost_usd = null;
2308
+ d.cost_usd_orchestrator = null;
2254
2309
  }
2255
- // Manual estimate alongside (always populated by log_claude_session.py).
2256
2310
  if (Number.isFinite(row.estimated)) {
2257
2311
  d.cost_usd_estimated = row.estimated;
2258
2312
  }
2313
+ d.cost_usd_subagent = Number.isFinite(row.subagent) ? row.subagent : 0;
2259
2314
  }
2260
2315
  }
2261
2316
  }
@@ -2284,6 +2339,229 @@ async function enrichSeoRuns(runs) {
2284
2339
  }
2285
2340
  }
2286
2341
 
2342
+ // Maps a Job History row's `script` (log_run.py canonical name) to the
2343
+ // `claude_sessions.script` values its wrapper actually spawns. Without this
2344
+ // constraint a 40-min post_reddit run's window catches every concurrent
2345
+ // pipeline's sessions (engage-dm-replies, seo_generate_page, etc.) and the
2346
+ // "cost" cell sums everything that ran in parallel — meaningless. Lookup is
2347
+ // keyed on the underscore form (post_reddit, dm_replies_reddit) that survives
2348
+ // classifyScript's normalization. Add new entries here when a new wrapper
2349
+ // pipeline ships.
2350
+ const _PHASE_FAMILY = {
2351
+ // Reddit
2352
+ post_reddit: ['post_reddit'],
2353
+ engage_reddit: ['engage_reddit'],
2354
+ thread_reddit: ['run-reddit-threads'],
2355
+ dm_outreach_reddit: ['dm-outreach-reddit'],
2356
+ dm_replies_reddit: ['engage-dm-replies'],
2357
+ link_edit_reddit: ['link-edit-reddit'],
2358
+ // Twitter
2359
+ post_twitter: ['run-twitter-cycle-scan', 'run-twitter-cycle-prep'],
2360
+ engage_twitter: ['engage-twitter-phaseB'],
2361
+ thread_twitter: ['run-twitter-threads'],
2362
+ dm_outreach_twitter: ['dm-outreach-twitter'],
2363
+ dm_replies_twitter: ['engage-dm-replies'],
2364
+ link_edit_twitter: ['link-edit-twitter'],
2365
+ // LinkedIn (run-linkedin bare = pre-phase-split legacy tag, kept for old rows)
2366
+ post_linkedin: ['run-linkedin-phaseA', 'run-linkedin-phaseB', 'run-linkedin'],
2367
+ engage_linkedin: ['engage-linkedin-phaseA', 'engage-linkedin-phaseB'],
2368
+ dm_replies_linkedin: ['engage-dm-replies'],
2369
+ dm_outreach_linkedin: ['dm-outreach-linkedin'],
2370
+ link_edit_linkedin: ['link-edit-linkedin'],
2371
+ // GitHub
2372
+ post_github: ['post_github'],
2373
+ engage_github: ['engage_github', 'github-engage', 'run-github-cycle'],
2374
+ link_edit_github: ['link-edit-github'],
2375
+ // Moltbook
2376
+ post_moltbook: ['run-moltbook-cycle'],
2377
+ // SEO
2378
+ gsc_seo: ['seo_generate_page', 'seo_generate_page_retry'],
2379
+ serp_seo: ['seo_generate_page', 'seo_generate_page_retry'],
2380
+ seo_improve: ['seo_improve_page'],
2381
+ seo_weekly_roundup: ['seo_generate_page'],
2382
+ seo_top_pages: ['seo_generate_page'],
2383
+ seo_top_posts: ['seo_generate_page'],
2384
+ };
2385
+
2386
+ function _phaseFamilyFor(runScript) {
2387
+ if (!runScript) return null;
2388
+ const norm = String(runScript).replace(/-/g, '_').toLowerCase();
2389
+ const aliased = SCRIPT_ALIASES[norm] || norm;
2390
+ const family = _PHASE_FAMILY[aliased];
2391
+ return family && family.length ? new Set(family) : null;
2392
+ }
2393
+
2394
+ // Per-phase Claude cost breakdown for Job History rows. For each completed
2395
+ // run, queries `claude_sessions` rows whose `script` belongs to the run's
2396
+ // known phase family (see _PHASE_FAMILY) AND whose `started_at` falls inside
2397
+ // [run.started_at - slack, run.finished_at + slack]. Groups results by
2398
+ // session script (the phase) and attaches:
2399
+ // run.result.cost_breakdown = {
2400
+ // total, orchestrator, subagent, estimated,
2401
+ // phases: [{phase, sessions, total, orch, sub, est}, ...] // desc by total
2402
+ // }
2403
+ // run.result.cost_usd = total (overrides the shell-log value when
2404
+ // we found ≥1 matching session — the wrapper
2405
+ // log line often misses sub-phase cost,
2406
+ // e.g. engage_twitter logs $0 but phaseB
2407
+ // spent real money).
2408
+ // run.result.cost_usd_from_log = original shell-log value (preserved for
2409
+ // audit/provenance).
2410
+ // run.result.cost_usd_* = orch/subagent/estimated lanes for the
2411
+ // 4-lane tooltip the Cost column renders.
2412
+ // Runs whose `script` isn't in _PHASE_FAMILY get NO breakdown (we don't know
2413
+ // which sessions to attribute) and keep their shell-log cost.
2414
+ //
2415
+ // cycle_id disambiguation (2026-05-15): time-window + family matching alone
2416
+ // mis-attributes overlapping cycles. Twitter cycles fire every 15 min, so a
2417
+ // 30-min run's window catches the previous cycle's late-running prep and the
2418
+ // next cycle's scan. Each wrapper invocation exports a unique SA_CYCLE_ID
2419
+ // that every child claude_sessions row inherits, so after window+family
2420
+ // matching we pick the ONE cycle_id whose earliest session is closest to the
2421
+ // run's started_at (that's the run's own scan/first phase) and drop every
2422
+ // session belonging to a different cycle_id. Sessions with NULL cycle_id are
2423
+ // only used when the run matched no cycle_id at all (pure fallback).
2424
+ async function enrichRunsCostBreakdown(runs) {
2425
+ const candidates = (runs || []).filter(r =>
2426
+ !r.running && r.started_at && r.finished_at && _phaseFamilyFor(r.script)
2427
+ );
2428
+ if (!candidates.length) return;
2429
+ let minStart = Infinity, maxEnd = 0;
2430
+ for (const r of candidates) {
2431
+ const s = Date.parse(r.started_at);
2432
+ const e = Date.parse(r.finished_at);
2433
+ if (Number.isFinite(s) && s < minStart) minStart = s;
2434
+ if (Number.isFinite(e) && e > maxEnd) maxEnd = e;
2435
+ }
2436
+ if (minStart === Infinity) return;
2437
+ const slackMs = _RUN_WINDOW_SLACK_MS;
2438
+ const since = new Date(minStart - slackMs).toISOString();
2439
+ const until = new Date(maxEnd + slackMs).toISOString();
2440
+ let rows;
2441
+ try {
2442
+ rows = await pq(
2443
+ // SDK-only mode: orchestrator_cost_usd is the only number we display.
2444
+ // Subagent and transcript-estimate are kept for diagnostic tooltips but
2445
+ // never added into the total. has_sdk lets the UI distinguish "session
2446
+ // happened but SDK didn't capture cost" from "session cost was $0".
2447
+ "SELECT script, started_at, cycle_id, " +
2448
+ "orchestrator_cost_usd IS NOT NULL AS has_sdk, " +
2449
+ "COALESCE(orchestrator_cost_usd, 0)::float8 AS orch, " +
2450
+ "COALESCE(total_cost_usd, 0)::float8 AS est, " +
2451
+ "COALESCE(subagent_cost_usd, 0)::float8 AS sub " +
2452
+ "FROM claude_sessions WHERE started_at BETWEEN $1::timestamp AND $2::timestamp",
2453
+ [since, until]
2454
+ );
2455
+ } catch (e) {
2456
+ console.error('[enrichRunsCostBreakdown] query failed:', e && e.message || e);
2457
+ return;
2458
+ }
2459
+ if (!rows || !rows.length) return;
2460
+ const sessionList = rows.map(r => ({
2461
+ ts: r.started_at instanceof Date ? r.started_at.getTime() : Date.parse(r.started_at),
2462
+ script: r.script || '(unknown)',
2463
+ cycleId: r.cycle_id || null,
2464
+ hasSdk: !!r.has_sdk,
2465
+ orch: Number(r.orch) || 0,
2466
+ est: Number(r.est) || 0,
2467
+ sub: Number(r.sub) || 0,
2468
+ // SDK-only: total = orch. Sessions without SDK contribute 0 to total
2469
+ // (they're flagged via hasSdk so the tooltip can show coverage).
2470
+ total: Number(r.orch) || 0,
2471
+ }));
2472
+ for (const r of candidates) {
2473
+ const runStartMs = Date.parse(r.started_at);
2474
+ const startMs = runStartMs - slackMs;
2475
+ const endMs = Date.parse(r.finished_at) + slackMs;
2476
+ const family = _phaseFamilyFor(r.script);
2477
+ // Step 1: window + family match (over-broad — catches overlapping cycles).
2478
+ const windowMatched = sessionList.filter(s =>
2479
+ s.ts >= startMs && s.ts <= endMs && (!family || family.has(s.script))
2480
+ );
2481
+ // Step 2: cycle_id disambiguation. Group matched sessions by cycle_id and
2482
+ // pick the group whose earliest session starts closest to this run's
2483
+ // started_at — that's the run's own cycle. Drop sessions from other
2484
+ // cycle_ids. If no session carries a cycle_id, fall back to the full
2485
+ // window-matched set (older sessions / pipelines that don't set
2486
+ // SA_CYCLE_ID).
2487
+ let attributed;
2488
+ const byCycle = new Map();
2489
+ for (const s of windowMatched) {
2490
+ if (!s.cycleId) continue;
2491
+ let g = byCycle.get(s.cycleId);
2492
+ if (!g) { g = { earliest: s.ts, sessions: [] }; byCycle.set(s.cycleId, g); }
2493
+ if (s.ts < g.earliest) g.earliest = s.ts;
2494
+ g.sessions.push(s);
2495
+ }
2496
+ if (byCycle.size > 0) {
2497
+ let bestCycle = null, bestDelta = Infinity;
2498
+ for (const [cid, g] of byCycle) {
2499
+ const delta = Math.abs(g.earliest - runStartMs);
2500
+ if (delta < bestDelta) { bestDelta = delta; bestCycle = cid; }
2501
+ }
2502
+ attributed = byCycle.get(bestCycle).sessions;
2503
+ } else {
2504
+ attributed = windowMatched;
2505
+ }
2506
+ const byPhase = {};
2507
+ let totalOrch = 0, totalEst = 0, totalSub = 0;
2508
+ let sessionsAll = 0, sessionsWithSdk = 0;
2509
+ for (const s of attributed) {
2510
+ const key = s.script;
2511
+ const cur = byPhase[key] || { phase: key, sessions: 0, sessions_with_sdk: 0, orch: 0, est: 0, sub: 0 };
2512
+ cur.sessions += 1;
2513
+ if (s.hasSdk) cur.sessions_with_sdk += 1;
2514
+ cur.orch += s.orch;
2515
+ cur.est += s.est;
2516
+ cur.sub += s.sub;
2517
+ byPhase[key] = cur;
2518
+ sessionsAll += 1;
2519
+ if (s.hasSdk) sessionsWithSdk += 1;
2520
+ totalOrch += s.orch;
2521
+ totalEst += s.est;
2522
+ totalSub += s.sub;
2523
+ }
2524
+ if (!sessionsAll) continue;
2525
+ const phases = Object.values(byPhase)
2526
+ .sort((a, b) => b.orch - a.orch || b.sessions - a.sessions)
2527
+ .map(p => ({
2528
+ phase: p.phase,
2529
+ sessions: p.sessions,
2530
+ sessions_with_sdk: p.sessions_with_sdk,
2531
+ sessions_missing_sdk: p.sessions - p.sessions_with_sdk,
2532
+ total: Number(p.orch.toFixed(6)),
2533
+ orch: Number(p.orch.toFixed(6)),
2534
+ sub: Number(p.sub.toFixed(6)),
2535
+ est: Number(p.est.toFixed(6)),
2536
+ }));
2537
+ if (!r.result) r.result = {};
2538
+ // Preserve provenance: the original shell-log cost (parseFloat'd from the
2539
+ // run_monitor line) is kept under cost_usd_from_log so an operator can
2540
+ // see what the wrapper reported vs what we recomputed.
2541
+ if (typeof r.result.cost_usd === 'number') {
2542
+ r.result.cost_usd_from_log = r.result.cost_usd;
2543
+ }
2544
+ // SDK-only displayed total. Sessions missing SDK contribute 0 — the
2545
+ // sessions_missing_sdk counter on each phase row is the only signal that
2546
+ // real spend went unrecorded.
2547
+ r.result.cost_breakdown = {
2548
+ total: Number(totalOrch.toFixed(6)),
2549
+ orchestrator: Number(totalOrch.toFixed(6)),
2550
+ subagent: Number(totalSub.toFixed(6)),
2551
+ estimated: Number(totalEst.toFixed(6)),
2552
+ sessions: sessionsAll,
2553
+ sessions_with_sdk: sessionsWithSdk,
2554
+ sessions_missing_sdk: sessionsAll - sessionsWithSdk,
2555
+ phases,
2556
+ };
2557
+ r.result.cost_usd = r.result.cost_breakdown.total;
2558
+ r.result.cost_usd_orchestrator = r.result.cost_breakdown.orchestrator;
2559
+ r.result.cost_usd_estimated = r.result.cost_breakdown.estimated;
2560
+ r.result.cost_usd_subagent = r.result.cost_breakdown.subagent;
2561
+ r.result.cost_sessions_missing_sdk = r.result.cost_breakdown.sessions_missing_sdk;
2562
+ }
2563
+ }
2564
+
2287
2565
  // DB-backed enrichment uses run.started_at and run.finished_at to define a
2288
2566
  // window, plus a small slack on each side for clock skew between the shell
2289
2567
  // trap that wrote the run_monitor line and the page row's completed_at.
@@ -3526,6 +3804,7 @@ async function handleApi(req, res) {
3526
3804
  await enrichPostCommentsTwitterRuns(runs);
3527
3805
  await enrichPostCommentsRedditRuns(runs);
3528
3806
  await enrichSeoRuns(runs);
3807
+ await enrichRunsCostBreakdown(runs);
3529
3808
  // Prepend in-progress pipelines so they appear at the top of the table.
3530
3809
  // Always included regardless of the hours window — a long-running job
3531
3810
  // started before the window is still relevant right now.
@@ -3632,28 +3911,37 @@ async function handleApi(req, res) {
3632
3911
  "), session_counts AS (" +
3633
3912
  "SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id" +
3634
3913
  "), session_cost AS (" +
3914
+ // SDK-only mode (2026-05-15): per_row_cost = orchestrator_cost_usd
3915
+ // alone, split evenly across activity rows. NULL when the wrapper
3916
+ // didn't pass --orchestrator-cost-usd (e.g. shell wrappers that omit
3917
+ // --output-format json so Claude never emits total_cost_usd). The
3918
+ // transcript estimate and subagent dollars are computed from a local
3919
+ // pricing table — kept in the JSON payload for diagnostics only,
3920
+ // never folded into the displayed total.
3635
3921
  "SELECT cs.session_id, " +
3636
- "(COALESCE(cs.orchestrator_cost_usd, cs.total_cost_usd) / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost, " +
3922
+ "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost, " +
3637
3923
  "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_orchestrator, " +
3638
- "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_estimated " +
3924
+ "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_estimated, " +
3925
+ "(cs.subagent_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_subagent " +
3639
3926
  "FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id" +
3640
3927
  ") " +
3641
3928
  "SELECT json_agg(row_to_json(r)) FROM (" +
3642
- "SELECT * FROM (SELECT posted_at AS occurred_at, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN 'posted_thread' ELSE 'posted_comment' END AS type, platform, our_account AS actor, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN COALESCE(thread_title, LEFT(our_content, 280)) ELSE LEFT(our_content, 280) END AS summary, engagement_style AS detail, our_url AS link, ('p' || posts.id) AS key, project_name AS project, sc.per_row_cost AS cost_usd, sc.per_row_cost_orchestrator AS cost_usd_orchestrator, sc.per_row_cost_estimated AS cost_usd_estimated, c.name AS campaign_name, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_title END AS context_title, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_url END AS context_url, LEFT(our_content, 3000) AS body FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id LEFT JOIN campaigns c ON c.id = posts.campaign_id WHERE posted_at IS NOT NULL AND our_content <> '(mention - no original post)' ORDER BY posted_at DESC LIMIT 150) x1 " +
3643
- "UNION ALL SELECT * FROM (SELECT r2.replied_at, 'replied', r2.platform, r2.their_author, COALESCE(LEFT(r2.our_reply_content, 280), LEFT(r2.their_content, 280)), CASE WHEN r2.is_recommendation THEN 'rec · ' || COALESCE(r2.engagement_style, '') ELSE r2.engagement_style END, r2.our_reply_url, ('r' || r2.id), p.project_name, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, c2.name, p.thread_title, p.thread_url, NULL::text FROM replies r2 LEFT JOIN posts p ON p.id = r2.post_id LEFT JOIN session_cost sc ON sc.session_id = r2.claude_session_id LEFT JOIN campaigns c2 ON c2.id = r2.campaign_id WHERE r2.status='replied' AND r2.replied_at IS NOT NULL ORDER BY r2.replied_at DESC LIMIT 150) x2 " +
3644
- "UNION ALL SELECT * FROM (SELECT COALESCE(r3.processing_at, r3.discovered_at), 'skipped', r3.platform, r3.their_author, LEFT(r3.their_content, 140), r3.skip_reason, r3.their_comment_url, ('s' || r3.id), p.project_name, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, c3.name, p.thread_title, p.thread_url, NULL::text FROM replies r3 LEFT JOIN posts p ON p.id = r3.post_id LEFT JOIN session_cost sc ON sc.session_id = r3.claude_session_id LEFT JOIN campaigns c3 ON c3.id = r3.campaign_id WHERE r3.status='skipped' ORDER BY COALESCE(r3.processing_at, r3.discovered_at) DESC LIMIT 150) x3 " +
3645
- "UNION ALL SELECT * FROM (SELECT COALESCE(source_timestamp, received_at), 'mention', platform, author, COALESCE(title, LEFT(body, 140)), sentiment, url, ('m' || id), NULL::text, NULL::numeric, NULL::numeric, NULL::numeric, NULL::text, NULL::text, NULL::text, NULL::text FROM octolens_mentions ORDER BY COALESCE(source_timestamp, received_at) DESC LIMIT 150) x4 " +
3646
- "UNION ALL SELECT * FROM (SELECT sent_at, 'dm_sent', platform, their_author, LEFT(our_dm_content, 140), NULL::text, chat_url, ('d' || dms.id), NULL::text, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM dms LEFT JOIN session_cost sc ON sc.session_id = dms.claude_session_id WHERE status='sent' AND sent_at IS NOT NULL ORDER BY sent_at DESC LIMIT 150) x5 " +
3647
- "UNION ALL SELECT * FROM (SELECT m.message_at, 'dm_reply_sent', d.platform, d.their_author, LEFT(m.content, 140), NULL::text, d.chat_url, ('dr' || m.id), NULL::text, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, c5.name, NULL::text, NULL::text, NULL::text FROM dm_messages m JOIN dms d ON d.id = m.dm_id LEFT JOIN session_cost sc ON sc.session_id = m.claude_session_id LEFT JOIN campaigns c5 ON c5.id = m.campaign_id WHERE m.direction = 'outbound' AND EXISTS (SELECT 1 FROM dm_messages m2 WHERE m2.dm_id = m.dm_id AND m2.direction = 'inbound' AND m2.message_at < m.message_at) ORDER BY m.message_at DESC LIMIT 150) x5b " +
3648
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_serp', 'seo', product, keyword, slug, page_url, ('k' || sk.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk LEFT JOIN session_cost sc ON sc.session_id = sk.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND COALESCE(source, '') NOT IN ('reddit', 'top_page', 'top_post', 'roundup') ORDER BY completed_at DESC LIMIT 150) x6 " +
3649
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_gsc', 'seo', product, query, page_slug, page_url, ('g' || gq.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM gsc_queries gq LEFT JOIN session_cost sc ON sc.session_id = gq.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL ORDER BY completed_at DESC LIMIT 150) x7 " +
3650
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_reddit', 'seo', product, keyword, slug, page_url, ('kr' || sk2.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk2 LEFT JOIN session_cost sc ON sc.session_id = sk2.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'reddit' ORDER BY completed_at DESC LIMIT 150) x8 " +
3651
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_top', 'seo', product, keyword, slug, page_url, ('kt' || sk3.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk3 LEFT JOIN session_cost sc ON sc.session_id = sk3.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'top_page' ORDER BY completed_at DESC LIMIT 150) x8b " +
3652
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_top_post', 'seo', product, keyword, slug, page_url, ('ktp' || sk5.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk5 LEFT JOIN session_cost sc ON sc.session_id = sk5.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'top_post' ORDER BY completed_at DESC LIMIT 150) x8tp " +
3653
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_roundup', 'seo', product, keyword, slug, page_url, ('kru' || sk4.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk4 LEFT JOIN session_cost sc ON sc.session_id = sk4.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'roundup' ORDER BY completed_at DESC LIMIT 150) x8r " +
3654
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_improved', 'seo', product, LEFT(COALESCE(rationale, diff_summary, page_path), 140), page_path, page_url, ('pi' || spi.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_page_improvements spi LEFT JOIN session_cost sc ON sc.session_id = spi.claude_session_id WHERE completed_at IS NOT NULL AND status = 'committed' ORDER BY completed_at DESC LIMIT 150) x8c " +
3655
- "UNION ALL SELECT * FROM (SELECT expired_at, 'page_expired', 'seo', product, regexp_replace(source_path, '^.*/', ''), 'imp=' || impressions_30d || ' clicks=0 age=' || COALESCE(file_age_days::int, 0) || 'd ' || COALESCE(reason,''), page_url, ('xp' || sep.id), product, NULL::numeric, NULL::numeric, NULL::numeric, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_expired_pages sep ORDER BY expired_at DESC LIMIT 150) x8d " +
3656
- "UNION ALL SELECT * FROM (SELECT resurrected_at AS occurred_at, 'resurrected' AS type, platform, our_account AS actor, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN COALESCE(thread_title, LEFT(our_content, 280)) ELSE LEFT(our_content, 280) END AS summary, NULL::text AS detail, our_url AS link, ('rr' || posts.id) AS key, project_name AS project, sc.per_row_cost AS cost_usd, sc.per_row_cost_orchestrator AS cost_usd_orchestrator, sc.per_row_cost_estimated AS cost_usd_estimated, c9.name AS campaign_name, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_title END AS context_title, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_url END AS context_url, LEFT(our_content, 3000) AS body FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id LEFT JOIN campaigns c9 ON c9.id = posts.campaign_id WHERE resurrected_at IS NOT NULL AND our_content <> '(mention - no original post)' ORDER BY resurrected_at DESC LIMIT 150) x9 " +
3929
+ "SELECT * FROM (SELECT posted_at AS occurred_at, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN 'posted_thread' ELSE 'posted_comment' END AS type, platform, our_account AS actor, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN COALESCE(thread_title, LEFT(our_content, 280)) ELSE LEFT(our_content, 280) END AS summary, engagement_style AS detail, our_url AS link, ('p' || posts.id) AS key, project_name AS project, sc.per_row_cost AS cost_usd, sc.per_row_cost_orchestrator AS cost_usd_orchestrator, sc.per_row_cost_estimated AS cost_usd_estimated, sc.per_row_cost_subagent AS cost_usd_subagent, c.name AS campaign_name, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_title END AS context_title, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_url END AS context_url, LEFT(our_content, 3000) AS body FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id LEFT JOIN campaigns c ON c.id = posts.campaign_id WHERE posted_at IS NOT NULL AND our_content <> '(mention - no original post)' ORDER BY posted_at DESC LIMIT 150) x1 " +
3930
+ "UNION ALL SELECT * FROM (SELECT r2.replied_at, 'replied', r2.platform, r2.their_author, COALESCE(LEFT(r2.our_reply_content, 280), LEFT(r2.their_content, 280)), CASE WHEN r2.is_recommendation THEN 'rec · ' || COALESCE(r2.engagement_style, '') ELSE r2.engagement_style END, r2.our_reply_url, ('r' || r2.id), p.project_name, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, c2.name, p.thread_title, p.thread_url, NULL::text FROM replies r2 LEFT JOIN posts p ON p.id = r2.post_id LEFT JOIN session_cost sc ON sc.session_id = r2.claude_session_id LEFT JOIN campaigns c2 ON c2.id = r2.campaign_id WHERE r2.status='replied' AND r2.replied_at IS NOT NULL ORDER BY r2.replied_at DESC LIMIT 150) x2 " +
3931
+ "UNION ALL SELECT * FROM (SELECT COALESCE(r3.processing_at, r3.discovered_at), 'skipped', r3.platform, r3.their_author, LEFT(r3.their_content, 140), r3.skip_reason, r3.their_comment_url, ('s' || r3.id), p.project_name, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, c3.name, p.thread_title, p.thread_url, NULL::text FROM replies r3 LEFT JOIN posts p ON p.id = r3.post_id LEFT JOIN session_cost sc ON sc.session_id = r3.claude_session_id LEFT JOIN campaigns c3 ON c3.id = r3.campaign_id WHERE r3.status='skipped' ORDER BY COALESCE(r3.processing_at, r3.discovered_at) DESC LIMIT 150) x3 " +
3932
+ "UNION ALL SELECT * FROM (SELECT COALESCE(source_timestamp, received_at), 'mention', platform, author, COALESCE(title, LEFT(body, 140)), sentiment, url, ('m' || id), NULL::text, NULL::numeric, NULL::numeric, NULL::numeric, NULL::numeric, NULL::text, NULL::text, NULL::text, NULL::text FROM octolens_mentions ORDER BY COALESCE(source_timestamp, received_at) DESC LIMIT 150) x4 " +
3933
+ "UNION ALL SELECT * FROM (SELECT sent_at, 'dm_sent', platform, their_author, LEFT(our_dm_content, 140), NULL::text, chat_url, ('d' || dms.id), NULL::text, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM dms LEFT JOIN session_cost sc ON sc.session_id = dms.claude_session_id WHERE status='sent' AND sent_at IS NOT NULL ORDER BY sent_at DESC LIMIT 150) x5 " +
3934
+ "UNION ALL SELECT * FROM (SELECT m.message_at, 'dm_reply_sent', d.platform, d.their_author, LEFT(m.content, 140), NULL::text, d.chat_url, ('dr' || m.id), NULL::text, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, c5.name, NULL::text, NULL::text, NULL::text FROM dm_messages m JOIN dms d ON d.id = m.dm_id LEFT JOIN session_cost sc ON sc.session_id = m.claude_session_id LEFT JOIN campaigns c5 ON c5.id = m.campaign_id WHERE m.direction = 'outbound' AND EXISTS (SELECT 1 FROM dm_messages m2 WHERE m2.dm_id = m.dm_id AND m2.direction = 'inbound' AND m2.message_at < m.message_at) ORDER BY m.message_at DESC LIMIT 150) x5b " +
3935
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_twitter', 'seo', product, keyword, slug, page_url, ('k' || sk.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk LEFT JOIN session_cost sc ON sc.session_id = sk.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'twitter' ORDER BY completed_at DESC LIMIT 150) x6 " +
3936
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_misc', 'seo', product, keyword, slug, page_url, ('km' || sk6.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk6 LEFT JOIN session_cost sc ON sc.session_id = sk6.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND COALESCE(source, '') NOT IN ('reddit', 'top_page', 'top_post', 'roundup', 'twitter') ORDER BY completed_at DESC LIMIT 150) x6m " +
3937
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_gsc', 'seo', product, query, page_slug, page_url, ('g' || gq.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM gsc_queries gq LEFT JOIN session_cost sc ON sc.session_id = gq.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL ORDER BY completed_at DESC LIMIT 150) x7 " +
3938
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_reddit', 'seo', product, keyword, slug, page_url, ('kr' || sk2.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk2 LEFT JOIN session_cost sc ON sc.session_id = sk2.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'reddit' ORDER BY completed_at DESC LIMIT 150) x8 " +
3939
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_top', 'seo', product, keyword, slug, page_url, ('kt' || sk3.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk3 LEFT JOIN session_cost sc ON sc.session_id = sk3.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'top_page' ORDER BY completed_at DESC LIMIT 150) x8b " +
3940
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_top_post', 'seo', product, keyword, slug, page_url, ('ktp' || sk5.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk5 LEFT JOIN session_cost sc ON sc.session_id = sk5.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'top_post' ORDER BY completed_at DESC LIMIT 150) x8tp " +
3941
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_roundup', 'seo', product, keyword, slug, page_url, ('kru' || sk4.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk4 LEFT JOIN session_cost sc ON sc.session_id = sk4.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'roundup' ORDER BY completed_at DESC LIMIT 150) x8r " +
3942
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_improved', 'seo', product, LEFT(COALESCE(rationale, diff_summary, page_path), 140), page_path, page_url, ('pi' || spi.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_page_improvements spi LEFT JOIN session_cost sc ON sc.session_id = spi.claude_session_id WHERE completed_at IS NOT NULL AND status = 'committed' ORDER BY completed_at DESC LIMIT 150) x8c " +
3943
+ "UNION ALL SELECT * FROM (SELECT expired_at, 'page_expired', 'seo', product, regexp_replace(source_path, '^.*/', ''), 'imp=' || impressions_30d || ' clicks=0 age=' || COALESCE(file_age_days::int, 0) || 'd ' || COALESCE(reason,''), page_url, ('xp' || sep.id), product, NULL::numeric, NULL::numeric, NULL::numeric, NULL::numeric, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_expired_pages sep ORDER BY expired_at DESC LIMIT 150) x8d " +
3944
+ "UNION ALL SELECT * FROM (SELECT resurrected_at AS occurred_at, 'resurrected' AS type, platform, our_account AS actor, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN COALESCE(thread_title, LEFT(our_content, 280)) ELSE LEFT(our_content, 280) END AS summary, NULL::text AS detail, our_url AS link, ('rr' || posts.id) AS key, project_name AS project, sc.per_row_cost AS cost_usd, sc.per_row_cost_orchestrator AS cost_usd_orchestrator, sc.per_row_cost_estimated AS cost_usd_estimated, sc.per_row_cost_subagent AS cost_usd_subagent, c9.name AS campaign_name, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_title END AS context_title, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_url END AS context_url, LEFT(our_content, 3000) AS body FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id LEFT JOIN campaigns c9 ON c9.id = posts.campaign_id WHERE resurrected_at IS NOT NULL AND our_content <> '(mention - no original post)' ORDER BY resurrected_at DESC LIMIT 150) x9 " +
3657
3945
  "ORDER BY 1 DESC LIMIT 500) r";
3658
3946
  return (async () => {
3659
3947
  const rows = await pq(q);
@@ -3668,6 +3956,7 @@ async function handleApi(req, res) {
3668
3956
  delete e.cost_usd;
3669
3957
  delete e.cost_usd_orchestrator;
3670
3958
  delete e.cost_usd_estimated;
3959
+ delete e.cost_usd_subagent;
3671
3960
  });
3672
3961
  }
3673
3962
  return json(res, { events });
@@ -4158,7 +4447,16 @@ async function handleApi(req, res) {
4158
4447
  // started with a prospect."
4159
4448
  parts.push("SELECT 'dm_sent' AS type, d.platform AS pl FROM dms d WHERE EXISTS (SELECT 1 FROM dm_messages m WHERE m.dm_id = d.id AND m.direction='outbound' AND m.message_at >= NOW() - " + win + " AND NOT EXISTS (SELECT 1 FROM dm_messages m2 WHERE m2.dm_id = d.id AND m2.direction='outbound' AND m2.message_at < m.message_at))" + dmsAliasedPc.clause);
4160
4449
  parts.push("SELECT 'dm_reply_sent' AS type, d.platform AS pl FROM dm_messages m JOIN dms d ON d.id = m.dm_id WHERE m.direction='outbound' AND m.message_at >= NOW() - " + win + " AND EXISTS (SELECT 1 FROM dm_messages m2 WHERE m2.dm_id = m.dm_id AND m2.direction='inbound' AND m2.message_at < m.message_at)" + dmsAliasedPc.clause);
4161
- parts.push("SELECT 'page_published_serp' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND COALESCE(source, '') NOT IN ('reddit', 'top_page', 'top_post', 'roundup')" + seoProdPc.clause);
4450
+ // Pre-2026-05-16: a single 'page_published_serp' bucket caught every
4451
+ // seo_keywords row whose source was not (reddit, top_page, top_post,
4452
+ // roundup). The real SERP pipeline was unloaded 2026-04-17; the cards
4453
+ // labelled "SERP SEO" since then have actually been Twitter-cycle
4454
+ // page-gen output (twitter_gen_links.py -> generate_page.py --trigger
4455
+ // twitter, source='twitter'). Split into an honest twitter bucket and a
4456
+ // misc catch-all for legacy/edge sources (existing_page, gsc,
4457
+ // suggestion:*, competitor:*, topic_template, etc).
4458
+ parts.push("SELECT 'page_published_twitter' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND source = 'twitter'" + seoProdPc.clause);
4459
+ parts.push("SELECT 'page_published_misc' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND COALESCE(source, '') NOT IN ('reddit', 'top_page', 'top_post', 'roundup', 'twitter')" + seoProdPc.clause);
4162
4460
  parts.push("SELECT 'page_published_gsc' AS type, 'seo' AS pl FROM gsc_queries WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL" + seoProdPc.clause);
4163
4461
  parts.push("SELECT 'page_published_reddit' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND source='reddit'" + seoProdPc.clause);
4164
4462
  parts.push("SELECT 'page_published_top' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND source='top_page'" + seoProdPc.clause);
@@ -4354,10 +4652,18 @@ async function handleApi(req, res) {
4354
4652
  const projectFilter = project
4355
4653
  ? " AND p.project_name = '" + project.replace(/'/g, "''") + "'"
4356
4654
  : '';
4655
+ // Split posts_made into threads_made (we authored the thread itself) vs
4656
+ // comments_made (we engaged on someone else's thread). Matches the
4657
+ // /api/activity classifier: thread iff thread_url = our_url AND
4658
+ // (thread_author IS NULL OR thread_author = our_account).
4659
+ const threadClause =
4660
+ "p.thread_url = p.our_url AND (p.thread_author IS NULL OR p.thread_author = p.our_account)";
4357
4661
  const q =
4358
4662
  "SELECT json_agg(row_to_json(r)) FROM (" +
4359
4663
  "SELECT to_char((p.posted_at AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD') AS day, " +
4360
- "COUNT(*)::bigint AS posts_made " +
4664
+ "COUNT(*)::bigint AS posts_made, " +
4665
+ "SUM(CASE WHEN " + threadClause + " THEN 1 ELSE 0 END)::bigint AS threads_made, " +
4666
+ "SUM(CASE WHEN " + threadClause + " THEN 0 ELSE 1 END)::bigint AS comments_made " +
4361
4667
  "FROM posts p " +
4362
4668
  "WHERE p.posted_at IS NOT NULL " +
4363
4669
  "AND p.posted_at >= CURRENT_DATE - INTERVAL '" + days + " days' " +
@@ -4506,7 +4812,8 @@ async function handleApi(req, res) {
4506
4812
  const sumCols =
4507
4813
  "COALESCE(SUM(sc.per_row_cost), 0)::numeric(12,4) AS total_cost_usd, " +
4508
4814
  "COALESCE(SUM(sc.per_row_cost_orchestrator), 0)::numeric(12,4) AS total_cost_usd_orchestrator, " +
4509
- "COALESCE(SUM(sc.per_row_cost_estimated), 0)::numeric(12,4) AS total_cost_usd_estimated";
4815
+ "COALESCE(SUM(sc.per_row_cost_estimated), 0)::numeric(12,4) AS total_cost_usd_estimated, " +
4816
+ "COALESCE(SUM(sc.per_row_cost_subagent), 0)::numeric(12,4) AS total_cost_usd_subagent";
4510
4817
  if (includeThread) {
4511
4818
  rowQueries.push(
4512
4819
  "SELECT 'thread' AS type, COUNT(*)::int AS count, " + sumCols + " " +
@@ -4545,15 +4852,46 @@ async function handleApi(req, res) {
4545
4852
  "WITH src AS (" + parts.join(' UNION ALL ') + "), " +
4546
4853
  "session_counts AS (SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id), " +
4547
4854
  "session_cost AS (SELECT cs.session_id, " +
4548
- "(COALESCE(cs.orchestrator_cost_usd, cs.total_cost_usd) / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
4855
+ "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
4549
4856
  "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_orchestrator, " +
4550
- "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated " +
4857
+ "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated, " +
4858
+ "(cs.subagent_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_subagent " +
4551
4859
  "FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id) " +
4552
4860
  "SELECT json_agg(row_to_json(r)) FROM (" + rowQueries.join(' UNION ALL ') + ") r";
4861
+ // Per-phase (script) cost rollup over the same window. Groups every
4862
+ // claude_sessions row in the window by its `script` column (e.g.
4863
+ // run-twitter-cycle-scan, post_reddit, seo_generate_page) and surfaces
4864
+ // total + orchestrator + subagent + estimate lanes. Independent of the
4865
+ // activity-type rollup above: a single post_reddit session might produce
4866
+ // 0 or 1 thread row, but its cost still shows up in the per-phase view.
4867
+ // SDK-only per-phase rollup. total_cost_usd = SUM(orchestrator_cost_usd)
4868
+ // — no transcript estimate, no subagent fold-in. Phases with 0% SDK
4869
+ // coverage (wrapper doesn't capture --orchestrator-cost-usd) show
4870
+ // total $0 but a non-zero sessions_missing_sdk count, which is the
4871
+ // signal to investigate. Also surfaces the estimate and subagent
4872
+ // columns as diagnostic-only fields.
4873
+ const phaseQ =
4874
+ "SELECT script AS phase, " +
4875
+ "COUNT(*)::int AS sessions, " +
4876
+ "COUNT(orchestrator_cost_usd)::int AS sessions_with_sdk, " +
4877
+ "(COUNT(*) - COUNT(orchestrator_cost_usd))::int AS sessions_missing_sdk, " +
4878
+ "COALESCE(SUM(orchestrator_cost_usd), 0)::numeric(12,4) AS total_cost_usd, " +
4879
+ "COALESCE(SUM(orchestrator_cost_usd), 0)::numeric(12,4) AS total_cost_usd_orchestrator, " +
4880
+ "COALESCE(SUM(total_cost_usd), 0)::numeric(12,4) AS total_cost_usd_estimated, " +
4881
+ "COALESCE(SUM(subagent_cost_usd), 0)::numeric(12,4) AS total_cost_usd_subagent " +
4882
+ "FROM claude_sessions " +
4883
+ "WHERE started_at >= NOW() - " + win + " " +
4884
+ "GROUP BY script " +
4885
+ "HAVING COUNT(*) > 0 " +
4886
+ "ORDER BY total_cost_usd DESC, sessions DESC " +
4887
+ "LIMIT 50";
4553
4888
  return (async () => {
4554
4889
  const dbRows = await pq(q);
4555
4890
  const value = (dbRows && dbRows.length && dbRows[0].json_agg) ? dbRows[0].json_agg : [];
4556
- return json(res, { windowHours, platform: plat || 'all', rows: value });
4891
+ let phases = [];
4892
+ try { phases = await pq(phaseQ) || []; }
4893
+ catch (e) { console.error('[cost/stats] phase query failed:', e && e.message || e); }
4894
+ return json(res, { windowHours, platform: plat || 'all', rows: value, phases });
4557
4895
  })().catch(e => json(res, { error: e.message }, 500));
4558
4896
  }
4559
4897
 
@@ -4674,7 +5012,7 @@ async function handleApi(req, res) {
4674
5012
  "WITH src AS (" + parts.join(' UNION ALL ') + "), " +
4675
5013
  "session_counts AS (SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id), " +
4676
5014
  "session_cost AS (SELECT cs.session_id, " +
4677
- "(COALESCE(cs.orchestrator_cost_usd, cs.total_cost_usd) / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost " +
5015
+ "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost " +
4678
5016
  "FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id), " +
4679
5017
  "in_window AS (" + inWindow.join(' UNION ALL ') + ") " +
4680
5018
  "SELECT json_agg(row_to_json(r)) FROM (" +
@@ -5181,10 +5519,47 @@ async function handleApi(req, res) {
5181
5519
  const pc = auth.projectClause(req.user, 'project_name', url.searchParams.get('project'));
5182
5520
  if (!pc.ok) return json(res, { posts: [], window: windowKey, platform: platformFilter || 'all', kind: kindFilter });
5183
5521
  if (pc.clause) whereParts.push(pc.clause.replace(/^\s*AND\s+/, ''));
5522
+ // 2026-05-18 dashboard parity for Reddit DM-rail follow-ups.
5523
+ // Reply-to-reply rows (we replied back to someone who replied to our
5524
+ // top-level comment) live in the `replies` table, not `posts`, so they
5525
+ // were invisible on the Top tab even though they have real engagement
5526
+ // (some routinely break 1000 views). LinkedIn migrated the analogous
5527
+ // data into `posts` on 2026-05-11, but the (platform, thread_url) dedup
5528
+ // on /api/v1/posts blocks the same migration for Reddit (most reply-
5529
+ // to-replies share thread_url with our top-level post in the same
5530
+ // thread). UNION-into-/api/top sidesteps that entirely: the `replies`
5531
+ // table stays the source of truth, and the Top tab surfaces both
5532
+ // surfaces. The activity feed (line ~3919) already filters
5533
+ // r.status='replied' so reply-to-replies appear ONCE there.
5534
+ //
5535
+ // Build the same WHERE clause for the replies branch, but referencing
5536
+ // `r2.replied_at` for the time window and `parent.thread_url` semantics
5537
+ // for the kind filter. Replies are by definition never threads, so
5538
+ // kind='threads' must exclude the whole branch.
5539
+ const repliesPc = auth.projectClause(req.user, 'parent.project_name', url.searchParams.get('project'));
5540
+ const repliesWhere = [
5541
+ "r2.platform = 'reddit'", // only Reddit has the gap today
5542
+ "r2.status = 'replied'",
5543
+ "r2.our_reply_id IS NOT NULL",
5544
+ "r2.our_reply_url IS NOT NULL",
5545
+ "r2.our_reply_content IS NOT NULL AND LENGTH(r2.our_reply_content) >= 30",
5546
+ ];
5547
+ if (windowHours != null) {
5548
+ repliesWhere.push("r2.replied_at >= NOW() - INTERVAL '" + windowHours + " hours'");
5549
+ }
5550
+ if (platformFilter && platformFilter !== 'reddit') {
5551
+ // Caller filtered to a non-Reddit platform; replies branch yields nothing.
5552
+ repliesWhere.push("FALSE");
5553
+ }
5554
+ // kind: 'threads' excludes replies entirely; 'comments' and 'all' include.
5555
+ if (kindFilter === 'threads') {
5556
+ repliesWhere.push("FALSE");
5557
+ }
5558
+ if (repliesPc.clause) repliesWhere.push(repliesPc.clause.replace(/^\s*AND\s+/, ''));
5184
5559
  // Moltbook and GitHub have no views metric; return NULL for those so the UI can
5185
5560
  // render a dash instead of a misleading 0. Score still uses COALESCE so they
5186
5561
  // rank alongside other platforms based on upvotes + comments only.
5187
- const q = "SELECT json_agg(row_to_json(r)) FROM (" +
5562
+ const postsBranch =
5188
5563
  "SELECT posts.id, posts.platform, " +
5189
5564
  // Upvotes are reported NET on Reddit/Moltbook (both auto-apply a +1 OP
5190
5565
  // self-upvote on every post). Strip it per row, clamped at 0 so
@@ -5216,7 +5591,8 @@ async function handleApi(req, res) {
5216
5591
  "COALESCE(pl.bot_clicks, 0)::int AS link_bot_clicks, " +
5217
5592
  "COALESCE(pl.backfill_real, 0)::int AS link_backfill_real, " +
5218
5593
  "COALESCE(pl.link_count, 0)::int AS link_count, " +
5219
- "pl.first_code AS link_code " +
5594
+ "pl.first_code AS link_code, " +
5595
+ "'post'::text AS row_kind " +
5220
5596
  "FROM posts LEFT JOIN campaigns c ON c.id = posts.campaign_id " +
5221
5597
  // pl rollup: legacy `total_clicks` reads the post_links.clicks integer
5222
5598
  // (humans-only after 2026-05-07; pre-existing rows include bots).
@@ -5245,6 +5621,50 @@ async function handleApi(req, res) {
5245
5621
  ") pl ON pl.post_id = posts.id " +
5246
5622
  "WHERE " + whereParts.join(' AND ') + " " +
5247
5623
  "ORDER BY upvotes DESC NULLS LAST, comments_count DESC NULLS LAST, views DESC NULLS LAST " +
5624
+ "LIMIT " + limit;
5625
+ // Replies branch: shape-compatible SELECT against `replies` JOIN `posts` (parent)
5626
+ // for thread context. ID is negated to guarantee uniqueness within the UNION
5627
+ // (posts.id and replies.id are independent sequences and would otherwise
5628
+ // collide for sort/key purposes). The FE only uses `id` as a React key, so
5629
+ // negative integers are fine.
5630
+ const repliesBranch =
5631
+ "SELECT (-r2.id)::int AS id, r2.platform, " +
5632
+ // Reddit-only branch today; strip the OP self-upvote like the posts branch.
5633
+ "GREATEST(0, COALESCE(r2.upvotes, 0) - 1)::int AS upvotes, " +
5634
+ "COALESCE(r2.comments_count, 0)::int AS comments_count, " +
5635
+ "COALESCE(r2.views, 0)::int AS views, " +
5636
+ // Same score formula. Views (Reddit only) contribute /100.
5637
+ "(COALESCE(r2.comments_count,0) * 5 " +
5638
+ "+ GREATEST(0, COALESCE(r2.upvotes,0) - 1) * 5 " +
5639
+ "+ COALESCE(r2.views,0) / 100)::int AS score, " +
5640
+ "FALSE AS is_thread, " +
5641
+ "r2.replied_at AS posted_at, " +
5642
+ "r2.engagement_updated_at, " +
5643
+ "r2.our_reply_content AS our_content, " +
5644
+ "r2.our_reply_url AS our_url, " +
5645
+ "parent.thread_url, parent.thread_title, " +
5646
+ "LEFT(COALESCE(parent.thread_content, ''), 400) AS thread_content, " +
5647
+ "parent.our_account, parent.project_name, " +
5648
+ "r2.engagement_style, r2.is_recommendation, " +
5649
+ "cr.name AS campaign_name, " +
5650
+ // No link tracking on reply-to-replies yet (we don't usually drop a CTA
5651
+ // there). All link counters return 0 so they sort to the bottom of
5652
+ // any link-clicks ordering.
5653
+ "0::int AS link_clicks, 0::int AS link_real_clicks, " +
5654
+ "0::int AS link_bot_clicks, 0::int AS link_backfill_real, " +
5655
+ "0::int AS link_count, NULL::text AS link_code, " +
5656
+ "'reply'::text AS row_kind " +
5657
+ "FROM replies r2 " +
5658
+ "LEFT JOIN posts parent ON parent.id = r2.post_id " +
5659
+ "LEFT JOIN campaigns cr ON cr.id = r2.campaign_id " +
5660
+ "WHERE " + repliesWhere.join(' AND ') + " " +
5661
+ "ORDER BY r2.upvotes DESC NULLS LAST, r2.comments_count DESC NULLS LAST, r2.views DESC NULLS LAST " +
5662
+ "LIMIT " + limit;
5663
+ const q = "SELECT json_agg(row_to_json(r)) FROM (" +
5664
+ "SELECT * FROM (" + postsBranch + ") posts_branch " +
5665
+ "UNION ALL " +
5666
+ "SELECT * FROM (" + repliesBranch + ") replies_branch " +
5667
+ "ORDER BY upvotes DESC NULLS LAST, comments_count DESC NULLS LAST, views DESC NULLS LAST " +
5248
5668
  "LIMIT " + limit +
5249
5669
  ") r";
5250
5670
  return (async () => {
@@ -5254,6 +5674,134 @@ async function handleApi(req, res) {
5254
5674
  })().catch(e => json(res, { error: e.message }, 500));
5255
5675
  }
5256
5676
 
5677
+ // GET /api/top/destinations - post links rolled up by target URL.
5678
+ // One row per unique destination URL (e.g. https://s4l.ai vs
5679
+ // https://s4l.ai/ghostwriting vs https://s4l.ai/t/<slug>), with click totals
5680
+ // aggregated across every short code that pointed at that URL. Used by the
5681
+ // "Links" subtab in the Top tab to answer "where are my posts sending
5682
+ // traffic and how many clicks does each destination get?"
5683
+ if (p === '/api/top/destinations' && req.method === 'GET') {
5684
+ const url = new URL(req.url, 'http://localhost');
5685
+ const WINDOW_HOURS = { '24h': 24, '7d': 24*7, '14d': 24*14, '30d': 24*30, '90d': 24*90, 'all': null };
5686
+ const rawWindow = String(url.searchParams.get('window') || '7d').toLowerCase();
5687
+ const windowKey = Object.prototype.hasOwnProperty.call(WINDOW_HOURS, rawWindow) ? rawWindow : '7d';
5688
+ const windowHours = WINDOW_HOURS[windowKey];
5689
+ const rawPlatform = String(url.searchParams.get('platform') || '').toLowerCase().trim();
5690
+ const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'github']);
5691
+ const platformFilter = ALLOWED_PLATFORMS.has(rawPlatform) ? rawPlatform : '';
5692
+ const pc = auth.projectClause(req.user, 'pl.project_name', url.searchParams.get('project'));
5693
+ if (!pc.ok) return json(res, { destinations: [], window: windowKey, platform: 'all' });
5694
+ const limit = Math.max(50, Math.min(500, parseInt(url.searchParams.get('limit') || '200', 10) || 200));
5695
+ const whereParts = [];
5696
+ if (windowHours != null) {
5697
+ whereParts.push("pl.minted_at >= NOW() - INTERVAL '" + windowHours + " hours'");
5698
+ }
5699
+ if (platformFilter) {
5700
+ whereParts.push("LOWER(pl.platform) = '" + platformFilter + "'");
5701
+ }
5702
+ if (pc.clause) whereParts.push(pc.clause.replace(/^\s*AND\s+/, ''));
5703
+ const whereSql = whereParts.length ? ('WHERE ' + whereParts.join(' AND ')) : '';
5704
+ // Grouping key is the URL with all query params + trailing slash stripped.
5705
+ // UTM params (utm_source / utm_medium / utm_campaign / utm_term /
5706
+ // utm_content) are baked into every target_url at mint time so each
5707
+ // post gets its own uniquely-tracked URL even when posting at the same
5708
+ // destination; without stripping, the same homepage would split into
5709
+ // 50 rows. Path is preserved (so /ghostwriting stays distinct from /).
5710
+ // Project + platform stay in GROUP BY so a multi-project repo (one
5711
+ // website hosting pages for several projects) keeps them on separate
5712
+ // rows.
5713
+ const q = "SELECT json_agg(row_to_json(r)) FROM (" +
5714
+ "SELECT " +
5715
+ "REGEXP_REPLACE(REGEXP_REPLACE(pl.target_url, '\\?.*$', ''), '/$', '') AS target_url, " +
5716
+ "pl.project_name, pl.platform, " +
5717
+ "COUNT(DISTINCT pl.post_id)::int AS posts, " +
5718
+ "COUNT(*)::int AS codes, " +
5719
+ "COALESCE(SUM(pl.clicks), 0)::int AS legacy_clicks, " +
5720
+ "COALESCE(SUM(plc.real_clicks), 0)::int AS real_clicks, " +
5721
+ "COALESCE(SUM(plc.bot_clicks), 0)::int AS bot_clicks, " +
5722
+ "COALESCE(SUM(pl.real_clicks), 0)::int AS backfill_real, " +
5723
+ "MIN(pl.minted_at) AS first_minted_at, " +
5724
+ "MAX(pl.last_click_at) AS last_click_at " +
5725
+ "FROM post_links pl " +
5726
+ "LEFT JOIN (" +
5727
+ "SELECT code, " +
5728
+ " COUNT(*) FILTER (WHERE is_bot = false)::int AS real_clicks, " +
5729
+ " COUNT(*) FILTER (WHERE is_bot = true)::int AS bot_clicks " +
5730
+ "FROM post_link_clicks GROUP BY code" +
5731
+ ") plc ON plc.code = pl.code " +
5732
+ whereSql + " " +
5733
+ "GROUP BY 1, pl.project_name, pl.platform " +
5734
+ "ORDER BY real_clicks DESC NULLS LAST, legacy_clicks DESC NULLS LAST, codes DESC " +
5735
+ "LIMIT " + limit +
5736
+ ") r";
5737
+ return (async () => {
5738
+ const rows = await pq(q);
5739
+ const destinations = (rows && rows.length && rows[0].json_agg) ? rows[0].json_agg : [];
5740
+ // Server-side classification of each destination URL into a kind bucket
5741
+ // and (when applicable) the audience-page angle. Reads config.json
5742
+ // once per request to look up each project's website host + audience
5743
+ // pages list; classification is plain hostname / path matching. Done
5744
+ // here so every consumer (UI, future CSV exports) sees the same
5745
+ // canonical label without having to re-implement classify logic.
5746
+ let cfg = null;
5747
+ try {
5748
+ cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
5749
+ } catch (_e) { cfg = { projects: [] }; }
5750
+ const projIdx = {};
5751
+ for (const p of (cfg && cfg.projects) || []) {
5752
+ if (!p || !p.name) continue;
5753
+ const host = String(p.website || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase();
5754
+ const audience = ((p.landing_pages || {}).audience_pages) || [];
5755
+ const apIdx = [];
5756
+ for (const a of audience) {
5757
+ try {
5758
+ const u = new URL(a.url);
5759
+ apIdx.push({
5760
+ angle: a.angle,
5761
+ host: (u.hostname || '').toLowerCase().replace(/^www\./, ''),
5762
+ path: (u.pathname || '/').replace(/\/+$/, '') || '/',
5763
+ });
5764
+ } catch (_e) {}
5765
+ }
5766
+ projIdx[p.name] = { website_host: host, audience_pages: apIdx };
5767
+ }
5768
+ const classify = (targetUrl, projectName) => {
5769
+ if (!targetUrl) return { kind: 'other', audience_page_angle: null };
5770
+ let host = '', pathName = '/';
5771
+ try {
5772
+ const u = new URL(targetUrl);
5773
+ host = (u.hostname || '').toLowerCase().replace(/^www\./, '');
5774
+ pathName = (u.pathname || '/').replace(/\/+$/, '') || '/';
5775
+ } catch (_e) { return { kind: 'other', audience_page_angle: null }; }
5776
+ if (/(^|\.)cal\.com$/.test(host) || /(^|\.)calendly\.com$/.test(host)) return { kind: 'booking', audience_page_angle: null };
5777
+ if (host === 'github.com') return { kind: 'github', audience_page_angle: null };
5778
+ const entry = projectName ? projIdx[projectName] : null;
5779
+ // Audience-page exact host+path match wins over generic SUBPAGE.
5780
+ if (entry) {
5781
+ for (const ap of entry.audience_pages || []) {
5782
+ if (ap.host === host && ap.path === pathName) {
5783
+ return { kind: 'audience_page', audience_page_angle: ap.angle || null };
5784
+ }
5785
+ }
5786
+ if (entry.website_host && host === entry.website_host) {
5787
+ if (pathName === '/' || pathName === '') return { kind: 'home', audience_page_angle: null };
5788
+ if (/^\/t\//.test(pathName)) return { kind: 'seo', audience_page_angle: null };
5789
+ return { kind: 'subpage', audience_page_angle: null };
5790
+ }
5791
+ }
5792
+ if (pathName === '/' || pathName === '') return { kind: 'other', audience_page_angle: null };
5793
+ if (/^\/t\//.test(pathName)) return { kind: 'seo', audience_page_angle: null };
5794
+ return { kind: 'external', audience_page_angle: null };
5795
+ };
5796
+ for (const d of destinations) {
5797
+ const c = classify(d.target_url, d.project_name);
5798
+ d.kind = c.kind;
5799
+ d.audience_page_angle = c.audience_page_angle;
5800
+ }
5801
+ return json(res, { destinations, window: windowKey, platform: platformFilter || 'all' });
5802
+ })().catch(e => json(res, { error: e.message }, 500));
5803
+ }
5804
+
5257
5805
  // GET /api/top/links - post short links ranked by click count.
5258
5806
  // Queries post_links joined with posts so the content snippet is available.
5259
5807
  // Returns links with >= 1 click, ordered by clicks desc. Used by the "Links"
@@ -5486,11 +6034,23 @@ async function handleApi(req, res) {
5486
6034
  "AND query IS NOT NULL AND length(trim(query)) > 0 " +
5487
6035
  "), " +
5488
6036
  "cand AS ( " +
5489
- "SELECT 'twitter' AS platform, c.search_topic AS query, " +
6037
+ // Twitter: c.search_topic is the SEED concept (e.g. "vibe coding") while
6038
+ // twitter_search_attempts.query is the literal X advanced-search string
6039
+ // (e.g. '("vibe coded" OR ...) min_faves:30 since:... -filter:replies').
6040
+ // The two never line up textually, so we associate each candidate to
6041
+ // its parent attempt via (batch_id, project_name) and project the
6042
+ // attempt's `query` as the join key. When one project ran multiple
6043
+ // queries in the same batch (~1.5% of cases), this attributes each
6044
+ // candidate to all of them — known minor over-attribution, acceptable
6045
+ // until we add a per-attempt seed column.
6046
+ "SELECT 'twitter' AS platform, a.query, " +
5490
6047
  "COALESCE(c.matched_project, '(none)') AS project_name, c.post_id " +
5491
6048
  "FROM twitter_candidates c " +
6049
+ "JOIN twitter_search_attempts a " +
6050
+ "ON a.batch_id = c.batch_id " +
6051
+ "AND COALESCE(a.project_name, '(none)') = COALESCE(c.matched_project, '(none)') " +
5492
6052
  "WHERE c.discovered_at >= NOW() - INTERVAL '" + windowHours + " hours' " +
5493
- "AND c.search_topic IS NOT NULL " +
6053
+ "AND c.batch_id IS NOT NULL " +
5494
6054
  "UNION ALL " +
5495
6055
  "SELECT 'linkedin', c.search_query, COALESCE(c.matched_project, '(none)'), c.post_id " +
5496
6056
  "FROM linkedin_candidates c " +
@@ -5680,7 +6240,7 @@ async function handleApi(req, res) {
5680
6240
  // suppresses the column. SDK and estimate lanes are surfaced separately
5681
6241
  // so the dashboard tooltip can show both, same UX as cost-stats.
5682
6242
  let costByProject = {};
5683
- let grandCost = 0, grandCostOrch = 0, grandCostEst = 0;
6243
+ let grandCost = 0, grandCostOrch = 0, grandCostEst = 0, grandCostSub = 0;
5684
6244
  if (req.user && req.user.admin) {
5685
6245
  const costSrcParts = [
5686
6246
  "SELECT claude_session_id FROM posts WHERE claude_session_id IS NOT NULL AND posted_at IS NOT NULL",
@@ -5694,24 +6254,24 @@ async function handleApi(req, res) {
5694
6254
  const costWin = "INTERVAL '" + hours + " hours'";
5695
6255
  const costAttributed = [
5696
6256
  "SELECT COALESCE(posts.project_name, '(none)') AS project, " +
5697
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6257
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5698
6258
  "FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id " +
5699
6259
  "WHERE posts.posted_at >= NOW() - " + costWin + " " +
5700
6260
  "AND posts.our_content <> '(mention - no original post)'",
5701
6261
  "SELECT COALESCE(replies.project_name, '(none)') AS project, " +
5702
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6262
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5703
6263
  "FROM replies LEFT JOIN session_cost sc ON sc.session_id = replies.claude_session_id " +
5704
6264
  "WHERE replies.status='replied' AND replies.replied_at >= NOW() - " + costWin,
5705
6265
  "SELECT COALESCE(dms.target_project, '(none)') AS project, " +
5706
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6266
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5707
6267
  "FROM dms LEFT JOIN session_cost sc ON sc.session_id = dms.claude_session_id " +
5708
6268
  "WHERE dms.status='sent' AND dms.sent_at >= NOW() - " + costWin,
5709
6269
  "SELECT COALESCE(seo_keywords.product, '(none)') AS project, " +
5710
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6270
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5711
6271
  "FROM seo_keywords LEFT JOIN session_cost sc ON sc.session_id = seo_keywords.claude_session_id " +
5712
6272
  "WHERE seo_keywords.completed_at >= NOW() - " + costWin + " AND seo_keywords.page_url IS NOT NULL",
5713
6273
  "SELECT COALESCE(gsc_queries.product, '(none)') AS project, " +
5714
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6274
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5715
6275
  "FROM gsc_queries LEFT JOIN session_cost sc ON sc.session_id = gsc_queries.claude_session_id " +
5716
6276
  "WHERE gsc_queries.completed_at >= NOW() - " + costWin + " AND gsc_queries.page_url IS NOT NULL",
5717
6277
  ];
@@ -5719,15 +6279,17 @@ async function handleApi(req, res) {
5719
6279
  "WITH src AS (" + costSrcParts.join(' UNION ALL ') + "), " +
5720
6280
  "session_counts AS (SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id), " +
5721
6281
  "session_cost AS (SELECT cs.session_id, " +
5722
- "(COALESCE(cs.orchestrator_cost_usd, cs.total_cost_usd) / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
6282
+ "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
5723
6283
  "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_orchestrator, " +
5724
- "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated " +
6284
+ "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated, " +
6285
+ "(cs.subagent_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_subagent " +
5725
6286
  "FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id), " +
5726
6287
  "attributed AS (" + costAttributed.join(' UNION ALL ') + ") " +
5727
6288
  "SELECT project, " +
5728
6289
  "COALESCE(SUM(per_row_cost), 0)::numeric(12,4) AS cost_usd, " +
5729
6290
  "COALESCE(SUM(per_row_cost_orchestrator), 0)::numeric(12,4) AS cost_usd_orchestrator, " +
5730
- "COALESCE(SUM(per_row_cost_estimated), 0)::numeric(12,4) AS cost_usd_estimated " +
6291
+ "COALESCE(SUM(per_row_cost_estimated), 0)::numeric(12,4) AS cost_usd_estimated, " +
6292
+ "COALESCE(SUM(per_row_cost_subagent), 0)::numeric(12,4) AS cost_usd_subagent " +
5731
6293
  "FROM attributed GROUP BY project";
5732
6294
  try {
5733
6295
  const costRows = await pq(costQ) || [];
@@ -5736,10 +6298,12 @@ async function handleApi(req, res) {
5736
6298
  const c = Number(r.cost_usd) || 0;
5737
6299
  const co = Number(r.cost_usd_orchestrator) || 0;
5738
6300
  const ce = Number(r.cost_usd_estimated) || 0;
5739
- costByProject[proj] = { cost_usd: c, cost_usd_orchestrator: co, cost_usd_estimated: ce };
6301
+ const cs = Number(r.cost_usd_subagent) || 0;
6302
+ costByProject[proj] = { cost_usd: c, cost_usd_orchestrator: co, cost_usd_estimated: ce, cost_usd_subagent: cs };
5740
6303
  grandCost += c;
5741
6304
  grandCostOrch += co;
5742
6305
  grandCostEst += ce;
6306
+ grandCostSub += cs;
5743
6307
  });
5744
6308
  } catch (e) {
5745
6309
  // Soft fail: log and continue without cost data. Don't block the
@@ -5753,6 +6317,7 @@ async function handleApi(req, res) {
5753
6317
  r.cost_usd = c ? c.cost_usd : 0;
5754
6318
  r.cost_usd_orchestrator = c ? c.cost_usd_orchestrator : 0;
5755
6319
  r.cost_usd_estimated = c ? c.cost_usd_estimated : 0;
6320
+ r.cost_usd_subagent = c ? c.cost_usd_subagent : 0;
5756
6321
  return r;
5757
6322
  };
5758
6323
  projects.forEach(attachCost);
@@ -5791,6 +6356,7 @@ async function handleApi(req, res) {
5791
6356
  grand_cost_usd: grandCost,
5792
6357
  grand_cost_usd_orchestrator: grandCostOrch,
5793
6358
  grand_cost_usd_estimated: grandCostEst,
6359
+ grand_cost_usd_subagent: grandCostSub,
5794
6360
  cost_available: !!(req.user && req.user.admin),
5795
6361
  can_edit_weight: !auth.CLIENT_MODE && !!(req.user && req.user.admin),
5796
6362
  projects,
@@ -6389,6 +6955,19 @@ const HTML = `<!DOCTYPE html>
6389
6955
  #top-pages-container .style-stats-table th,
6390
6956
  #top-pages-container .style-stats-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 10px 10px; }
6391
6957
  #top-pages-container .style-stats-table td[data-col-key="path"] { white-space: normal; overflow: visible; text-overflow: clip; word-break: break-all; }
6958
+
6959
+ #top-links-container .style-stats-table { table-layout: fixed; }
6960
+ #top-links-container .style-stats-table th,
6961
+ #top-links-container .style-stats-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 10px 10px; vertical-align: top; }
6962
+ #top-links-container .style-stats-table td[data-col-key="target_url"] { white-space: normal; overflow: visible; text-overflow: clip; word-break: break-all; }
6963
+ #top-links-container .dest-kind-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; }
6964
+ #top-links-container .dest-kind-home { background: rgba(16,185,129,0.18); color: #10b981; }
6965
+ #top-links-container .dest-kind-subpage { background: rgba(59,130,246,0.18); color: #3b82f6; }
6966
+ #top-links-container .dest-kind-seo { background: rgba(168,85,247,0.18); color: #a855f7; }
6967
+ #top-links-container .dest-kind-booking { background: rgba(245,158,11,0.18); color: #f59e0b; }
6968
+ #top-links-container .dest-kind-github { background: rgba(148,163,184,0.18); color: #94a3b8; }
6969
+ #top-links-container .dest-kind-external { background: rgba(239,68,68,0.18); color: #ef4444; }
6970
+ #top-links-container .dest-kind-other { background: rgba(148,163,184,0.18); color: #94a3b8; }
6392
6971
  /* DMs sub-tab */
6393
6972
  #top-dms-container .style-stats-table { table-layout: fixed; }
6394
6973
  #top-dms-container .style-stats-table th,
@@ -6662,6 +7241,24 @@ const HTML = `<!DOCTYPE html>
6662
7241
  .style-stats-pill:hover { border-color: var(--border-strong); background: var(--bg-hover); }
6663
7242
  .style-stats-pill.active { background: var(--accent-panel-bg); border-color: #3b82f6; color: var(--text); }
6664
7243
 
7244
+ /* In-place loading state for stats containers. Dims the previously-rendered
7245
+ grid/table so it stays visible (no layout jump) but reads clearly as stale.
7246
+ Used by loadActivityStats / loadCohortStats / loadStyleStats while their
7247
+ fetches are in flight. */
7248
+ .is-loading { opacity: 0.5; transition: opacity 0.12s linear; pointer-events: none; }
7249
+
7250
+ /* Disable Stats-tab filter pills while any stats fetch is in flight
7251
+ (body.sa-stats-busy) so users can't queue overlapping requests across
7252
+ rapid pill changes. Covers the three pill rows that drive
7253
+ reloadStatsTabSections: stats window + style-stats platform/project. */
7254
+ body.sa-stats-busy #stats-window-pills .style-stats-pill,
7255
+ body.sa-stats-busy #style-stats-platform-pills .style-stats-pill,
7256
+ body.sa-stats-busy #style-stats-project-pills .style-stats-pill {
7257
+ pointer-events: none;
7258
+ opacity: 0.55;
7259
+ cursor: wait;
7260
+ }
7261
+
6665
7262
  @media (max-width: 600px) { .cards { grid-template-columns: 1fr; } .content { padding: 16px; } }
6666
7263
 
6667
7264
  /* Client-mode auth overlay. Non-admin users see the app with admin-only
@@ -7096,6 +7693,11 @@ const HTML = `<!DOCTYPE html>
7096
7693
  <span class="top-subtab-label">DMs</span>
7097
7694
  <span class="top-subtab-sub">prospect chats</span>
7098
7695
  </span>
7696
+ <span class="top-subtab" data-subtab="links" role="tab" aria-selected="false" title="Destination URLs across all posts, ranked by clicks. Homepage vs audience pages vs SEO pages.">
7697
+ <span class="top-subtab-icon" aria-hidden="true">\ud83d\udd17</span>
7698
+ <span class="top-subtab-label">Links</span>
7699
+ <span class="top-subtab-sub">destinations</span>
7700
+ </span>
7099
7701
  </div>
7100
7702
  <div class="top-controls">
7101
7703
  <input id="top-search" class="top-search" type="search" placeholder="Search posts\u2026" />
@@ -7197,6 +7799,9 @@ const HTML = `<!DOCTYPE html>
7197
7799
  <div id="top-dms-container" class="hidden">
7198
7800
  <div class="style-stats-empty">Loading\u2026</div>
7199
7801
  </div>
7802
+ <div id="top-links-container" class="hidden">
7803
+ <div class="style-stats-empty">Loading\u2026</div>
7804
+ </div>
7200
7805
  </div>
7201
7806
 
7202
7807
  <div class="content hidden" id="tab-logs">
@@ -7855,31 +8460,53 @@ function renderResult(run) {
7855
8460
  'salvaged <span style="color:var(--text);font-weight:600;">' + salvPrimary + '</span>' +
7856
8461
  salvBracket +
7857
8462
  '</span>';
7858
- const tooltip = 'searches: ' + searches +
7859
- ' / raw tweets: ' + raw +
7860
- ' / passed score-time cuts: ' + passed +
7861
- ' / dropped pre-score (already-posted or age>18h): ' + dropped +
7862
- ' / expired (delta<1 floor): ' + expired +
7863
- ' / above review cap (delta>=10, gates POST_LIMIT=3): ' + aboveFloor +
7864
- ' / posted: ' + posted +
7865
- ' / Phase 0 salvaged into this cycle: ' + salvAttempted +
7866
- ' (of which posted: ' + salvPosted + ')' +
7867
- ' / salvageable now (pool size for next cycle): ' + salvageableLive +
7868
- ' (+' + salvAdded + ' became salvageable / -' + salvDrained + ' drained this run)' +
7869
- ' / pending end-of-run: ' + queue +
7870
- ' (start: ' + queueStart + ', +' + qAdded + ' added, -' + qDrained + ' drained = ' +
7871
- qDrainedPosted + ' posted + ' + qDrainedExpired + ' expired + ' + qDrainedSkipped + ' skipped)' +
7872
- ' / pending right now (live): ' + pendingLive;
8463
+ // Tooltip is grouped by cycle phase so the funnel reads chronologically.
8464
+ // String.fromCharCode(10) sidesteps the outer HTML backtick template
8465
+ // that strips literal backslash-n escapes (see feedback_server_js_template_regex).
8466
+ // CSS .sa-tooltip white-space:pre-line turns these into line breaks.
8467
+ const NL = String.fromCharCode(10);
8468
+ const tooltip =
8469
+ 'Phase 0 (cleanup):' + NL +
8470
+ ' salvaged ' + salvAttempted + ': orphan rows adopted from prior dead cycles (' + salvPosted + ' posted this cycle)' + NL +
8471
+ ' pool for next cycle: ' + salvageableLive + ' salvageable (+' + salvAdded + ' / -' + salvDrained + ' this run)' + NL +
8472
+ NL +
8473
+ 'Phase 1 (scrape):' + NL +
8474
+ ' searches ' + searches + ': queries run' + NL +
8475
+ ' raw ' + raw + ': tweets returned' + NL +
8476
+ '• passed ' + passed + ': after dedup + age>18h cuts (' + dropped + ' dropped)' + NL +
8477
+ NL +
8478
+ 'Phase 2a (Δ re-score):' + NL +
8479
+ '• expired ' + expired + ': below Δ<1 likes floor' + NL +
8480
+ NL +
8481
+ 'Phase 2b (draft + post):' + NL +
8482
+ '• Δ≥10 ' + aboveFloor + ': crossed POST_LIMIT=3 review cap' + NL +
8483
+ '• posted ' + posted + ': shipped' + NL +
8484
+ '• failed ' + failed + ': post errors' + NL +
8485
+ NL +
8486
+ 'Pending end-of-run: ' + queue +
8487
+ ' (start ' + queueStart + ', +' + qAdded + ' / -' + qDrained + ' = ' +
8488
+ qDrainedPosted + ' posted, ' + qDrainedExpired + ' expired, ' + qDrainedSkipped + ' skipped)' + NL +
8489
+ 'Pending live: ' + pendingLive;
8490
+ // Pill order mirrors the tooltip story: salvaged (Phase 0 input) leads,
8491
+ // then Phase 1 funnel (searches, raw, passed), Phase 2a drop (expired),
8492
+ // Phase 2b decision and outcome (Δ≥10, posted, failed).
7873
8493
  return (
7874
8494
  '<span title="' + tooltip.replace(/"/g, '&quot;') + '" style="display:inline-block;">' +
8495
+ queuePill +
7875
8496
  pill('searches', searches, searches > 0 ? 'var(--text)' : 'var(--muted)') +
7876
8497
  pill('raw', raw, raw > 0 ? 'var(--text)' : 'var(--muted)') +
7877
8498
  pill('passed', passed, passed > 0 ? '#22c55e' : 'var(--muted)') +
7878
8499
  pill('expired', expired, expired > 0 ? 'var(--text)' : 'var(--muted)') +
7879
8500
  pill('Δ≥10', aboveFloor, aboveFloor > 0 ? '#a78bfa' : 'var(--muted)') +
7880
8501
  pill('posted', posted, posted > 0 ? '#22c55e' : 'var(--muted)') +
7881
- queuePill +
7882
8502
  renderFailedPill() +
8503
+ (Array.isArray(r.projects_worked) && r.projects_worked.length
8504
+ ? '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
8505
+ + 'projects '
8506
+ + '<span style="color:var(--text);font-weight:600;">'
8507
+ + r.projects_worked.join(', ')
8508
+ + '</span></span>'
8509
+ : '') +
7883
8510
  '</span>'
7884
8511
  );
7885
8512
  }
@@ -8153,6 +8780,26 @@ function renderResult(run) {
8153
8780
  // the old "posted=18216" pill was the total active-posts count from the
8154
8781
  // DB, which had nothing to do with what the run did. Render the real
8155
8782
  // per-run counters parsed out of the stats log instead.
8783
+ //
8784
+ // 2026-05-18 relabel: split the misleading single "updated" pill into
8785
+ // four explicit pills (scanned / checked / changed / views) so the
8786
+ // operator can read at a glance what the run actually did:
8787
+ //
8788
+ // scanned -> total rows considered (= polled + every flavor of skip)
8789
+ // skipped -> rows we deliberately did NOT poll (covered by Step 1
8790
+ // scrape or stable cooldown). Saves API calls.
8791
+ // checked -> rows we actually hit the platform API for this run.
8792
+ // changed -> subset of "checked" where any tracked metric moved.
8793
+ // Was the original intent of "updated" but the legacy
8794
+ // field also summed in the Step 1 view-scrape count.
8795
+ // views -> Step 1 scrape leg only: rows where the cheap profile-
8796
+ // page scrape wrote a fresh view count (Reddit) or
8797
+ // fxtwitter view (Twitter). Distinct from "changed".
8798
+ // replies -> per-reply rows refreshed (DM-rail follow-ups we made
8799
+ // on someone else thread, live in the "replies" table).
8800
+ //
8801
+ // Each pill carries data-tooltip so hovering surfaces the meaning
8802
+ // line-by-line via the global .sa-tooltip handler.
8156
8803
  if (run.job_type === 'stats') {
8157
8804
  const checked = r.checked || 0;
8158
8805
  const updated = r.updated || 0;
@@ -8162,19 +8809,71 @@ function renderResult(run) {
8162
8809
  const skipped = r.skipped || 0;
8163
8810
  const failed = r.failed || 0;
8164
8811
  const repliesRefreshed = r.replies_refreshed || 0;
8812
+ // New 2026-05-18 fields; fall back to derived values when the log line
8813
+ // pre-dates the relabel pass (so historical rows still render sanely).
8814
+ const scanned = r.scanned || (checked + skipped) || 0;
8815
+ const changed = r.changed || 0;
8816
+ const viewsRefreshed = r.views_refreshed || 0;
8165
8817
  if (!checked && !updated && !removed && !unavailable && !notFound &&
8166
- !skipped && !failed && !repliesRefreshed) {
8818
+ !skipped && !failed && !repliesRefreshed &&
8819
+ !scanned && !changed && !viewsRefreshed) {
8167
8820
  return '<span style="color:var(--muted);font-size:12px;">—</span>';
8168
8821
  }
8822
+ // Inline helper: pill with a data-tooltip attribute for per-pill hover
8823
+ // explanations. Plain pill() (above) has no tooltip slot; this is
8824
+ // local to stats-job rendering only.
8825
+ const tipPill = (label, n, color, tip) => {
8826
+ const tipEsc = (tip || '').replace(/"/g, '&quot;');
8827
+ return '<span data-tooltip="' + tipEsc + '" style="display:inline-block;' +
8828
+ 'margin-right:10px;font-size:12px;color:var(--muted);cursor:help;">' +
8829
+ label + ' <span style="color:' + (color || 'var(--text)') +
8830
+ ';font-weight:600;">' + n + '</span></span>';
8831
+ };
8169
8832
  return (
8170
- pill('checked', checked, 'var(--text)') +
8171
- pill('updated', updated, '#22c55e') +
8172
- (removed ? pill('removed', removed, '#eab308') : '') +
8173
- (unavailable ? pill('unavail', unavailable, '#eab308') : '') +
8174
- (notFound ? pill('not found', notFound, 'var(--muted)') : '') +
8175
- (skipped ? pill('skipped', skipped, 'var(--muted)') : '') +
8176
- (repliesRefreshed ? pill('replies', repliesRefreshed, '#3b82f6') : '') +
8177
- (failed ? pill('failed', failed, '#ef4444') : '')
8833
+ tipPill('scanned', scanned, 'var(--text)',
8834
+ 'Total rows the run considered (every active row in the relevant ' +
8835
+ 'platform tables). = checked + skipped + bypassed-as-fresh.') +
8836
+ (skipped ? tipPill('skipped', skipped, 'var(--muted)',
8837
+ 'Rows we deliberately did NOT poll this run. Two reasons: ' +
8838
+ '(1) already refreshed by the cheap scrape leg within the last 4h, ' +
8839
+ '(2) stable cooldown (2+ scans with no metric change AND older than ' +
8840
+ '3 days). Saves API calls; data is still current.') : '') +
8841
+ tipPill('checked', checked, 'var(--text)',
8842
+ 'Rows we actually hit the platform API for this run ' +
8843
+ '(Reddit old.reddit.com JSON / fxtwitter / LinkedIn activity feed). ' +
8844
+ 'Includes both successful polls and the ones that errored mid-fetch.') +
8845
+ tipPill('changed', changed, '#22c55e',
8846
+ 'Subset of CHECKED where any tracked metric (upvotes, ' +
8847
+ 'comments_count, views) actually moved since the prior scan. ' +
8848
+ 'The real-activity signal; everything else is no-op polling.') +
8849
+ (viewsRefreshed ? tipPill('views', viewsRefreshed, '#06b6d4',
8850
+ 'Rows where the cheap view-scrape leg wrote a fresh view count ' +
8851
+ 'this run. Reddit: Step 1 profile-page scrape (sees every ' +
8852
+ 'comment + thread on /user/<name>/). Twitter: built-in to the ' +
8853
+ 'fxtwitter call. Separate from CHANGED because views can ' +
8854
+ 'tick up without upvotes/comments moving.') : '') +
8855
+ (repliesRefreshed ? tipPill('replies', repliesRefreshed, '#3b82f6',
8856
+ 'Per-reply rows refreshed: comments we authored AS replies to ' +
8857
+ 'someone else reply to our original comment (the DM-rail ' +
8858
+ 'follow-up). Live in the replies table, not posts. ' +
8859
+ 'Reddit refreshes upvotes + reply-count via batch JSON API. ' +
8860
+ 'Twitter also refreshes views via fxtwitter.') : '') +
8861
+ (removed ? tipPill('removed', removed, '#eab308',
8862
+ 'Posts newly flagged deleted/removed this run. Reddit: comment ' +
8863
+ 'gone from thread JSON for 2+ consecutive scans (deletion_detect_' +
8864
+ 'count threshold). LinkedIn: post returned "Post unavailable".') : '') +
8865
+ (unavailable ? tipPill('unavail', unavailable, '#eab308',
8866
+ 'LinkedIn only: post explicitly returned a Post Unavailable ' +
8867
+ 'string. Subset of REMOVED; rendered as its own pill so an ' +
8868
+ 'operator can tell hard-deletion from rate-limit or network noise.') : '') +
8869
+ (notFound ? tipPill('not found', notFound, 'var(--muted)',
8870
+ 'LinkedIn only: post is still active on LinkedIn but our specific ' +
8871
+ 'comment couldn\'t be located on the activity feed (may have ' +
8872
+ 'aged off our visible recent-activity window).') : '') +
8873
+ (failed ? tipPill('failed', failed, '#ef4444',
8874
+ 'API errors during the run, broken down by category: 404 not_found, ' +
8875
+ 'rate-limited (429), empty / malformed response, or other / network. ' +
8876
+ 'Includes step-exit failures from the shell pipeline as well.') : '')
8178
8877
  );
8179
8878
  }
8180
8879
  // seo_expire (delete dead-weight pages): repurposes posted/skipped from the
@@ -8332,7 +9031,7 @@ function buildSeoDetailRows(run) {
8332
9031
  if (!details.length) return '';
8333
9032
  const subRows = details.map(d => {
8334
9033
  const cost = (typeof d.cost_usd === 'number' && d.cost_usd > 0)
8335
- ? fmtCostCell(d.cost_usd, d.cost_usd_orchestrator, d.cost_usd_estimated)
9034
+ ? fmtCostCell(d.cost_usd, d.cost_usd_orchestrator, d.cost_usd_estimated, d.cost_usd_subagent)
8336
9035
  : '<span style="color:var(--muted);">—</span>';
8337
9036
  const turns = (typeof d.num_turns === 'number' && d.num_turns > 0)
8338
9037
  ? d.num_turns
@@ -8378,6 +9077,85 @@ function buildSeoDetailRows(run) {
8378
9077
  );
8379
9078
  }
8380
9079
 
9080
+ // Cost cell for a Job History row. SDK-only mode (2026-05-15): the headline
9081
+ // total is the sum of orchestrator_cost_usd across the phases of this run.
9082
+ // Sessions whose wrapper didn't capture SDK cost contribute 0; the per-phase
9083
+ // breakdown surfaces a "missing SDK" count so the operator can spot pipelines
9084
+ // where real spend went unrecorded (the cost cell shows the smaller real
9085
+ // number, not an inflated estimate).
9086
+ function _jobHistoryCostCell(result) {
9087
+ const fmtLane = (v) => {
9088
+ if (v == null) return 'n/a';
9089
+ const n = Number(v);
9090
+ if (!isFinite(n)) return 'n/a';
9091
+ if (n === 0) return '$0';
9092
+ if (n < 0.01) return '$' + n.toFixed(4);
9093
+ return '$' + n.toFixed(4);
9094
+ };
9095
+ const bd = result.cost_breakdown;
9096
+ const orch = result.cost_usd_orchestrator != null ? Number(result.cost_usd_orchestrator) : null;
9097
+ const sub = result.cost_usd_subagent != null ? Number(result.cost_usd_subagent) : null;
9098
+ const est = result.cost_usd_estimated != null ? Number(result.cost_usd_estimated) : null;
9099
+ const sessionsAll = bd ? Number(bd.sessions) || 0 : 0;
9100
+ const sessionsWithSdk = bd ? Number(bd.sessions_with_sdk) || 0 : 0;
9101
+ const sessionsMissing = Math.max(0, sessionsAll - sessionsWithSdk);
9102
+ const totalForDisplay = orch != null ? orch : 0;
9103
+ // Header value: "n/a" when no SDK data captured for any of the run's
9104
+ // sessions, otherwise the orchestrator sum (with a "(N missing SDK)"
9105
+ // hint inline when partial).
9106
+ let headerHtml;
9107
+ if (sessionsAll === 0) {
9108
+ headerHtml = '<span style="color:var(--text-very-faint);">n/a</span>';
9109
+ } else if (sessionsWithSdk === 0) {
9110
+ headerHtml = '<span style="color:var(--text-very-faint);">n/a</span>';
9111
+ } else if (sessionsMissing > 0) {
9112
+ headerHtml = fmtCost(totalForDisplay) +
9113
+ ' <span style="color:#eab308;font-size:11px;">(' + sessionsMissing + ' missing)</span>';
9114
+ } else {
9115
+ headerHtml = fmtCost(totalForDisplay);
9116
+ }
9117
+ const lines = [
9118
+ 'Cost (SDK orchestrator): ' + (sessionsWithSdk > 0 ? fmtLane(orch) : 'n/a'),
9119
+ ];
9120
+ if (sessionsAll > 0) {
9121
+ lines.push(' Sessions: ' + sessionsAll +
9122
+ ' · with SDK data: ' + sessionsWithSdk +
9123
+ ' · missing SDK: ' + sessionsMissing);
9124
+ }
9125
+ if (bd && Array.isArray(bd.phases) && bd.phases.length) {
9126
+ lines.push('');
9127
+ lines.push('Per-phase (claude_sessions.script grouping):');
9128
+ const shown = bd.phases.slice(0, 10);
9129
+ for (const p of shown) {
9130
+ const missing = (p.sessions_missing_sdk && p.sessions_missing_sdk > 0)
9131
+ ? (' [' + p.sessions_missing_sdk + ' missing SDK]')
9132
+ : '';
9133
+ const orchVal = (p.sessions_with_sdk && p.sessions_with_sdk > 0)
9134
+ ? fmtLane(p.orch)
9135
+ : 'n/a';
9136
+ lines.push(' ' + (p.phase || '(unknown)') + ' x' + p.sessions +
9137
+ ' ' + orchVal + missing);
9138
+ }
9139
+ if (bd.phases.length > shown.length) {
9140
+ lines.push(' …(' + (bd.phases.length - shown.length) + ' more)');
9141
+ }
9142
+ }
9143
+ if (typeof result.cost_usd_from_log === 'number') {
9144
+ lines.push('');
9145
+ lines.push('Wrapper shell-log value: ' + fmtLane(result.cost_usd_from_log));
9146
+ }
9147
+ lines.push('');
9148
+ lines.push('Diagnostic-only (local pricing estimate, not actual billing):');
9149
+ lines.push(' Transcript estimate: ' + fmtLane(est));
9150
+ lines.push(' Subagent (est): ' + fmtLane(sub));
9151
+ lines.push('');
9152
+ lines.push('SDK-only mode: shows orchestrator_cost_usd captured by the SDK result event. "missing SDK" = wrapper script didn\\'t pass --output-format json to claude, so no result event = no cost data recorded. Patch the wrapper to fix.');
9153
+ const tip = lines.join('\\n');
9154
+ return '<span data-tooltip="' + escapeHtml(tip) +
9155
+ '" style="cursor:help;border-bottom:1px dotted var(--text-muted);">' +
9156
+ headerHtml + '</span>';
9157
+ }
9158
+
8381
9159
  // Stable identity for a job-history row across polls. (script, started_at)
8382
9160
  // is unique in practice; pid is appended as a tiebreaker for the rare case
8383
9161
  // where two parallel fires of the same script land in the same second.
@@ -8406,8 +9184,16 @@ function _jobHistoryRowSig(r) {
8406
9184
  ].join('|');
8407
9185
  }
8408
9186
  function _buildJobHistoryRowGroup(r, idx) {
8409
- const cost = r.result && r.result.cost_usd;
8410
- const costCell = cost ? fmtCost(cost) : '<span style="color:var(--muted);">—</span>';
9187
+ // SDK-only mode: render the cost cell whenever the run produced any
9188
+ // claude_sessions rows (cost_breakdown is attached), even if the SDK lane
9189
+ // is NULL across all of them — the tooltip surfaces "missing SDK" so the
9190
+ // operator sees pipelines whose wrappers haven't been patched yet.
9191
+ const hasBreakdown = r.result && r.result.cost_breakdown
9192
+ && Number(r.result.cost_breakdown.sessions) > 0;
9193
+ const hasLogCost = r.result && typeof r.result.cost_usd === 'number';
9194
+ const costCell = (hasBreakdown || hasLogCost)
9195
+ ? _jobHistoryCostCell(r.result)
9196
+ : '<span style="color:var(--muted);">—</span>';
8411
9197
  const hasDetails = Array.isArray(r.details) && r.details.length;
8412
9198
  const caret = hasDetails
8413
9199
  ? '<span class="sa-job-caret" style="display:inline-block;width:12px;color:var(--muted);cursor:pointer;user-select:none;transition:transform 0.15s ease;">&#9656;</span> '
@@ -8972,8 +9758,12 @@ async function saveSettings() {
8972
9758
  }
8973
9759
 
8974
9760
  // Activity tab
8975
- const EVENT_TYPES = ['posted_thread', 'posted_comment', 'replied', 'skipped', 'mention', 'dm_sent', 'dm_reply_sent', 'page_published_serp', 'page_published_gsc', 'page_published_reddit', 'page_published_top', 'page_published_top_post', 'page_published_roundup', 'page_improved', 'page_expired', 'resurrected'];
8976
- const EVENT_LABELS = { posted_thread: 'thread posted', posted_comment: 'comment posted', replied: 'engage replied', skipped: 'engage skipped', mention: 'mention', dm_sent: 'dm sent', dm_reply_sent: 'dm reply', page_published_serp: 'page (serp)', page_published_gsc: 'page (gsc)', page_published_reddit: 'page (reddit)', page_published_top: 'page (top)', page_published_top_post: 'page (top post)', page_published_roundup: 'page (roundup)', page_improved: 'page (improved)', page_expired: 'page expired', resurrected: 'resurrected' };
9761
+ // Page-generation event types. Folded into a single 'pages_generated' card
9762
+ // in renderActivityStats (with per-pipeline breakdown in the body + tooltip).
9763
+ // SQL still emits each subtype so the breakdown is faithful.
9764
+ const PAGE_GEN_EVENT_TYPES = ['page_published_twitter', 'page_published_gsc', 'page_published_reddit', 'page_published_top', 'page_published_top_post', 'page_published_roundup', 'page_published_misc', 'page_improved'];
9765
+ const EVENT_TYPES = ['posted_thread', 'posted_comment', 'replied', 'skipped', 'mention', 'dm_sent', 'dm_reply_sent', ...PAGE_GEN_EVENT_TYPES, 'page_expired', 'resurrected'];
9766
+ const EVENT_LABELS = { posted_thread: 'thread posted', posted_comment: 'comment posted', replied: 'engage replied', skipped: 'engage skipped', mention: 'mention', dm_sent: 'dm sent', dm_reply_sent: 'dm reply', page_published_twitter: 'page (twitter)', page_published_gsc: 'page (gsc)', page_published_reddit: 'page (reddit)', page_published_top: 'page (top)', page_published_top_post: 'page (top post)', page_published_roundup: 'page (roundup)', page_published_misc: 'page (misc)', page_improved: 'page (improved)', page_expired: 'page expired', resurrected: 'resurrected', pages_generated: 'pages generated' };
8977
9767
  const EVENT_DESCRIPTIONS = {
8978
9768
  posted_thread: 'Original thread the bot published (Post Threads job): a new top-level Reddit submission, tweet/X post, LinkedIn post, etc. Identified by thread_url = our_url AND thread_author is empty or matches our_account.',
8979
9769
  posted_comment: 'Comment the bot left on someone else’s thread (Post Comments job): a Reddit/Twitter/LinkedIn/Moltbook/GitHub reply where thread_url ≠ our_url, or where thread_url = our_url but thread_author is someone else (LinkedIn comment job stores the parent URL in both columns).',
@@ -8982,13 +9772,15 @@ const EVENT_DESCRIPTIONS = {
8982
9772
  mention: 'Someone mentioned one of our products on a tracked platform. Detection only, no engagement action.',
8983
9773
  dm_sent: 'New direct-message conversation the bot started with a prospect.',
8984
9774
  dm_reply_sent: 'Follow-up message sent inside an existing DM conversation.',
8985
- page_published_serp: 'SEO landing page generated from the SERP pipeline (based on ranked search results for target keywords).',
9775
+ pages_generated: 'Total SEO/landing pages produced across every pipeline in this window. Hover the breakdown chips for the per-pipeline counts. The standalone SERP pipeline was unloaded 2026-04-17; any page tagged "twitter" came from the Twitter cycle page-gen A/B lane (twitter_gen_links.py), not a dedicated SERP run.',
9776
+ page_published_twitter: 'SEO landing page generated by the Twitter cycle page-gen A/B lane (twitter_gen_links.py -> generate_page.py --trigger twitter). Source rows have seo_keywords.source=twitter. This is what the legacy "SERP SEO" pill was actually counting after the standalone SERP pipeline was unloaded on 2026-04-17.',
8986
9777
  page_published_gsc: 'SEO page generated from a Google Search Console query the site already gets impressions for.',
8987
9778
  page_published_reddit: 'SEO page generated from a high-intent Reddit thread.',
8988
- page_published_top: 'SEO page generated for a top-of-funnel ranking opportunity.',
9779
+ page_published_top: 'SEO page generated for a top-of-funnel ranking opportunity (top_pages pipeline).',
8989
9780
  page_published_top_post: 'SEO page generated retroactively for a viral social-autoposter post (>=10k views, last 7d) whose link still points at the homepage. Pipeline also mounts a NewsStrip on the homepage routing existing organic clickthroughs to the new /t/ page.',
8990
9781
  page_published_roundup: 'Roundup or list-style SEO page (comparisons, best-of, alternatives).',
8991
- page_improved: 'Existing SEO page that was updated or rewritten to improve rankings.',
9782
+ page_published_misc: 'SEO page from a legacy or edge source (existing_page, suggestion:*, competitor:*, topic_template, feature_template, client_blog, etc). Mostly historical; near-zero new volume in 2026-05.',
9783
+ page_improved: 'Existing SEO page that was updated or rewritten to improve rankings (seo_page_improvements table).',
8992
9784
  page_expired: 'SEO page deleted by the daily expire pipeline because it had zero clicks in the last 30 days. The on-disk source file was removed; Next.js now returns 404 for the URL. Logged for audit/revert in seo_expired_pages.',
8993
9785
  resurrected: 'Previously archived or unavailable item brought back into rotation (e.g., a removed post restored after reappearing).',
8994
9786
  };
@@ -9000,7 +9792,10 @@ const ACTIVITY_CAMPAIGN_ORGANIC = '(organic)';
9000
9792
  let _activitySeen = new Set();
9001
9793
  let _activityFirstLoad = true;
9002
9794
  // Activity-tab filters/sort/search are persisted across reloads.
9003
- let _activityTypeFilter = saLoadSet('sa.activity.typeFilter.v2', EVENT_TYPES);
9795
+ // Bumped v2 -> v3 on 2026-05-16 when page_published_serp was split into
9796
+ // page_published_twitter + page_published_misc; old saved Sets did not
9797
+ // include the new types, so they'd silently disappear from the feed.
9798
+ let _activityTypeFilter = saLoadSet('sa.activity.typeFilter.v3', EVENT_TYPES);
9004
9799
  let _activityPlatformFilter = saLoadSet('sa.activity.platformFilter.v1', ACTIVITY_PLATFORMS);
9005
9800
  let _activityProjectFilter = saLoadSet('sa.activity.projectFilter.v1', []);
9006
9801
  let _activityKnownProjects = saLoad('sa.activity.knownProjects.v1', []);
@@ -9098,7 +9893,7 @@ function buildActivityFilters() {
9098
9893
  var added;
9099
9894
  if (_activityTypeFilter.has(t)) { _activityTypeFilter.delete(t); el.classList.remove('active'); added = false; }
9100
9895
  else { _activityTypeFilter.add(t); el.classList.add('active'); added = true; }
9101
- saSaveSet('sa.activity.typeFilter.v2', _activityTypeFilter);
9896
+ saSaveSet('sa.activity.typeFilter.v3', _activityTypeFilter);
9102
9897
  try { window.posthog && window.posthog.capture('filter_toggle', { table: 'activity', dimension: 'type', value: t, action: added ? 'add' : 'remove' }); } catch (er) {}
9103
9898
  _activityPage = 0;
9104
9899
  renderActivity(_lastActivityEvents || []);
@@ -9151,11 +9946,11 @@ function buildActivityFilters() {
9151
9946
  if (a === 'type-all') {
9152
9947
  _activityTypeFilter = new Set(EVENT_TYPES);
9153
9948
  tEl.querySelectorAll('[data-type]').forEach(c => c.classList.add('active'));
9154
- saSaveSet('sa.activity.typeFilter.v2', _activityTypeFilter);
9949
+ saSaveSet('sa.activity.typeFilter.v3', _activityTypeFilter);
9155
9950
  } else if (a === 'type-none') {
9156
9951
  _activityTypeFilter = new Set();
9157
9952
  tEl.querySelectorAll('[data-type]').forEach(c => c.classList.remove('active'));
9158
- saSaveSet('sa.activity.typeFilter.v2', _activityTypeFilter);
9953
+ saSaveSet('sa.activity.typeFilter.v3', _activityTypeFilter);
9159
9954
  } else if (a === 'platform-all') {
9160
9955
  _activityPlatformFilter = new Set(ACTIVITY_PLATFORMS);
9161
9956
  pEl.querySelectorAll('[data-platform]').forEach(c => c.classList.add('active'));
@@ -9257,19 +10052,21 @@ function fmtCost(c) {
9257
10052
  //
9258
10053
  // Args (no backticks anywhere; this whole helper sits inside the dashboard
9259
10054
  // HTML template literal, see feedback_server_js_template_regex memory):
9260
- // displayed value rendered in the cell. Already prefers SDK, falls
9261
- // back to estimate. Source of truth for the text.
10055
+ // displayed value rendered in the cell. Total = COALESCE(orch,
10056
+ // estimate) + subagent. Source of truth for the text.
9262
10057
  // orchestrator native SDK orchestrator cost (claude_sessions.
9263
10058
  // orchestrator_cost_usd, captured from streamRes.
9264
10059
  // total_cost_usd). Authoritative for orchestrator billing
9265
10060
  // but EXCLUDES Task subagent costs (anthropics/claude-code
9266
- // issue #43945).
9267
- // estimated manual transcript-derived estimate using local pricing
9268
- // tables (claude_sessions.total_cost_usd, written by
10061
+ // issue #43945). Subagent is now folded in via the 4th arg.
10062
+ // estimated manual transcript-derived estimate of orchestrator turns
10063
+ // only (claude_sessions.total_cost_usd, written by
9269
10064
  // log_claude_session.py).
9270
- function fmtCostCell(displayed, orchestrator, estimated) {
9271
- const text = fmtCost(displayed);
9272
- if (text === '') return '';
10065
+ // subagent Task/Agent subagent cost from sidechain transcripts +
10066
+ // sibling subagents/*.jsonl files (claude_sessions.
10067
+ // subagent_cost_usd). Added on top of the orch/estimate
10068
+ // lane to form the displayed total.
10069
+ function fmtCostCell(displayed, orchestrator, estimated, subagent) {
9273
10070
  const fmtLane = (v) => {
9274
10071
  if (v == null) return 'n/a';
9275
10072
  const n = Number(v);
@@ -9278,13 +10075,23 @@ function fmtCostCell(displayed, orchestrator, estimated) {
9278
10075
  if (n < 0.01) return '$' + n.toFixed(4);
9279
10076
  return '$' + n.toFixed(4);
9280
10077
  };
10078
+ // SDK-only mode (2026-05-15): the displayed value is orchestrator_cost_usd
10079
+ // alone. When that's NULL we render "n/a" — not $0, since the session DID
10080
+ // spend money but the wrapper didn't capture --orchestrator-cost-usd.
10081
+ // Estimate and subagent are diagnostic-only (computed from local pricing
10082
+ // table; not actual billing data).
10083
+ const hasOrch = orchestrator != null && Number.isFinite(Number(orchestrator));
10084
+ const text = hasOrch
10085
+ ? fmtCost(Number(orchestrator))
10086
+ : '<span style="color:var(--text-very-faint);">n/a</span>';
9281
10087
  const lines = [
9282
- 'Orchestrator (SDK): ' + fmtLane(orchestrator),
9283
- 'Estimated (transcript): ' + fmtLane(estimated),
10088
+ 'Cost (SDK orchestrator): ' + fmtLane(orchestrator),
9284
10089
  '',
9285
- 'Displayed value prefers the SDK orchestrator cost (native streamRes.total_cost_usd, matches Anthropic billing for the orchestrator session) and falls back to the manual transcript-derived estimate when the SDK value is unavailable.',
10090
+ 'Diagnostic-only (not actual billing):',
10091
+ ' Transcript estimate: ' + fmtLane(estimated),
10092
+ ' Subagent (est): ' + fmtLane(subagent),
9286
10093
  '',
9287
- 'Note: orchestrator cost EXCLUDES Task subagent spend (anthropics/claude-code #43945). The estimate uses our local pricing table over the parent transcript only and has the same exclusion.',
10094
+ 'Displays Anthropic-reported orchestrator_cost_usd only. "n/a" means the wrapper didn\\'t capture the SDK cost (no --output-format json on the claude call). Transcript estimate and subagent figures are computed locally from a hardcoded pricing table informational, not billing-accurate on subscription plans.',
9288
10095
  ];
9289
10096
  const tip = lines.join('\\n');
9290
10097
  return '<span data-tooltip="' + escapeHtml(tip) +
@@ -9581,22 +10388,31 @@ function currentStatsProject() {
9581
10388
  const row = document.getElementById('style-stats-project-pills');
9582
10389
  return (row && row.dataset.selected) || 'all';
9583
10390
  }
9584
- function reloadStatsTabSections() {
9585
- loadActivityStats();
9586
- loadCohortStats();
9587
- loadStyleStats();
10391
+ // Sets body.sa-stats-busy while the batch of stats fetches kicked off by a
10392
+ // single filter change is in flight. CSS uses the class to disable the four
10393
+ // pill rows (status window, stats window, style-stats platform/project) so
10394
+ // users can't queue overlapping reloads across rapid pill clicks.
10395
+ async function reloadStatsTabSections() {
10396
+ document.body.classList.add('sa-stats-busy');
10397
+ const pending = [
10398
+ loadActivityStats(),
10399
+ loadCohortStats(),
10400
+ loadStyleStats(),
10401
+ ];
9588
10402
  // daily-metrics chart now lives on its own Trends tab with its own filter
9589
10403
  // bar; intentionally NOT reloaded on stats-tab window/platform/project
9590
10404
  // of the filter bar.
9591
10405
  const funnelEl = document.getElementById('funnel-stats');
9592
10406
  if (funnelEl && funnelEl.open) {
9593
10407
  if (_lastFunnelPayload) renderFunnelStats(_lastFunnelPayload);
9594
- else loadFunnelStats(true);
10408
+ else pending.push(loadFunnelStats(true));
9595
10409
  }
9596
10410
  const dmEl = document.getElementById('dm-stats');
9597
- if (dmEl && dmEl.open) loadDmStats(true);
10411
+ if (dmEl && dmEl.open) pending.push(loadDmStats(true));
9598
10412
  const sqEl = document.getElementById('search-queries-stats');
9599
- if (sqEl && sqEl.open) loadSearchQueriesStats(true);
10413
+ if (sqEl && sqEl.open) pending.push(loadSearchQueriesStats(true));
10414
+ try { await Promise.allSettled(pending); }
10415
+ finally { document.body.classList.remove('sa-stats-busy'); }
9600
10416
  }
9601
10417
  function syncStatsHeadings() {
9602
10418
  const win = currentStatsWindow();
@@ -9641,7 +10457,63 @@ function renderActivityStats(payload) {
9641
10457
  grandTotal += n;
9642
10458
  });
9643
10459
  if (totalEl) totalEl.textContent = grandTotal + ' events in ' + currentStatsWindow().labelLong;
9644
- grid.innerHTML = EVENT_TYPES.map(t => {
10460
+ // Fold all page-generation subtypes into a single synthetic 'pages_generated'
10461
+ // card so the grid stops looking like six near-empty SEO cells. The breakdown
10462
+ // row inside the card lists each pipeline + count chip; the hover tooltip on
10463
+ // each chip shows the long description for that pipeline. Card-level "i"
10464
+ // icon shows the umbrella tooltip.
10465
+ const pagesBucket = { total: 0, subtypes: {} };
10466
+ PAGE_GEN_EVENT_TYPES.forEach(t => {
10467
+ const b = byType[t];
10468
+ if (!b) return;
10469
+ pagesBucket.total += b.total;
10470
+ if (b.total > 0) pagesBucket.subtypes[t] = b.total;
10471
+ });
10472
+ const PAGE_GEN_CHIP_LABELS = {
10473
+ page_published_twitter: 'twitter',
10474
+ page_published_gsc: 'gsc',
10475
+ page_published_reddit: 'reddit',
10476
+ page_published_top: 'top',
10477
+ page_published_top_post: 'top post',
10478
+ page_published_roundup: 'roundup',
10479
+ page_published_misc: 'misc',
10480
+ page_improved: 'improved',
10481
+ };
10482
+ // Render order: keep PAGE_GEN_EVENT_TYPES order, then drop the non-page tail
10483
+ // cards (page_expired, resurrected) at the end. Suppress individual page_*
10484
+ // cards since we render the umbrella card instead.
10485
+ const renderOrder = [];
10486
+ EVENT_TYPES.forEach(t => {
10487
+ if (PAGE_GEN_EVENT_TYPES.includes(t)) return; // folded into umbrella
10488
+ renderOrder.push(t);
10489
+ });
10490
+ // Insert the umbrella where the first page card used to live (after dm_reply_sent).
10491
+ const dmIdx = renderOrder.indexOf('dm_reply_sent');
10492
+ if (dmIdx >= 0) renderOrder.splice(dmIdx + 1, 0, '__pages_generated__');
10493
+ else renderOrder.push('__pages_generated__');
10494
+
10495
+ grid.innerHTML = renderOrder.map(t => {
10496
+ if (t === '__pages_generated__') {
10497
+ const total = pagesBucket.total;
10498
+ const subtypes = Object.keys(pagesBucket.subtypes).sort((a, b) => pagesBucket.subtypes[b] - pagesBucket.subtypes[a]);
10499
+ const chips = subtypes.length
10500
+ ? subtypes.map(st => {
10501
+ const lab = PAGE_GEN_CHIP_LABELS[st] || st.replace(/^page_published_/, '');
10502
+ const desc = EVENT_DESCRIPTIONS[st] || '';
10503
+ const titleAttr = desc ? ' data-tooltip="' + escapeHtml(desc) + '"' : '';
10504
+ return '<span class="stat-plat"' + titleAttr + '><span class="stat-plat-text">' + escapeHtml(lab) + '</span><span class="stat-plat-count">' + pagesBucket.subtypes[st] + '</span></span>';
10505
+ }).join('')
10506
+ : '<span style="color:var(--text-very-faint);">\u2014</span>';
10507
+ const umbrellaDesc = EVENT_DESCRIPTIONS.pages_generated || '';
10508
+ const infoIcon = '<span class="stat-card-info" data-tooltip="' + escapeHtml(umbrellaDesc) + '" aria-label="' + escapeHtml(umbrellaDesc) + '">i</span>';
10509
+ return '<div class="stat-card ev-pages-generated' + (total === 0 ? ' zero' : '') + '">' +
10510
+ '<div class="stat-card-head">' +
10511
+ '<span class="stat-card-label">pages generated' + infoIcon + '</span>' +
10512
+ '<span class="stat-card-count">' + total + '</span>' +
10513
+ '</div>' +
10514
+ '<div class="stat-card-breakdown">' + chips + '</div>' +
10515
+ '</div>';
10516
+ }
9645
10517
  const bucket = byType[t];
9646
10518
  const total = bucket.total;
9647
10519
  const plats = Object.keys(bucket.platforms).sort((a, b) => bucket.platforms[b] - bucket.platforms[a]);
@@ -9668,6 +10540,13 @@ function renderActivityStats(payload) {
9668
10540
  }
9669
10541
 
9670
10542
  async function loadActivityStats() {
10543
+ // Immediate visual feedback on filter change. Without this the previously
10544
+ // rendered grid sits frozen until the 9-way UNION returns; on a cold cache
10545
+ // miss that's a couple seconds with zero indication anything is happening.
10546
+ const grid = document.getElementById('stats-grid');
10547
+ const totalEl = document.getElementById('stats-total');
10548
+ if (grid) grid.classList.add('is-loading');
10549
+ if (totalEl) totalEl.textContent = 'loading…';
9671
10550
  try {
9672
10551
  const hours = currentStatsWindow().hours;
9673
10552
  const plat = currentStatsPlatform();
@@ -9678,7 +10557,9 @@ async function loadActivityStats() {
9678
10557
  const res = await fetch('/api/activity/stats?' + params.join('&'));
9679
10558
  const data = await res.json();
9680
10559
  renderActivityStats(data);
9681
- } catch {}
10560
+ } catch {} finally {
10561
+ if (grid) grid.classList.remove('is-loading');
10562
+ }
9682
10563
  }
9683
10564
 
9684
10565
  // Combined daily-metrics line chart (Trends tab). Fetches 4 endpoints (2
@@ -9698,6 +10579,14 @@ async function loadActivityStats() {
9698
10579
  // to a capture day; expect those lines to sit at 0 until at least two
9699
10580
  // consecutive days of snapshots have accumulated per post.
9700
10581
  let DAILY_METRICS = [
10582
+ // Output volume: posts we made per day, split by type. 'threads' counts
10583
+ // posts where we authored the thread itself; 'comments_made' counts posts
10584
+ // where we engaged on someone else's thread. Both come from the same
10585
+ // /api/posts/per-day endpoint (server returns threads_made + comments_made
10586
+ // alongside posts_made). 'comments_made' is intentionally distinct from
10587
+ // the 'comments' pill below, which counts comments EARNED on our posts.
10588
+ { id: 'threads', label: 'Threads', color: '#a855f7', endpoint: '/api/posts/per-day', valueKey: 'threads_made', platformAware: true },
10589
+ { id: 'comments_made', label: 'Comments Made', color: '#d946ef', endpoint: '/api/posts/per-day', valueKey: 'comments_made', platformAware: true },
9701
10590
  { id: 'views', label: 'Views', color: '#6366f1', endpoint: '/api/views/per-day', valueKey: 'views_gained', platformAware: true },
9702
10591
  { id: 'upvotes', label: 'Upvotes', color: '#f97316', endpoint: '/api/upvotes/per-day', valueKey: 'upvotes_gained', platformAware: true },
9703
10592
  { id: 'comments', label: 'Comments', color: '#14b8a6', endpoint: '/api/comments/per-day', valueKey: 'comments_gained', platformAware: true },
@@ -10452,6 +11341,8 @@ async function loadDailyMetrics() {
10452
11341
  intoSeries('bookings', bookings.rows, 'bookings_gained');
10453
11342
  intoSeries('cost', cost.rows, 'cost_usd');
10454
11343
  intoSeries('posts', posts.rows, 'posts_made');
11344
+ intoSeries('threads', posts.rows, 'threads_made');
11345
+ intoSeries('comments_made', posts.rows, 'comments_made');
10455
11346
  DAILY_METRICS.filter(m => m.funnel).forEach(m => {
10456
11347
  intoSeries(m.id, funnel.rows, m.valueKey);
10457
11348
  });
@@ -10971,6 +11862,10 @@ function getStyleMeta() {
10971
11862
  }
10972
11863
 
10973
11864
  async function loadStyleStats() {
11865
+ const body = document.getElementById('style-stats-body');
11866
+ const totalEl = document.getElementById('style-stats-total');
11867
+ if (body) body.classList.add('is-loading');
11868
+ if (totalEl) totalEl.textContent = 'loading…';
10974
11869
  try {
10975
11870
  const platformRow = document.getElementById('style-stats-platform-pills');
10976
11871
  const projectRow = document.getElementById('style-stats-project-pills');
@@ -10985,7 +11880,9 @@ async function loadStyleStats() {
10985
11880
  getStyleMeta(),
10986
11881
  ]);
10987
11882
  renderStyleStats(statsRes, meta);
10988
- } catch {}
11883
+ } catch {} finally {
11884
+ if (body) body.classList.remove('is-loading');
11885
+ }
10989
11886
  }
10990
11887
 
10991
11888
  // Score-cohort distribution. Buckets posts in the trailing window into
@@ -11088,6 +11985,10 @@ function renderCohortStats(payload) {
11088
11985
  }
11089
11986
 
11090
11987
  async function loadCohortStats() {
11988
+ const body = document.getElementById('cohort-stats-body');
11989
+ const totalEl = document.getElementById('cohort-stats-total');
11990
+ if (body) body.classList.add('is-loading');
11991
+ if (totalEl) totalEl.textContent = 'loading…';
11091
11992
  try {
11092
11993
  const platformRow = document.getElementById('style-stats-platform-pills');
11093
11994
  const projectRow = document.getElementById('style-stats-project-pills');
@@ -11101,8 +12002,9 @@ async function loadCohortStats() {
11101
12002
  const data = await res.json();
11102
12003
  renderCohortStats(data);
11103
12004
  } catch (e) {
11104
- const body = document.getElementById('cohort-stats-body');
11105
12005
  if (body) body.innerHTML = '<div class="style-stats-empty">Failed to load cohort stats.</div>';
12006
+ } finally {
12007
+ if (body) body.classList.remove('is-loading');
11106
12008
  }
11107
12009
  }
11108
12010
 
@@ -11637,34 +12539,40 @@ function renderCostStats(payload) {
11637
12539
  const byType = {};
11638
12540
  rows.forEach(r => { byType[r.type] = r; });
11639
12541
  const merged = COST_TYPE_ORDER.map(t => {
11640
- const r = byType[t] || { count: 0, total_cost_usd: 0, total_cost_usd_orchestrator: 0, total_cost_usd_estimated: 0 };
12542
+ const r = byType[t] || { count: 0, total_cost_usd: 0, total_cost_usd_orchestrator: 0, total_cost_usd_estimated: 0, total_cost_usd_subagent: 0 };
11641
12543
  const count = Number(r.count) || 0;
11642
- const total = Number(r.total_cost_usd) || 0;
12544
+ // SDK-only: total = orchestrator. Estimate/subagent kept for diagnostic
12545
+ // tooltips. /api/cost/stats SQL emits total_cost_usd = SUM(per_row_cost)
12546
+ // which itself is SUM(orchestrator/rows), so it equals total_cost_usd_
12547
+ // orchestrator by construction in SDK-only mode.
11643
12548
  const totalOrch = r.total_cost_usd_orchestrator != null ? Number(r.total_cost_usd_orchestrator) : null;
11644
12549
  const totalEst = r.total_cost_usd_estimated != null ? Number(r.total_cost_usd_estimated) : null;
12550
+ const totalSub = r.total_cost_usd_subagent != null ? Number(r.total_cost_usd_subagent) : null;
12551
+ const total = totalOrch != null ? totalOrch : 0;
11645
12552
  return {
11646
12553
  type: t, label: COST_TYPE_LABELS[t], count: count,
11647
- total: total, totalOrch: totalOrch, totalEst: totalEst,
12554
+ total: total, totalOrch: totalOrch, totalEst: totalEst, totalSub: totalSub,
11648
12555
  avg: count > 0 ? total / count : 0,
11649
12556
  avgOrch: count > 0 && totalOrch != null ? totalOrch / count : null,
11650
12557
  avgEst: count > 0 && totalEst != null ? totalEst / count : null,
12558
+ avgSub: count > 0 && totalSub != null ? totalSub / count : null,
11651
12559
  };
11652
12560
  });
11653
12561
  const totalCount = merged.reduce(function (a, r) { return a + r.count; }, 0);
11654
12562
  const totalCost = merged.reduce(function (a, r) { return a + r.total; }, 0);
11655
12563
  const totalOrch = merged.reduce(function (a, r) { return a + (r.totalOrch || 0); }, 0);
11656
12564
  const totalEst = merged.reduce(function (a, r) { return a + (r.totalEst || 0); }, 0);
12565
+ const totalSub = merged.reduce(function (a, r) { return a + (r.totalSub || 0); }, 0);
11657
12566
  if (totalEl) {
11658
12567
  totalEl.textContent = '$' + totalCost.toFixed(2) + ' · ' + totalCount.toLocaleString() + ' activit' + (totalCount === 1 ? 'y' : 'ies');
11659
- // Tooltip on the header pill so users can see both lanes for the
11660
- // headline figure without expanding the table.
11661
12568
  const tipLines = [
11662
- 'Orchestrator (SDK): $' + totalOrch.toFixed(4),
11663
- 'Estimated (transcript): $' + totalEst.toFixed(4),
12569
+ 'Cost (SDK orchestrator): $' + totalOrch.toFixed(4),
11664
12570
  '',
11665
- 'Displayed total prefers the SDK orchestrator cost (native streamRes.total_cost_usd) and falls back to the manual transcript estimate where the SDK value is missing.',
12571
+ 'Diagnostic-only (local pricing estimate, not actual billing):',
12572
+ ' Transcript estimate: $' + totalEst.toFixed(4),
12573
+ ' Subagent (est): $' + totalSub.toFixed(4),
11666
12574
  '',
11667
- 'Note: orchestrator cost EXCLUDES Task subagent spend (anthropics/claude-code #43945).',
12575
+ 'Anthropic SDK-reported orchestrator_cost_usd. Pipelines whose wrappers don\\'t capture --orchestrator-cost-usd contribute $0 see the per-phase table for which scripts are missing coverage.',
11668
12576
  ];
11669
12577
  totalEl.setAttribute('data-tooltip', tipLines.join('\\n'));
11670
12578
  totalEl.style.cursor = 'help';
@@ -11677,56 +12585,110 @@ function renderCostStats(payload) {
11677
12585
  return '$' + n.toFixed(2);
11678
12586
  }
11679
12587
  function fmtCount(v) { return (Number(v) || 0).toLocaleString(); }
11680
- // Wraps a money cell in a span that exposes both cost lanes via tooltip.
11681
- function moneyCell(displayed, orch, est) {
12588
+ function moneyCell(displayed, orch, est, sub) {
11682
12589
  const tip = [
11683
- 'Orchestrator (SDK): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
11684
- 'Estimated (transcript): ' + (est != null ? fmtMoney(est) : 'n/a'),
12590
+ 'Cost (SDK orchestrator): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
11685
12591
  '',
11686
- 'Displayed value prefers SDK; falls back to transcript estimate. Subagent costs excluded (anthropics/claude-code #43945).',
12592
+ 'Diagnostic-only (local pricing estimate):',
12593
+ ' Transcript estimate: ' + (est != null ? fmtMoney(est) : 'n/a'),
12594
+ ' Subagent (est): ' + (sub != null ? fmtMoney(sub) : 'n/a'),
11687
12595
  ].join('\\n');
11688
12596
  return '<span data-tooltip="' + escapeHtml(tip) +
11689
12597
  '" style="cursor:help;border-bottom:1px dotted var(--text-muted);">' +
11690
12598
  fmtMoney(displayed) + '</span>';
11691
12599
  }
11692
12600
  const rowsHtml = merged.map(function (r) {
11693
- const totalCellHtml = moneyCell(r.total, r.totalOrch, r.totalEst);
12601
+ const totalCellHtml = moneyCell(r.total, r.totalOrch, r.totalEst, r.totalSub);
12602
+ const subCellHtml = r.totalSub != null && r.totalSub > 0 ? fmtMoney(r.totalSub) : '<span style="color:var(--text-very-faint);">$0</span>';
11694
12603
  const avgCellHtml = r.count > 0
11695
- ? moneyCell(r.avg, r.avgOrch, r.avgEst)
12604
+ ? moneyCell(r.avg, r.avgOrch, r.avgEst, r.avgSub)
11696
12605
  : '&mdash;';
11697
12606
  return '<tr>' +
11698
12607
  '<td>' + escapeHtml(r.label) + '</td>' +
11699
12608
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + fmtCount(r.count) + '</td>' +
11700
12609
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + totalCellHtml + '</td>' +
12610
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + subCellHtml + '</td>' +
11701
12611
  '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + avgCellHtml + '</td>' +
11702
12612
  '</tr>';
11703
12613
  }).join('');
11704
- const footerTotalHtml = moneyCell(totalCost, totalOrch, totalEst);
12614
+ const footerTotalHtml = moneyCell(totalCost, totalOrch, totalEst, totalSub);
12615
+ const footerSubHtml = totalSub > 0 ? fmtMoney(totalSub) : '<span style="color:var(--text-very-faint);">$0</span>';
11705
12616
  const footerAvgHtml = totalCount > 0
11706
12617
  ? moneyCell(totalCost / totalCount,
11707
12618
  totalOrch / totalCount,
11708
- totalEst / totalCount)
12619
+ totalEst / totalCount,
12620
+ totalSub / totalCount)
11709
12621
  : '&mdash;';
11710
12622
  const footerHtml =
11711
12623
  '<tr style="border-top:2px solid var(--border);font-weight:600;background:var(--bg-subtle);">' +
11712
12624
  '<td>Total</td>' +
11713
12625
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + fmtCount(totalCount) + '</td>' +
11714
12626
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + footerTotalHtml + '</td>' +
12627
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + footerSubHtml + '</td>' +
11715
12628
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + footerAvgHtml + '</td>' +
11716
12629
  '</tr>';
12630
+ // Per-phase (script) breakdown. Same window, separate query — gives the
12631
+ // operator an answer to "which phase of which pipeline is burning cash?"
12632
+ // independent of the activity-type rollup above. A row like
12633
+ // run-twitter-cycle-scan dominating the spend is the signal to investigate.
12634
+ const phases = (payload && payload.phases) || [];
12635
+ let phaseTableHtml = '';
12636
+ if (phases.length) {
12637
+ const phaseRowsHtml = phases.map(function (p) {
12638
+ const orch = p.total_cost_usd_orchestrator != null ? Number(p.total_cost_usd_orchestrator) : 0;
12639
+ const est = p.total_cost_usd_estimated != null ? Number(p.total_cost_usd_estimated) : null;
12640
+ const sub = p.total_cost_usd_subagent != null ? Number(p.total_cost_usd_subagent) : null;
12641
+ const sessions = Number(p.sessions) || 0;
12642
+ const withSdk = Number(p.sessions_with_sdk) || 0;
12643
+ const missing = Math.max(0, sessions - withSdk);
12644
+ // SDK-only: per-phase total = orchestrator sum. Phases with 0% SDK
12645
+ // coverage show $0 with a "(N/N missing)" hint so it's obvious the
12646
+ // wrapper needs patching.
12647
+ const totalCellInner = withSdk > 0
12648
+ ? moneyCell(orch, orch, est, sub)
12649
+ : '<span style="color:var(--text-very-faint);">n/a</span>';
12650
+ const coverageCell = missing === 0
12651
+ ? ('<span style="color:#15803d;">' + sessions + '/' + sessions + '</span>')
12652
+ : ('<span style="color:#b91c1c;" title="' + missing + ' sessions missing SDK cost (wrapper script needs --output-format json on claude call)">' +
12653
+ withSdk + '/' + sessions + '</span>');
12654
+ const perSession = withSdk > 0
12655
+ ? moneyCell(orch / withSdk, orch / withSdk, est != null ? est / sessions : null, sub != null ? sub / sessions : null)
12656
+ : '&mdash;';
12657
+ return '<tr>' +
12658
+ '<td style="font-family:ui-monospace,monospace;font-size:12px;">' + escapeHtml(p.phase || '(unknown)') + '</td>' +
12659
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + fmtCount(sessions) + '</td>' +
12660
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;font-size:11px;">' + coverageCell + '</td>' +
12661
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + totalCellInner + '</td>' +
12662
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + perSession + '</td>' +
12663
+ '</tr>';
12664
+ }).join('');
12665
+ phaseTableHtml =
12666
+ '<div style="font-size:12px;font-weight:600;padding:12px 2px 4px;color:var(--text-secondary);">Cost per Phase (Claude session script)</div>' +
12667
+ '<table class="style-stats-table">' +
12668
+ '<thead><tr>' +
12669
+ '<th style="text-align:left;">Phase</th>' +
12670
+ '<th style="text-align:right;">Sessions</th>' +
12671
+ '<th style="text-align:right;" title="Sessions with SDK cost captured / total sessions. Red = wrapper needs patching.">SDK coverage</th>' +
12672
+ '<th style="text-align:right;">Cost (SDK)</th>' +
12673
+ '<th style="text-align:right;">Cost per Session</th>' +
12674
+ '</tr></thead>' +
12675
+ '<tbody>' + phaseRowsHtml + '</tbody>' +
12676
+ '</table>';
12677
+ }
11717
12678
  body.innerHTML =
11718
12679
  '<table class="style-stats-table">' +
11719
12680
  '<thead><tr>' +
11720
12681
  '<th style="text-align:left;">Type</th>' +
11721
12682
  '<th style="text-align:right;">Activities</th>' +
11722
12683
  '<th style="text-align:right;">Total Cost</th>' +
12684
+ '<th style="text-align:right;">Subagent</th>' +
11723
12685
  '<th style="text-align:right;">Cost per Activity</th>' +
11724
12686
  '</tr></thead>' +
11725
12687
  '<tbody>' + rowsHtml + footerHtml + '</tbody>' +
11726
12688
  '</table>' +
12689
+ phaseTableHtml +
11727
12690
  '<div style="font-size:11px;color:var(--text-muted);padding:8px 2px 2px;">' +
11728
- 'Cost is Claude session spend split evenly across the activity rows each session produced. ' +
11729
- 'Totals here exclude skipped replies, resurrected posts, DM replies, and mentions.' +
12691
+ 'SDK-only mode (2026-05-15): cost = Anthropic\\'s orchestrator_cost_usd from the SDK result event. Pipelines whose wrappers don\\'t pass --output-format json to claude show $0 with a red SDK-coverage cell — that\\'s a missing-data signal, not a real $0. Transcript estimate and Task-subagent figures (tooltip-only) come from a local pricing table and don\\'t reflect subscription billing.' +
11730
12692
  '</div>';
11731
12693
  }
11732
12694
 
@@ -11779,6 +12741,10 @@ let _topDmsTableState = { sortField: 'rank', sortDir: 'asc', filters: {} };
11779
12741
  let _topDmsLoaded = false;
11780
12742
  let _topDmsLoading = false;
11781
12743
  let _topDmsPayload = null;
12744
+ let _topLinksTableState = { sortField: 'real_clicks', sortDir: 'desc', filters: {} };
12745
+ let _topLinksLoaded = false;
12746
+ let _topLinksLoading = false;
12747
+ let _topLinksPayload = null;
11782
12748
  let _topDmDir = saLoad('sa.top.dmDir.v1', 'all');
11783
12749
  let _topDmInterest = saLoad('sa.top.dmInterest.v1', 'all');
11784
12750
  let _topDmMode = saLoad('sa.top.dmMode.v1', 'all');
@@ -12311,6 +13277,7 @@ const TOP_SUBTAB_HELP = {
12311
13277
  comments: 'Top comments your accounts have left under other people’s threads, ranked by reach and reactions.',
12312
13278
  pages: 'Top landing/SEO pages on your sites this period, ranked by pageviews.',
12313
13279
  dms: 'Direct message conversations with prospects, ranked by recent activity.',
13280
+ links: 'Destination URLs across all posts, ranked by clicks. One row per unique target URL (homepage vs audience pages vs SEO pages vs booking).',
12314
13281
  };
12315
13282
  function syncTopSubtabHelp() {
12316
13283
  const el = document.getElementById('top-subtab-help');
@@ -12363,12 +13330,14 @@ function initTopFilters() {
12363
13330
  saveDashboardWindow(_topWindow);
12364
13331
  if (_topSubtab === 'pages') loadTopPages(true);
12365
13332
  else if (_topSubtab === 'dms') { _topDmOffset = 0; loadTopDms(true); }
13333
+ else if (_topSubtab === 'links') loadTopLinks(true);
12366
13334
  else loadTopPosts(true);
12367
13335
  });
12368
13336
  wireTopPillRow('top-platform-pills', (v) => {
12369
13337
  _topPlatform = v || 'all';
12370
13338
  saSave('sa.top.platform.v1', _topPlatform);
12371
13339
  if (_topSubtab === 'dms') { _topDmOffset = 0; loadTopDms(true); }
13340
+ else if (_topSubtab === 'links') loadTopLinks(true);
12372
13341
  else loadTopPosts(true);
12373
13342
  });
12374
13343
  wireTopPillRow('top-project-pills', (v) => {
@@ -12376,6 +13345,7 @@ function initTopFilters() {
12376
13345
  saSave('sa.top.project.v1', _topProject);
12377
13346
  if (_topSubtab === 'pages') renderTopPagesFromCache();
12378
13347
  else if (_topSubtab === 'dms') { if (_topDmsPayload) renderTopDms(_topDmsPayload); }
13348
+ else if (_topSubtab === 'links') loadTopLinks(true);
12379
13349
  else loadTopPosts(true); // refetch so the SQL LIMIT applies AFTER project filter
12380
13350
  });
12381
13351
  wireTopPillRow('top-campaign-pills', (v) => {
@@ -12494,6 +13464,7 @@ function applyTopSubtabState(sub, loadData) {
12494
13464
  const pagesC = document.getElementById('top-pages-container');
12495
13465
  const pagesUnknownC = document.getElementById('top-pages-unknown-container');
12496
13466
  const dmsC = document.getElementById('top-dms-container');
13467
+ const linksC = document.getElementById('top-links-container');
12497
13468
  const platRowEl = document.getElementById('top-platform-pills');
12498
13469
  const projRowEl = document.getElementById('top-project-pills');
12499
13470
  const campRowEl = document.getElementById('top-campaign-pills');
@@ -12520,6 +13491,7 @@ function applyTopSubtabState(sub, loadData) {
12520
13491
  if (sub === 'pages') {
12521
13492
  if (postsC) postsC.classList.add('hidden');
12522
13493
  if (dmsC) dmsC.classList.add('hidden');
13494
+ if (linksC) linksC.classList.add('hidden');
12523
13495
  if (pagesC) pagesC.classList.remove('hidden');
12524
13496
  if (pagesUnknownC) pagesUnknownC.classList.remove('hidden');
12525
13497
  if (platRowEl) platRowEl.classList.add('hidden');
@@ -12533,6 +13505,7 @@ function applyTopSubtabState(sub, loadData) {
12533
13505
  if (postsC) postsC.classList.add('hidden');
12534
13506
  if (pagesC) pagesC.classList.add('hidden');
12535
13507
  if (pagesUnknownC) pagesUnknownC.classList.add('hidden');
13508
+ if (linksC) linksC.classList.add('hidden');
12536
13509
  if (dmsC) dmsC.classList.remove('hidden');
12537
13510
  if (platRowEl) platRowEl.classList.remove('hidden');
12538
13511
  if (srcRowEl) srcRowEl.classList.add('hidden');
@@ -12546,10 +13519,29 @@ function applyTopSubtabState(sub, loadData) {
12546
13519
  searchElDm.value = _topDmSearch || '';
12547
13520
  }
12548
13521
  if (loadData) loadTopDms(true);
13522
+ } else if (sub === 'links') {
13523
+ if (postsC) postsC.classList.add('hidden');
13524
+ if (pagesC) pagesC.classList.add('hidden');
13525
+ if (pagesUnknownC) pagesUnknownC.classList.add('hidden');
13526
+ if (dmsC) dmsC.classList.add('hidden');
13527
+ if (linksC) linksC.classList.remove('hidden');
13528
+ if (platRowEl) platRowEl.classList.remove('hidden');
13529
+ if (srcRowEl) srcRowEl.classList.add('hidden');
13530
+ if (campRowEl) campRowEl.classList.add('hidden');
13531
+ setDmRowsHidden(true);
13532
+ setLinkPillHidden(true);
13533
+ if (totalEl) totalEl.textContent = '';
13534
+ const searchElLinks = document.getElementById('top-search');
13535
+ if (searchElLinks) {
13536
+ searchElLinks.placeholder = 'Filter destinations by URL\u2026';
13537
+ searchElLinks.value = (_topLinksTableState && _topLinksTableState.globalQuery) || '';
13538
+ }
13539
+ if (loadData) loadTopLinks(true);
12549
13540
  } else {
12550
13541
  if (pagesC) pagesC.classList.add('hidden');
12551
13542
  if (pagesUnknownC) pagesUnknownC.classList.add('hidden');
12552
13543
  if (dmsC) dmsC.classList.add('hidden');
13544
+ if (linksC) linksC.classList.add('hidden');
12553
13545
  if (postsC) postsC.classList.remove('hidden');
12554
13546
  if (platRowEl) platRowEl.classList.remove('hidden');
12555
13547
  if (srcRowEl) srcRowEl.classList.add('hidden');
@@ -12729,6 +13721,151 @@ async function loadTopPages(force) {
12729
13721
  }
12730
13722
  }
12731
13723
 
13724
+ // Render the colored kind badge for a destination row. The server's
13725
+ // /api/top/destinations endpoint classifies each row into one of seven kind
13726
+ // buckets (home / subpage / audience_page / seo / booking / github /
13727
+ // external / other) by reading config.json, so the client just looks up
13728
+ // the label and color here.
13729
+ function destinationKindBadge(kind, audienceAngle) {
13730
+ const map = {
13731
+ home: { cls: 'dest-kind-home', label: 'HOME' },
13732
+ subpage: { cls: 'dest-kind-subpage', label: 'SUBPAGE' },
13733
+ audience_page: { cls: 'dest-kind-subpage', label: 'AUDIENCE' },
13734
+ seo: { cls: 'dest-kind-seo', label: 'SEO' },
13735
+ booking: { cls: 'dest-kind-booking', label: 'BOOKING' },
13736
+ github: { cls: 'dest-kind-github', label: 'GITHUB' },
13737
+ external: { cls: 'dest-kind-external', label: 'EXT' },
13738
+ other: { cls: 'dest-kind-other', label: 'OTHER' },
13739
+ };
13740
+ const m = map[kind] || map.other;
13741
+ let label = m.label;
13742
+ if (kind === 'audience_page' && audienceAngle) {
13743
+ label = 'AUDIENCE: ' + audienceAngle;
13744
+ }
13745
+ return '<span class="dest-kind-badge ' + m.cls + '" title="' + escapeHtml(kind) + '">' + escapeHtml(label) + '</span>';
13746
+ }
13747
+
13748
+ async function loadTopLinks(force) {
13749
+ if (_topLinksLoading) return;
13750
+ const container = document.getElementById('top-links-container');
13751
+ if (!_topLinksPayload && container) {
13752
+ container.innerHTML = '<div class="style-stats-empty">Loading\u2026</div>';
13753
+ }
13754
+ _topLinksLoading = true;
13755
+ try {
13756
+ const params = new URLSearchParams();
13757
+ if (_topWindow) params.set('window', _topWindow);
13758
+ if (_topPlatform && _topPlatform !== 'all') params.set('platform', _topPlatform);
13759
+ if (_topProject && _topProject !== 'all') params.set('project', _topProject);
13760
+ const res = await fetch('/api/top/destinations?' + params.toString());
13761
+ const data = await res.json();
13762
+ _topLinksPayload = data;
13763
+ renderTopLinks(data);
13764
+ _topLinksLoaded = true;
13765
+ } catch (e) {
13766
+ if (container) container.innerHTML = '<div class="style-stats-empty">Failed to load.</div>';
13767
+ } finally {
13768
+ _topLinksLoading = false;
13769
+ }
13770
+ }
13771
+
13772
+ function renderTopLinks(payload) {
13773
+ const container = document.getElementById('top-links-container');
13774
+ if (!container) return;
13775
+ const totalEl = document.getElementById('top-total');
13776
+ const fmt = n => (Number(n) || 0).toLocaleString();
13777
+ const dests = Array.isArray(payload && payload.destinations) ? payload.destinations : [];
13778
+ if (!dests.length) {
13779
+ container.innerHTML = '<div class="style-stats-empty">No destinations in this window yet. Posts with linked URLs will show up here once they accrue clicks.</div>';
13780
+ if (totalEl) totalEl.textContent = '';
13781
+ return;
13782
+ }
13783
+ // Roll up real_clicks vs the legacy/backfill columns: prefer plc.real_clicks
13784
+ // (post-2026-05-07 per-hit log), fall back to pl.real_clicks (PostHog
13785
+ // backfill for older rows), final fallback pl.clicks (legacy counter).
13786
+ const rows = dests.map(d => {
13787
+ const kind = d.kind || 'other';
13788
+ const realClicks = Number(d.real_clicks || 0);
13789
+ const backfillReal = Number(d.backfill_real || 0);
13790
+ const legacyClicks = Number(d.legacy_clicks || 0);
13791
+ const botClicks = Number(d.bot_clicks || 0);
13792
+ const effectiveClicks = realClicks > 0 ? realClicks : (backfillReal > 0 ? backfillReal : legacyClicks);
13793
+ return {
13794
+ target_url: d.target_url || '',
13795
+ project_name: d.project_name || '',
13796
+ platform: d.platform || '',
13797
+ kind,
13798
+ audience_page_angle: d.audience_page_angle || null,
13799
+ kind_label: kind,
13800
+ posts: Number(d.posts || 0),
13801
+ codes: Number(d.codes || 0),
13802
+ real_clicks: realClicks,
13803
+ backfill_real: backfillReal,
13804
+ legacy_clicks: legacyClicks,
13805
+ bot_clicks: botClicks,
13806
+ effective_clicks: effectiveClicks,
13807
+ first_minted_at: d.first_minted_at || null,
13808
+ last_click_at: d.last_click_at || null,
13809
+ };
13810
+ });
13811
+ if (totalEl) {
13812
+ const totalClicks = rows.reduce((a, r) => a + r.effective_clicks, 0);
13813
+ totalEl.textContent = rows.length + ' destination' + (rows.length === 1 ? '' : 's') + ' \u00b7 ' + fmt(totalClicks) + ' clicks';
13814
+ }
13815
+ const fmtUrl = (_v, r) => {
13816
+ const safe = escapeHtml(r.target_url);
13817
+ return '<a href="' + safe + '" target="_blank" rel="noopener" class="top-post-link">'
13818
+ + destinationKindBadge(r.kind, r.audience_page_angle)
13819
+ + ' <span style="word-break:break-all">' + safe + '</span>'
13820
+ + '</a>';
13821
+ };
13822
+ const fmtAgo = (v) => {
13823
+ if (!v) return '\u2014';
13824
+ try {
13825
+ const d = new Date(v);
13826
+ const diff = Date.now() - d.getTime();
13827
+ const days = Math.floor(diff / 86400000);
13828
+ if (days < 1) {
13829
+ const hours = Math.floor(diff / 3600000);
13830
+ if (hours < 1) {
13831
+ const mins = Math.max(0, Math.floor(diff / 60000));
13832
+ return mins + 'm ago';
13833
+ }
13834
+ return hours + 'h ago';
13835
+ }
13836
+ return days + 'd ago';
13837
+ } catch (_e) { return '\u2014'; }
13838
+ };
13839
+ const fmtClicks = (v, r) => {
13840
+ const main = fmt(r.effective_clicks);
13841
+ const bits = [];
13842
+ if (r.real_clicks > 0) bits.push(r.real_clicks + ' real');
13843
+ else if (r.backfill_real > 0) bits.push(r.backfill_real + ' backfill');
13844
+ else if (r.legacy_clicks > 0) bits.push(r.legacy_clicks + ' legacy');
13845
+ if (r.bot_clicks > 0) bits.push(r.bot_clicks + ' bot');
13846
+ const sub = bits.length ? '<div style="font-size:11px;color:var(--text-secondary)">' + escapeHtml(bits.join(' \u00b7 ')) + '</div>' : '';
13847
+ return '<div style="font-weight:600">' + main + '</div>' + sub;
13848
+ };
13849
+ const columns = [
13850
+ { key: 'target_url', label: 'Destination', type: 'text', align: 'left', widthPct: 48, formatter: fmtUrl },
13851
+ { key: 'project_name', label: 'Project', type: 'text', align: 'left', widthPct: 10, formatter: v => escapeHtml(v) },
13852
+ { key: 'platform', label: 'Platform', type: 'text', align: 'left', widthPct: 8, formatter: v => escapeHtml(v) },
13853
+ { key: 'posts', label: 'Posts', type: 'numeric', align: 'right', widthPct: 7, formatter: fmt },
13854
+ { key: 'codes', label: 'Codes', type: 'numeric', align: 'right', widthPct: 7, formatter: fmt },
13855
+ { key: 'effective_clicks', label: 'Clicks', type: 'numeric', align: 'right', widthPct: 12, formatter: fmtClicks },
13856
+ { key: 'last_click_at', label: 'Last click', type: 'date', align: 'right', widthPct: 8, formatter: fmtAgo },
13857
+ ];
13858
+ container.innerHTML = '';
13859
+ mountSortableTable({
13860
+ containerId: 'top-links-container',
13861
+ rows,
13862
+ state: _topLinksTableState,
13863
+ storageKey: 'sa.topLinksTable.v1',
13864
+ columns,
13865
+ emptyMessage: 'No destinations in this window yet.',
13866
+ });
13867
+ }
13868
+
12732
13869
  function dmClassBadge(dm) {
12733
13870
  const status = String(dm.conversation_status || '').toLowerCase();
12734
13871
  const interest = String(dm.interest_level || '').toLowerCase();
@@ -13960,6 +15097,7 @@ function renderProjectStatus(data, opts) {
13960
15097
  const grandCost = Number(data && data.grand_cost_usd) || 0;
13961
15098
  const grandCostOrch = Number(data && data.grand_cost_usd_orchestrator) || 0;
13962
15099
  const grandCostEst = Number(data && data.grand_cost_usd_estimated) || 0;
15100
+ const grandCostSub = Number(data && data.grand_cost_usd_subagent) || 0;
13963
15101
  // Money formatter mirrors fmtCost: $0, $0.0042, $12.34.
13964
15102
  const fmtMoney = (v) => {
13965
15103
  const n = Number(v) || 0;
@@ -13969,12 +15107,17 @@ function renderProjectStatus(data, opts) {
13969
15107
  };
13970
15108
  // Money cell with tooltip exposing SDK + estimate lanes, same UX as
13971
15109
  // moneyCell in renderCostStats so operators see consistent numbers.
13972
- const costCell = (displayed, orch, est, opts) => {
15110
+ const costCell = (displayed, orch, est, sub, opts) => {
15111
+ // SDK-only mode: displayed value comes from orchestrator_cost_usd; the
15112
+ // estimate and subagent are diagnostic-only (local pricing table).
13973
15113
  const tip = [
13974
- 'Orchestrator (SDK): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
13975
- 'Estimated (transcript): ' + (est != null ? fmtMoney(est) : 'n/a'),
15114
+ 'Cost (SDK orchestrator): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
15115
+ '',
15116
+ 'Diagnostic-only (local pricing estimate, not actual billing):',
15117
+ ' Transcript estimate: ' + (est != null ? fmtMoney(est) : 'n/a'),
15118
+ ' Subagent (est): ' + (sub != null ? fmtMoney(sub) : 'n/a'),
13976
15119
  '',
13977
- 'Displayed value prefers SDK; falls back to transcript estimate. Subagent costs excluded (anthropics/claude-code #43945).',
15120
+ 'Anthropic SDK-reported cost only. "n/a" or $0 means the wrapper didn\\'t capture --orchestrator-cost-usd (no --output-format json on the claude call) for one or more sessions in this window. Subagent and transcript estimates are computed from a local pricing table and are not billing-accurate on subscription plans.',
13978
15121
  ].join('\\n');
13979
15122
  const style = 'text-align:right;font-variant-numeric:tabular-nums;' + (opts && opts.extra || '');
13980
15123
  const inner = '<span data-tooltip="' + escapeHtml(tip) +
@@ -13990,10 +15133,13 @@ function renderProjectStatus(data, opts) {
13990
15133
  : base;
13991
15134
  if (costAvailable) {
13992
15135
  const tipLines = [
13993
- 'Orchestrator (SDK): ' + fmtMoney(grandCostOrch),
13994
- 'Estimated (transcript): ' + fmtMoney(grandCostEst),
15136
+ 'Cost (SDK orchestrator): ' + fmtMoney(grandCostOrch),
13995
15137
  '',
13996
- 'Total Claude session cost across all activity rows (posts, comments, DMs, SEO pages) attributed to projects in this window. Same attribution model as Cost per Activity.',
15138
+ 'Diagnostic-only (local pricing estimate, not actual billing):',
15139
+ ' Transcript estimate: ' + fmtMoney(grandCostEst),
15140
+ ' Subagent (est): ' + fmtMoney(grandCostSub),
15141
+ '',
15142
+ 'Anthropic SDK-reported orchestrator_cost_usd across all activity rows in this window. Per-session attribution is the session\\'s cost split evenly across its activity rows. Pipelines whose wrappers don\\'t pass --output-format json to claude contribute $0.',
13997
15143
  ];
13998
15144
  totalEl.setAttribute('data-tooltip', tipLines.join('\\n'));
13999
15145
  totalEl.style.cursor = 'help';
@@ -14072,7 +15218,7 @@ function renderProjectStatus(data, opts) {
14072
15218
  : nameCell;
14073
15219
  const totalCell = cellWithShare(r.total, grandTotal, targetShare, { extra: 'font-weight:600;', showZeroShare: true });
14074
15220
  const costCellHtml = costAvailable
14075
- ? costCell(Number(r.cost_usd) || 0, Number(r.cost_usd_orchestrator) || 0, Number(r.cost_usd_estimated) || 0, { extra: 'color:var(--text-secondary);' })
15221
+ ? costCell(Number(r.cost_usd) || 0, Number(r.cost_usd_orchestrator) || 0, Number(r.cost_usd_estimated) || 0, Number(r.cost_usd_subagent) || 0, { extra: 'color:var(--text-secondary);' })
14076
15222
  : '';
14077
15223
  const weightVal = Number(r.weight) || 0;
14078
15224
  const editable = canEditWeight && (!r.unassigned || r.configured);
@@ -14108,7 +15254,7 @@ function renderProjectStatus(data, opts) {
14108
15254
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + (Number(totals[p]) || 0) + '</td>'
14109
15255
  ).join('');
14110
15256
  const footerCostCell = costAvailable
14111
- ? costCell(grandCost, grandCostOrch, grandCostEst, { extra: 'font-weight:600;' })
15257
+ ? costCell(grandCost, grandCostOrch, grandCostEst, grandCostSub, { extra: 'font-weight:600;' })
14112
15258
  : '';
14113
15259
  const footerHtml =
14114
15260
  '<tr style="border-top:2px solid var(--border);font-weight:600;background:var(--bg-subtle);">' +
@@ -14451,7 +15597,7 @@ function renderActivity(events) {
14451
15597
  '</div>' +
14452
15598
  '</td>' +
14453
15599
  '<td class="activity-summary">' + summaryHtml + '</td>' +
14454
- '<td class="sa-admin-only" style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-secondary);">' + fmtCostCell(e.cost_usd, e.cost_usd_orchestrator, e.cost_usd_estimated) + '</td>' +
15600
+ '<td class="sa-admin-only" style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-secondary);">' + fmtCostCell(e.cost_usd, e.cost_usd_orchestrator, e.cost_usd_estimated, e.cost_usd_subagent) + '</td>' +
14455
15601
  '<td style="text-align:center;">' + renderDeleteBtnHtml(e) + '</td>' +
14456
15602
  '</tr>';
14457
15603
  }).join('');
@@ -15013,6 +16159,25 @@ function renderHtml() {
15013
16159
  .replace('__SA_POSTHOG_CONFIG_PLACEHOLDER__', JSON.stringify(posthogWebConfig()));
15014
16160
  }
15015
16161
 
16162
+ function renderTikTokOauthCallback(rawUrl) {
16163
+ const u = new URL(rawUrl, 'http://localhost');
16164
+ const code = u.searchParams.get('code') || '';
16165
+ const state = u.searchParams.get('state') || '';
16166
+ const scopes = u.searchParams.get('scopes') || '';
16167
+ const err = u.searchParams.get('error') || '';
16168
+ const errDesc = u.searchParams.get('error_description') || '';
16169
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
16170
+ const ok = !!code && !err;
16171
+ const banner = ok
16172
+ ? '<div style="background:#ecfdf5;color:#065f46;padding:12px 16px;border-radius:8px;margin-bottom:24px">Authorization successful. Copy the code below and paste it into the local oauth_helper.py script.</div>'
16173
+ : `<div style="background:#fef2f2;color:#991b1b;padding:12px 16px;border-radius:8px;margin-bottom:24px">Authorization error: ${esc(err || 'no code in callback')}${errDesc ? ` &mdash; ${esc(errDesc)}` : ''}</div>`;
16174
+ const codeBox = ok ? `<div><label style="display:block;font-size:13px;color:#52525b;margin-bottom:6px">Authorization code</label><textarea id="tt-code" readonly style="width:100%;font-family:ui-monospace,SFMono-Regular,monospace;font-size:14px;padding:10px;border:1px solid #d4d4d8;border-radius:8px;background:#fafafa" rows="3" onclick="this.select()">${esc(code)}</textarea><button id="tt-copy" style="margin-top:8px;padding:8px 14px;background:#18181b;color:#fff;border:0;border-radius:6px;cursor:pointer;font-size:13px">Copy</button></div><script>(function(){var b=document.getElementById('tt-copy'),t=document.getElementById('tt-code');if(b&&t){b.addEventListener('click',function(){navigator.clipboard.writeText(t.value).then(function(){b.textContent='Copied';setTimeout(function(){b.textContent='Copy'},1500)})})}})();</script>` : '';
16175
+ const next = ok ? `<details style="margin-top:24px"><summary style="cursor:pointer;color:#27272a;font-weight:500">Next steps</summary><pre style="background:#0a0a0a;color:#fafafa;padding:16px;border-radius:8px;overflow:auto;font-size:13px;line-height:1.6;margin-top:12px">cd ~/social-autoposter
16176
+ python3 scripts/tiktok/oauth_helper.py exchange '${esc(code)}'</pre><p style="color:#52525b;font-size:13px;margin-top:12px">This exchanges the code for an access token + refresh token via the TikTok API using the client secret stored in your local macOS keychain, then writes the tokens to <code>~/tiktok-content-api/.env</code>.</p></details>` : '';
16177
+ const meta = ok ? `<dl style="display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin-top:24px;font-size:13px;color:#52525b"><dt>state</dt><dd style="font-family:ui-monospace,SFMono-Regular,monospace">${esc(state) || '<i>(none)</i>'}</dd><dt>scopes</dt><dd style="font-family:ui-monospace,SFMono-Regular,monospace">${esc(scopes) || '<i>(none)</i>'}</dd></dl>` : '';
16178
+ return `<!doctype html><html><head><meta charset="utf-8"><title>TikTok OAuth callback &middot; Meditation Fellow Studio</title><meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font-family:-apple-system,BlinkMacSystemFont,Inter,system-ui,sans-serif;max-width:680px;margin:48px auto;padding:0 24px;color:#18181b;background:#fff;line-height:1.5}h1{font-size:24px;font-weight:600;margin:0 0 24px;letter-spacing:-0.01em}code{font-family:ui-monospace,SFMono-Regular,monospace;background:#f4f4f5;padding:1px 6px;border-radius:4px;font-size:0.9em}</style></head><body><h1>TikTok OAuth callback</h1>${banner}${codeBox}${meta}${next}</body></html>`;
16179
+ }
16180
+
15016
16181
  // --- Server ---
15017
16182
 
15018
16183
  const server = http.createServer((req, res) => {
@@ -15038,6 +16203,14 @@ const server = http.createServer((req, res) => {
15038
16203
  Promise.resolve(handleApi(req, res)).catch(e => {
15039
16204
  try { json(res, { error: e.message || String(e) }, 500); } catch {}
15040
16205
  });
16206
+ } else if (pathname === '/oauth/tiktok/callback') {
16207
+ // Minimal TikTok OAuth landing page. We do not exchange the code here
16208
+ // because the dashboard Cloud Run service does not carry the TikTok
16209
+ // client_secret. The operator pastes the displayed code into the local
16210
+ // scripts/tiktok/oauth_helper.py script which does the exchange via
16211
+ // keychain-stored credentials and writes ~/tiktok-content-api/.env.
16212
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
16213
+ res.end(renderTikTokOauthCallback(req.url));
15041
16214
  } else {
15042
16215
  res.writeHead(404);
15043
16216
  res.end('Not found');