getlull 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/README.md +33 -0
- package/bin/lull.mjs +128 -0
- package/package.json +40 -0
- package/release-public.pem +3 -0
- package/src/adapters.mjs +137 -0
- package/src/api.mjs +41 -0
- package/src/codex-hook.mjs +42 -0
- package/src/config.mjs +64 -0
- package/src/identity.mjs +90 -0
- package/src/index.mjs +9 -0
- package/src/keychain.mjs +110 -0
- package/src/prefs.mjs +59 -0
- package/src/render-core.mjs +76 -0
- package/src/runtime.mjs +86 -0
- package/src/statusline.mjs +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# ◆ getlull
|
|
2
|
+
|
|
3
|
+
**Get paid for the idle seconds you already spend watching your AI coding agent think.**
|
|
4
|
+
|
|
5
|
+
Lull shows **one** short, clearly-labeled sponsor line in your coding agent's spinner / status
|
|
6
|
+
line during wait states — then it's gone. Between ads, your status line shows your balance ticking
|
|
7
|
+
up live (`⚡ Lull · $0.42 today · $7.11 total`). You keep **70%**. It's open-source, ships signed
|
|
8
|
+
releases, and **never reads your prompts, code, or files**.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx getlull init # create a device key, approve in the browser, wire into your agent
|
|
12
|
+
lull status # install state + device id + balance
|
|
13
|
+
lull wallet # earnings
|
|
14
|
+
lull config country IN # (optional) see country-targeted sponsors — self-declared, never geolocated
|
|
15
|
+
lull pause # mute the line
|
|
16
|
+
lull uninstall # restore your original config exactly
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Why you'd trust it
|
|
20
|
+
|
|
21
|
+
- **Status-line only.** The client drains the agent's input *without reading it* — zero access to
|
|
22
|
+
your prompts, files, diffs, or commits. The entire impression payload is
|
|
23
|
+
`{ deviceId, creativeId, dwellMs, signature }`.
|
|
24
|
+
- **Open-source + signed releases.** Read it, build it, diff it against the signed manifest.
|
|
25
|
+
- **Documented extension points only** — it never patches your editor's bundle.
|
|
26
|
+
- **Earnings can't be forged in your name** — every impression is signed by a per-device Ed25519
|
|
27
|
+
key that never leaves your machine; abuse is bounded by rate caps, a clearing hold, and review.
|
|
28
|
+
- **Real payouts** — UPI in India (₹400 floor) or USD worldwide via Stripe ($10 floor). No points.
|
|
29
|
+
|
|
30
|
+
🔗 [getlull.dev](https://getlull.dev) · [How it works](https://getlull.dev/how-it-works/) · [Source](https://github.com/sonusrujan/lull) · [Apache-2.0](../LICENSE)
|
|
31
|
+
|
|
32
|
+
> **Status: pre-launch.** The client works with **Claude Code** today; Codex and Cursor/VS Code are
|
|
33
|
+
> in progress. Payouts are manual for the first cohort. A product of **NarraTech Pvt Ltd**.
|
package/bin/lull.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Lull CLI (Plan 1). Commands: init | status | off | uninstall | wallet
|
|
3
|
+
// init -> create device identity, register public key, wire into Claude Code
|
|
4
|
+
// status -> show install state + device id + balance
|
|
5
|
+
// wallet -> show current earnings
|
|
6
|
+
// off -> remove the status line but keep the device identity
|
|
7
|
+
// uninstall -> remove the status line (identity file stays unless --purge)
|
|
8
|
+
|
|
9
|
+
import { createDevice, loadDevice, saveToken, purgeDevice } from '../src/identity.mjs';
|
|
10
|
+
import { installStatusLine, uninstallStatusLine, isInstalled, agentStatus, setSpinnerVerbs, clearSpinnerVerbs, SETTINGS } from '../src/config.mjs';
|
|
11
|
+
import { keychainBackend } from '../src/keychain.mjs';
|
|
12
|
+
import { getPrefs, setPref, blockCategory, unblockCategory } from '../src/prefs.mjs';
|
|
13
|
+
import { api } from '../src/api.mjs';
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
const cmd = process.argv[2] || 'help';
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
|
|
19
|
+
function openBrowser(u) {
|
|
20
|
+
const c = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
21
|
+
execFile(c, [u], () => {});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function init() {
|
|
25
|
+
let device = loadDevice();
|
|
26
|
+
if (!device) { device = createDevice(); console.log(`[lull] created device ${device.deviceId} (key in ${keychainBackend()})`); }
|
|
27
|
+
else console.log(`[lull] using existing device ${device.deviceId}`);
|
|
28
|
+
|
|
29
|
+
// Device-authorization flow: human approves in the browser before any token is issued.
|
|
30
|
+
try {
|
|
31
|
+
const start = await api.deviceStart(device.deviceId, device.publicKeyPem);
|
|
32
|
+
console.log('');
|
|
33
|
+
console.log(' Approve this device to start earning:');
|
|
34
|
+
console.log(` ${start.verification_uri_complete}`);
|
|
35
|
+
console.log(` code: ${start.user_code}`);
|
|
36
|
+
console.log('');
|
|
37
|
+
openBrowser(start.verification_uri_complete);
|
|
38
|
+
|
|
39
|
+
process.stdout.write(' waiting for approval');
|
|
40
|
+
const deadline = Date.now() + (start.expires_in || 600) * 1000;
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
await sleep((start.interval || 2) * 1000);
|
|
43
|
+
const r = await api.devicePoll(start.device_code);
|
|
44
|
+
if (r.token) {
|
|
45
|
+
saveToken(device.deviceId, r.token);
|
|
46
|
+
console.log(`\n[lull] approved. token stored in ${keychainBackend()} (scopes: ${r.scopes.join(', ')})`);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
process.stdout.write('.');
|
|
50
|
+
}
|
|
51
|
+
if (!loadDevice().token) { console.log('\n[lull] not approved in time — re-run: lull init'); return; }
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.log(`\n[lull] WARNING: backend not reachable (${api.base}) — start the server, then re-init.`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const r = installStatusLine();
|
|
58
|
+
console.log(`[lull] wired into agents: ${r.installed.join(', ') || '(none detected)'}`);
|
|
59
|
+
// Seed the "thinking" spinner with a sponsor line (Claude Code 2.1.23+) so it shows on next start.
|
|
60
|
+
const sm = getPrefs().spinnerMode;
|
|
61
|
+
if (sm !== 'off') {
|
|
62
|
+
try { const { creative } = await api.serve({ deviceId: device.deviceId }); setSpinnerVerbs([`▸ Sponsored · ${creative.text} · getlull.dev`], sm || 'replace'); console.log(`[lull] sponsor line added to the thinking spinner (mode: ${sm}). Disable: lull config spinnerMode off`); } catch {}
|
|
63
|
+
}
|
|
64
|
+
console.log(`[lull] originals backed up alongside each config. Revert anytime: lull uninstall`);
|
|
65
|
+
console.log('[lull] restart your agent(s) to see it.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function status() {
|
|
69
|
+
const device = loadDevice();
|
|
70
|
+
const p = getPrefs();
|
|
71
|
+
console.log(`installed: ${isInstalled() ? 'yes' : 'no'}`);
|
|
72
|
+
console.log(`device: ${device ? device.deviceId : '(none — run: lull init)'}`);
|
|
73
|
+
console.log(`keychain: ${keychainBackend()}`);
|
|
74
|
+
console.log(`token: ${device?.token ? 'present (least-privilege, in keychain)' : 'none'}`);
|
|
75
|
+
console.log(`backend: ${api.base}`);
|
|
76
|
+
console.log(`display: ${p.enabled ? 'on' : 'paused'} · ${p.adSeconds}s ad / ${p.restSeconds}s rest · cap ${p.maxPerHour}/hr`);
|
|
77
|
+
console.log(`blocked: ${p.blockedCategories.length ? p.blockedCategories.join(', ') : '(none — lull block <category>)'}`);
|
|
78
|
+
console.log('agents:');
|
|
79
|
+
for (const a of agentStatus()) {
|
|
80
|
+
const mark = !a.detected ? 'not found' : a.installed ? 'installed' : 'detected (run: lull init)';
|
|
81
|
+
console.log(` ${a.installed ? '●' : a.detected ? '○' : '·'} ${a.name.padEnd(12)} ${mark}`);
|
|
82
|
+
}
|
|
83
|
+
if (device) await wallet();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function config() {
|
|
87
|
+
const [, , , key, value] = process.argv; // lull config <key> <value>
|
|
88
|
+
if (!key) { console.log('config:'); for (const [k, v] of Object.entries(getPrefs())) console.log(` ${k} = ${v}`); console.log('\nset with: lull config <key> <value> (base, adSeconds, restSeconds, maxPerHour, enabled)'); return; }
|
|
89
|
+
if (value === undefined) { console.log(`${key} = ${getPrefs()[key]}`); return; }
|
|
90
|
+
const out = setPref(key, value);
|
|
91
|
+
console.log(`[lull] set ${key} = ${out[key]}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function wallet() {
|
|
95
|
+
const device = loadDevice();
|
|
96
|
+
if (!device) return console.log('no device — run: lull init');
|
|
97
|
+
try {
|
|
98
|
+
const w = await api.wallet(device.deviceId);
|
|
99
|
+
console.log(`balance: $${w.balanceUsd} (${w.impressionsBillable} billable / ${w.impressionsDropped} dropped)`);
|
|
100
|
+
} catch { console.log('balance: (backend unreachable)'); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function off() { clearSpinnerVerbs(); uninstallStatusLine(); console.log('[lull] status line + spinner removed (device kept). Restart Claude Code.'); }
|
|
104
|
+
function uninstall() {
|
|
105
|
+
clearSpinnerVerbs();
|
|
106
|
+
const r = uninstallStatusLine();
|
|
107
|
+
if (process.argv.includes('--purge')) {
|
|
108
|
+
const d = loadDevice(); if (d) { purgeDevice(d.deviceId); console.log('[lull] purged device keys from keychain.'); }
|
|
109
|
+
}
|
|
110
|
+
console.log(`[lull] uninstalled (${r.restoredFrom ? 'restored backup' : 'removed key'}). Restart Claude Code.`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function pause() { setPref('enabled', false); console.log('[lull] paused — sponsor line stays quiet (device + install kept). Resume: lull resume'); }
|
|
114
|
+
function resume() { setPref('enabled', true); console.log('[lull] resumed.'); }
|
|
115
|
+
|
|
116
|
+
const CATEGORIES = ['database', 'observability', 'ai-tools', 'api', 'hiring', 'security', 'cloud', 'devtools'];
|
|
117
|
+
function block() {
|
|
118
|
+
const cat = process.argv[3];
|
|
119
|
+
if (!cat) { console.log('your inventory, your veto. block a category so those ads never show:\n categories: ' + CATEGORIES.join(', ') + '\n blocked: ' + (getPrefs().blockedCategories.join(', ') || '(none)') + '\n usage: lull block <category>'); return; }
|
|
120
|
+
const out = blockCategory(cat); console.log(`[lull] blocked "${cat}". now hidden: ${out.blockedCategories.join(', ')}`);
|
|
121
|
+
}
|
|
122
|
+
function unblock() { const cat = process.argv[3]; if (!cat) return console.log('usage: lull unblock <category>'); const out = unblockCategory(cat); console.log(`[lull] unblocked "${cat}". blocked: ${out.blockedCategories.join(', ') || '(none)'}`); }
|
|
123
|
+
|
|
124
|
+
const table = {
|
|
125
|
+
init, status, wallet, config, pause, resume, block, unblock, off, uninstall,
|
|
126
|
+
help: () => console.log('usage: lull <init|status|wallet|config|block|unblock|pause|resume|off|uninstall>'),
|
|
127
|
+
};
|
|
128
|
+
(table[cmd] || table.help)();
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "getlull",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Get paid for the idle time you spend watching your AI coding agent think. Open-source, signed, status-line only. You keep 70%.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lull": "bin/lull.mjs"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/",
|
|
15
|
+
"release-public.pem",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"lull": "node bin/lull.mjs"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"ai", "coding-agent", "claude-code", "cursor", "codex", "status-line",
|
|
26
|
+
"ads", "monetization", "developer-tools", "passive-income"
|
|
27
|
+
],
|
|
28
|
+
"homepage": "https://getlull.dev",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/sonusrujan/lull.git"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/sonusrujan/lull/issues"
|
|
35
|
+
},
|
|
36
|
+
"license": "Apache-2.0",
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/adapters.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Lull client — multi-agent adapter layer.
|
|
2
|
+
// The render core (status line + signed beacon) is identical across agents; only the
|
|
3
|
+
// "where do I install the hook" differs. Each adapter knows how to: detect the agent,
|
|
4
|
+
// install the Lull hook (backing up first), uninstall (restoring), and report state.
|
|
5
|
+
//
|
|
6
|
+
// Surfaces: Claude Code (native status line) and Codex CLI (lifecycle hook in hooks.json →
|
|
7
|
+
// terminal render) hook into config files; Cursor / VS Code load a companion status-bar
|
|
8
|
+
// extension. Each degrades to "not detected" rather than touching a tool that isn't there, and
|
|
9
|
+
// only ever adds/removes its OWN entry (backing up first), never clobbering the user's config.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync, cpSync, rmSync, mkdirSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join, resolve, dirname } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const STATUSLINE = resolve(__dir, 'statusline.mjs');
|
|
18
|
+
// Use the ABSOLUTE node path — Claude Code runs the status-line command with a minimal PATH
|
|
19
|
+
// that may not include Homebrew's bin, so a bare `node` would fail silently (blank status line).
|
|
20
|
+
const CMD = `${process.execPath} ${STATUSLINE}`;
|
|
21
|
+
const CODEX_HOOK = resolve(__dir, 'codex-hook.mjs'); // Codex lifecycle-hook renderer
|
|
22
|
+
const CODEX_CMD = `${process.execPath} ${CODEX_HOOK}`;
|
|
23
|
+
const EXT_SRC = resolve(__dir, '..', '..', 'extension'); // the VS Code/Cursor extension source
|
|
24
|
+
const EXT_FOLDER = 'lull.lull-vscode-0.1.0'; // <publisher>.<name>-<version>
|
|
25
|
+
|
|
26
|
+
function readJson(p) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return {}; }
|
|
27
|
+
}
|
|
28
|
+
function backupOnce(file) {
|
|
29
|
+
const bak = file + '.lull-backup';
|
|
30
|
+
if (existsSync(file) && !existsSync(bak)) copyFileSync(file, bak);
|
|
31
|
+
return bak;
|
|
32
|
+
}
|
|
33
|
+
function restore(file) {
|
|
34
|
+
const bak = file + '.lull-backup';
|
|
35
|
+
if (existsSync(bak)) { copyFileSync(bak, file); return { restoredFrom: bak }; }
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---- Claude Code: native statusLine command ----
|
|
40
|
+
const claude = {
|
|
41
|
+
id: 'claude-code',
|
|
42
|
+
name: 'Claude Code',
|
|
43
|
+
configPath: join(homedir(), '.claude', 'settings.json'),
|
|
44
|
+
detect() { return existsSync(join(homedir(), '.claude')); },
|
|
45
|
+
isInstalled() {
|
|
46
|
+
const s = readJson(this.configPath);
|
|
47
|
+
return !!(s.statusLine && String(s.statusLine.command || '').includes('statusline.mjs'));
|
|
48
|
+
},
|
|
49
|
+
install() {
|
|
50
|
+
backupOnce(this.configPath);
|
|
51
|
+
const s = existsSync(this.configPath) ? readJson(this.configPath) : {};
|
|
52
|
+
s.statusLine = { type: 'command', command: CMD, padding: 0 };
|
|
53
|
+
writeFileSync(this.configPath, JSON.stringify(s, null, 2));
|
|
54
|
+
return { agent: this.id, configPath: this.configPath };
|
|
55
|
+
},
|
|
56
|
+
uninstall() {
|
|
57
|
+
const r = restore(this.configPath);
|
|
58
|
+
if (!r) { const s = readJson(this.configPath); delete s.statusLine; writeFileSync(this.configPath, JSON.stringify(s, null, 2)); }
|
|
59
|
+
return { agent: this.id, ...(r || { removedKey: true }) };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ---- Codex CLI: lifecycle hook via ~/.codex/hooks.json ----
|
|
64
|
+
// Codex has no command-backed status line, but it DOES run lifecycle hooks (PreToolUse, etc.)
|
|
65
|
+
// from ~/.codex/hooks.json (same shape as Claude Code's hooks). We register a PreToolUse hook
|
|
66
|
+
// matching every tool; it runs codex-hook.mjs, which prints the OSC-8 sponsor line to the
|
|
67
|
+
// terminal while the agent works. config.toml is never touched. We only ever add/remove OUR
|
|
68
|
+
// entry, preserving any hooks the user already has.
|
|
69
|
+
const CODEX_MARK = 'codex-hook.mjs';
|
|
70
|
+
const hasOurHook = (h) => (h?.hooks?.PreToolUse || []).some((e) => (e.hooks || []).some((x) => String(x.command || '').includes(CODEX_MARK)));
|
|
71
|
+
const stripOurHook = (h) => {
|
|
72
|
+
if (!h?.hooks?.PreToolUse) return h;
|
|
73
|
+
h.hooks.PreToolUse = h.hooks.PreToolUse
|
|
74
|
+
.map((e) => ({ ...e, hooks: (e.hooks || []).filter((x) => !String(x.command || '').includes(CODEX_MARK)) }))
|
|
75
|
+
.filter((e) => (e.hooks || []).length);
|
|
76
|
+
if (!h.hooks.PreToolUse.length) delete h.hooks.PreToolUse;
|
|
77
|
+
if (h.hooks && !Object.keys(h.hooks).length) delete h.hooks;
|
|
78
|
+
return h;
|
|
79
|
+
};
|
|
80
|
+
const codex = {
|
|
81
|
+
id: 'codex',
|
|
82
|
+
name: 'Codex CLI',
|
|
83
|
+
configPath: join(homedir(), '.codex', 'hooks.json'),
|
|
84
|
+
detect() { return existsSync(join(homedir(), '.codex')); },
|
|
85
|
+
isInstalled() { return hasOurHook(readJson(this.configPath)); },
|
|
86
|
+
install() {
|
|
87
|
+
backupOnce(this.configPath);
|
|
88
|
+
const h = existsSync(this.configPath) ? readJson(this.configPath) : {};
|
|
89
|
+
stripOurHook(h); // idempotent: drop any stale Lull entry first
|
|
90
|
+
h.hooks = h.hooks || {};
|
|
91
|
+
h.hooks.PreToolUse = h.hooks.PreToolUse || [];
|
|
92
|
+
h.hooks.PreToolUse.push({ matcher: '.*', hooks: [{ type: 'command', command: CODEX_CMD, timeout: 5, statusMessage: 'Lull' }] });
|
|
93
|
+
writeFileSync(this.configPath, JSON.stringify(h, null, 2));
|
|
94
|
+
return { agent: this.id, configPath: this.configPath, note: 'codex hook installed — verify render in a live Codex session' };
|
|
95
|
+
},
|
|
96
|
+
uninstall() {
|
|
97
|
+
const r = restore(this.configPath);
|
|
98
|
+
if (r) return { agent: this.id, ...r };
|
|
99
|
+
if (existsSync(this.configPath)) {
|
|
100
|
+
const h = stripOurHook(readJson(this.configPath));
|
|
101
|
+
if (!Object.keys(h).length) { rmSync(this.configPath, { force: true }); return { agent: this.id, removedFile: true }; }
|
|
102
|
+
writeFileSync(this.configPath, JSON.stringify(h, null, 2));
|
|
103
|
+
}
|
|
104
|
+
return { agent: this.id, removedKey: true };
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ---- Cursor / VS Code: status bar via a companion extension ----
|
|
109
|
+
// These agents expose the status bar to *extensions*, not a config command. The Lull hook is a
|
|
110
|
+
// tiny extension (../extension) calling the same /serve + signed-beacon API. We "install" it by
|
|
111
|
+
// copying it into the editor's extensions dir (loaded on next reload). Status-bar only.
|
|
112
|
+
const cursor = extensionAdapter('cursor', 'Cursor', join(homedir(), '.cursor', 'extensions'));
|
|
113
|
+
const vscode = extensionAdapter('vscode', 'VS Code', join(homedir(), '.vscode', 'extensions'));
|
|
114
|
+
|
|
115
|
+
function extensionAdapter(id, name, extDir) {
|
|
116
|
+
const target = join(extDir, EXT_FOLDER);
|
|
117
|
+
return {
|
|
118
|
+
id, name, configPath: target,
|
|
119
|
+
detect() { return existsSync(extDir) || existsSync(dirname(extDir)); },
|
|
120
|
+
isInstalled() { return existsSync(join(target, 'extension.js')); },
|
|
121
|
+
install() {
|
|
122
|
+
if (!existsSync(EXT_SRC)) return { agent: id, error: 'extension source missing' };
|
|
123
|
+
mkdirSync(extDir, { recursive: true });
|
|
124
|
+
cpSync(EXT_SRC, target, { recursive: true });
|
|
125
|
+
return { agent: id, installedTo: target, note: `reload ${name} to activate` };
|
|
126
|
+
},
|
|
127
|
+
uninstall() {
|
|
128
|
+
if (existsSync(target)) rmSync(target, { recursive: true, force: true });
|
|
129
|
+
return { agent: id, removed: target };
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const ADAPTERS = [claude, codex, cursor, vscode];
|
|
135
|
+
|
|
136
|
+
export function detectAgents() { return ADAPTERS.filter((a) => a.detect()); }
|
|
137
|
+
export function getAdapter(id) { return ADAPTERS.find((a) => a.id === id); }
|
package/src/api.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Lull client — backend API helper (zero deps, fast timeouts so the status line never hangs).
|
|
2
|
+
// Reads the base URL from prefs (env > config.json > default) and speaks http or https
|
|
3
|
+
// automatically, so the same client works against localhost and a deployed https backend.
|
|
4
|
+
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import https from 'node:https';
|
|
7
|
+
import { getBase } from './prefs.mjs';
|
|
8
|
+
|
|
9
|
+
function call(method, path, body) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const url = new URL(path, getBase());
|
|
12
|
+
const mod = url.protocol === 'https:' ? https : http;
|
|
13
|
+
const data = body ? JSON.stringify(body) : null;
|
|
14
|
+
const headers = { 'content-type': 'application/json' };
|
|
15
|
+
if (data) headers['content-length'] = Buffer.byteLength(data);
|
|
16
|
+
const req = mod.request(url, { method, timeout: 800, headers }, (res) => {
|
|
17
|
+
let d = ''; res.on('data', (c) => (d += c));
|
|
18
|
+
res.on('end', () => { try { resolve(JSON.parse(d || '{}')); } catch (e) { reject(e); } });
|
|
19
|
+
});
|
|
20
|
+
req.on('error', reject);
|
|
21
|
+
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
22
|
+
if (data) req.write(data);
|
|
23
|
+
req.end();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const api = {
|
|
28
|
+
get base() { return getBase(); },
|
|
29
|
+
deviceStart: (deviceId, publicKeyPem) => call('POST', '/v1/auth/device/start', { deviceId, publicKeyPem }),
|
|
30
|
+
devicePoll: (device_code) => call('POST', '/v1/auth/device/poll', { device_code }),
|
|
31
|
+
serve: (opts = {}) => {
|
|
32
|
+
const q = new URLSearchParams();
|
|
33
|
+
if (opts.deviceId) q.set('device', opts.deviceId);
|
|
34
|
+
if (opts.blocked && opts.blocked.length) q.set('block', opts.blocked.join(','));
|
|
35
|
+
if (opts.country) q.set('country', String(opts.country).toUpperCase()); // self-declared, optional
|
|
36
|
+
const s = q.toString();
|
|
37
|
+
return call('GET', '/v1/serve' + (s ? '?' + s : ''));
|
|
38
|
+
},
|
|
39
|
+
reportImpression: (payload, signature) => call('POST', '/v1/impressions', { payload, signature }),
|
|
40
|
+
wallet: (deviceId) => call('GET', `/v1/wallet?deviceId=${encodeURIComponent(deviceId)}`),
|
|
41
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Lull hook for Codex CLI. Registered in ~/.codex/hooks.json on PreToolUse, so Codex runs it
|
|
3
|
+
// while the agent is actively working. Codex has no command-backed status line, so instead of a
|
|
4
|
+
// footer we print the sponsor line straight to the CONTROLLING TERMINAL (/dev/tty) with an OSC-8
|
|
5
|
+
// hyperlink (clickable in iTerm2/Kitty/WezTerm; plain text elsewhere).
|
|
6
|
+
//
|
|
7
|
+
// Why /dev/tty and not stdout: Codex captures a hook's stdout for its hook protocol, so writing
|
|
8
|
+
// the ad there would corrupt it. We write to /dev/tty and leave stdout empty + exit 0 cleanly.
|
|
9
|
+
// Privacy: the hook event JSON on stdin is drained and never inspected.
|
|
10
|
+
|
|
11
|
+
import { loadDevice } from './identity.mjs';
|
|
12
|
+
import { getPrefs } from './prefs.mjs';
|
|
13
|
+
import { produce } from './render-core.mjs';
|
|
14
|
+
import { openSync, writeSync, closeSync } from 'node:fs';
|
|
15
|
+
|
|
16
|
+
function drainStdin() {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let n = 0;
|
|
19
|
+
process.stdin.on('data', (c) => (n += c.length));
|
|
20
|
+
process.stdin.on('end', () => resolve(n));
|
|
21
|
+
setTimeout(() => resolve(n), 40);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Print straight to the user's terminal, bypassing stdout. Returns false if there's no tty
|
|
26
|
+
// (e.g. piped/headless) so we degrade silently instead of throwing.
|
|
27
|
+
export function writeTty(line) {
|
|
28
|
+
let fd;
|
|
29
|
+
try { fd = openSync('/dev/tty', 'w'); writeSync(fd, line + '\n'); return true; }
|
|
30
|
+
catch { return false; }
|
|
31
|
+
finally { if (fd !== undefined) try { closeSync(fd); } catch {} }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function main() {
|
|
35
|
+
await drainStdin();
|
|
36
|
+
const device = loadDevice();
|
|
37
|
+
if (!device) return; // not signed in → clean no-op
|
|
38
|
+
const line = await produce(device, getPrefs(), { setVerbs: false });
|
|
39
|
+
if (line) writeTty(line); // empty during the quiet phase → show nothing
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
main();
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Lull client — agent integration (multi-agent via adapters.mjs).
|
|
2
|
+
// Installs/uninstalls the Lull hook across every detected coding agent. Backward-compatible
|
|
3
|
+
// function names are kept for the CLI.
|
|
4
|
+
|
|
5
|
+
import { detectAgents, ADAPTERS, getAdapter } from './adapters.mjs';
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
// Back-compat alias used elsewhere (Claude Code's settings path).
|
|
11
|
+
const SETTINGS = join(homedir(), '.claude', 'settings.json');
|
|
12
|
+
const BACKUP = SETTINGS + '.lull-backup';
|
|
13
|
+
|
|
14
|
+
function readSettings() { try { return JSON.parse(readFileSync(SETTINGS, 'utf8')); } catch { return {}; } }
|
|
15
|
+
// Atomic write: stage to a temp file then rename, so the agent never reads a partial settings.json.
|
|
16
|
+
function writeSettings(s) {
|
|
17
|
+
const tmp = `${SETTINGS}.tmp-${process.pid}`;
|
|
18
|
+
writeFileSync(tmp, JSON.stringify(s, null, 2));
|
|
19
|
+
renameSync(tmp, SETTINGS);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Sponsored "thinking" verbs — Claude Code 2.1.23+ `spinnerVerbs` replaces/append the spinner
|
|
23
|
+
// text. This puts the sponsor line where the "Crunching…/Cooking…" text shows. The statusLine
|
|
24
|
+
// command keeps recording the precise signed impression; the spinner is display-only.
|
|
25
|
+
export function setSpinnerVerbs(verbs, mode = 'replace') {
|
|
26
|
+
if (!existsSync(SETTINGS) || !verbs?.length) return;
|
|
27
|
+
const s = readSettings();
|
|
28
|
+
s.spinnerVerbs = { mode, verbs };
|
|
29
|
+
writeSettings(s);
|
|
30
|
+
}
|
|
31
|
+
export function clearSpinnerVerbs() {
|
|
32
|
+
if (!existsSync(SETTINGS)) return;
|
|
33
|
+
const s = readSettings();
|
|
34
|
+
if (s.spinnerVerbs) { delete s.spinnerVerbs; writeSettings(s); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Install into all detected agents; returns per-agent results.
|
|
38
|
+
export function installStatusLine() {
|
|
39
|
+
const agents = detectAgents();
|
|
40
|
+
const results = agents.map((a) => a.install());
|
|
41
|
+
const primary = agents.find((a) => a.id === 'claude-code') || agents[0];
|
|
42
|
+
return {
|
|
43
|
+
settingsPath: primary ? primary.configPath : SETTINGS,
|
|
44
|
+
backupPath: BACKUP,
|
|
45
|
+
agents: results,
|
|
46
|
+
installed: agents.map((a) => a.id),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function uninstallStatusLine() {
|
|
51
|
+
const results = detectAgents().map((a) => a.uninstall());
|
|
52
|
+
const restored = results.some((r) => r.restoredFrom);
|
|
53
|
+
return { restoredFrom: restored ? BACKUP : null, agents: results };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isInstalled() {
|
|
57
|
+
return detectAgents().some((a) => a.isInstalled());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function agentStatus() {
|
|
61
|
+
return ADAPTERS.map((a) => ({ id: a.id, name: a.name, detected: a.detect(), installed: a.detect() && a.isInstalled() }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { SETTINGS, BACKUP };
|
package/src/identity.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Lull client — device identity.
|
|
2
|
+
// Each install owns an Ed25519 keypair. The PRIVATE key never leaves the machine and is used
|
|
3
|
+
// to SIGN impression beacons; the server only ever sees the PUBLIC key. This makes earnings
|
|
4
|
+
// unspoofable: you can't forge impressions for a device without its private key.
|
|
5
|
+
//
|
|
6
|
+
// Storage: the PRIVATE key + token live in the OS keychain (macOS Keychain / Linux libsecret),
|
|
7
|
+
// falling back to a 0600 file in ~/.lull on headless/Windows/locked-keyring systems — see
|
|
8
|
+
// keychain.mjs. Only NON-secret metadata (deviceId, public key) is written to ~/.lull/device.json.
|
|
9
|
+
|
|
10
|
+
import { generateKeyPairSync, sign as edSign, createPrivateKey, createPublicKey } from 'node:crypto';
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { setSecret, getSecret, deleteSecret, keychainBackend } from './keychain.mjs';
|
|
15
|
+
|
|
16
|
+
const DIR = join(homedir(), '.lull');
|
|
17
|
+
const DEVICE_FILE = join(DIR, 'device.json'); // NON-secret metadata only (id + public key)
|
|
18
|
+
|
|
19
|
+
function ensureDir() {
|
|
20
|
+
if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Create a new device identity (called once at `init`).
|
|
24
|
+
// Secrets (private key) -> OS keychain. Metadata (id, public key, token state) -> device.json.
|
|
25
|
+
export function createDevice() {
|
|
26
|
+
ensureDir();
|
|
27
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
28
|
+
const priv = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
29
|
+
const pub = publicKey.export({ type: 'spki', format: 'pem' });
|
|
30
|
+
const deviceId = 'dev_' + cryptoRandomId();
|
|
31
|
+
|
|
32
|
+
setSecret(`${deviceId}:privkey`, priv); // -> keychain
|
|
33
|
+
const meta = { deviceId, publicKeyPem: pub, hasToken: false, createdAt: nowIso(),
|
|
34
|
+
keychain: keychainBackend() };
|
|
35
|
+
writeFileSync(DEVICE_FILE, JSON.stringify(meta, null, 2));
|
|
36
|
+
chmodSync(DEVICE_FILE, 0o600);
|
|
37
|
+
return loadDevice();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Returns a record with the private key + token hydrated from the keychain.
|
|
41
|
+
export function loadDevice() {
|
|
42
|
+
if (!existsSync(DEVICE_FILE)) return null;
|
|
43
|
+
const meta = JSON.parse(readFileSync(DEVICE_FILE, 'utf8'));
|
|
44
|
+
return {
|
|
45
|
+
...meta,
|
|
46
|
+
privateKeyPem: getSecret(`${meta.deviceId}:privkey`),
|
|
47
|
+
token: getSecret(`${meta.deviceId}:token`) || null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Persist the issued device token into the keychain (not the plaintext file).
|
|
52
|
+
export function saveToken(deviceId, token) {
|
|
53
|
+
setSecret(`${deviceId}:token`, token);
|
|
54
|
+
const meta = JSON.parse(readFileSync(DEVICE_FILE, 'utf8'));
|
|
55
|
+
meta.hasToken = true;
|
|
56
|
+
writeFileSync(DEVICE_FILE, JSON.stringify(meta, null, 2));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function purgeDevice(deviceId) {
|
|
60
|
+
deleteSecret(`${deviceId}:privkey`);
|
|
61
|
+
deleteSecret(`${deviceId}:token`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Sign an arbitrary payload object; returns base64 signature over its canonical JSON.
|
|
65
|
+
export function signPayload(privateKeyPem, payload) {
|
|
66
|
+
const key = createPrivateKey(privateKeyPem);
|
|
67
|
+
const msg = Buffer.from(canonical(payload));
|
|
68
|
+
return edSign(null, msg, key).toString('base64');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Used by the server side too (re-exported for tests).
|
|
72
|
+
export function publicKeyFromPem(pem) {
|
|
73
|
+
return createPublicKey(pem);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Deterministic JSON so client and server hash/sign the exact same bytes.
|
|
77
|
+
export function canonical(obj) {
|
|
78
|
+
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
79
|
+
if (Array.isArray(obj)) return '[' + obj.map(canonical).join(',') + ']';
|
|
80
|
+
const keys = Object.keys(obj).sort();
|
|
81
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonical(obj[k])).join(',') + '}';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cryptoRandomId() {
|
|
85
|
+
// 16 random bytes hex, no Math.random.
|
|
86
|
+
return Buffer.from(generateKeyPairSync('ed25519').publicKey.export({ type: 'spki', format: 'der' }))
|
|
87
|
+
.subarray(-8).toString('hex');
|
|
88
|
+
}
|
|
89
|
+
function nowIso() { return new Date().toISOString(); }
|
|
90
|
+
export { DEVICE_FILE, DIR };
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Lull client — public programmatic API (for embedding/testing).
|
|
2
|
+
// The primary entrypoint is the CLI (bin/lull.mjs); this re-exports the building blocks.
|
|
3
|
+
export { createDevice, loadDevice, saveToken, purgeDevice, signPayload } from './identity.mjs';
|
|
4
|
+
export { installStatusLine, uninstallStatusLine, isInstalled, agentStatus } from './config.mjs';
|
|
5
|
+
export { detectAgents, getAdapter, ADAPTERS } from './adapters.mjs';
|
|
6
|
+
export { keychainBackend } from './keychain.mjs';
|
|
7
|
+
export { getPrefs, setPref, getBase } from './prefs.mjs';
|
|
8
|
+
export { tick, commitCreative, resetRuntime } from './runtime.mjs';
|
|
9
|
+
export { api } from './api.mjs';
|
package/src/keychain.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Lull client — OS keychain storage for secrets (device private key + token).
|
|
2
|
+
// macOS: native `security` keychain
|
|
3
|
+
// Linux: `secret-tool` (libsecret) when a usable keyring is present
|
|
4
|
+
// Windows / headless / anything else: a 0600 file in ~/.lull (clearly less safe; we mark it)
|
|
5
|
+
//
|
|
6
|
+
// Robustness rules (these are what make it work across machines, not just on a dev's mac):
|
|
7
|
+
// - If a native backend FAILS at write time (locked keyring, no D-Bus, CI), we fall back to the
|
|
8
|
+
// file store instead of silently losing the secret (which would brick the device).
|
|
9
|
+
// - Reads always consult the file fallback too, so a secret written via fallback is still found.
|
|
10
|
+
// - `LULL_KEYCHAIN=file|macos|linux|auto` forces a backend (file is handy for CI / containers).
|
|
11
|
+
//
|
|
12
|
+
// Non-secret metadata (deviceId, public key) lives in ~/.lull/device.json — never here.
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
|
|
16
|
+
import { homedir, platform } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const SERVICE = 'lull';
|
|
20
|
+
const DIR = join(homedir(), '.lull');
|
|
21
|
+
const FALLBACK = join(DIR, 'secrets.json');
|
|
22
|
+
|
|
23
|
+
function ensureDir() { if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true, mode: 0o700 }); }
|
|
24
|
+
|
|
25
|
+
// Run a command, capturing whether it actually succeeded (exit 0). `ok:false` means "it threw or
|
|
26
|
+
// exited non-zero" — distinct from a command that succeeds with empty output (e.g. `secret-tool store`).
|
|
27
|
+
function run(cmd, args, input) {
|
|
28
|
+
try { return { ok: true, out: execFileSync(cmd, args, { input, stdio: ['pipe', 'pipe', 'ignore'] }).toString() }; }
|
|
29
|
+
catch { return { ok: false, out: null }; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function commandExists(cmd) {
|
|
33
|
+
// Portable presence check that doesn't depend on `which` being installed.
|
|
34
|
+
return run(process.platform === 'win32' ? 'where' : 'command', process.platform === 'win32' ? [cmd] : ['-v', cmd]).ok
|
|
35
|
+
|| run(cmd, ['--version']).ok;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function backend() {
|
|
39
|
+
const forced = (process.env.LULL_KEYCHAIN || '').toLowerCase();
|
|
40
|
+
if (['file', 'macos', 'linux'].includes(forced)) return forced;
|
|
41
|
+
if (platform() === 'darwin') return 'macos';
|
|
42
|
+
if (platform() === 'linux' && commandExists('secret-tool')) return 'linux';
|
|
43
|
+
return 'file';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Secrets are base64-encoded before storage. This keeps them single-line — critical on macOS,
|
|
47
|
+
// where `security -w` returns HEX (not the raw value) if the stored data contains newlines.
|
|
48
|
+
const encv = (v) => Buffer.from(v, 'utf8').toString('base64');
|
|
49
|
+
const decv = (v) => Buffer.from(v, 'base64').toString('utf8');
|
|
50
|
+
|
|
51
|
+
// ---- file fallback (also the durable store when a native write fails) ----
|
|
52
|
+
function fileSet(account, stored) {
|
|
53
|
+
ensureDir();
|
|
54
|
+
const all = existsSync(FALLBACK) ? JSON.parse(readFileSync(FALLBACK, 'utf8')) : {};
|
|
55
|
+
all[account] = stored;
|
|
56
|
+
writeFileSync(FALLBACK, JSON.stringify(all));
|
|
57
|
+
try { chmodSync(FALLBACK, 0o600); } catch { /* chmod is a no-op/unsupported on some Windows FS */ }
|
|
58
|
+
}
|
|
59
|
+
function fileGet(account) {
|
|
60
|
+
if (!existsSync(FALLBACK)) return null;
|
|
61
|
+
try { return JSON.parse(readFileSync(FALLBACK, 'utf8'))[account] || null; } catch { return null; }
|
|
62
|
+
}
|
|
63
|
+
function fileDel(account) {
|
|
64
|
+
if (!existsSync(FALLBACK)) return;
|
|
65
|
+
try {
|
|
66
|
+
const all = JSON.parse(readFileSync(FALLBACK, 'utf8')); delete all[account];
|
|
67
|
+
if (Object.keys(all).length) writeFileSync(FALLBACK, JSON.stringify(all)); else unlinkSync(FALLBACK);
|
|
68
|
+
} catch { /* ignore */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function setSecret(account, value) {
|
|
72
|
+
const b = backend();
|
|
73
|
+
const stored = encv(value);
|
|
74
|
+
if (b === 'macos') {
|
|
75
|
+
const r = run('security', ['add-generic-password', '-U', '-a', account, '-s', SERVICE, '-w', stored]);
|
|
76
|
+
if (r.ok) return 'macos-keychain';
|
|
77
|
+
fileSet(account, stored); return 'file-0600 (keychain unavailable)';
|
|
78
|
+
}
|
|
79
|
+
if (b === 'linux') {
|
|
80
|
+
const r = run('secret-tool', ['store', '--label=Lull', 'service', SERVICE, 'account', account], stored);
|
|
81
|
+
if (r.ok) return 'libsecret';
|
|
82
|
+
fileSet(account, stored); return 'file-0600 (libsecret unavailable)';
|
|
83
|
+
}
|
|
84
|
+
fileSet(account, stored);
|
|
85
|
+
return 'file-0600';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getSecret(account) {
|
|
89
|
+
const b = backend();
|
|
90
|
+
let stored = null;
|
|
91
|
+
if (b === 'macos') {
|
|
92
|
+
const r = run('security', ['find-generic-password', '-a', account, '-s', SERVICE, '-w']);
|
|
93
|
+
stored = r.ok && r.out ? r.out.trim() : null;
|
|
94
|
+
} else if (b === 'linux') {
|
|
95
|
+
const r = run('secret-tool', ['lookup', 'service', SERVICE, 'account', account]);
|
|
96
|
+
stored = r.ok && r.out ? r.out.trim() : null;
|
|
97
|
+
}
|
|
98
|
+
if (!stored) stored = fileGet(account); // always consult the fallback (native write may have failed earlier)
|
|
99
|
+
if (!stored) return null;
|
|
100
|
+
try { return decv(stored); } catch { return stored; } // tolerate legacy plaintext
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function deleteSecret(account) {
|
|
104
|
+
const b = backend();
|
|
105
|
+
if (b === 'macos') run('security', ['delete-generic-password', '-a', account, '-s', SERVICE]);
|
|
106
|
+
else if (b === 'linux') run('secret-tool', ['clear', 'service', SERVICE, 'account', account]);
|
|
107
|
+
fileDel(account); // also clear the fallback so a secret can't linger after delete
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function keychainBackend() { return backend(); }
|
package/src/prefs.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Lull client — persistent preferences (~/.lull/config.json).
|
|
2
|
+
// Holds the backend base URL and the dev's display controls. Read by the CLI, the status-line
|
|
3
|
+
// client, and (via settings) the editor extension. Env vars override the file for one-offs.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const DIR = join(homedir(), '.lull');
|
|
10
|
+
const FILE = join(DIR, 'config.json');
|
|
11
|
+
|
|
12
|
+
const DEFAULTS = {
|
|
13
|
+
base: 'http://localhost:8787',
|
|
14
|
+
adSeconds: 8, // how long a sponsor line stays up per display
|
|
15
|
+
restSeconds: 22, // quiet gap between sponsor lines — "gets out of the way"
|
|
16
|
+
maxPerHour: 20, // frequency cap: never show more than this many sponsor lines/hour
|
|
17
|
+
enabled: true,
|
|
18
|
+
blockedCategories: [], // ad categories this dev refuses (their inventory, their veto)
|
|
19
|
+
spinnerMode: 'replace', // 'replace' | 'append' | 'off' — sponsor line in the "thinking" spinner
|
|
20
|
+
slEverySeconds: 20, // throttle for premium status-line day-slot impressions
|
|
21
|
+
showEarnings: true, // show live "$X today · $Y total" in the status line during quiet phases
|
|
22
|
+
country: '', // SELF-DECLARED country (ISO-2, e.g. IN) for country-targeted sponsors.
|
|
23
|
+
// Optional. We never geolocate you — unset means worldwide ads only.
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function getPrefs() {
|
|
27
|
+
let saved = {};
|
|
28
|
+
if (existsSync(FILE)) { try { saved = JSON.parse(readFileSync(FILE, 'utf8')); } catch {} }
|
|
29
|
+
return { ...DEFAULTS, ...saved };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function setPref(key, value) {
|
|
33
|
+
if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
34
|
+
const p = getPrefs();
|
|
35
|
+
// coerce numeric prefs
|
|
36
|
+
if (['adSeconds', 'restSeconds', 'maxPerHour'].includes(key)) value = Number(value);
|
|
37
|
+
if (key === 'enabled' || key === 'showEarnings') value = value === true || value === 'true';
|
|
38
|
+
if (key === 'country') { value = String(value).toUpperCase().trim(); if (!/^([A-Z]{2})?$/.test(value)) throw new Error('country must be a 2-letter ISO code (e.g. IN) or empty'); }
|
|
39
|
+
p[key] = value;
|
|
40
|
+
const out = {}; for (const k of Object.keys(DEFAULTS)) out[k] = p[k];
|
|
41
|
+
writeFileSync(FILE, JSON.stringify(out, null, 2));
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Base URL precedence: env override > saved config > default.
|
|
46
|
+
export function getBase() {
|
|
47
|
+
return process.env.LULL_BASE || getPrefs().base;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function blockCategory(cat) {
|
|
51
|
+
const p = getPrefs(); const set = new Set(p.blockedCategories); set.add(cat);
|
|
52
|
+
return setPref('blockedCategories', [...set]);
|
|
53
|
+
}
|
|
54
|
+
export function unblockCategory(cat) {
|
|
55
|
+
const p = getPrefs(); const set = new Set(p.blockedCategories); set.delete(cat);
|
|
56
|
+
return setPref('blockedCategories', [...set]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { FILE as PREFS_FILE };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Lull client — shared render core for every agent surface.
|
|
2
|
+
// Runs one display-cycle tick, reports the qualified (signed) impression, and returns the
|
|
3
|
+
// rendered sponsor line as an OSC-8 clickable hyperlink — or '' during the quiet/rest phase.
|
|
4
|
+
// The OUTPUT TARGET differs per surface and is the caller's job:
|
|
5
|
+
// • Claude Code → write the returned string to stdout (Claude renders it in the status line)
|
|
6
|
+
// • Codex CLI → write it to the controlling terminal (/dev/tty) from a hook
|
|
7
|
+
// Keeping the decision/report/fetch logic here means every agent shares identical, audited behavior.
|
|
8
|
+
|
|
9
|
+
import { signPayload } from './identity.mjs';
|
|
10
|
+
import { api } from './api.mjs';
|
|
11
|
+
import { setSpinnerVerbs } from './config.mjs';
|
|
12
|
+
import { tick, commitCreative, walletCache, walletDue } from './runtime.mjs';
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
function reportImpression(device, creativeId, dwellMs) {
|
|
16
|
+
const payload = { deviceId: device.deviceId, creativeId, dwellMs: Math.round(dwellMs), nonce: randomUUID(), ts: new Date().toISOString() };
|
|
17
|
+
try { api.reportImpression(payload, signPayload(device.privateKeyPem, payload)).catch(() => {}); } catch {}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function label(text) { return `▸ Sponsored · ${text} · getlull.dev`; }
|
|
21
|
+
|
|
22
|
+
// OSC 8 terminal hyperlink: the sponsor line becomes clickable → a tracked redirect that bills
|
|
23
|
+
// the click (CPC) and forwards the dev to the advertiser. Plain text in terminals without OSC-8.
|
|
24
|
+
export function renderLine(device, creative) {
|
|
25
|
+
const cid = creative.campaignId || 'house';
|
|
26
|
+
const url = `${api.base}/c/${encodeURIComponent(cid)}?device=${encodeURIComponent(device.deviceId)}`;
|
|
27
|
+
return `\x1b]8;;${url}\x07${label(creative.text)}\x1b]8;;\x07`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Dim "live earnings" line for the persistent status line, e.g. "⚡ Lull · $0.42 today · $7.11".
|
|
31
|
+
// Small balances keep 4 decimals so the number visibly ticks; larger ones round to cents.
|
|
32
|
+
function fmtUsd(v) { const n = Number(v); if (!isFinite(n)) return null; return n >= 1 ? n.toFixed(2) : n.toFixed(4); }
|
|
33
|
+
async function earningsLine(device, prefs) {
|
|
34
|
+
if (prefs.showEarnings === false) return '';
|
|
35
|
+
// Refresh on a slow throttle; the status-line process is short-lived so we must await the fetch
|
|
36
|
+
// (a background promise would be killed on exit), but it only happens ~once/minute.
|
|
37
|
+
if (walletDue()) {
|
|
38
|
+
try {
|
|
39
|
+
const w = await api.wallet(device.deviceId);
|
|
40
|
+
if (w && w.balanceUsd != null) walletCache({ totalUsd: w.balanceUsd, todayUsd: w.todayUsd ?? null });
|
|
41
|
+
} catch { /* keep last cached value */ }
|
|
42
|
+
}
|
|
43
|
+
const w = walletCache();
|
|
44
|
+
const total = w && fmtUsd(w.totalUsd);
|
|
45
|
+
if (total == null) return '';
|
|
46
|
+
const today = w.todayUsd != null && fmtUsd(w.todayUsd);
|
|
47
|
+
const body = today != null ? `$${today} today · $${total} total` : `$${total} earned`;
|
|
48
|
+
return `\x1b[2m⚡ Lull · ${body} · getlull.dev\x1b[0m`; // dim, so it never competes with your code
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// One display-cycle tick. `setVerbs` is Claude-Code-only (it edits ~/.claude spinnerVerbs);
|
|
52
|
+
// `earnings` fills the quiet phase with the live balance (persistent status line only — NOT the
|
|
53
|
+
// Codex tty, where it would spam a new line each hook). Returns the line to show, or ''.
|
|
54
|
+
export async function produce(device, prefs, { setVerbs = false, earnings = false } = {}) {
|
|
55
|
+
const decision = tick(prefs);
|
|
56
|
+
|
|
57
|
+
// Report impressions: spinner CPM (on dwell) + premium status-line day slot (throttled).
|
|
58
|
+
if (decision.report) reportImpression(device, decision.report.creativeId, decision.report.dwellMs);
|
|
59
|
+
if (decision.slReport) reportImpression(device, decision.slReport.creativeId, decision.slReport.dwellMs);
|
|
60
|
+
|
|
61
|
+
if (decision.needCreative) {
|
|
62
|
+
try {
|
|
63
|
+
const { creative, spinner, statusline } = await api.serve({ deviceId: device.deviceId, blocked: prefs.blockedCategories, country: prefs.country });
|
|
64
|
+
if (setVerbs && prefs.spinnerMode !== 'off') {
|
|
65
|
+
try { setSpinnerVerbs((spinner && spinner.length ? spinner : [creative.text]).map(label), prefs.spinnerMode || 'replace'); } catch {}
|
|
66
|
+
}
|
|
67
|
+
commitCreative(creative, statusline || null);
|
|
68
|
+
return renderLine(device, statusline || creative);
|
|
69
|
+
} catch { return earnings ? earningsLine(device, prefs) : ''; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const show = decision.sl || decision.show;
|
|
73
|
+
if (show) return renderLine(device, show);
|
|
74
|
+
// Quiet phase: show the dev their money ticking up instead of a blank line.
|
|
75
|
+
return earnings ? earningsLine(device, prefs) : '';
|
|
76
|
+
}
|
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Lull client — display-cycle runtime (~/.lull/runtime.json).
|
|
2
|
+
// The status-line command is invoked many times per second by the agent. This module turns that
|
|
3
|
+
// firehose into an HONEST, RESPECTFUL display cycle:
|
|
4
|
+
//
|
|
5
|
+
// [ ad phase: show one creative for adSeconds ] -> report ONE impression with real dwell
|
|
6
|
+
// [ rest phase: show nothing for restSeconds ] -> the line gets out of the way
|
|
7
|
+
//
|
|
8
|
+
// Plus a per-hour frequency cap. State persists across the short-lived status-line processes.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const DIR = join(homedir(), '.lull');
|
|
15
|
+
const FILE = join(DIR, 'runtime.json');
|
|
16
|
+
|
|
17
|
+
function load() {
|
|
18
|
+
if (!existsSync(FILE)) return { phase: 'rest', phaseStart: 0, creative: null, impressionLog: [] };
|
|
19
|
+
try { return JSON.parse(readFileSync(FILE, 'utf8')); } catch { return { phase: 'rest', phaseStart: 0, creative: null, impressionLog: [] }; }
|
|
20
|
+
}
|
|
21
|
+
function save(s) {
|
|
22
|
+
try { if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true, mode: 0o700 }); writeFileSync(FILE, JSON.stringify(s)); } catch {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Returns a decision for THIS invocation:
|
|
26
|
+
// { show: text|null, report: {creativeId, dwellMs} | null, needCreative: bool }
|
|
27
|
+
// Caller fetches a creative when needCreative is true, then calls commitCreative().
|
|
28
|
+
export function tick(prefs, now = Date.now()) {
|
|
29
|
+
const s = load();
|
|
30
|
+
const adMs = prefs.adSeconds * 1000;
|
|
31
|
+
const restMs = prefs.restSeconds * 1000;
|
|
32
|
+
|
|
33
|
+
// rolling 1h impression count for the frequency cap
|
|
34
|
+
s.impressionLog = (s.impressionLog || []).filter((t) => now - t < 3_600_000);
|
|
35
|
+
const overCap = s.impressionLog.length >= prefs.maxPerHour;
|
|
36
|
+
|
|
37
|
+
// `sl` is the premium STATUS-LINE day-slot ad — shown CONSTANTLY (separate from the spinner
|
|
38
|
+
// ad/rest cycle) and reported on a slow throttle (slEvery).
|
|
39
|
+
const sl = s.sl || null;
|
|
40
|
+
const slDue = sl && now - (s.slReportAt || 0) >= (prefs.slEverySeconds || 20) * 1000;
|
|
41
|
+
|
|
42
|
+
if (s.phase === 'ad' && s.creative) {
|
|
43
|
+
const elapsed = now - s.phaseStart;
|
|
44
|
+
if (elapsed >= adMs) {
|
|
45
|
+
const report = { creativeId: s.creative.id, dwellMs: elapsed };
|
|
46
|
+
s.impressionLog.push(now);
|
|
47
|
+
s.phase = 'rest'; s.phaseStart = now; s.creative = null;
|
|
48
|
+
save(s);
|
|
49
|
+
return { show: null, report, needCreative: false, sl, slReport: slDue ? slReport(s, sl, now) : null };
|
|
50
|
+
}
|
|
51
|
+
return { show: s.creative, report: null, needCreative: false, sl, slReport: slDue ? slReport(s, sl, now) : null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const restedEnough = now - s.phaseStart >= restMs;
|
|
55
|
+
if (!prefs.enabled || overCap || !restedEnough) {
|
|
56
|
+
return { show: null, report: null, needCreative: false, sl, slReport: slDue ? slReport(s, sl, now) : null };
|
|
57
|
+
}
|
|
58
|
+
return { show: null, report: null, needCreative: true, sl, slReport: slDue ? slReport(s, sl, now) : null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function slReport(s, sl, now) { s.slReportAt = now; save(s); return { creativeId: sl.id, dwellMs: 5000 }; }
|
|
62
|
+
|
|
63
|
+
// Caller got a creative for a new ad phase; record it (and the current status-line day-slot ad).
|
|
64
|
+
export function commitCreative(creative, sl = undefined, now = Date.now()) {
|
|
65
|
+
const s = load();
|
|
66
|
+
s.phase = 'ad'; s.phaseStart = now; s.creative = creative;
|
|
67
|
+
if (sl !== undefined) s.sl = sl || null;
|
|
68
|
+
save(s);
|
|
69
|
+
return creative.text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resetRuntime() { save({ phase: 'rest', phaseStart: 0, creative: null, sl: null, slReportAt: 0, impressionLog: [] }); }
|
|
73
|
+
|
|
74
|
+
// Cached wallet snapshot for the live "earnings ticking" status line. The status-line command is
|
|
75
|
+
// short-lived and spawned per render, so we persist the last balance here and only re-fetch on a
|
|
76
|
+
// slow throttle — the render never blocks on the network except once per interval.
|
|
77
|
+
export function walletCache(update, now = Date.now()) {
|
|
78
|
+
const s = load();
|
|
79
|
+
if (update) { s.wallet = { ...update, at: now }; save(s); return s.wallet; }
|
|
80
|
+
return s.wallet || null;
|
|
81
|
+
}
|
|
82
|
+
export function walletDue(intervalMs = 60_000, now = Date.now()) {
|
|
83
|
+
const s = load();
|
|
84
|
+
return now - ((s.wallet && s.wallet.at) || 0) >= intervalMs;
|
|
85
|
+
}
|
|
86
|
+
export { FILE as RUNTIME_FILE };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Lull status-line command for Claude Code. Claude invokes this many times/sec and renders our
|
|
3
|
+
// STDOUT in the status line. The shared decision/report/fetch logic lives in render-core.mjs.
|
|
4
|
+
// Privacy: stdin (the agent session JSON) is drained and never inspected — the privacy claim, proven.
|
|
5
|
+
|
|
6
|
+
import { loadDevice } from './identity.mjs';
|
|
7
|
+
import { getPrefs } from './prefs.mjs';
|
|
8
|
+
import { produce } from './render-core.mjs';
|
|
9
|
+
|
|
10
|
+
function drainStdin() {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
let n = 0;
|
|
13
|
+
process.stdin.on('data', (c) => (n += c.length));
|
|
14
|
+
process.stdin.on('end', () => resolve(n));
|
|
15
|
+
setTimeout(() => resolve(n), 40);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
await drainStdin();
|
|
21
|
+
const device = loadDevice();
|
|
22
|
+
if (!device) { process.stdout.write(''); return; }
|
|
23
|
+
process.stdout.write(await produce(device, getPrefs(), { setVerbs: true, earnings: true }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
main();
|