tokenwait 0.0.0-stage → 0.1.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.
package/bin/hook.js ADDED
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Хук агента: hook.js <agent> <start|stop> --twhook
4
+ // Пишет только время, событие, агента и id сессии. Ничего не печатает:
5
+ // у Claude/Codex stdout хука UserPromptSubmit попадает в контекст модели,
6
+ // у Gemini stdout должен быть пустым или JSON.
7
+ const fs = require('fs');
8
+
9
+ try {
10
+ const [agent, phase] = process.argv.slice(2);
11
+ if (!['claude', 'codex', 'gemini'].includes(agent) || !['start', 'stop'].includes(phase)) process.exit(0);
12
+
13
+ let input = {};
14
+ try {
15
+ input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
16
+ } catch (_) {}
17
+ const session = String(input.session_id || input.sessionId || process.env.GEMINI_SESSION_ID || process.env.CODEX_SESSION_ID || '').slice(0, 100);
18
+
19
+ const { p, readJson } = require('../lib/util');
20
+ const acc = require('../lib/accounting');
21
+ const worker = require('../lib/worker');
22
+ const now = Date.now();
23
+
24
+ fs.mkdirSync(p(), { recursive: true });
25
+ acc.appendLine(p('events.jsonl'), { t: now, e: phase, a: agent, s: session });
26
+
27
+ if (phase === 'stop') {
28
+ const ads = readJson(p('ads.json'), null);
29
+ const cfg = readJson(p('config.json'), {});
30
+ const interval = acc.waitInterval(acc.readLines(p('events.jsonl')), agent, session, now);
31
+ if (ads && interval) {
32
+ const add = acc.attribute({
33
+ agent,
34
+ interval,
35
+ vis: acc.readLines(p('visibility.jsonl')),
36
+ ads: ads.ads,
37
+ rotateSeconds: cfg.rotate_seconds || 30,
38
+ });
39
+ if (add.visibleMs > 0) acc.addToPending(add);
40
+ }
41
+ }
42
+ if (worker.adsStale(now) || worker.reportDue(now)) worker.spawnWorker();
43
+ } catch (_) {
44
+ // хук никогда не должен ломать агента
45
+ }
46
+ process.exit(0);
package/bin/status.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // statusLine для Claude Code: status.js --tw [--wrap <base64url(JSON прежней statusLine)>]
4
+ // На stdin — JSON сессии. Транскрипт (transcript_path) НЕ читаем.
5
+ const fs = require('fs');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ function wrapped() {
9
+ const i = process.argv.indexOf('--wrap');
10
+ if (i < 0 || !process.argv[i + 1]) return '';
11
+ try {
12
+ const v = JSON.parse(Buffer.from(process.argv[i + 1], 'base64url').toString('utf8'));
13
+ return typeof v === 'string' ? v : v && typeof v.command === 'string' ? v.command : '';
14
+ } catch (_) {
15
+ return '';
16
+ }
17
+ }
18
+
19
+ function main() {
20
+ let raw = '';
21
+ try {
22
+ raw = fs.readFileSync(0, 'utf8');
23
+ } catch (_) {}
24
+
25
+ let orig = '';
26
+ const cmd = wrapped();
27
+ if (cmd) {
28
+ const r = spawnSync(cmd, { shell: true, input: raw, encoding: 'utf8', timeout: 2000, maxBuffer: 1 << 20 });
29
+ orig = (r.stdout || '').replace(/\s+$/, '');
30
+ }
31
+
32
+ const { p, readJson, hostOf } = require('../lib/util');
33
+ const { pick } = require('../lib/rotation');
34
+ const worker = require('../lib/worker');
35
+ const ads = readJson(p('ads.json'), null);
36
+ const cfg = readJson(p('config.json'), {});
37
+ let line = '';
38
+ const ad = ads && pick(ads.ads, Date.now(), cfg.rotate_seconds || 30);
39
+ if (ad) {
40
+ const host = hostOf(ad.url);
41
+ line = 'Sponsored · ' + ad.text + (host ? ' (' + host + ')' : '');
42
+ }
43
+ if (worker.adsStale()) worker.spawnWorker();
44
+
45
+ let out;
46
+ if (orig && line) out = orig.includes('\n') ? orig + '\n' + line : orig + ' │ ' + line;
47
+ else out = orig || line;
48
+ process.stdout.write(out + '\n');
49
+ }
50
+
51
+ try {
52
+ main();
53
+ } catch (_) {
54
+ process.stdout.write('\n');
55
+ }
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // TokenWait CLI.
4
+ // npx tokenwait install [--agents claude,codex,gemini] [--server URL] [--yes] [--no-login]
5
+ // tokenwait uninstall [--purge]
6
+ // tokenwait login | logout | status [--json]
7
+ // tokenwait link-start --json | me --json | sync (для расширений IDE)
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const readline = require('readline');
12
+ const { spawn } = require('child_process');
13
+
14
+ const util = require('../lib/util');
15
+ const agents = require('../lib/agents');
16
+ const api = require('../lib/api');
17
+ const worker = require('../lib/worker');
18
+ const { pick } = require('../lib/rotation');
19
+
20
+ const VERSION = require('../package.json').version;
21
+ const args = process.argv.slice(2);
22
+ const flag = (n) => args.includes('--' + n);
23
+ function opt(n) {
24
+ const i = args.indexOf('--' + n);
25
+ return i >= 0 ? args[i + 1] : undefined;
26
+ }
27
+ const json = flag('json');
28
+ const out = (o) => process.stdout.write(JSON.stringify(o) + '\n');
29
+
30
+ function ask(question) {
31
+ if (!process.stdin.isTTY) return Promise.resolve('');
32
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
33
+ return new Promise((res) => rl.question(question, (a) => (rl.close(), res(a.trim()))));
34
+ }
35
+
36
+ function openBrowser(url) {
37
+ try {
38
+ const cmd = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
39
+ spawn(cmd[0], cmd[1], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
40
+ return true;
41
+ } catch (_) {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /** Копия пакета в ~/.tokenwait/app: кеш npx может исчезнуть, а хуки должны работать всегда. */
47
+ function copyApp() {
48
+ const src = path.resolve(__dirname, '..');
49
+ const dst = util.p('app');
50
+ if (path.resolve(src) === path.resolve(dst)) return dst;
51
+ const tmp = util.p('app.new');
52
+ fs.rmSync(tmp, { recursive: true, force: true });
53
+ for (const d of ['bin', 'lib']) {
54
+ fs.mkdirSync(path.join(tmp, d), { recursive: true });
55
+ for (const f of fs.readdirSync(path.join(src, d))) {
56
+ if (f.endsWith('.js')) fs.copyFileSync(path.join(src, d, f), path.join(tmp, d, f));
57
+ }
58
+ }
59
+ fs.copyFileSync(path.join(src, 'package.json'), path.join(tmp, 'package.json'));
60
+ fs.rmSync(dst, { recursive: true, force: true });
61
+ fs.renameSync(tmp, dst);
62
+ return dst;
63
+ }
64
+
65
+ function chooseAgents() {
66
+ const list = opt('agents');
67
+ if (list && list !== 'auto') {
68
+ const ids = list.split(',').map((s) => s.trim()).filter(Boolean);
69
+ for (const id of ids) if (!agents.AGENTS[id]) throw new Error(`Unknown agent "${id}". Supported: ${Object.keys(agents.AGENTS).join(', ')}`);
70
+ return ids;
71
+ }
72
+ return Object.keys(agents.AGENTS).filter(agents.detected);
73
+ }
74
+
75
+ const CONSENT = `TokenWait shows a short, clearly labelled sponsored line while your AI coding agent is thinking,
76
+ and pays you a share of the ad revenue.
77
+
78
+ What it changes:
79
+ • adds two hooks (prompt start / response end) to each supported agent's config
80
+ • for Claude Code, adds a status line (your existing status line keeps working)
81
+ • stores ads, counters and an install key in ~/.tokenwait
82
+ What it never does:
83
+ • read your code, prompts, responses or transcripts
84
+ • send anything except ad ids, impression counts, waiting time and the install key
85
+ Undo at any time: npx tokenwait uninstall
86
+ `;
87
+
88
+ async function cmdInstall() {
89
+ const ids = chooseAgents();
90
+ if (!ids.length) throw new Error('No supported agent found (Claude Code, Codex CLI, Gemini CLI). Use --agents claude to install anyway.');
91
+ if (!flag('yes')) {
92
+ console.log(CONSENT);
93
+ console.log('Agents: ' + ids.map((id) => agents.AGENTS[id].name).join(', ') + '\n');
94
+ const a = await ask('Install TokenWait? [y/N] ');
95
+ if (!/^y(es)?$/i.test(a)) return console.log('Cancelled. Nothing was changed.');
96
+ }
97
+ const cfg = util.readJson(util.p('config.json'), {});
98
+ if (opt('server')) cfg.server = opt('server').replace(/\/+$/, '');
99
+ cfg.rotate_seconds = cfg.rotate_seconds || 30;
100
+ cfg.installed_at = cfg.installed_at || Date.now();
101
+ util.writeJson(util.p('config.json'), cfg);
102
+
103
+ const app = copyApp();
104
+ const node = process.execPath;
105
+ const results = [];
106
+ for (const id of ids) {
107
+ try {
108
+ const r = agents.install(id, node, app);
109
+ results.push({ agent: id, ok: true, file: r.file });
110
+ if (!json) console.log(`✓ ${agents.AGENTS[id].name}: ${r.file}`);
111
+ if (!json && agents.AGENTS[id].note) console.log(' ' + agents.AGENTS[id].note);
112
+ } catch (e) {
113
+ results.push({ agent: id, ok: false, error: e.message });
114
+ if (!json) console.log(`✗ ${agents.AGENTS[id].name}: ${e.message}`);
115
+ }
116
+ }
117
+ try {
118
+ await api.fetchAds();
119
+ } catch (e) {
120
+ if (!json) console.log('Could not reach the server yet (' + e.message + '). Ads will load automatically later.');
121
+ }
122
+ if (json) return out({ ok: results.every((r) => r.ok), agents: results });
123
+ console.log('\nRestart your agent sessions to pick up the changes.');
124
+ if (!flag('no-login') && api.credentials()) {
125
+ const a = await ask('Sign in now (GitHub, Google or Apple) so earnings go to your account? [Y/n] ');
126
+ if (!/^n/i.test(a)) await cmdLogin();
127
+ }
128
+ }
129
+
130
+ async function cmdUninstall() {
131
+ const res = [];
132
+ for (const id of Object.keys(agents.AGENTS)) {
133
+ try {
134
+ const r = agents.uninstall(id);
135
+ if (r.changed) res.push(id);
136
+ } catch (e) {
137
+ if (!json) console.log(`✗ ${agents.AGENTS[id].name}: ${e.message}`);
138
+ }
139
+ }
140
+ if (flag('purge')) fs.rmSync(util.home(), { recursive: true, force: true });
141
+ if (json) return out({ ok: true, removed: res });
142
+ console.log(res.length ? 'Removed from: ' + res.map((id) => agents.AGENTS[id].name).join(', ') : 'Nothing to remove.');
143
+ console.log('Restart your agent sessions to apply.');
144
+ }
145
+
146
+ async function cmdLinkStart() {
147
+ const r = await api.linkStart();
148
+ if (!String(r.url).startsWith(api.server() + '/')) throw new Error('Server returned an unexpected URL');
149
+ return r;
150
+ }
151
+
152
+ async function cmdLogin() {
153
+ const r = await cmdLinkStart();
154
+ console.log(`\nOpening ${r.url}`);
155
+ console.log(`Sign in with GitHub, Google or Apple, check that the page shows code ${r.code} and press "Link".`);
156
+ if (!openBrowser(r.url)) console.log('Open the link above in your browser.');
157
+ const deadline = Date.now() + 10 * 60 * 1000;
158
+ while (Date.now() < deadline) {
159
+ await new Promise((res) => setTimeout(res, 3000));
160
+ try {
161
+ const m = await api.me();
162
+ if (m.linked) return console.log(`✓ Signed in as ${m.login}`);
163
+ } catch (_) {}
164
+ }
165
+ console.log('The code expired. Run: npx tokenwait login');
166
+ }
167
+
168
+ async function cmdLogout() {
169
+ await api.unlink();
170
+ await api.me().catch(() => {});
171
+ json ? out({ ok: true }) : console.log('Signed out. This install is no longer linked to your account.');
172
+ }
173
+
174
+ function statusData() {
175
+ const ads = util.readJson(util.p('ads.json'), null);
176
+ const me = util.readJson(util.p('me.json'), null);
177
+ const cfg = util.readJson(util.p('config.json'), {});
178
+ const ad = ads && pick(ads.ads, Date.now(), cfg.rotate_seconds || 30);
179
+ return {
180
+ version: VERSION,
181
+ server: api.server(),
182
+ registered: !!api.credentials(),
183
+ agents: Object.fromEntries(Object.keys(agents.AGENTS).map((id) => [id, { detected: agents.detected(id), installed: agents.isInstalled(id) }])),
184
+ account: me ? { linked: !!me.linked, login: me.login || null } : null,
185
+ impressions: me ? me.impressions : null,
186
+ earnings_cents: me ? me.earnings_cents : null,
187
+ ads: ads ? ads.ads.length : 0,
188
+ current_ad: ad || null,
189
+ };
190
+ }
191
+
192
+ async function cmdStatus() {
193
+ await worker.run().catch(() => {});
194
+ const s = statusData();
195
+ if (json) return out(s);
196
+ console.log(`TokenWait ${s.version} · server ${s.server}`);
197
+ for (const [id, a] of Object.entries(s.agents)) {
198
+ console.log(` ${agents.AGENTS[id].name.padEnd(12)} ${a.installed ? 'installed' : a.detected ? 'not installed' : 'not found'}`);
199
+ }
200
+ console.log(`Account: ${s.account && s.account.linked ? s.account.login : 'not linked (run: npx tokenwait login)'}`);
201
+ if (s.impressions) {
202
+ console.log(`Impressions: today ${s.impressions.today}, total ${s.impressions.total}`);
203
+ console.log(`Earnings: $${(s.earnings_cents.total / 100).toFixed(2)} total`);
204
+ }
205
+ console.log(`Ads loaded: ${s.ads}${s.current_ad ? ' · now: ' + s.current_ad.text : ''}`);
206
+ }
207
+
208
+ function help() {
209
+ console.log(`TokenWait ${VERSION} — get paid while your AI coding agent thinks.
210
+
211
+ npx tokenwait install set up Claude Code / Codex CLI / Gemini CLI
212
+ npx tokenwait login link this machine to your account (GitHub, Google or Apple)
213
+ npx tokenwait status what is installed, impressions and earnings
214
+ npx tokenwait logout unlink this machine
215
+ npx tokenwait uninstall remove everything from agent configs (--purge also deletes ~/.tokenwait)
216
+
217
+ Options: --agents claude,codex,gemini --server URL --yes --json`);
218
+ }
219
+
220
+ const commands = {
221
+ install: cmdInstall,
222
+ uninstall: cmdUninstall,
223
+ login: cmdLogin,
224
+ logout: cmdLogout,
225
+ status: cmdStatus,
226
+ 'link-start': async () => out(await cmdLinkStart()),
227
+ me: async () => out(await api.me()),
228
+ sync: async () => {
229
+ await worker.run({ forceAds: flag('force-ads') });
230
+ if (json) out(statusData());
231
+ },
232
+ version: () => console.log(VERSION),
233
+ help,
234
+ };
235
+
236
+ const cmd = args[0] || 'help';
237
+ if (!commands[cmd]) {
238
+ help();
239
+ process.exit(1);
240
+ }
241
+ Promise.resolve()
242
+ .then(() => commands[cmd]())
243
+ .then(() => process.exit(0))
244
+ .catch((e) => {
245
+ if (json) out({ ok: false, error: e.message });
246
+ else console.error('Error: ' + e.message);
247
+ process.exit(1);
248
+ });
package/bin/worker.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ require('../lib/worker')
4
+ .run({ forceAds: process.argv.includes('--force-ads') })
5
+ .catch(() => {})
6
+ .finally(() => process.exit(0));
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+ // Учёт показов по хукам. Чистые функции + работа с файлами состояния.
3
+ //
4
+ // events.jsonl {t, e: "start"|"stop", a: agent, s: session} — пишут хуки
5
+ // visibility.jsonl {t, f: 0|1, tv: 0|1, src} — пишут расширения IDE
6
+ // f — окно IDE в фокусе (видна нижняя строка IDE с рекламой),
7
+ // tv — видна панель терминала (видна statusLine Claude Code).
8
+ // IDE пишет при изменении и раз в 15 с как «пульс».
9
+ // pending.json {ads: {id: ms}, wait_ms, last_report} — неотправленное
10
+ //
11
+ // Где реклама реально видна:
12
+ // claude + IDE -> f && tv (statusLine в терминале IDE)
13
+ // claude без IDE -> всё ожидание (терминал без проверки видимости)
14
+ // codex/gemini + IDE -> f (реклама в нижней строке IDE)
15
+ // codex/gemini без IDE -> ничего: показать негде
16
+
17
+ const fs = require('fs');
18
+ const { p, readJson, writeJson, withLock } = require('./util');
19
+ const { pick } = require('./rotation');
20
+
21
+ const MAX_WAIT_MS = 10 * 60 * 1000;
22
+ const HEARTBEAT_MS = 20 * 1000;
23
+ const STEP_MS = 500;
24
+
25
+ function readLines(file, max = 2000) {
26
+ try {
27
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
28
+ return lines.slice(-max).map((l) => {
29
+ try {
30
+ return JSON.parse(l);
31
+ } catch (_) {
32
+ return null;
33
+ }
34
+ }).filter(Boolean);
35
+ } catch (_) {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ function appendLine(file, obj, keep = 1000) {
41
+ try {
42
+ if (fs.statSync(file).size > 256 * 1024) {
43
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
44
+ fs.writeFileSync(file, lines.slice(-keep).join('\n') + '\n');
45
+ }
46
+ } catch (_) {}
47
+ fs.appendFileSync(file, JSON.stringify(obj) + '\n');
48
+ }
49
+
50
+ /** Интервал ожидания, который закрывается этим stop: последний start той же сессии. */
51
+ function waitInterval(events, agent, session, stopT) {
52
+ let start = null;
53
+ for (const e of events) {
54
+ if (e.a !== agent || e.s !== session || e.t >= stopT) continue; // сам stop уже может быть в журнале
55
+ if (e.e === 'start') start = e.t;
56
+ else if (e.e === 'stop') start = null;
57
+ }
58
+ if (start === null) return null;
59
+ const t0 = Math.max(start, stopT - MAX_WAIT_MS);
60
+ return t0 < stopT ? [t0, stopT] : null;
61
+ }
62
+
63
+ /** Состояние видимости в момент x: последняя запись не старше HEARTBEAT_MS, иначе null (IDE нет). */
64
+ function visibilityAt(vis, x) {
65
+ let cur = null;
66
+ for (const v of vis) {
67
+ if (v.t > x) break;
68
+ cur = v;
69
+ }
70
+ return cur && x - cur.t <= HEARTBEAT_MS ? cur : null;
71
+ }
72
+
73
+ /**
74
+ * Сколько миллисекунд какое объявление было реально видно за интервал ожидания.
75
+ * Возвращает {ads: {id: ms}, visibleMs}.
76
+ */
77
+ function attribute({ agent, interval, vis, ads, rotateSeconds }) {
78
+ const out = { ads: {}, visibleMs: 0 };
79
+ if (!interval || !ads || !ads.length) return out;
80
+ const sorted = vis.slice().sort((a, b) => a.t - b.t);
81
+ for (let x = interval[0]; x < interval[1]; x += STEP_MS) {
82
+ const dt = Math.min(STEP_MS, interval[1] - x);
83
+ const v = visibilityAt(sorted, x);
84
+ let shown;
85
+ if (agent === 'claude') shown = v ? !!(v.f && v.tv) : true;
86
+ else shown = v ? !!v.f : false;
87
+ if (!shown) continue;
88
+ const ad = pick(ads, x, rotateSeconds);
89
+ if (!ad) continue;
90
+ out.ads[ad.id] = (out.ads[ad.id] || 0) + dt;
91
+ out.visibleMs += dt;
92
+ }
93
+ return out;
94
+ }
95
+
96
+ function addToPending(add) {
97
+ return withLock('pending', () => {
98
+ const pend = readJson(p('pending.json'), { ads: {}, wait_ms: 0, last_report: 0 });
99
+ for (const [id, ms] of Object.entries(add.ads)) pend.ads[id] = (pend.ads[id] || 0) + ms;
100
+ pend.wait_ms = Math.min((pend.wait_ms || 0) + add.visibleMs, 24 * 3600 * 1000);
101
+ writeJson(p('pending.json'), pend);
102
+ return pend;
103
+ });
104
+ }
105
+
106
+ /** Целые показы из накопленных миллисекунд; остаток остаётся. */
107
+ function toImpressions(pendAds, impressionSeconds) {
108
+ const impMs = impressionSeconds * 1000;
109
+ const counts = {};
110
+ const rest = {};
111
+ for (const [id, ms] of Object.entries(pendAds || {})) {
112
+ const n = Math.floor(ms / impMs);
113
+ if (n > 0) counts[id] = n;
114
+ if (ms - n * impMs > 0) rest[id] = ms - n * impMs;
115
+ }
116
+ return { counts, rest };
117
+ }
118
+
119
+ module.exports = { MAX_WAIT_MS, HEARTBEAT_MS, readLines, appendLine, waitInterval, visibilityAt, attribute, addToPending, toImpressions };
package/lib/agents.js ADDED
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+ // Адаптеры агентов. Каждый умеет: найти себя, поставить/снять хуки ожидания,
3
+ // и (если есть) поставить свою «витрину» в терминале.
4
+ //
5
+ // claude — ~/.claude/settings.json: statusLine (витрина) + хуки UserPromptSubmit/Stop
6
+ // codex — ~/.codex/hooks.json: хуки UserPromptSubmit/Stop (витрины в терминале нет;
7
+ // новые хуки нужно один раз одобрить в Codex командой /hooks)
8
+ // gemini — ~/.gemini/settings.json: хуки BeforeAgent/AfterAgent (витрины в терминале нет)
9
+ //
10
+ // Наши записи помечены маркерами (--tw, --twhook), чужие не трогаем.
11
+ // Маркеры прежнего названия (--sl, --slhook) убираются при установке — миграция со Sponsorline.
12
+
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+ const { readJsonStrict, writeJson, backup, q, p } = require('./util');
17
+
18
+ const HOOK_MARK = '--twhook';
19
+ const LEGACY_HOOK_MARK = '--slhook';
20
+ const STATUS_RE = / --(tw|sl)(?: --wrap (\S+))?\s*$/;
21
+
22
+ const AGENTS = {
23
+ claude: {
24
+ name: 'Claude Code',
25
+ dir: () => path.join(os.homedir(), '.claude'),
26
+ file: () => path.join(os.homedir(), '.claude', 'settings.json'),
27
+ events: { UserPromptSubmit: 'start', Stop: 'stop' },
28
+ statusLine: true,
29
+ },
30
+ codex: {
31
+ name: 'Codex CLI',
32
+ dir: () => path.join(os.homedir(), '.codex'),
33
+ file: () => path.join(os.homedir(), '.codex', 'hooks.json'),
34
+ events: { UserPromptSubmit: 'start', Stop: 'stop' },
35
+ statusLine: false,
36
+ note: 'Codex runs new hooks only after you approve them: start codex and run /hooks once.',
37
+ },
38
+ gemini: {
39
+ name: 'Gemini CLI',
40
+ dir: () => path.join(os.homedir(), '.gemini'),
41
+ file: () => path.join(os.homedir(), '.gemini', 'settings.json'),
42
+ events: { BeforeAgent: 'start', AfterAgent: 'stop' },
43
+ statusLine: false,
44
+ },
45
+ };
46
+
47
+ function detected(id) {
48
+ return fs.existsSync(AGENTS[id].dir());
49
+ }
50
+
51
+ function commandOf(v) {
52
+ if (typeof v === 'string') return v;
53
+ if (v && typeof v === 'object' && typeof v.command === 'string') return v.command;
54
+ return null;
55
+ }
56
+
57
+ // ---------- statusLine (Claude Code) ----------
58
+
59
+ function parseStatusLine(v) {
60
+ const cmd = commandOf(v);
61
+ if (!cmd) return null;
62
+ const m = cmd.match(STATUS_RE);
63
+ if (!m) return null;
64
+ let wrap;
65
+ if (m[2]) wrap = JSON.parse(Buffer.from(m[2], 'base64url').toString('utf8'));
66
+ return { legacy: m[1] === 'sl', wrap };
67
+ }
68
+
69
+ function buildStatusLine(node, statusJs, wrap) {
70
+ let c = q(node) + ' ' + q(statusJs) + ' --tw';
71
+ if (wrap !== undefined) c += ' --wrap ' + Buffer.from(JSON.stringify(wrap)).toString('base64url');
72
+ return { type: 'command', command: c };
73
+ }
74
+
75
+ /** Снимает нашу (или старую) statusLine, возвращая чужую. true, если что-то поменялось. */
76
+ function unwrapStatusLine(s) {
77
+ let changed = false;
78
+ for (let i = 0; i < 5; i++) {
79
+ const ours = parseStatusLine(s.statusLine);
80
+ if (!ours) break;
81
+ if (ours.wrap === undefined) delete s.statusLine;
82
+ else s.statusLine = ours.wrap;
83
+ changed = true;
84
+ }
85
+ return changed;
86
+ }
87
+
88
+ // ---------- хуки (общий формат Claude/Codex/Gemini) ----------
89
+
90
+ function isOurHookGroup(g, marks) {
91
+ return (
92
+ g &&
93
+ typeof g === 'object' &&
94
+ Array.isArray(g.hooks) &&
95
+ g.hooks.some((h) => h && typeof h.command === 'string' && marks.some((m) => h.command.includes(m)))
96
+ );
97
+ }
98
+
99
+ function removeHooks(s, events, marks) {
100
+ if (!s.hooks || typeof s.hooks !== 'object') return false;
101
+ let changed = false;
102
+ for (const ev of events) {
103
+ const arr = s.hooks[ev];
104
+ if (!Array.isArray(arr)) continue;
105
+ const kept = arr.filter((g) => !isOurHookGroup(g, marks));
106
+ if (kept.length !== arr.length) {
107
+ changed = true;
108
+ if (kept.length) s.hooks[ev] = kept;
109
+ else delete s.hooks[ev];
110
+ }
111
+ }
112
+ if (changed && Object.keys(s.hooks).length === 0) delete s.hooks;
113
+ return changed;
114
+ }
115
+
116
+ function addHooks(s, agentId, node, hookJs) {
117
+ const a = AGENTS[agentId];
118
+ if (!s.hooks || typeof s.hooks !== 'object' || Array.isArray(s.hooks)) s.hooks = {};
119
+ for (const [ev, phase] of Object.entries(a.events)) {
120
+ if (!Array.isArray(s.hooks[ev])) s.hooks[ev] = [];
121
+ const hook = { type: 'command', command: `${q(node)} ${q(hookJs)} ${agentId} ${phase} ${HOOK_MARK}` };
122
+ if (agentId === 'codex') hook.timeout = 10; // секунды
123
+ if (agentId === 'gemini') {
124
+ hook.name = 'tokenwait-' + phase;
125
+ hook.timeout = 10000; // миллисекунды
126
+ }
127
+ s.hooks[ev].push({ hooks: [hook] });
128
+ }
129
+ }
130
+
131
+ /** Ставит (или обновляет) интеграцию агента. Возвращает описание изменений. */
132
+ function install(agentId, node, appDir) {
133
+ const a = AGENTS[agentId];
134
+ const file = a.file();
135
+ const { value: s } = readJsonStrict(file);
136
+ const before = JSON.stringify(s);
137
+ const events = Object.keys(a.events);
138
+ // всё старое и наше снимаем, затем ставим заново — так обновляются пути после переустановки
139
+ removeHooks(s, events, [HOOK_MARK, LEGACY_HOOK_MARK]);
140
+ addHooks(s, agentId, node, path.join(appDir, 'bin', 'hook.js'));
141
+ if (a.statusLine) {
142
+ unwrapStatusLine(s);
143
+ s.statusLine = buildStatusLine(node, path.join(appDir, 'bin', 'status.js'), s.statusLine);
144
+ }
145
+ if (agentId === 'claude') restoreLegacySpinner(s);
146
+ if (JSON.stringify(s) === before) return { file, changed: false };
147
+ backup(file);
148
+ writeJson(file, s);
149
+ return { file, changed: true };
150
+ }
151
+
152
+ function uninstall(agentId) {
153
+ const a = AGENTS[agentId];
154
+ const file = a.file();
155
+ if (!fs.existsSync(file)) return { file, changed: false };
156
+ const { value: s } = readJsonStrict(file);
157
+ let changed = removeHooks(s, Object.keys(a.events), [HOOK_MARK, LEGACY_HOOK_MARK]);
158
+ if (a.statusLine && unwrapStatusLine(s)) changed = true;
159
+ if (agentId === 'claude' && restoreLegacySpinner(s)) changed = true;
160
+ if (!changed) return { file, changed: false };
161
+ backup(file);
162
+ writeJson(file, s);
163
+ return { file, changed: true };
164
+ }
165
+
166
+ function isInstalled(agentId) {
167
+ const a = AGENTS[agentId];
168
+ let s;
169
+ try {
170
+ s = readJsonStrict(a.file()).value;
171
+ } catch (_) {
172
+ return false;
173
+ }
174
+ const hooksOk = Object.keys(a.events).every((ev) => Array.isArray(s.hooks && s.hooks[ev]) && s.hooks[ev].some((g) => isOurHookGroup(g, [HOOK_MARK])));
175
+ const slOk = !a.statusLine || (parseStatusLine(s.statusLine) || {}).legacy === false;
176
+ return hooksOk && slOk;
177
+ }
178
+
179
+ /** Плагин Sponsorline 0.1–0.2 мог заменить spinnerVerbs; вернём оригинал. */
180
+ function restoreLegacySpinner(s) {
181
+ const f = path.join(os.homedir(), '.sponsorline', 'spinner-original.json');
182
+ if (!fs.existsSync(f)) return false;
183
+ try {
184
+ const orig = JSON.parse(fs.readFileSync(f, 'utf8')).original;
185
+ if (orig === null || orig === undefined) delete s.spinnerVerbs;
186
+ else s.spinnerVerbs = orig;
187
+ fs.renameSync(f, f + '.restored');
188
+ return true;
189
+ } catch (_) {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ module.exports = { AGENTS, detected, install, uninstall, isInstalled, parseStatusLine, HOOK_MARK, _p: p };
package/lib/api.js ADDED
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+ // Клиент API сервера. Ключ установки — ~/.tokenwait/credentials.json (права 600),
3
+ // общий для CLI и расширений IDE: одна машина = одна установка.
4
+ const { p, readJson, writeJson, DEFAULT_SERVER } = require('./util');
5
+ const { normalizeAds } = require('./rotation');
6
+
7
+ class HttpError extends Error {
8
+ constructor(status, body) {
9
+ super(`HTTP ${status}: ${String(body).slice(0, 200)}`);
10
+ this.status = status;
11
+ }
12
+ }
13
+
14
+ function config() {
15
+ return readJson(p('config.json'), {});
16
+ }
17
+
18
+ function server() {
19
+ return (process.env.TOKENWAIT_SERVER || config().server || DEFAULT_SERVER).replace(/\/+$/, '');
20
+ }
21
+
22
+ function credentials() {
23
+ const c = readJson(p('credentials.json'), null);
24
+ return c && c.server === server() && c.client_id && c.client_secret ? c : null;
25
+ }
26
+
27
+ async function request(method, path, body, auth = true, timeoutMs = 5000) {
28
+ const headers = { accept: 'application/json', 'user-agent': 'tokenwait/' + require('../package.json').version };
29
+ if (body !== undefined) headers['content-type'] = 'application/json';
30
+ if (auth) {
31
+ const c = credentials() || (await register());
32
+ headers.authorization = `Bearer ${c.client_id}.${c.client_secret}`;
33
+ }
34
+ const res = await fetch(server() + path, {
35
+ method,
36
+ headers,
37
+ body: body === undefined ? undefined : JSON.stringify(body),
38
+ signal: AbortSignal.timeout(timeoutMs),
39
+ });
40
+ const text = await res.text();
41
+ if (res.status === 401 && auth) writeJson(p('credentials.json'), {}, 0o600); // ключ не принят — получим новый
42
+ if (!res.ok) throw new HttpError(res.status, text);
43
+ try {
44
+ return JSON.parse(text);
45
+ } catch (_) {
46
+ throw new Error('Server returned non-JSON: ' + text.slice(0, 200));
47
+ }
48
+ }
49
+
50
+ async function register() {
51
+ const r = await request('POST', '/v1/register', undefined, false);
52
+ const c = { server: server(), client_id: r.client_id, client_secret: r.client_secret };
53
+ writeJson(p('credentials.json'), c, 0o600);
54
+ return c;
55
+ }
56
+
57
+ async function fetchAds() {
58
+ const r = await request('GET', '/v1/ads');
59
+ const data = {
60
+ fetched_at: Date.now(),
61
+ ttl_seconds: Math.min(Math.max(Number(r.ttl_seconds) || 600, 60), 3600),
62
+ impression_seconds: Math.min(Math.max(Number(r.impression_seconds) || 5, 1), 60),
63
+ report_seconds: Math.min(Math.max(Number(r.report_seconds) || 60, 30), 3600),
64
+ ads: normalizeAds(r.ads),
65
+ };
66
+ writeJson(p('ads.json'), data);
67
+ return data;
68
+ }
69
+
70
+ async function me() {
71
+ const r = await request('GET', '/v1/me');
72
+ const data = { ...r, fetched_at: Date.now() };
73
+ writeJson(p('me.json'), data);
74
+ return data;
75
+ }
76
+
77
+ module.exports = {
78
+ HttpError,
79
+ server,
80
+ config,
81
+ credentials,
82
+ register,
83
+ fetchAds,
84
+ me,
85
+ reportImpressions: (items, waitMs) => request('POST', '/v1/impressions', { items, wait_ms: waitMs }),
86
+ linkStart: () => request('POST', '/v1/link/start', {}),
87
+ unlink: () => request('POST', '/v1/unlink', {}),
88
+ };
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+ // Детерминированная ротация: какое объявление показано в момент t, вычисляется
3
+ // одинаково в statusLine, в хуках и в расширениях IDE (Kotlin/TS повторяют этот код).
4
+ // Тестовые векторы: core/test/rotation-vectors.json.
5
+
6
+ const { sanitizeText, safeUrl } = require('./util');
7
+
8
+ function mulberry32(seed) {
9
+ let a = seed | 0;
10
+ a = (a + 0x6d2b79f5) | 0;
11
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
12
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
13
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
14
+ }
15
+
16
+ function slotOf(t, rotateSeconds) {
17
+ return Math.floor(t / (rotateSeconds * 1000));
18
+ }
19
+
20
+ /** ads: [{id, text, url, weight}] в порядке сервера. */
21
+ function pick(ads, t, rotateSeconds) {
22
+ if (!Array.isArray(ads) || ads.length === 0) return null;
23
+ const total = ads.reduce((s, a) => s + Math.max(1, a.weight | 0), 0);
24
+ let r = Math.floor(mulberry32(slotOf(t, rotateSeconds)) * total);
25
+ for (const a of ads) {
26
+ r -= Math.max(1, a.weight | 0);
27
+ if (r < 0) return a;
28
+ }
29
+ return ads[ads.length - 1];
30
+ }
31
+
32
+ /** Очистка списка с сервера. */
33
+ function normalizeAds(list) {
34
+ if (!Array.isArray(list)) return [];
35
+ return list
36
+ .map((a) => ({
37
+ id: sanitizeText(a && a.id, 64),
38
+ text: sanitizeText(a && a.text),
39
+ url: safeUrl(a && a.url),
40
+ weight: Math.max(1, Math.floor(Number(a && a.weight)) || 1),
41
+ }))
42
+ .filter((a) => a.id && a.text)
43
+ .slice(0, 50);
44
+ }
45
+
46
+ module.exports = { mulberry32, slotOf, pick, normalizeAds };
package/lib/util.js ADDED
@@ -0,0 +1,184 @@
1
+ 'use strict';
2
+ // Общие утилиты: пути, атомарная запись JSON, блокировки, очистка текста.
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const DEFAULT_SERVER = 'https://tokenwait.dev-line.workers.dev';
8
+
9
+ function home() {
10
+ return process.env.TOKENWAIT_HOME || path.join(os.homedir(), '.tokenwait');
11
+ }
12
+
13
+ function p(...parts) {
14
+ return path.join(home(), ...parts);
15
+ }
16
+
17
+ /** Наши служебные файлы: нет файла или он битый — возвращаем fallback. */
18
+ function readJson(file, fallback) {
19
+ try {
20
+ const txt = fs.readFileSync(file, 'utf8').replace(/^/, '');
21
+ return txt.trim() ? JSON.parse(txt) : fallback;
22
+ } catch (_) {
23
+ return fallback;
24
+ }
25
+ }
26
+
27
+ /** Строгое чтение: битый JSON -> исключение (для чужих конфигов, которые нельзя портить). */
28
+ function readJsonStrict(file) {
29
+ let txt;
30
+ try {
31
+ txt = fs.readFileSync(file, 'utf8').replace(/^/, '');
32
+ } catch (e) {
33
+ if (e.code === 'ENOENT') return { value: {}, existed: false };
34
+ throw e;
35
+ }
36
+ if (!txt.trim()) return { value: {}, existed: true };
37
+ let v;
38
+ try {
39
+ v = JSON.parse(txt);
40
+ } catch (e) {
41
+ throw new Error(`Cannot parse ${file}: ${e.message}. File left untouched.`);
42
+ }
43
+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error(`${file}: expected a JSON object. File left untouched.`);
44
+ return { value: v, existed: true };
45
+ }
46
+
47
+ function writeJson(file, obj, mode) {
48
+ fs.mkdirSync(path.dirname(file), { recursive: true });
49
+ const tmp = file + '.tmp-' + process.pid + '-' + Math.random().toString(36).slice(2);
50
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', mode ? { mode } : undefined);
51
+ try {
52
+ if (!mode) fs.chmodSync(tmp, fs.statSync(file).mode & 0o777);
53
+ } catch (_) {}
54
+ fs.renameSync(tmp, file);
55
+ }
56
+
57
+ function backup(file) {
58
+ if (!fs.existsSync(file)) return null;
59
+ const dir = p('backups');
60
+ fs.mkdirSync(dir, { recursive: true });
61
+ const name = path.basename(path.dirname(file)) + '-' + path.basename(file) + '-' + new Date().toISOString().replace(/[:.]/g, '-') + '.bak';
62
+ const target = path.join(dir, name);
63
+ fs.copyFileSync(file, target);
64
+ return target;
65
+ }
66
+
67
+ /** Простая межпроцессная блокировка через O_EXCL; протухшая (> staleMs) снимается. */
68
+ function withLock(name, fn, { staleMs = 30000, waitMs = 2000 } = {}) {
69
+ const file = p(name + '.lock');
70
+ fs.mkdirSync(home(), { recursive: true });
71
+ const deadline = Date.now() + waitMs;
72
+ for (;;) {
73
+ try {
74
+ fs.writeFileSync(file, String(process.pid), { flag: 'wx' });
75
+ break;
76
+ } catch (e) {
77
+ if (e.code !== 'EEXIST') throw e;
78
+ try {
79
+ if (Date.now() - fs.statSync(file).mtimeMs > staleMs) {
80
+ fs.unlinkSync(file);
81
+ continue;
82
+ }
83
+ } catch (_) {
84
+ continue;
85
+ }
86
+ if (Date.now() > deadline) return undefined;
87
+ const until = Date.now() + 25;
88
+ while (Date.now() < until) {} // короткое ожидание без async
89
+ }
90
+ }
91
+ try {
92
+ return fn();
93
+ } finally {
94
+ try {
95
+ fs.unlinkSync(file);
96
+ } catch (_) {}
97
+ }
98
+ }
99
+
100
+ async function withLockAsync(name, fn, opts = {}) {
101
+ const file = p(name + '.lock');
102
+ fs.mkdirSync(home(), { recursive: true });
103
+ try {
104
+ fs.writeFileSync(file, String(process.pid), { flag: 'wx' });
105
+ } catch (e) {
106
+ if (e.code !== 'EEXIST') throw e;
107
+ try {
108
+ if (Date.now() - fs.statSync(file).mtimeMs <= (opts.staleMs || 60000)) return undefined;
109
+ fs.unlinkSync(file);
110
+ fs.writeFileSync(file, String(process.pid), { flag: 'wx' });
111
+ } catch (_) {
112
+ return undefined;
113
+ }
114
+ }
115
+ try {
116
+ return await fn();
117
+ } finally {
118
+ try {
119
+ fs.unlinkSync(file);
120
+ } catch (_) {}
121
+ }
122
+ }
123
+
124
+ // Внешний текст рекламы: никаких управляющих последовательностей терминала.
125
+ const ANSI_OSC = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
126
+ const ANSI_CSI = /\x1b\[[0-9;?]*[ -\/]*[@-~]/g;
127
+ const BAD = /[\u0000-\u001f\u007f-\u009f​-‏‪-‮⁦-⁩]/g;
128
+
129
+ function sanitizeText(input, max = 80) {
130
+ let s = String(input == null ? '' : input)
131
+ .replace(ANSI_OSC, '')
132
+ .replace(ANSI_CSI, '')
133
+ .replace(BAD, ' ')
134
+ .replace(/\s+/g, ' ')
135
+ .trim();
136
+ const chars = Array.from(s);
137
+ if (chars.length > max) s = chars.slice(0, max - 1).join('') + '…';
138
+ return s;
139
+ }
140
+
141
+ function hostOf(url) {
142
+ try {
143
+ const u = new URL(String(url));
144
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
145
+ return sanitizeText(u.host, 40);
146
+ } catch (_) {
147
+ return '';
148
+ }
149
+ }
150
+
151
+ function safeUrl(url) {
152
+ try {
153
+ const u = new URL(String(url));
154
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : '';
155
+ } catch (_) {
156
+ return '';
157
+ }
158
+ }
159
+
160
+ /** Путь для shell-команды: на Windows Claude Code/Codex запускают команды через bash/cmd, прямые слэши понимают оба. */
161
+ function shellPath(p) {
162
+ return process.platform === 'win32' ? p.replace(/\\/g, '/') : p;
163
+ }
164
+
165
+ function q(p) {
166
+ return '"' + shellPath(p) + '"';
167
+ }
168
+
169
+ module.exports = {
170
+ DEFAULT_SERVER,
171
+ home,
172
+ p,
173
+ readJson,
174
+ readJsonStrict,
175
+ writeJson,
176
+ backup,
177
+ withLock,
178
+ withLockAsync,
179
+ sanitizeText,
180
+ hostOf,
181
+ safeUrl,
182
+ shellPath,
183
+ q,
184
+ };
package/lib/worker.js ADDED
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+ // Фоновая синхронизация: обновить рекламу, отправить показы, обновить статистику.
3
+ // Запускается отсоединённым процессом из хуков и statusLine, чтобы не задерживать агента.
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { spawn } = require('child_process');
7
+ const { p, readJson, writeJson, withLock, withLockAsync } = require('./util');
8
+ const api = require('./api');
9
+ const { toImpressions } = require('./accounting');
10
+
11
+ function adsStale(now = Date.now()) {
12
+ const a = readJson(p('ads.json'), null);
13
+ return !a || now - (a.fetched_at || 0) > (a.ttl_seconds || 600) * 1000;
14
+ }
15
+
16
+ function reportDue(now = Date.now()) {
17
+ const a = readJson(p('ads.json'), {});
18
+ const pend = readJson(p('pending.json'), null);
19
+ if (!pend || !pend.ads || !Object.keys(pend.ads).length) return false;
20
+ return now - (pend.last_report || 0) >= (a.report_seconds || 60) * 1000;
21
+ }
22
+
23
+ /** Запустить worker в фоне, не чаще раза в 10 секунд. */
24
+ function spawnWorker(force = false) {
25
+ try {
26
+ const mark = p('worker.spawned');
27
+ if (!force) {
28
+ try {
29
+ if (Date.now() - fs.statSync(mark).mtimeMs < 10000) return false;
30
+ } catch (_) {}
31
+ }
32
+ fs.mkdirSync(path.dirname(mark), { recursive: true });
33
+ fs.writeFileSync(mark, String(Date.now()));
34
+ const child = spawn(process.execPath, [path.join(__dirname, '..', 'bin', 'worker.js')], {
35
+ detached: true,
36
+ stdio: 'ignore',
37
+ windowsHide: true,
38
+ env: process.env,
39
+ });
40
+ child.unref();
41
+ return true;
42
+ } catch (_) {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ async function report() {
48
+ const ads = readJson(p('ads.json'), {});
49
+ const impSec = ads.impression_seconds || 5;
50
+ const impMs = impSec * 1000;
51
+ // забираем целые показы из пачки под блокировкой
52
+ const taken = withLock('pending', () => {
53
+ const pend = readJson(p('pending.json'), { ads: {}, wait_ms: 0, last_report: 0 });
54
+ if (Date.now() - (pend.last_report || 0) < (ads.report_seconds || 60) * 1000) return null;
55
+ const { counts, rest } = toImpressions(pend.ads, impSec);
56
+ if (!Object.keys(counts).length) return null;
57
+ const waitMs = pend.wait_ms || 0;
58
+ writeJson(p('pending.json'), { ads: rest, wait_ms: 0, last_report: Date.now() });
59
+ return { counts, waitMs };
60
+ });
61
+ if (!taken) return null;
62
+ const items = Object.entries(taken.counts).map(([ad_id, count]) => ({ ad_id, count }));
63
+ try {
64
+ const r = await api.reportImpressions(items, taken.waitMs);
65
+ log(`report: sent ${items.reduce((s, i) => s + i.count, 0)}, accepted ${r.accepted}`);
66
+ return r;
67
+ } catch (e) {
68
+ // сервер не обработал пачку — возвращаем её; прочие 4xx (например, неизвестная реклама) отбрасываем
69
+ const retry = !(e instanceof api.HttpError) || e.status === 429 || e.status === 409 || e.status >= 500;
70
+ if (retry) {
71
+ withLock('pending', () => {
72
+ const pend = readJson(p('pending.json'), { ads: {}, wait_ms: 0, last_report: 0 });
73
+ for (const [id, n] of Object.entries(taken.counts)) pend.ads[id] = (pend.ads[id] || 0) + n * impMs;
74
+ pend.wait_ms = Math.min((pend.wait_ms || 0) + taken.waitMs, 24 * 3600 * 1000);
75
+ if (e.status === 429) pend.last_report = Date.now();
76
+ else pend.last_report = 0;
77
+ writeJson(p('pending.json'), pend);
78
+ });
79
+ }
80
+ log('report failed: ' + e.message);
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function log(msg) {
86
+ try {
87
+ const f = p('worker.log');
88
+ try {
89
+ if (fs.statSync(f).size > 64 * 1024) fs.writeFileSync(f, '');
90
+ } catch (_) {}
91
+ fs.appendFileSync(f, new Date().toISOString() + ' ' + msg + '\n');
92
+ } catch (_) {}
93
+ }
94
+
95
+ async function run({ forceAds = false } = {}) {
96
+ return withLockAsync('worker', async () => {
97
+ if (forceAds || adsStale()) {
98
+ try {
99
+ const a = await api.fetchAds();
100
+ log(`ads: ${a.ads.length}`);
101
+ } catch (e) {
102
+ log('ads failed: ' + e.message);
103
+ }
104
+ }
105
+ const r = await report();
106
+ const meFile = readJson(p('me.json'), null);
107
+ if (r || !meFile || Date.now() - (meFile.fetched_at || 0) > 60000) {
108
+ try {
109
+ await api.me();
110
+ } catch (e) {
111
+ log('me failed: ' + e.message);
112
+ }
113
+ }
114
+ });
115
+ }
116
+
117
+ module.exports = { run, report, spawnWorker, adsStale, reportDue, log };
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "tokenwait",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Get paid while your AI coding agent thinks. A clearly labelled sponsored line for Claude Code, Codex CLI and Gemini CLI.",
5
+ "bin": {
6
+ "tokenwait": "bin/tokenwait.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "lib"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18.17"
14
+ },
15
+ "scripts": {
16
+ "test": "node test/test.js"
17
+ },
18
+ "keywords": [
19
+ "claude-code",
20
+ "codex",
21
+ "gemini-cli",
22
+ "statusline",
23
+ "ai-agents"
24
+ ],
25
+ "license": "UNLICENSED"
26
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.