adsinagents 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/dist/index.js CHANGED
@@ -4124,7 +4124,16 @@ var AdSchema = external_exports.object({
4124
4124
  * the daemon is running standalone (no server) — those imps earn nothing.
4125
4125
  * See shared/src/nonce.ts.
4126
4126
  */
4127
- nonce: external_exports.string().default("")
4127
+ nonce: external_exports.string().default(""),
4128
+ /**
4129
+ * Publisher payout RATES for this ad, in millicents, computed server-side from
4130
+ * the live CPM + share + click multiplier (earnings.ts) so the device never
4131
+ * hardcodes the math — change the campaign cpm and these move with it. Shown in
4132
+ * the opt-in payout segment ("+$0.002/view · +$0.10/click"). Default 0 so older
4133
+ * ads / standalone daemons (no server rate) simply render nothing.
4134
+ */
4135
+ payoutPerImpMillicents: external_exports.number().min(0).default(0),
4136
+ payoutPerClickMillicents: external_exports.number().min(0).default(0)
4128
4137
  });
4129
4138
  var GravityAdSchema = external_exports.object({
4130
4139
  adText: external_exports.string(),
@@ -4166,16 +4175,20 @@ var ConfigSchema = external_exports.object({
4166
4175
  deviceId: external_exports.string().default(""),
4167
4176
  /** Single-use token to claim this install at /claim (attach a login + payout). */
4168
4177
  claimToken: external_exports.string().default(""),
4169
- /** Ad cache poll interval (seconds) — how often a new ad rotates in. Billing
4170
- * is gated separately by rateCapSec, so a faster poll rotates creatives
4171
- * without changing earnings. */
4178
+ /** Ad cache poll interval (seconds) — how often a new ad rotates in. Synced
4179
+ * with the fire: rateCapSec defaults to the SAME value, so every new ad fires
4180
+ * exactly once (no silent "every other ad skipped"). The next ad is preloaded
4181
+ * a few seconds early so the swap is instant. */
4172
4182
  pollIntervalSec: external_exports.number().int().positive().default(30),
4173
4183
  /** Hard daily fired-impression cap. */
4174
4184
  dailyCap: external_exports.number().int().positive().default(300),
4175
- /** Min seconds of verified view between two fired impressions. */
4176
- rateCapSec: external_exports.number().int().positive().default(60),
4177
- /** Continuous focus seconds required before an impression verifies. */
4178
- focusDwellSec: external_exports.number().int().positive().default(5),
4185
+ /** Min seconds of verified view between two fired impressions. Defaults to the
4186
+ * rotation period (30s) so fire == rotation: each new ad booms once. */
4187
+ rateCapSec: external_exports.number().int().positive().default(30),
4188
+ /** Continuous focus seconds before an impression verifies. 1s = the IAB/MRC
4189
+ * viewability standard (≥1 continuous second in view). A real glance counts;
4190
+ * a sub-second flick-past still doesn't bill the advertiser. */
4191
+ focusDwellSec: external_exports.number().int().positive().default(1),
4179
4192
  /**
4180
4193
  * Seconds the ad stays painted after a turn ends (Stop hook). Past the grace
4181
4194
  * the status line goes clean until the next prompt. 0 = hide on Stop.
@@ -4186,6 +4199,8 @@ var ConfigSchema = external_exports.object({
4186
4199
  killSwitchUrl: external_exports.string().default(""),
4187
4200
  /** Show a live earnings segment in the status line (separate from the ad). Off by default. */
4188
4201
  showEarnings: external_exports.boolean().default(false),
4202
+ /** Show the per-event payout rate for the current ad ("+$0.002/view · +$0.10/click"). On by default. */
4203
+ showPayout: external_exports.boolean().default(true),
4189
4204
  /** Terminal styling for ad/earnings segments. Plain (no ANSI) by default. */
4190
4205
  theme: ThemeSchema.default("plain")
4191
4206
  });
@@ -4209,7 +4224,25 @@ var DaemonStateSchema = external_exports.object({
4209
4224
  todayCents: external_exports.number().int().nonnegative().default(0),
4210
4225
  /** Claude is mid-turn (prompt received, Stop not yet). Drives the statusline
4211
4226
  * working glyph. Additive — older daemons omit it. */
4212
- working: external_exports.boolean().default(false)
4227
+ working: external_exports.boolean().default(false),
4228
+ /** Unix ms of the last ad rotation (== lastPollMs). Anchors the rotation
4229
+ * countdown bar; the statusline derives fill from (now - rotatedAtMs).
4230
+ * Additive — 0 from older daemons hides the bar. */
4231
+ rotatedAtMs: external_exports.number().int().nonnegative().default(0),
4232
+ /** Rotation period in ms (== pollIntervalSec * 1000). Bar denominator.
4233
+ * Additive — 0 from older daemons hides the bar. */
4234
+ rotationMs: external_exports.number().int().nonnegative().default(0),
4235
+ /** Unix ms of the last counted impression fire. Anchors the transient
4236
+ * "+$" impact flash; the statusline shows it while now-this < IMPACT_FLASH_MS.
4237
+ * Additive — 0 from older daemons hides the flash. */
4238
+ lastImpFiredAtMs: external_exports.number().int().nonnegative().default(0),
4239
+ /** Per-impression payout (millicents) of the last counted fire — the amount
4240
+ * the impact flash pops. Additive — 0 hides the flash. */
4241
+ lastImpEarnedMillicents: external_exports.number().int().nonnegative().default(0),
4242
+ /** Viewability machine's current reason string (why this tick did/didn't
4243
+ * fire). Drives the "why no boom" hint when the rotation bar is near full.
4244
+ * Additive — "" from older daemons hides the hint. */
4245
+ viewReason: external_exports.string().default("")
4213
4246
  });
4214
4247
  var ImpressionSchema = external_exports.object({
4215
4248
  id: external_exports.string(),
@@ -4246,11 +4279,20 @@ var TERMINAL_BUNDLE_IDS = /* @__PURE__ */ new Set([
4246
4279
  "com.mitchellh.ghostty",
4247
4280
  "dev.warp.Warp-Stable",
4248
4281
  "dev.warp.Warp-Preview",
4282
+ "com.cmuxterm.app",
4283
+ // cmux
4249
4284
  // VS Code / Cursor integrated terminal => coarser confidence, still counts.
4250
4285
  "com.microsoft.VSCode",
4251
4286
  "com.todesktop.230313mzl4w4u92"
4252
4287
  // Cursor
4253
4288
  ]);
4289
+ var ENV_TERMINAL_BUNDLE_IDS = new Set((process.env.ADSINAGENTS_TERMINAL_BUNDLE_IDS ?? "").split(",").map((s) => s.trim()).filter(Boolean));
4290
+ function isTerminalBundle(bundleId) {
4291
+ return TERMINAL_BUNDLE_IDS.has(bundleId) || ENV_TERMINAL_BUNDLE_IDS.has(bundleId);
4292
+ }
4293
+
4294
+ // ../../shared/dist/earnings.js
4295
+ var DAILY_CAP_MILLICENTS = 1e4 * 1e3;
4254
4296
 
4255
4297
  // ../../shared/dist/nonce.js
4256
4298
  var NONCE_TTL_MS = 30 * 60 * 1e3;
@@ -4277,10 +4319,12 @@ var DarwinPlatform = class {
4277
4319
  const { stdout } = await pExecFile(PATHS.frontmostBin, [], { timeout: 800 });
4278
4320
  const parsed = FocusProbeSchema.safeParse(JSON.parse(stdout));
4279
4321
  if (parsed.success) {
4322
+ const focused = isTerminalBundle(parsed.data.bundleId);
4280
4323
  return {
4281
- focused: TERMINAL_BUNDLE_IDS.has(parsed.data.bundleId),
4324
+ focused,
4282
4325
  locked: parsed.data.locked,
4283
- screenAwake: parsed.data.screenAwake
4326
+ screenAwake: parsed.data.screenAwake,
4327
+ unknownFrontApp: focused ? void 0 : parsed.data.bundleId || void 0
4284
4328
  };
4285
4329
  }
4286
4330
  } catch {
@@ -4296,7 +4340,13 @@ var DarwinPlatform = class {
4296
4340
  'tell application "System Events" to get bundle identifier of first application process whose frontmost is true'
4297
4341
  ], { timeout: 800 });
4298
4342
  const bundleId = stdout.trim();
4299
- return { focused: TERMINAL_BUNDLE_IDS.has(bundleId), locked: false, screenAwake: true };
4343
+ const focused = isTerminalBundle(bundleId);
4344
+ return {
4345
+ focused,
4346
+ locked: false,
4347
+ screenAwake: true,
4348
+ unknownFrontApp: focused ? void 0 : bundleId || void 0
4349
+ };
4300
4350
  } catch {
4301
4351
  return { focused: false, locked: false, screenAwake: true };
4302
4352
  }
@@ -4880,6 +4930,19 @@ async function runDoctor() {
4880
4930
  ok: focusOk,
4881
4931
  detail: focusOk ? "native frontmost helper present" : "Node fallback (osascript)"
4882
4932
  });
4933
+ try {
4934
+ const fm = await platform.frontmost();
4935
+ if (fm.unknownFrontApp) {
4936
+ checks.push({
4937
+ name: "Terminal recognized",
4938
+ ok: false,
4939
+ detail: `frontmost app "${fm.unknownFrontApp}" isn't a known terminal \u2014 impressions won't fire. If this is your terminal: set ADSINAGENTS_TERMINAL_BUNDLE_IDS=${fm.unknownFrontApp} and restart the daemon.`
4940
+ });
4941
+ } else if (fm.focused) {
4942
+ checks.push({ name: "Terminal recognized", ok: true, detail: "frontmost app is a known terminal" });
4943
+ }
4944
+ } catch {
4945
+ }
4883
4946
  let key = null;
4884
4947
  try {
4885
4948
  key = await platform.getSecret("publisher-key");
@@ -4131,7 +4131,16 @@ var AdSchema = external_exports.object({
4131
4131
  * the daemon is running standalone (no server) — those imps earn nothing.
4132
4132
  * See shared/src/nonce.ts.
4133
4133
  */
4134
- nonce: external_exports.string().default("")
4134
+ nonce: external_exports.string().default(""),
4135
+ /**
4136
+ * Publisher payout RATES for this ad, in millicents, computed server-side from
4137
+ * the live CPM + share + click multiplier (earnings.ts) so the device never
4138
+ * hardcodes the math — change the campaign cpm and these move with it. Shown in
4139
+ * the opt-in payout segment ("+$0.002/view · +$0.10/click"). Default 0 so older
4140
+ * ads / standalone daemons (no server rate) simply render nothing.
4141
+ */
4142
+ payoutPerImpMillicents: external_exports.number().min(0).default(0),
4143
+ payoutPerClickMillicents: external_exports.number().min(0).default(0)
4135
4144
  });
4136
4145
  var GravityAdSchema = external_exports.object({
4137
4146
  adText: external_exports.string(),
@@ -4173,16 +4182,20 @@ var ConfigSchema = external_exports.object({
4173
4182
  deviceId: external_exports.string().default(""),
4174
4183
  /** Single-use token to claim this install at /claim (attach a login + payout). */
4175
4184
  claimToken: external_exports.string().default(""),
4176
- /** Ad cache poll interval (seconds) — how often a new ad rotates in. Billing
4177
- * is gated separately by rateCapSec, so a faster poll rotates creatives
4178
- * without changing earnings. */
4185
+ /** Ad cache poll interval (seconds) — how often a new ad rotates in. Synced
4186
+ * with the fire: rateCapSec defaults to the SAME value, so every new ad fires
4187
+ * exactly once (no silent "every other ad skipped"). The next ad is preloaded
4188
+ * a few seconds early so the swap is instant. */
4179
4189
  pollIntervalSec: external_exports.number().int().positive().default(30),
4180
4190
  /** Hard daily fired-impression cap. */
4181
4191
  dailyCap: external_exports.number().int().positive().default(300),
4182
- /** Min seconds of verified view between two fired impressions. */
4183
- rateCapSec: external_exports.number().int().positive().default(60),
4184
- /** Continuous focus seconds required before an impression verifies. */
4185
- focusDwellSec: external_exports.number().int().positive().default(5),
4192
+ /** Min seconds of verified view between two fired impressions. Defaults to the
4193
+ * rotation period (30s) so fire == rotation: each new ad booms once. */
4194
+ rateCapSec: external_exports.number().int().positive().default(30),
4195
+ /** Continuous focus seconds before an impression verifies. 1s = the IAB/MRC
4196
+ * viewability standard (≥1 continuous second in view). A real glance counts;
4197
+ * a sub-second flick-past still doesn't bill the advertiser. */
4198
+ focusDwellSec: external_exports.number().int().positive().default(1),
4186
4199
  /**
4187
4200
  * Seconds the ad stays painted after a turn ends (Stop hook). Past the grace
4188
4201
  * the status line goes clean until the next prompt. 0 = hide on Stop.
@@ -4193,11 +4206,13 @@ var ConfigSchema = external_exports.object({
4193
4206
  killSwitchUrl: external_exports.string().default(""),
4194
4207
  /** Show a live earnings segment in the status line (separate from the ad). Off by default. */
4195
4208
  showEarnings: external_exports.boolean().default(false),
4209
+ /** Show the per-event payout rate for the current ad ("+$0.002/view · +$0.10/click"). On by default. */
4210
+ showPayout: external_exports.boolean().default(true),
4196
4211
  /** Terminal styling for ad/earnings segments. Plain (no ANSI) by default. */
4197
4212
  theme: ThemeSchema.default("plain")
4198
4213
  });
4199
4214
  var DEFAULT_CONFIG = ConfigSchema.parse({});
4200
- var DAEMON_STATE_SCHEMA_VERSION = 1;
4215
+ var DAEMON_STATE_SCHEMA_VERSION = 2;
4201
4216
  var DaemonStateSchema = external_exports.object({
4202
4217
  /** Contract version (see DAEMON_STATE_SCHEMA_VERSION). Missing => 1. */
4203
4218
  schemaVersion: external_exports.number().int().positive().default(1),
@@ -4217,7 +4232,25 @@ var DaemonStateSchema = external_exports.object({
4217
4232
  todayCents: external_exports.number().int().nonnegative().default(0),
4218
4233
  /** Claude is mid-turn (prompt received, Stop not yet). Drives the statusline
4219
4234
  * working glyph. Additive — older daemons omit it. */
4220
- working: external_exports.boolean().default(false)
4235
+ working: external_exports.boolean().default(false),
4236
+ /** Unix ms of the last ad rotation (== lastPollMs). Anchors the rotation
4237
+ * countdown bar; the statusline derives fill from (now - rotatedAtMs).
4238
+ * Additive — 0 from older daemons hides the bar. */
4239
+ rotatedAtMs: external_exports.number().int().nonnegative().default(0),
4240
+ /** Rotation period in ms (== pollIntervalSec * 1000). Bar denominator.
4241
+ * Additive — 0 from older daemons hides the bar. */
4242
+ rotationMs: external_exports.number().int().nonnegative().default(0),
4243
+ /** Unix ms of the last counted impression fire. Anchors the transient
4244
+ * "+$" impact flash; the statusline shows it while now-this < IMPACT_FLASH_MS.
4245
+ * Additive — 0 from older daemons hides the flash. */
4246
+ lastImpFiredAtMs: external_exports.number().int().nonnegative().default(0),
4247
+ /** Per-impression payout (millicents) of the last counted fire — the amount
4248
+ * the impact flash pops. Additive — 0 hides the flash. */
4249
+ lastImpEarnedMillicents: external_exports.number().int().nonnegative().default(0),
4250
+ /** Viewability machine's current reason string (why this tick did/didn't
4251
+ * fire). Drives the "why no boom" hint when the rotation bar is near full.
4252
+ * Additive — "" from older daemons hides the hint. */
4253
+ viewReason: external_exports.string().default("")
4221
4254
  });
4222
4255
  var ImpressionSchema = external_exports.object({
4223
4256
  id: external_exports.string(),
@@ -4254,11 +4287,20 @@ var TERMINAL_BUNDLE_IDS = /* @__PURE__ */ new Set([
4254
4287
  "com.mitchellh.ghostty",
4255
4288
  "dev.warp.Warp-Stable",
4256
4289
  "dev.warp.Warp-Preview",
4290
+ "com.cmuxterm.app",
4291
+ // cmux
4257
4292
  // VS Code / Cursor integrated terminal => coarser confidence, still counts.
4258
4293
  "com.microsoft.VSCode",
4259
4294
  "com.todesktop.230313mzl4w4u92"
4260
4295
  // Cursor
4261
4296
  ]);
4297
+ var ENV_TERMINAL_BUNDLE_IDS = new Set((process.env.ADSINAGENTS_TERMINAL_BUNDLE_IDS ?? "").split(",").map((s) => s.trim()).filter(Boolean));
4298
+ function isTerminalBundle(bundleId) {
4299
+ return TERMINAL_BUNDLE_IDS.has(bundleId) || ENV_TERMINAL_BUNDLE_IDS.has(bundleId);
4300
+ }
4301
+
4302
+ // ../../shared/dist/earnings.js
4303
+ var DAILY_CAP_MILLICENTS = 1e4 * 1e3;
4262
4304
 
4263
4305
  // ../../shared/dist/nonce.js
4264
4306
  var NONCE_TTL_MS = 30 * 60 * 1e3;
@@ -4285,10 +4327,12 @@ var DarwinPlatform = class {
4285
4327
  const { stdout } = await pExecFile(PATHS.frontmostBin, [], { timeout: 800 });
4286
4328
  const parsed = FocusProbeSchema.safeParse(JSON.parse(stdout));
4287
4329
  if (parsed.success) {
4330
+ const focused = isTerminalBundle(parsed.data.bundleId);
4288
4331
  return {
4289
- focused: TERMINAL_BUNDLE_IDS.has(parsed.data.bundleId),
4332
+ focused,
4290
4333
  locked: parsed.data.locked,
4291
- screenAwake: parsed.data.screenAwake
4334
+ screenAwake: parsed.data.screenAwake,
4335
+ unknownFrontApp: focused ? void 0 : parsed.data.bundleId || void 0
4292
4336
  };
4293
4337
  }
4294
4338
  } catch {
@@ -4304,7 +4348,13 @@ var DarwinPlatform = class {
4304
4348
  'tell application "System Events" to get bundle identifier of first application process whose frontmost is true'
4305
4349
  ], { timeout: 800 });
4306
4350
  const bundleId = stdout.trim();
4307
- return { focused: TERMINAL_BUNDLE_IDS.has(bundleId), locked: false, screenAwake: true };
4351
+ const focused = isTerminalBundle(bundleId);
4352
+ return {
4353
+ focused,
4354
+ locked: false,
4355
+ screenAwake: true,
4356
+ unknownFrontApp: focused ? void 0 : bundleId || void 0
4357
+ };
4308
4358
  } catch {
4309
4359
  return { focused: false, locked: false, screenAwake: true };
4310
4360
  }
@@ -4961,6 +5011,14 @@ var FocusPoller = class {
4961
5011
  terminalFocused() {
4962
5012
  return this.last.focused;
4963
5013
  }
5014
+ /**
5015
+ * Bundle id of the frontmost app when it's a foreground app we DON'T recognize
5016
+ * as a terminal — the daemon warns on it so an unlisted terminal can't silently
5017
+ * zero out earnings. Undefined when on a known terminal. Diagnostics only.
5018
+ */
5019
+ unknownFrontApp() {
5020
+ return this.last.unknownFrontApp;
5021
+ }
4964
5022
  async poll() {
4965
5023
  try {
4966
5024
  this.last = await this.platform.frontmost();
@@ -5090,11 +5148,44 @@ var AdSource = class {
5090
5148
  this.cfg = cfg;
5091
5149
  }
5092
5150
  rotation = 0;
5151
+ // Preloaded next ad — fetched a few seconds before the swap so the rotation is
5152
+ // instant (no network gap, no "loading" flicker). Holding it is NOT a bill:
5153
+ // the impression only fires once this ad becomes the visible one and the
5154
+ // viewability gates pass. Fetch != fire.
5155
+ preloaded = null;
5156
+ preloading = false;
5093
5157
  setConfig(cfg) {
5094
5158
  this.cfg = cfg;
5095
5159
  }
5096
- /** Fetch the next ad. Returns null on explicit no-fill. */
5160
+ /**
5161
+ * Fetch the next ad. Returns null on explicit no-fill. Consumes a preloaded ad
5162
+ * if one is in hand (instant swap); otherwise fetches synchronously.
5163
+ */
5097
5164
  async next(sessionId, nowMs) {
5165
+ if (this.preloaded) {
5166
+ const ad = this.preloaded;
5167
+ this.preloaded = null;
5168
+ return ad;
5169
+ }
5170
+ return this.fetchOne(sessionId, nowMs);
5171
+ }
5172
+ /**
5173
+ * Warm the next ad into the buffer ahead of the swap. Idempotent + non-blocking
5174
+ * for the caller: if a fetch is already in flight or one is buffered, no-op.
5175
+ * Call this a few seconds before the rotation deadline.
5176
+ */
5177
+ async preloadNext(sessionId, nowMs) {
5178
+ if (this.preloaded || this.preloading) return;
5179
+ this.preloading = true;
5180
+ try {
5181
+ const ad = await this.fetchOne(sessionId, nowMs);
5182
+ if (ad) this.preloaded = ad;
5183
+ } finally {
5184
+ this.preloading = false;
5185
+ }
5186
+ }
5187
+ /** One ad from the server, or the built-in test rotation if unreachable. */
5188
+ async fetchOne(sessionId, nowMs) {
5098
5189
  const fromServer = await this.fromServer(sessionId, nowMs);
5099
5190
  if (fromServer !== void 0) return fromServer;
5100
5191
  return this.builtinTestAd(nowMs);
@@ -5333,8 +5424,10 @@ function pendingReason(s) {
5333
5424
 
5334
5425
  // ../daemon/src/main.ts
5335
5426
  var TICK_MS = 1e3;
5427
+ var AD_PRELOAD_LEAD_MS = 3e3;
5336
5428
  var SESSION_REAP_MS = 6 * 60 * 60 * 1e3;
5337
5429
  var SYNC_EVERY_TICKS = 30;
5430
+ var UNKNOWN_TERM_WARN_MS = 10 * 60 * 1e3;
5338
5431
  async function fileLog(line) {
5339
5432
  try {
5340
5433
  writeFileSync4(PATHS.daemonLog, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
@@ -5354,6 +5447,9 @@ async function main() {
5354
5447
  let currentAd = null;
5355
5448
  let lastPollMs = 0;
5356
5449
  let tickCount = 0;
5450
+ let lastImpFiredAtMs = 0;
5451
+ let lastImpEarnedMillicents = 0;
5452
+ const unknownTermWarnedAt = /* @__PURE__ */ new Map();
5357
5453
  let earnings = { balanceCents: 0, todayCents: 0 };
5358
5454
  const now = () => Date.now();
5359
5455
  const { server, port } = await startServer({
@@ -5380,7 +5476,15 @@ async function main() {
5380
5476
  if (tickCount % 10 === 0) await killSwitch.refreshRemote();
5381
5477
  const disabled = killSwitch.disabled() || cfg.placement === "off";
5382
5478
  const sessionLive = sessions.sessionLive();
5383
- if (!disabled && sessionLive && nowMs - lastPollMs >= cfg.pollIntervalSec * 1e3) {
5479
+ const pollMs = cfg.pollIntervalSec * 1e3;
5480
+ if (!disabled && sessionLive && lastPollMs > 0) {
5481
+ const untilSwap = pollMs - (nowMs - lastPollMs);
5482
+ if (untilSwap <= AD_PRELOAD_LEAD_MS && untilSwap > 0) {
5483
+ const sid = sessions.currentSessionId() ?? "anonymous";
5484
+ void adSource.preloadNext(sid, nowMs).catch((e) => fileLog(`preload error: ${String(e)}`));
5485
+ }
5486
+ }
5487
+ if (!disabled && sessionLive && nowMs - lastPollMs >= pollMs) {
5384
5488
  lastPollMs = nowMs;
5385
5489
  const sid = sessions.currentSessionId() ?? "anonymous";
5386
5490
  try {
@@ -5416,6 +5520,16 @@ async function main() {
5416
5520
  const lastStop = sessions.lastStop();
5417
5521
  const adDisplayable = working || lastStop !== null && nowMs - lastStop <= cfg.idleGraceSec * 1e3;
5418
5522
  const probe = focus.current();
5523
+ const unknownTerm = focus.unknownFrontApp();
5524
+ if (sessionLive && currentAd !== null && adDisplayable && unknownTerm) {
5525
+ const lastWarn = unknownTermWarnedAt.get(unknownTerm) ?? 0;
5526
+ if (nowMs - lastWarn >= UNKNOWN_TERM_WARN_MS) {
5527
+ unknownTermWarnedAt.set(unknownTerm, nowMs);
5528
+ await fileLog(
5529
+ `WARN unrecognized terminal "${unknownTerm}" is frontmost \u2014 impressions can't fire. If this is your terminal, set ADSINAGENTS_TERMINAL_BUNDLE_IDS=${unknownTerm} and restart the daemon.`
5530
+ );
5531
+ }
5532
+ }
5419
5533
  const sample = {
5420
5534
  nowMs,
5421
5535
  sessionLive,
@@ -5437,6 +5551,8 @@ async function main() {
5437
5551
  if (isNew) {
5438
5552
  view = markFired(view, result.fireAdId, nowMs);
5439
5553
  if (currentAd.impUrl) firePixel(currentAd.impUrl);
5554
+ lastImpFiredAtMs = nowMs;
5555
+ lastImpEarnedMillicents = currentAd.payoutPerImpMillicents;
5440
5556
  await fileLog(`FIRED ${result.fireAdId} (${currentAd.brandName})`);
5441
5557
  }
5442
5558
  }
@@ -5449,7 +5565,15 @@ async function main() {
5449
5565
  updatedAt: nowMs,
5450
5566
  balanceCents: earnings.balanceCents,
5451
5567
  todayCents: earnings.todayCents,
5452
- working
5568
+ working,
5569
+ // Anchor + period for the statusline rotation countdown bar. lastPollMs
5570
+ // is the last ad swap; the bar fills over pollIntervalSec toward the next.
5571
+ rotatedAtMs: lastPollMs,
5572
+ rotationMs: cfg.pollIntervalSec * 1e3,
5573
+ lastImpFiredAtMs,
5574
+ lastImpEarnedMillicents,
5575
+ // Why this tick did/didn't fire — drives the "why no boom" hint.
5576
+ viewReason: result.reason
5453
5577
  };
5454
5578
  writeDaemonState(state);
5455
5579
  if (tickCount % SYNC_EVERY_TICKS === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adsinagents",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Get paid while you build. AdsInAgents is the terminal-native ad layer for AI coding agents. Verified impressions pay you.",
5
5
  "homepage": "https://adsinagents.com",
6
6
  "license": "UNLICENSED",
@@ -4108,7 +4108,16 @@ var AdSchema = external_exports.object({
4108
4108
  * the daemon is running standalone (no server) — those imps earn nothing.
4109
4109
  * See shared/src/nonce.ts.
4110
4110
  */
4111
- nonce: external_exports.string().default("")
4111
+ nonce: external_exports.string().default(""),
4112
+ /**
4113
+ * Publisher payout RATES for this ad, in millicents, computed server-side from
4114
+ * the live CPM + share + click multiplier (earnings.ts) so the device never
4115
+ * hardcodes the math — change the campaign cpm and these move with it. Shown in
4116
+ * the opt-in payout segment ("+$0.002/view · +$0.10/click"). Default 0 so older
4117
+ * ads / standalone daemons (no server rate) simply render nothing.
4118
+ */
4119
+ payoutPerImpMillicents: external_exports.number().min(0).default(0),
4120
+ payoutPerClickMillicents: external_exports.number().min(0).default(0)
4112
4121
  });
4113
4122
  var GravityAdSchema = external_exports.object({
4114
4123
  adText: external_exports.string(),
@@ -4150,16 +4159,20 @@ var ConfigSchema = external_exports.object({
4150
4159
  deviceId: external_exports.string().default(""),
4151
4160
  /** Single-use token to claim this install at /claim (attach a login + payout). */
4152
4161
  claimToken: external_exports.string().default(""),
4153
- /** Ad cache poll interval (seconds) — how often a new ad rotates in. Billing
4154
- * is gated separately by rateCapSec, so a faster poll rotates creatives
4155
- * without changing earnings. */
4162
+ /** Ad cache poll interval (seconds) — how often a new ad rotates in. Synced
4163
+ * with the fire: rateCapSec defaults to the SAME value, so every new ad fires
4164
+ * exactly once (no silent "every other ad skipped"). The next ad is preloaded
4165
+ * a few seconds early so the swap is instant. */
4156
4166
  pollIntervalSec: external_exports.number().int().positive().default(30),
4157
4167
  /** Hard daily fired-impression cap. */
4158
4168
  dailyCap: external_exports.number().int().positive().default(300),
4159
- /** Min seconds of verified view between two fired impressions. */
4160
- rateCapSec: external_exports.number().int().positive().default(60),
4161
- /** Continuous focus seconds required before an impression verifies. */
4162
- focusDwellSec: external_exports.number().int().positive().default(5),
4169
+ /** Min seconds of verified view between two fired impressions. Defaults to the
4170
+ * rotation period (30s) so fire == rotation: each new ad booms once. */
4171
+ rateCapSec: external_exports.number().int().positive().default(30),
4172
+ /** Continuous focus seconds before an impression verifies. 1s = the IAB/MRC
4173
+ * viewability standard (≥1 continuous second in view). A real glance counts;
4174
+ * a sub-second flick-past still doesn't bill the advertiser. */
4175
+ focusDwellSec: external_exports.number().int().positive().default(1),
4163
4176
  /**
4164
4177
  * Seconds the ad stays painted after a turn ends (Stop hook). Past the grace
4165
4178
  * the status line goes clean until the next prompt. 0 = hide on Stop.
@@ -4170,6 +4183,8 @@ var ConfigSchema = external_exports.object({
4170
4183
  killSwitchUrl: external_exports.string().default(""),
4171
4184
  /** Show a live earnings segment in the status line (separate from the ad). Off by default. */
4172
4185
  showEarnings: external_exports.boolean().default(false),
4186
+ /** Show the per-event payout rate for the current ad ("+$0.002/view · +$0.10/click"). On by default. */
4187
+ showPayout: external_exports.boolean().default(true),
4173
4188
  /** Terminal styling for ad/earnings segments. Plain (no ANSI) by default. */
4174
4189
  theme: ThemeSchema.default("plain")
4175
4190
  });
@@ -4193,7 +4208,25 @@ var DaemonStateSchema = external_exports.object({
4193
4208
  todayCents: external_exports.number().int().nonnegative().default(0),
4194
4209
  /** Claude is mid-turn (prompt received, Stop not yet). Drives the statusline
4195
4210
  * working glyph. Additive — older daemons omit it. */
4196
- working: external_exports.boolean().default(false)
4211
+ working: external_exports.boolean().default(false),
4212
+ /** Unix ms of the last ad rotation (== lastPollMs). Anchors the rotation
4213
+ * countdown bar; the statusline derives fill from (now - rotatedAtMs).
4214
+ * Additive — 0 from older daemons hides the bar. */
4215
+ rotatedAtMs: external_exports.number().int().nonnegative().default(0),
4216
+ /** Rotation period in ms (== pollIntervalSec * 1000). Bar denominator.
4217
+ * Additive — 0 from older daemons hides the bar. */
4218
+ rotationMs: external_exports.number().int().nonnegative().default(0),
4219
+ /** Unix ms of the last counted impression fire. Anchors the transient
4220
+ * "+$" impact flash; the statusline shows it while now-this < IMPACT_FLASH_MS.
4221
+ * Additive — 0 from older daemons hides the flash. */
4222
+ lastImpFiredAtMs: external_exports.number().int().nonnegative().default(0),
4223
+ /** Per-impression payout (millicents) of the last counted fire — the amount
4224
+ * the impact flash pops. Additive — 0 hides the flash. */
4225
+ lastImpEarnedMillicents: external_exports.number().int().nonnegative().default(0),
4226
+ /** Viewability machine's current reason string (why this tick did/didn't
4227
+ * fire). Drives the "why no boom" hint when the rotation bar is near full.
4228
+ * Additive — "" from older daemons hides the hint. */
4229
+ viewReason: external_exports.string().default("")
4197
4230
  });
4198
4231
  var ImpressionSchema = external_exports.object({
4199
4232
  id: external_exports.string(),
@@ -4222,6 +4255,7 @@ var FocusProbeSchema = external_exports.object({
4222
4255
  locked: external_exports.boolean(),
4223
4256
  screenAwake: external_exports.boolean()
4224
4257
  });
4258
+ var ENV_TERMINAL_BUNDLE_IDS = new Set((process.env.ADSINAGENTS_TERMINAL_BUNDLE_IDS ?? "").split(",").map((s) => s.trim()).filter(Boolean));
4225
4259
 
4226
4260
  // ../../shared/dist/render.js
4227
4261
  function osc8(url, text) {
@@ -4232,11 +4266,16 @@ var DIM = (s) => `\x1B[2m${s}\x1B[22m`;
4232
4266
  var BOLD = (s) => `\x1B[1m${s}\x1B[22m`;
4233
4267
  var GREEN = (s) => `\x1B[32m${s}\x1B[39m`;
4234
4268
  var AMBER = (s) => `\x1B[38;5;215m${s}\x1B[39m`;
4269
+ var FLASH = (s) => `\x1B[1;92m${s}\x1B[22;39m`;
4235
4270
  var THEMES = {
4236
- plain: { disclosure: ID, mark: ID, brand: ID, money: ID },
4237
- dim: { disclosure: DIM, mark: ID, brand: ID, money: ID },
4238
- accent: { disclosure: DIM, mark: AMBER, brand: BOLD, money: GREEN }
4271
+ // plain MUST emit zero ANSI (VS Code) the flash/hint are plain text there,
4272
+ // still appearing/disappearing, just uncolored.
4273
+ plain: { disclosure: ID, mark: ID, brand: ID, money: ID, flash: ID, hint: ID },
4274
+ // hint = amber even in dim, so an actionable "why no boom" block stands out.
4275
+ dim: { disclosure: DIM, mark: ID, brand: ID, money: ID, flash: FLASH, hint: AMBER },
4276
+ accent: { disclosure: DIM, mark: AMBER, brand: BOLD, money: GREEN, flash: FLASH, hint: AMBER }
4239
4277
  };
4278
+ var IMPACT_FLASH_MS = 3e3;
4240
4279
  var MAX_AD_TEXT = 60;
4241
4280
  function renderAdSegment(ad, opts) {
4242
4281
  const t = THEMES[opts.theme ?? "plain"];
@@ -4246,13 +4285,66 @@ function renderAdSegment(ad, opts) {
4246
4285
  const label = ad.brandName ? `${text} \xB7 ${t.brand(ad.brandName)}` : text;
4247
4286
  const clickUrl = `${opts.clickBase}/click/${encodeURIComponent(ad.id)}`;
4248
4287
  const linked = opts.supportsOsc8 ? osc8(clickUrl, `${star} ${label} ${arrow}`) : opts.plainLink ? `${star} ${label} \xB7 ${opts.plainLink} ${arrow}` : `${star} ${label} ${arrow}`;
4249
- return `${t.disclosure("Ad:")} ${linked} ${t.disclosure("\xB7 sponsored")}`;
4288
+ return `${t.disclosure("Ad:")} ${linked}`;
4250
4289
  }
4251
4290
  function renderEarningsSegment(balanceCents, todayCents, theme = "plain") {
4252
4291
  const t = THEMES[theme];
4253
4292
  const d = (c) => t.money(`$${(Math.max(0, c) / 100).toFixed(2)}`);
4254
4293
  return `${d(todayCents)} today \xB7 ${d(balanceCents)}`;
4255
4294
  }
4295
+ function renderPayoutSegment(perImpMillicents, perClickMillicents, theme = "plain") {
4296
+ const imp = Math.max(0, perImpMillicents);
4297
+ const click = Math.max(0, perClickMillicents);
4298
+ if (imp === 0 && click === 0)
4299
+ return "";
4300
+ const t = THEMES[theme];
4301
+ const fmt = (millicents) => {
4302
+ const dollars = millicents / 1e5;
4303
+ const s = dollars >= 0.01 ? dollars.toFixed(2) : dollars.toFixed(4).replace(/0+$/, "");
4304
+ return `$${s}`;
4305
+ };
4306
+ return `${t.money(`+${fmt(imp)}`)}/view \xB7 ${t.money(`+${fmt(click)}`)}/click`;
4307
+ }
4308
+ function renderImpactFlash(nowMs, firedAtMs, earnedMillicents, theme = "plain") {
4309
+ if (firedAtMs <= 0 || earnedMillicents <= 0)
4310
+ return "";
4311
+ if (nowMs - firedAtMs > IMPACT_FLASH_MS || nowMs < firedAtMs)
4312
+ return "";
4313
+ const dollars = earnedMillicents / 1e5;
4314
+ const s = dollars >= 0.01 ? dollars.toFixed(2) : dollars.toFixed(4).replace(/0+$/, "");
4315
+ return THEMES[theme].flash(`+$${s}`);
4316
+ }
4317
+ function classifyEarningState(reason) {
4318
+ if (reason === "verified \u2014 fire" || reason === "already fired this ad" || reason === "rate-capped" || reason.startsWith("dwell ")) {
4319
+ return { earning: true, why: "" };
4320
+ }
4321
+ const why = reason === "no live session" ? "no active session" : reason === "terminal not focused" ? "terminal not focused" : reason === "no recent prompt (idle)" ? "idle - prompt to resume" : reason === "screen locked" ? "screen locked" : reason === "display asleep" ? "display asleep" : reason === "disabled (kill switch)" ? "turned off" : reason === "daily cap reached" ? "daily max reached" : reason === "no ad cached" ? "loading ad" : "paused";
4322
+ return { earning: false, why };
4323
+ }
4324
+ var EARNING_PULSE_FRAMES = ["\u25CF", "\u25CD", "\u25C9", "\u25CD"];
4325
+ function renderEarningDot(reason, theme = "plain", nowMs) {
4326
+ const t = THEMES[theme];
4327
+ const { earning, why } = classifyEarningState(reason);
4328
+ if (earning) {
4329
+ if (nowMs === void 0)
4330
+ return t.flash("\u25CF earning");
4331
+ const step = Math.floor(nowMs / 1e3);
4332
+ const dot = EARNING_PULSE_FRAMES[step % EARNING_PULSE_FRAMES.length];
4333
+ const bright = dot === "\u25CF" || dot === "\u25C9";
4334
+ const paint = bright ? t.flash : t.money;
4335
+ return paint(`${dot} earning`);
4336
+ }
4337
+ return t.disclosure(`\u25CB paused: ${why}`);
4338
+ }
4339
+ var ROTATION_BAR_CELLS = 8;
4340
+ function renderRotationBar(nowMs, rotatedAtMs, rotationMs, theme = "plain") {
4341
+ if (rotationMs <= 0 || rotatedAtMs <= 0)
4342
+ return "";
4343
+ const frac = Math.min(1, Math.max(0, (nowMs - rotatedAtMs) / rotationMs));
4344
+ const filled = Math.min(ROTATION_BAR_CELLS, Math.round(frac * ROTATION_BAR_CELLS));
4345
+ const bar = "\u25B0".repeat(filled) + "\u25B1".repeat(ROTATION_BAR_CELLS - filled);
4346
+ return THEMES[theme ?? "plain"].mark(bar);
4347
+ }
4256
4348
  function renderLoadingSegment(theme = "plain") {
4257
4349
  const s = "Ad: loading\u2026";
4258
4350
  return theme === "plain" ? s : DIM(s);
@@ -4267,6 +4359,9 @@ function composeStatusLine(priorOutput, ...segments) {
4267
4359
  return parts.join(" \u2502 ");
4268
4360
  }
4269
4361
 
4362
+ // ../../shared/dist/earnings.js
4363
+ var DAILY_CAP_MILLICENTS = 1e4 * 1e3;
4364
+
4270
4365
  // ../../shared/dist/nonce.js
4271
4366
  var NONCE_TTL_MS = 30 * 60 * 1e3;
4272
4367
 
@@ -4353,6 +4448,7 @@ function main() {
4353
4448
  const stale = Date.now() - state.updatedAt > STATE_STALE_MS;
4354
4449
  const cfg = ConfigSchema.safeParse(readJson(PATHS.config));
4355
4450
  const showEarnings = cfg.success && cfg.data.showEarnings;
4451
+ const showPayout = cfg.success && cfg.data.showPayout;
4356
4452
  const theme = cfg.success ? cfg.data.theme : "plain";
4357
4453
  const earningsSeg = showEarnings && state.sessionLive && !stale ? renderEarningsSegment(state.balanceCents, state.todayCents, theme) : "";
4358
4454
  const adParsed = AdSchema.safeParse(readJson(PATHS.currentAd));
@@ -4369,7 +4465,24 @@ function main() {
4369
4465
  plainLink: plainClickLink(adParsed.data, serverUrl, clickBase),
4370
4466
  theme
4371
4467
  });
4372
- const seg = state.working ? `${workingGlyph(Date.now(), theme)} ${adSeg}` : adSeg;
4373
- process.stdout.write(composeStatusLine(prior, seg, earningsSeg) + "\n");
4468
+ const seg = adSeg;
4469
+ const payoutSeg = showPayout ? renderPayoutSegment(
4470
+ adParsed.data.payoutPerImpMillicents,
4471
+ adParsed.data.payoutPerClickMillicents,
4472
+ theme
4473
+ ) : "";
4474
+ const now = Date.now();
4475
+ const flashSeg = renderImpactFlash(
4476
+ now,
4477
+ state.lastImpFiredAtMs,
4478
+ state.lastImpEarnedMillicents,
4479
+ theme
4480
+ );
4481
+ const earningDotSeg = flashSeg === "" ? renderEarningDot(state.viewReason, theme, now) : "";
4482
+ const rotationSeg = renderRotationBar(now, state.rotatedAtMs, state.rotationMs, theme);
4483
+ const spinnerSeg = state.working ? workingGlyph(now, theme) : "";
4484
+ process.stdout.write(
4485
+ composeStatusLine(prior, seg, payoutSeg, flashSeg, earningDotSeg, rotationSeg, spinnerSeg, earningsSeg) + "\n"
4486
+ );
4374
4487
  }
4375
4488
  main();