claude-brink 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/LICENSE +21 -0
- package/README.md +265 -0
- package/config.example.json +22 -0
- package/hooks/hooks.json +19 -0
- package/package.json +21 -0
- package/src/adapters/claude.js +43 -0
- package/src/adapters/codex.js +80 -0
- package/src/arm-resume.ps1 +27 -0
- package/src/brink.js +168 -0
- package/src/cli.js +165 -0
- package/src/core/handoff.js +72 -0
- package/src/core/reset.js +46 -0
- package/src/core/resume.js +48 -0
- package/src/core/thresholds.js +44 -0
- package/src/install.js +79 -0
- package/src/notify.js +72 -0
- package/src/notify.ps1 +13 -0
- package/src/resume-once.ps1 +57 -0
- package/src/statusline-brink.js +114 -0
package/src/brink.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Brink core driver. Usage: node brink.js <claude|codex> <warn|pause>
|
|
3
|
+
// Reads usage via the adapter, decides via the shared threshold engine, then:
|
|
4
|
+
// warn -> fires a debounced notification (once per window/band/reset-block)
|
|
5
|
+
// pause -> Brink WRITES HANDOFF.md itself, then emits the adapter's native deny with a
|
|
6
|
+
// short, credible, non-contradictory reason (see Phase 5 / live-test findings).
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { spawn, spawnSync } = require('child_process');
|
|
11
|
+
const { decide } = require('./core/thresholds');
|
|
12
|
+
const { writeHandoff } = require('./core/handoff');
|
|
13
|
+
const { shouldArm, armArgs } = require('./core/resume');
|
|
14
|
+
|
|
15
|
+
const DIR = process.env.BRINK_DIR || path.join(os.homedir(), '.claude', 'brink');
|
|
16
|
+
const HATCH = path.join(DIR, 'DISABLED');
|
|
17
|
+
const FLAG_TTL_MS = 14 * 24 * 3600 * 1000; // GC debounce/armed flags older than 14 days
|
|
18
|
+
|
|
19
|
+
const DEFAULT_CFG = {
|
|
20
|
+
five_hour: { warn: [75, 85], pause: Number(process.env.BRINK_PAUSE || 93) },
|
|
21
|
+
seven_day: { warn: [80, 90], pause: Number(process.env.BRINK_WEEKLY_PAUSE || 95) },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function loadRawCfg() {
|
|
25
|
+
try {
|
|
26
|
+
const raw = fs.readFileSync(path.join(DIR, 'config.json'), 'utf8');
|
|
27
|
+
return JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw);
|
|
28
|
+
} catch { return {}; }
|
|
29
|
+
}
|
|
30
|
+
// Deep-merge per window so a partial override ({five_hour:{pause:97}}) cannot
|
|
31
|
+
// silently drop the warn bands or the other window's pause (review finding).
|
|
32
|
+
function mergeThresholds(user) {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const w of ['five_hour', 'seven_day']) {
|
|
35
|
+
const d = DEFAULT_CFG[w];
|
|
36
|
+
const u = (user && typeof user[w] === 'object' && user[w]) || {};
|
|
37
|
+
out[w] = {
|
|
38
|
+
warn: Array.isArray(u.warn) && u.warn.every((n) => typeof n === 'number') ? u.warn : d.warn,
|
|
39
|
+
pause: typeof u.pause === 'number' ? u.pause : d.pause,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
function loadAdapter(name) {
|
|
45
|
+
if (name === 'claude') return require('./adapters/claude');
|
|
46
|
+
if (name === 'codex') return require('./adapters/codex');
|
|
47
|
+
throw new Error('unknown adapter: ' + name);
|
|
48
|
+
}
|
|
49
|
+
function notify(msg) {
|
|
50
|
+
try {
|
|
51
|
+
const c = spawn('node', [path.join(__dirname, 'notify.js'), msg],
|
|
52
|
+
{ detached: true, stdio: 'ignore', windowsHide: true });
|
|
53
|
+
c.on('error', () => {}); // async spawn failure must never crash the hook
|
|
54
|
+
c.unref();
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
function resetText(epoch) {
|
|
58
|
+
if (typeof epoch !== 'number') return '';
|
|
59
|
+
const d = new Date(epoch * 1000);
|
|
60
|
+
if (isNaN(d.getTime())) return '';
|
|
61
|
+
// A weekly reset can be days out — "resets at 02:30" alone would mislead.
|
|
62
|
+
const far = epoch * 1000 - Date.now() > 20 * 3600 * 1000;
|
|
63
|
+
return far
|
|
64
|
+
? d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
65
|
+
: d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
66
|
+
}
|
|
67
|
+
// A window whose resets_at is in the past has ROLLED — its stored pct is stale.
|
|
68
|
+
// Without this gate a resumed headless session (no statusline running yet) reads
|
|
69
|
+
// the pre-pause 99% and denies its own first tool call (review finding: the
|
|
70
|
+
// Phase 7 resume would self-block). 60s slack absorbs clock skew.
|
|
71
|
+
function dropStaleWindows(usage) {
|
|
72
|
+
const now = Math.floor(Date.now() / 1000);
|
|
73
|
+
if (typeof usage.five_reset === 'number' && usage.five_reset < now - 60) usage.five_pct = null;
|
|
74
|
+
if (typeof usage.week_reset === 'number' && usage.week_reset < now - 60) usage.week_pct = null;
|
|
75
|
+
return usage;
|
|
76
|
+
}
|
|
77
|
+
function gcFlags() {
|
|
78
|
+
try {
|
|
79
|
+
const cutoff = Date.now() - FLAG_TTL_MS;
|
|
80
|
+
for (const f of fs.readdirSync(DIR)) {
|
|
81
|
+
if (!/^(notified|armed)_/.test(f)) continue;
|
|
82
|
+
const p = path.join(DIR, f);
|
|
83
|
+
try { if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p); } catch {}
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
// Only the pause path needs the hook payload (transcript_path/cwd). Read stdin lazily.
|
|
88
|
+
function readStdin() {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
if (process.stdin.isTTY) return resolve({});
|
|
91
|
+
let s = ''; let done = false;
|
|
92
|
+
const fin = () => { if (done) return; done = true; try { resolve(JSON.parse(s || '{}')); } catch { resolve({}); } };
|
|
93
|
+
process.stdin.on('data', (d) => { s += d; });
|
|
94
|
+
process.stdin.on('end', fin);
|
|
95
|
+
process.stdin.on('error', () => resolve({}));
|
|
96
|
+
setTimeout(fin, 800); // fallback so a hook never hangs waiting on stdin
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function main() {
|
|
101
|
+
const [adapterName, mode = 'pause'] = process.argv.slice(2);
|
|
102
|
+
const adapter = loadAdapter(adapterName);
|
|
103
|
+
|
|
104
|
+
if (fs.existsSync(HATCH)) process.exit(0); // kill switch
|
|
105
|
+
const usage = adapter.readUsage();
|
|
106
|
+
if (!usage) process.exit(0); // no data => never act blind
|
|
107
|
+
dropStaleWindows(usage);
|
|
108
|
+
|
|
109
|
+
const rawCfg = loadRawCfg();
|
|
110
|
+
const cfg = mergeThresholds(rawCfg.thresholds);
|
|
111
|
+
const d = decide(usage, cfg);
|
|
112
|
+
if (d.action === 'allow') process.exit(0);
|
|
113
|
+
|
|
114
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
115
|
+
gcFlags();
|
|
116
|
+
const flag = path.join(DIR, `notified_${adapterName}_${d.windowKey}_${d.band}_${d.reset}`.replace(/[^\w.-]/g, '_'));
|
|
117
|
+
const once = (msg) => { if (!fs.existsSync(flag)) { fs.writeFileSync(flag, ''); notify(msg); } };
|
|
118
|
+
|
|
119
|
+
if (mode === 'warn' && d.action === 'warn') {
|
|
120
|
+
once(`Brink: ${d.window} usage at ${Math.round(d.pct)}%`);
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (mode === 'pause' && d.action === 'pause') {
|
|
125
|
+
const hook = await readStdin();
|
|
126
|
+
const cwd = hook.cwd || usage.cwd || process.cwd();
|
|
127
|
+
const rt = resetText(d.reset);
|
|
128
|
+
// Brink writes the handoff itself — robust even if the model won't cooperate.
|
|
129
|
+
const handoffPath = writeHandoff({ transcriptPath: hook.transcript_path, cwd, pct: d.pct, window: d.window, resetText: rt });
|
|
130
|
+
once(`Brink: paused - ${d.window} at ${Math.round(d.pct)}%`);
|
|
131
|
+
|
|
132
|
+
// Resume (Phase 7, opt-in): register a one-shot scheduler job for the reset time.
|
|
133
|
+
// SYNCHRONOUS on purpose — live-fire testing proved PowerShell 5.1 dies when
|
|
134
|
+
// spawned detached (DETACHED_PROCESS = no console) and dies with the parent's
|
|
135
|
+
// console when attached, so fire-and-forget silently never registers the task.
|
|
136
|
+
// A pause is a terminal event; blocking ~1-2s here is correct. The armed flag is
|
|
137
|
+
// written only AFTER the scheduler confirms, so a failure can retry next call.
|
|
138
|
+
// Prefer the hook's own session_id — the shared state.json may hold another
|
|
139
|
+
// concurrent session's id (multi-window finding).
|
|
140
|
+
const ctx = { session_id: hook.session_id || usage.session_id, reset: d.reset, proj: cwd };
|
|
141
|
+
if (shouldArm(rawCfg, ctx, os.platform())) {
|
|
142
|
+
const armFlag = path.join(DIR, `armed_${ctx.session_id}_${ctx.reset}`.replace(/[^\w.-]/g, '_'));
|
|
143
|
+
if (!fs.existsSync(armFlag)) {
|
|
144
|
+
try {
|
|
145
|
+
const ps = path.join(__dirname, 'arm-resume.ps1');
|
|
146
|
+
const r = spawnSync('powershell',
|
|
147
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ps, ...armArgs(rawCfg, ctx)],
|
|
148
|
+
{ stdio: 'ignore', windowsHide: true, timeout: 20000 });
|
|
149
|
+
if (r.status === 0) fs.writeFileSync(armFlag, '');
|
|
150
|
+
else notify('Brink: resume arming FAILED - will not auto-resume');
|
|
151
|
+
} catch { /* arming is best-effort; never block the pause */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const file = handoffPath ? path.basename(handoffPath) : '';
|
|
156
|
+
const saved = file
|
|
157
|
+
? `Your progress and next steps are already saved to ${file}. `
|
|
158
|
+
: ''; // never claim a save that did not happen (review finding)
|
|
159
|
+
const reason = `Paused by Brink: you are at ${Math.round(d.pct)}% of your ${d.window} usage limit${rt ? `, which resets at ${rt}` : ''}. ${saved}Stop here and reply to the user in plain text — do not start new work; it will resume${file ? ' from the handoff' : ''} when the limit resets.`;
|
|
160
|
+
const out = adapter.denyOutput(reason);
|
|
161
|
+
if (out.stdout) process.stdout.write(out.stdout);
|
|
162
|
+
if (out.stderr) process.stderr.write(out.stderr);
|
|
163
|
+
process.exit(out.exitCode || 0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
168
|
+
main();
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Brink CLI — the one command a stranger needs. Zero deps, Node >=18.
|
|
3
|
+
// brink init wire hooks + statusline into Claude Code (wraps install.js)
|
|
4
|
+
// brink off | on kill switch (create/remove the DISABLED file)
|
|
5
|
+
// brink uninstall surgically remove Brink from settings.json (restore statusline)
|
|
6
|
+
// brink doctor self-check the whole chain: sensor -> state -> hook -> notify
|
|
7
|
+
// brink version
|
|
8
|
+
// Born from the 2026-07-04 launch council: the kill switch, the doctor, and a TRUE
|
|
9
|
+
// npm install story collapse into this file (docs/council-2026-07-04.html).
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { spawnSync } = require('child_process');
|
|
14
|
+
|
|
15
|
+
const SRC = __dirname;
|
|
16
|
+
const DIR = process.env.BRINK_DIR || path.join(os.homedir(), '.claude', 'brink');
|
|
17
|
+
const HATCH = path.join(DIR, 'DISABLED');
|
|
18
|
+
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
const cmd = argv[0] || 'help';
|
|
21
|
+
const getOpt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? (argv[i + 1] || true) : d; };
|
|
22
|
+
const settingsPath = getOpt('--settings', path.join(os.homedir(), '.claude', 'settings.json'));
|
|
23
|
+
|
|
24
|
+
const readJson = (p) => {
|
|
25
|
+
try { const r = fs.readFileSync(p, 'utf8'); return JSON.parse(r.charCodeAt(0) === 0xFEFF ? r.slice(1) : r); }
|
|
26
|
+
catch { return null; }
|
|
27
|
+
};
|
|
28
|
+
const ok = (m) => console.log(' ok ' + m);
|
|
29
|
+
const bad = (m) => console.log(' FAIL ' + m);
|
|
30
|
+
const warn = (m) => console.log(' warn ' + m);
|
|
31
|
+
|
|
32
|
+
// ---- init: delegate to install.js (it owns idempotency, backup, abort-on-bad-JSON) ----
|
|
33
|
+
function init() {
|
|
34
|
+
const args = [path.join(SRC, 'install.js'), '--settings', settingsPath];
|
|
35
|
+
if (!argv.includes('--no-statusline')) args.push('--statusline');
|
|
36
|
+
const r = spawnSync('node', args, { encoding: 'utf8' });
|
|
37
|
+
process.stdout.write(r.stdout || '');
|
|
38
|
+
process.stderr.write(r.stderr || '');
|
|
39
|
+
if (r.status !== 0) process.exit(r.status || 1);
|
|
40
|
+
console.log('Brink installed. Takes effect on your next Claude Code session.');
|
|
41
|
+
console.log('Verify anytime: brink doctor Kill switch: brink off');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---- kill switch ----
|
|
45
|
+
function off() {
|
|
46
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
47
|
+
fs.writeFileSync(HATCH, 'created by `brink off` ' + new Date().toISOString() + '\n');
|
|
48
|
+
console.log('Brink DISABLED (kill switch on). Hooks stay installed but do nothing.');
|
|
49
|
+
console.log('Re-enable: brink on');
|
|
50
|
+
}
|
|
51
|
+
function on() {
|
|
52
|
+
try { fs.unlinkSync(HATCH); console.log('Brink enabled.'); }
|
|
53
|
+
catch { console.log('Brink was not disabled (no kill-switch file).'); }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- uninstall: surgical — remove ONLY Brink's entries, keep everything else ----
|
|
57
|
+
function uninstall() {
|
|
58
|
+
const s = readJson(settingsPath);
|
|
59
|
+
if (s === null) {
|
|
60
|
+
console.error(`Cannot parse ${settingsPath} - nothing changed. Fix or remove it manually.`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
const removed = [];
|
|
64
|
+
for (const event of ['PreToolUse', 'PostToolUse']) {
|
|
65
|
+
const groups = (s.hooks && s.hooks[event]) || [];
|
|
66
|
+
const kept = groups.filter((g) => !(g.hooks || []).some((h) => /brink\.js/.test(h.command || '')));
|
|
67
|
+
if (kept.length !== groups.length) { s.hooks[event] = kept; removed.push(event); }
|
|
68
|
+
if (s.hooks && Array.isArray(s.hooks[event]) && s.hooks[event].length === 0) delete s.hooks[event];
|
|
69
|
+
}
|
|
70
|
+
if (s.statusLine && /statusline-brink\.js/.test(JSON.stringify(s.statusLine))) {
|
|
71
|
+
const bak = readJson(settingsPath + '.brink-bak');
|
|
72
|
+
if (bak && bak.statusLine) { s.statusLine = bak.statusLine; removed.push('statusLine(restored from backup)'); }
|
|
73
|
+
else { delete s.statusLine; removed.push('statusLine(removed)'); }
|
|
74
|
+
}
|
|
75
|
+
fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2) + '\n');
|
|
76
|
+
console.log(removed.length
|
|
77
|
+
? `Brink removed from ${settingsPath}: ${removed.join(', ')}`
|
|
78
|
+
: `Nothing to remove - Brink was not installed in ${settingsPath}.`);
|
|
79
|
+
console.log(`State dir kept at ${DIR} (delete it yourself, or run: brink uninstall --purge)`);
|
|
80
|
+
if (argv.includes('--purge')) {
|
|
81
|
+
try { fs.rmSync(DIR, { recursive: true, force: true }); console.log('State dir purged.'); } catch {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---- doctor: verify the WHOLE chain on THIS machine. Exists because the detached-
|
|
86
|
+
// PowerShell bug passed every unit test for weeks while never working in reality —
|
|
87
|
+
// only end-to-end, on-machine checks catch environment-dependent silent failure. ----
|
|
88
|
+
function doctor() {
|
|
89
|
+
let fails = 0;
|
|
90
|
+
const check = (name, pass, failMsg, warnOnly) => {
|
|
91
|
+
if (pass) ok(name);
|
|
92
|
+
else if (warnOnly) warn(name + ' - ' + failMsg);
|
|
93
|
+
else { bad(name + ' - ' + failMsg); fails++; }
|
|
94
|
+
return pass;
|
|
95
|
+
};
|
|
96
|
+
console.log(`brink doctor (v${version()}, ${os.platform()} ${os.release()}, node ${process.version})`);
|
|
97
|
+
|
|
98
|
+
// 1. config surface
|
|
99
|
+
const s = readJson(settingsPath);
|
|
100
|
+
check('settings.json parses', s !== null, `cannot read/parse ${settingsPath}`);
|
|
101
|
+
const hookCmds = s ? JSON.stringify(s.hooks || {}) : '';
|
|
102
|
+
check('PreToolUse pause hook installed', /brink\.js\\?" claude pause|brink\.js" claude pause/.test(hookCmds) || /brink\.js/.test(hookCmds) && /pause/.test(hookCmds), 'run: brink init');
|
|
103
|
+
check('PostToolUse warn hook installed', /brink\.js/.test(hookCmds) && /warn/.test(hookCmds), 'run: brink init');
|
|
104
|
+
const sensorWired = s && s.statusLine && /statusline-brink\.js|state\.json/.test(JSON.stringify(s.statusLine));
|
|
105
|
+
check('statusline sensor wired', !!sensorWired,
|
|
106
|
+
'no Brink-aware statusLine found - the hooks are BLIND without it (run: brink init, or merge the sensor into your custom statusline)', !s ? false : !sensorWired && !!s.statusLine);
|
|
107
|
+
|
|
108
|
+
// 2. kill switch
|
|
109
|
+
check('kill switch not active', !fs.existsSync(HATCH), 'DISABLED file present - run: brink on', true);
|
|
110
|
+
|
|
111
|
+
// 3. live state freshness (only meaningful during/after a real session)
|
|
112
|
+
const statePath = path.join(DIR, 'state.json');
|
|
113
|
+
const state = readJson(statePath);
|
|
114
|
+
if (state === null) warn('state.json not written yet - start a Claude Code session, then re-run doctor');
|
|
115
|
+
else {
|
|
116
|
+
ok('state.json exists and parses');
|
|
117
|
+
const age = (Date.now() - fs.statSync(statePath).mtimeMs) / 60000;
|
|
118
|
+
check('state is fresh (<30 min)', age < 30, `last write ${Math.round(age)} min ago - no active session, or the sensor is not running`, true);
|
|
119
|
+
check('usage data present', typeof state.five_pct === 'number' || typeof state.week_pct === 'number',
|
|
120
|
+
'sensor runs but rate_limits are empty - API-key/Bedrock setups may not receive usage data; Brink cannot arm', true);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 4. end-to-end pause simulation in a sandbox (never touches your real state)
|
|
124
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'brink-doctor-'));
|
|
125
|
+
try {
|
|
126
|
+
const future = Math.floor(Date.now() / 1000) + 3600;
|
|
127
|
+
fs.writeFileSync(path.join(sb, 'state.json'),
|
|
128
|
+
JSON.stringify({ five_pct: 99, week_pct: 10, five_reset: future, week_reset: future + 500000, session_id: 'doctor', cwd: sb.replace(/\\/g, '/') }));
|
|
129
|
+
const env = { ...process.env, BRINK_DIR: sb, BRINK_SILENT: '1' };
|
|
130
|
+
const r = spawnSync('node', [path.join(SRC, 'brink.js'), 'claude', 'pause'],
|
|
131
|
+
{ encoding: 'utf8', env, input: JSON.stringify({ cwd: sb.replace(/\\/g, '/') }), timeout: 15000 });
|
|
132
|
+
check('pause fires at threshold (sandbox)', (r.stdout || '').includes('"permissionDecision":"deny"'), 'deny did not fire: ' + (r.stderr || r.stdout || 'no output').slice(0, 200));
|
|
133
|
+
check('HANDOFF.md written (sandbox)', fs.existsSync(path.join(sb, 'HANDOFF.md')), 'handoff writer failed');
|
|
134
|
+
fs.writeFileSync(path.join(sb, 'DISABLED'), '');
|
|
135
|
+
const r2 = spawnSync('node', [path.join(SRC, 'brink.js'), 'claude', 'pause'], { encoding: 'utf8', env, input: '{}', timeout: 15000 });
|
|
136
|
+
check('kill switch blocks the pause (sandbox)', (r2.stdout || '').trim() === '', 'DISABLED file did not stop the hook');
|
|
137
|
+
} finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
138
|
+
|
|
139
|
+
// 5. live notification (real toast on Windows) — the step unit tests can never prove
|
|
140
|
+
if (argv.includes('--no-toast') || process.env.BRINK_SILENT) warn('notification test skipped (--no-toast/BRINK_SILENT)');
|
|
141
|
+
else {
|
|
142
|
+
const n = spawnSync('node', [path.join(SRC, 'notify.js'), 'Brink doctor: notifications work'], { encoding: 'utf8', timeout: 15000 });
|
|
143
|
+
let res = {}; try { res = JSON.parse((n.stdout || '{}').trim()); } catch {}
|
|
144
|
+
check('desktop notification fired', res.desktop === 'win-toast' || String(res.desktop || '').startsWith('desktop-todo'),
|
|
145
|
+
'toast chain failed: ' + JSON.stringify(res), String(res.desktop || '').startsWith('desktop-todo'));
|
|
146
|
+
if (String(res.desktop) === 'win-toast') console.log(' (a toast should be on your screen right now)');
|
|
147
|
+
if (res.push && !/skipped/.test(res.push)) ok('phone push: ' + res.push);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log(fails ? `\n${fails} check(s) FAILED - paste this whole output into a GitHub issue.` : '\nAll critical checks passed.');
|
|
151
|
+
process.exit(fails ? 1 : 0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function version() { return (readJson(path.join(SRC, '..', 'package.json')) || {}).version || '?'; }
|
|
155
|
+
|
|
156
|
+
function help() {
|
|
157
|
+
console.log(`brink v${version()} - graceful auto-pause + handoff for Claude Code
|
|
158
|
+
brink init [--no-statusline] [--settings <path>] install hooks + sensor
|
|
159
|
+
brink doctor [--no-toast] verify the whole chain end-to-end
|
|
160
|
+
brink off | on kill switch (instant disable/enable)
|
|
161
|
+
brink uninstall [--purge] remove cleanly (restores your statusline)
|
|
162
|
+
brink version`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
({ init, off, on, uninstall, doctor, version: () => console.log(version()), '--version': () => console.log(version()), help }[cmd] || (() => { console.error(`unknown command: ${cmd}`); help(); process.exit(1); }))();
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Brink core — handoff writer (Phase 5).
|
|
2
|
+
// Brink writes HANDOFF.md ITSELF (deterministically, from the session transcript) rather
|
|
3
|
+
// than instructing the model to — because the live test (2026-06-26) showed the model may
|
|
4
|
+
// distrust a tool-result that orders it around and refuse. The handoff must survive even if
|
|
5
|
+
// the model won't cooperate. Best-effort transcript parse; degrades gracefully.
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
function readTranscript(p) {
|
|
10
|
+
try {
|
|
11
|
+
let raw = fs.readFileSync(p, 'utf8'); if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
|
|
12
|
+
return raw.split(/\r?\n/).filter(Boolean)
|
|
13
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
14
|
+
} catch { return []; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function summarize(events) {
|
|
18
|
+
let lastUser = '';
|
|
19
|
+
const actions = [];
|
|
20
|
+
for (const e of events) {
|
|
21
|
+
const msg = e.message || e;
|
|
22
|
+
const role = msg.role || e.type;
|
|
23
|
+
const content = msg.content;
|
|
24
|
+
if (role === 'user' && content) {
|
|
25
|
+
const txt = typeof content === 'string'
|
|
26
|
+
? content
|
|
27
|
+
: (Array.isArray(content) && (content.find((c) => c.type === 'text') || {}).text);
|
|
28
|
+
// skip tool_result echoes; keep real user prose
|
|
29
|
+
if (txt && !(Array.isArray(content) && content.some((c) => c.type === 'tool_result'))) lastUser = txt;
|
|
30
|
+
}
|
|
31
|
+
if (role === 'assistant' && Array.isArray(content)) {
|
|
32
|
+
for (const c of content) {
|
|
33
|
+
if (c.type === 'tool_use') {
|
|
34
|
+
const tgt = (c.input && (c.input.file_path || c.input.path || c.input.command)) || '';
|
|
35
|
+
actions.push(`${c.name}${tgt ? ' -> ' + String(tgt).slice(0, 80) : ''}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { lastUser: String(lastUser || '').slice(0, 800), actions: actions.slice(-8) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeHandoff({ transcriptPath, cwd, pct, window, resetText }) {
|
|
44
|
+
const events = transcriptPath ? readTranscript(transcriptPath) : [];
|
|
45
|
+
const { lastUser, actions } = summarize(events);
|
|
46
|
+
const dir = cwd || '.';
|
|
47
|
+
const isGit = fs.existsSync(path.join(dir, '.git'));
|
|
48
|
+
const lines = [
|
|
49
|
+
'# HANDOFF - paused by Brink',
|
|
50
|
+
'',
|
|
51
|
+
`Paused at **${Math.round(pct)}%** of your ${window} usage limit${resetText ? ` (resets ${resetText})` : ''}.`,
|
|
52
|
+
'This file was written automatically so no progress is lost. To resume, read it and continue the task.',
|
|
53
|
+
'',
|
|
54
|
+
'## The task',
|
|
55
|
+
'',
|
|
56
|
+
lastUser ? '> ' + lastUser.replace(/\n/g, '\n> ') : '_(could not read the original request from the transcript)_',
|
|
57
|
+
'',
|
|
58
|
+
'## Recent actions before the pause',
|
|
59
|
+
'',
|
|
60
|
+
actions.length ? actions.map((a) => '- ' + a).join('\n') : '_(no recent tool actions captured)_',
|
|
61
|
+
'',
|
|
62
|
+
'## Next steps',
|
|
63
|
+
'',
|
|
64
|
+
'- Continue the task above from where it stopped.',
|
|
65
|
+
isGit ? '- This IS a git repo - review uncommitted changes before continuing.' : '- (not a git repo - nothing to commit)',
|
|
66
|
+
'',
|
|
67
|
+
];
|
|
68
|
+
const out = path.join(dir, 'HANDOFF.md');
|
|
69
|
+
try { fs.writeFileSync(out, lines.join('\n')); return out; } catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { writeHandoff, summarize, readTranscript };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Brink core — reset-ping detector (pure, no I/O). Phase 6.
|
|
2
|
+
// Detects when a usage window has ROLLED OVER (its resets_at epoch jumped forward)
|
|
3
|
+
// so Brink can fire a "your budget is back" ping. Only meaningful for a window you
|
|
4
|
+
// were actually near the limit on — a floor gate keeps a reset from 12% silent
|
|
5
|
+
// (that is not budget you were waiting on, it is noise).
|
|
6
|
+
//
|
|
7
|
+
// prev / cur: { five_pct, week_pct, five_reset, week_reset } (last-seen + current state)
|
|
8
|
+
// cfg.reset_ping: { five_hour:{enabled,floor}, seven_day:{enabled,floor} }
|
|
9
|
+
//
|
|
10
|
+
// Signal = resets_at advancing. Within a window resets_at is fixed; on rollover it
|
|
11
|
+
// jumps to the next window's end (hours for 5h, days for weekly). GUARD kills float
|
|
12
|
+
// jitter so only a genuine rollover counts.
|
|
13
|
+
|
|
14
|
+
const GUARD = 60; // seconds
|
|
15
|
+
|
|
16
|
+
const WINDOWS = [
|
|
17
|
+
{ key: 'five_hour', label: '5h', pct: 'five_pct', reset: 'five_reset' },
|
|
18
|
+
{ key: 'seven_day', label: 'weekly', pct: 'week_pct', reset: 'week_reset' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function detectResets(prev, cur, cfg) {
|
|
22
|
+
if (!prev || !cur) return []; // first run (no prior state) never pings
|
|
23
|
+
const rp = (cfg && cfg.reset_ping) || {};
|
|
24
|
+
const events = [];
|
|
25
|
+
for (const w of WINDOWS) {
|
|
26
|
+
const spec = rp[w.key] || {};
|
|
27
|
+
if (!spec.enabled) continue;
|
|
28
|
+
const pr = prev[w.reset];
|
|
29
|
+
const cr = cur[w.reset];
|
|
30
|
+
if (typeof pr !== 'number' || typeof cr !== 'number') continue;
|
|
31
|
+
if (cr <= pr + GUARD) continue; // didn't roll over
|
|
32
|
+
const prevPct = prev[w.pct];
|
|
33
|
+
const floor = typeof spec.floor === 'number' ? spec.floor : 0;
|
|
34
|
+
if (typeof prevPct === 'number' && prevPct < floor) continue; // weren't near the limit
|
|
35
|
+
events.push({
|
|
36
|
+
windowKey: w.key,
|
|
37
|
+
label: w.label,
|
|
38
|
+
prevPct: typeof prevPct === 'number' ? Math.round(prevPct) : null,
|
|
39
|
+
message: `Brink: ${w.label} limit reset - full quota again`
|
|
40
|
+
+ (typeof prevPct === 'number' ? ` (was ${Math.round(prevPct)}%)` : ''),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return events;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { detectResets, GUARD };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Brink core — resume arming decision + arg builder (pure, no I/O). Phase 7.
|
|
2
|
+
// A paused Claude can't wake itself, so resume is EXTERNAL: on pause, Brink (opt-in)
|
|
3
|
+
// registers a one-shot Windows Task Scheduler job for the window's reset time that
|
|
4
|
+
// relaunches `claude --resume` from HANDOFF.md. This module decides WHETHER to arm
|
|
5
|
+
// and builds the PowerShell args; the actual scheduling lives in arm-resume.ps1.
|
|
6
|
+
//
|
|
7
|
+
// cfg.resume: { enabled:false, buffer_seconds:90, skip_permissions:false }
|
|
8
|
+
|
|
9
|
+
const DEFAULT_RESUME = { enabled: false, buffer_seconds: 90, skip_permissions: false };
|
|
10
|
+
|
|
11
|
+
function resumeCfg(cfg) {
|
|
12
|
+
const r = (cfg && cfg.resume) || {};
|
|
13
|
+
const out = { ...DEFAULT_RESUME, ...r };
|
|
14
|
+
// coerce / sanity-clamp
|
|
15
|
+
out.enabled = out.enabled === true;
|
|
16
|
+
out.buffer_seconds = Number.isFinite(out.buffer_seconds) && out.buffer_seconds >= 0
|
|
17
|
+
? Math.floor(out.buffer_seconds) : DEFAULT_RESUME.buffer_seconds;
|
|
18
|
+
out.skip_permissions = out.skip_permissions === true;
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Arm only when: resume is enabled, we're on a platform with a scheduler (Windows in v1),
|
|
23
|
+
// and we actually have the session id + reset epoch the resume needs.
|
|
24
|
+
function shouldArm(cfg, ctx, platform) {
|
|
25
|
+
if (!resumeCfg(cfg).enabled) return false;
|
|
26
|
+
if (platform !== 'win32') return false; // mac/Linux (launchd/cron) documented, not v1
|
|
27
|
+
if (!ctx || !ctx.session_id) return false;
|
|
28
|
+
if (typeof ctx.reset !== 'number' || !isFinite(ctx.reset)) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// PowerShell args for arm-resume.ps1. Strings only (spawn args).
|
|
33
|
+
// Proj is stripped of trailing (back)slashes: the scheduled task embeds it inside
|
|
34
|
+
// an escaped-quoted -Argument string, where a trailing backslash would escape the
|
|
35
|
+
// closing quote and mangle the whole command line (review finding).
|
|
36
|
+
function armArgs(cfg, ctx) {
|
|
37
|
+
const r = resumeCfg(cfg);
|
|
38
|
+
const proj = String(ctx.proj || '').replace(/[\\/]+$/, '');
|
|
39
|
+
return [
|
|
40
|
+
'-ResetsAt', String(ctx.reset),
|
|
41
|
+
'-Sid', String(ctx.session_id || ''),
|
|
42
|
+
'-Proj', proj,
|
|
43
|
+
'-Buffer', String(r.buffer_seconds),
|
|
44
|
+
'-Skip', r.skip_permissions ? '1' : '0',
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { resumeCfg, shouldArm, armArgs, DEFAULT_RESUME };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Brink core — threshold decision engine (pure, no I/O). Phase 3.
|
|
2
|
+
// Shared by every adapter (Claude, Codex, ...). Given normalized usage + config,
|
|
3
|
+
// decide allow | warn | pause for the most-urgent window.
|
|
4
|
+
//
|
|
5
|
+
// usage: { five_pct, week_pct, five_reset, week_reset } (pct = number 0-100 or null)
|
|
6
|
+
// cfg: { five_hour:{warn:[75,85], pause:93}, seven_day:{warn:[80,90], pause:95} }
|
|
7
|
+
|
|
8
|
+
function bandFor(pct, spec) {
|
|
9
|
+
// highest band crossed: 'pause' | 'warn:<n>' | null
|
|
10
|
+
if (typeof pct !== 'number') return null;
|
|
11
|
+
if (typeof spec.pause === 'number' && pct >= spec.pause) return 'pause';
|
|
12
|
+
const warns = (spec.warn || []).slice().sort((a, b) => b - a); // high -> low
|
|
13
|
+
for (const w of warns) if (pct >= w) return `warn:${w}`;
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function decide(usage, cfg) {
|
|
18
|
+
const windows = [
|
|
19
|
+
{ key: 'five_hour', label: '5h', pct: usage.five_pct, reset: usage.five_reset },
|
|
20
|
+
{ key: 'seven_day', label: 'weekly', pct: usage.week_pct, reset: usage.week_reset },
|
|
21
|
+
];
|
|
22
|
+
const hit = windows
|
|
23
|
+
.map((w) => ({ ...w, band: bandFor(w.pct, cfg[w.key] || {}) }))
|
|
24
|
+
.filter((w) => w.band);
|
|
25
|
+
|
|
26
|
+
// pause beats warn; then higher pct wins
|
|
27
|
+
hit.sort((a, b) => {
|
|
28
|
+
const rank = (x) => (x.band === 'pause' ? 2 : 1);
|
|
29
|
+
return rank(b) - rank(a) || b.pct - a.pct;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const top = hit[0];
|
|
33
|
+
if (!top) return { action: 'allow' };
|
|
34
|
+
return {
|
|
35
|
+
action: top.band === 'pause' ? 'pause' : 'warn',
|
|
36
|
+
band: top.band,
|
|
37
|
+
window: top.label,
|
|
38
|
+
windowKey: top.key,
|
|
39
|
+
pct: top.pct,
|
|
40
|
+
reset: top.reset,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { decide, bandFor };
|
package/src/install.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Brink installer — Claude Code adapter (the `brink init` for Claude).
|
|
3
|
+
// Usage: node install.js [--settings <path>] [--statusline]
|
|
4
|
+
// Registers the Brink hooks into the target Claude Code settings.json:
|
|
5
|
+
// PreToolUse -> node brink.js claude pause (graceful auto-pause before the limit)
|
|
6
|
+
// PostToolUse -> node brink.js claude warn (threshold warning notifications)
|
|
7
|
+
// --statusline also points statusLine -> node statusline-brink.js (the usage sensor)
|
|
8
|
+
// Idempotent (won't duplicate the brink hook for an event). Backs up the original once.
|
|
9
|
+
//
|
|
10
|
+
// SAFE BY DEFAULT for testing: pass --settings <sandbox> to target a throwaway file.
|
|
11
|
+
// Bare `node` is used (not an absolute path) so the command works whether Claude Code
|
|
12
|
+
// runs the hook in bash or PowerShell — node is on PATH wherever Claude Code runs.
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const SRC = __dirname;
|
|
18
|
+
const q = (p) => `"${String(p).replace(/\\/g, '/')}"`;
|
|
19
|
+
const cmd = (script, ...args) => ['node', q(path.join(SRC, script)), ...args].join(' ');
|
|
20
|
+
|
|
21
|
+
const argv = process.argv.slice(2);
|
|
22
|
+
const getOpt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? (argv[i + 1] || true) : d; };
|
|
23
|
+
const settingsPath = getOpt('--settings', path.join(os.homedir(), '.claude', 'settings.json'));
|
|
24
|
+
const withStatusline = argv.includes('--statusline');
|
|
25
|
+
|
|
26
|
+
const PAUSE = cmd('brink.js', 'claude', 'pause');
|
|
27
|
+
const WARN = cmd('brink.js', 'claude', 'warn');
|
|
28
|
+
const STATUS = cmd('statusline-brink.js');
|
|
29
|
+
|
|
30
|
+
// BOM-tolerant read. Returns null (NOT {}) on a parse failure so the caller can
|
|
31
|
+
// ABORT — silently proceeding here wiped the user's whole settings.json down to
|
|
32
|
+
// Brink-only content (review finding: a UTF-8 BOM was enough to trigger it).
|
|
33
|
+
const read = (p) => {
|
|
34
|
+
try {
|
|
35
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
36
|
+
return JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw);
|
|
37
|
+
} catch { return null; }
|
|
38
|
+
};
|
|
39
|
+
const hasBrinkHook = (s, event) =>
|
|
40
|
+
((s.hooks && s.hooks[event]) || []).some((g) => (g.hooks || []).some((h) => /brink\.js/.test(h.command || '')));
|
|
41
|
+
|
|
42
|
+
function addHook(s, event, command) {
|
|
43
|
+
s.hooks = s.hooks || {};
|
|
44
|
+
s.hooks[event] = s.hooks[event] || [];
|
|
45
|
+
if (hasBrinkHook(s, event)) return false;
|
|
46
|
+
s.hooks[event].push({ hooks: [{ type: 'command', command }] });
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function main() {
|
|
51
|
+
const existed = fs.existsSync(settingsPath);
|
|
52
|
+
let s = {};
|
|
53
|
+
if (existed) {
|
|
54
|
+
s = read(settingsPath);
|
|
55
|
+
if (s === null) {
|
|
56
|
+
console.error(`Brink install ABORTED: ${settingsPath} exists but is not valid JSON. ` +
|
|
57
|
+
'Fix it (or remove it) and re-run - refusing to overwrite your settings.');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
if (!fs.existsSync(settingsPath + '.brink-bak')) {
|
|
61
|
+
fs.copyFileSync(settingsPath, settingsPath + '.brink-bak'); // one-time backup
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const changed = [];
|
|
65
|
+
if (addHook(s, 'PreToolUse', PAUSE)) changed.push('PreToolUse(pause)');
|
|
66
|
+
if (addHook(s, 'PostToolUse', WARN)) changed.push('PostToolUse(warn)');
|
|
67
|
+
if (withStatusline && !/brink/.test(JSON.stringify(s.statusLine || ''))) {
|
|
68
|
+
if (s.statusLine) {
|
|
69
|
+
// be loud, not silent: the user had a custom statusline (review finding)
|
|
70
|
+
console.error(`note: replacing existing statusLine (original kept in ${settingsPath}.brink-bak)`);
|
|
71
|
+
}
|
|
72
|
+
s.statusLine = { type: 'command', command: STATUS };
|
|
73
|
+
changed.push('statusLine');
|
|
74
|
+
}
|
|
75
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
76
|
+
fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2) + '\n');
|
|
77
|
+
console.log(JSON.stringify({ settingsPath, changed, alreadyInstalled: changed.length === 0 }));
|
|
78
|
+
}
|
|
79
|
+
main();
|