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/notify.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Brink — notifier (Phase 2). CLI-agnostic: `node notify.js "message"` fires a
|
|
3
|
+
// cross-platform phone push (ntfy) + a native desktop toast (Windows-first).
|
|
4
|
+
// Because it's just "send a message", it works from a hook, a daemon, or any caller.
|
|
5
|
+
//
|
|
6
|
+
// Config: reads notify.{ntfy_topic, windows_toast} from $BRINK_DIR/config.json
|
|
7
|
+
// (the documented path — was previously env-only, a review finding). Priority:
|
|
8
|
+
// explicit arg > $BRINK_NTFY > config.json.
|
|
9
|
+
const { spawn } = require('child_process');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
function loadNotifyCfg() {
|
|
15
|
+
try {
|
|
16
|
+
const dir = process.env.BRINK_DIR || path.join(os.homedir(), '.claude', 'brink');
|
|
17
|
+
const raw = fs.readFileSync(path.join(dir, 'config.json'), 'utf8');
|
|
18
|
+
const c = JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw);
|
|
19
|
+
return (c && typeof c.notify === 'object' && c.notify) || {};
|
|
20
|
+
} catch { return {}; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function push(topic, msg) {
|
|
24
|
+
if (!topic || /^REPLACE/.test(topic)) return { push: 'skipped (no ntfy topic)' };
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch(`https://ntfy.sh/${encodeURIComponent(topic)}`, {
|
|
27
|
+
method: 'POST', body: msg, headers: { Title: 'Brink' },
|
|
28
|
+
});
|
|
29
|
+
return { push: `ntfy ${res.status}` };
|
|
30
|
+
} catch (e) { return { push: 'error: ' + e.message }; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Windows toast. NOT detached, and awaited: live-fire testing proved PowerShell 5.1
|
|
34
|
+
// dies at startup when spawned DETACHED_PROCESS (no console), and an attached child
|
|
35
|
+
// dies when the parent's console closes — so the parent must outlive it. notify.js
|
|
36
|
+
// itself is the detached (node survives detachment) fire-and-forget layer, so
|
|
37
|
+
// waiting ~1-2s here blocks nobody. -ExecutionPolicy Bypass: stock Windows ships
|
|
38
|
+
// Restricted, which would silently refuse the .ps1 (review finding).
|
|
39
|
+
function desktop(msg, cfg) {
|
|
40
|
+
if (os.platform() === 'win32') {
|
|
41
|
+
if (cfg.windows_toast === false) return Promise.resolve('win-toast-disabled');
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
try {
|
|
44
|
+
const c = spawn('powershell',
|
|
45
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File',
|
|
46
|
+
path.join(__dirname, 'notify.ps1'), '-Msg', msg],
|
|
47
|
+
{ stdio: 'ignore', windowsHide: true });
|
|
48
|
+
const t = setTimeout(() => resolve('win-toast-timeout'), 8000);
|
|
49
|
+
c.on('error', () => { clearTimeout(t); resolve('win-toast-spawn-error'); });
|
|
50
|
+
c.on('close', (code) => { clearTimeout(t); resolve(code === 0 ? 'win-toast' : `win-toast-exit-${code}`); });
|
|
51
|
+
} catch { resolve('win-toast-error'); }
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
// TODO Phase 8: macOS (osascript -e 'display notification ...') + Linux (notify-send)
|
|
55
|
+
return Promise.resolve(`desktop-todo(${os.platform()})`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function notify(msg, topic) {
|
|
59
|
+
if (process.env.BRINK_SILENT) return { silent: true };
|
|
60
|
+
const cfg = loadNotifyCfg();
|
|
61
|
+
const [d, p] = await Promise.all([
|
|
62
|
+
desktop(msg, cfg),
|
|
63
|
+
push(topic || process.env.BRINK_NTFY || cfg.ntfy_topic || '', msg),
|
|
64
|
+
]);
|
|
65
|
+
return { desktop: d, ...p };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (require.main === module) {
|
|
69
|
+
const msg = process.argv.slice(2).join(' ') || 'Brink test notification';
|
|
70
|
+
notify(msg).then((r) => console.log(JSON.stringify(r)));
|
|
71
|
+
}
|
|
72
|
+
module.exports = { notify };
|
package/src/notify.ps1
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Brink — Windows toast helper (Phase 2). The Windows leaf called by notify.js,
|
|
2
|
+
# which owns cross-platform push (ntfy/Pushover). AppId reuses the Claude.Code path.
|
|
3
|
+
param([string]$Msg)
|
|
4
|
+
|
|
5
|
+
# native Windows toast
|
|
6
|
+
try {
|
|
7
|
+
[void][Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime]
|
|
8
|
+
[void][Windows.Data.Xml.Dom.XmlDocument,Windows.Data.Xml.Dom.XmlDocument,ContentType=WindowsRuntime]
|
|
9
|
+
$x = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
10
|
+
$safe = [System.Security.SecurityElement]::Escape($Msg)
|
|
11
|
+
$x.LoadXml("<toast><visual><binding template='ToastGeneric'><text>Brink</text><text>$safe</text></binding></visual></toast>")
|
|
12
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Claude.Code').Show([Windows.UI.Notifications.ToastNotification]::new($x))
|
|
13
|
+
} catch {}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Brink - resume once (Phase 7, opt-in)
|
|
2
|
+
# Fires at reset: relaunches Claude headless from HANDOFF.md, then cleans up its task.
|
|
3
|
+
# NOTE: fresh process - continuity depends entirely on HANDOFF.md + the session id.
|
|
4
|
+
param([string]$Sid, [string]$Proj, [int]$Buffer = 90, [string]$Skip = '0')
|
|
5
|
+
|
|
6
|
+
$taskName = 'BrinkResume_' + ($Sid -replace '[^\w\-]', '_')
|
|
7
|
+
|
|
8
|
+
# Project gone at fire time? Nothing to resume into - clean up and bail
|
|
9
|
+
# (previously Set-Location failed non-terminating and claude ran in System32).
|
|
10
|
+
if (-not ($Proj -and (Test-Path -LiteralPath $Proj))) {
|
|
11
|
+
try { & node (Join-Path $PSScriptRoot 'notify.js') "Brink: resume skipped - project folder missing" | Out-Null } catch { }
|
|
12
|
+
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
Set-Location -LiteralPath $Proj # scheduled tasks start in System32 otherwise
|
|
16
|
+
|
|
17
|
+
# Weekly-cap pre-check: a 5h reset can fire while the 7-day window is still maxed -
|
|
18
|
+
# relaunching would just immediately re-pause. If last-known 7d usage is still at/above
|
|
19
|
+
# the weekly pause threshold, re-arm for the WEEKLY reset instead of relaunching now.
|
|
20
|
+
# (Uses the last state.json; it may be stale, but the 7d window moves slowly.)
|
|
21
|
+
$brinkDir = if ($env:BRINK_DIR) { $env:BRINK_DIR } else { Join-Path $env:USERPROFILE '.claude\brink' }
|
|
22
|
+
$statePath = Join-Path $brinkDir 'state.json'
|
|
23
|
+
if (Test-Path $statePath) {
|
|
24
|
+
try {
|
|
25
|
+
$state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json
|
|
26
|
+
$weeklyPause = 95
|
|
27
|
+
$cfgPath = Join-Path $brinkDir 'config.json'
|
|
28
|
+
if (Test-Path $cfgPath) {
|
|
29
|
+
$cfg = Get-Content -LiteralPath $cfgPath -Raw | ConvertFrom-Json
|
|
30
|
+
if ($cfg.thresholds.seven_day.pause) { $weeklyPause = [double]$cfg.thresholds.seven_day.pause }
|
|
31
|
+
}
|
|
32
|
+
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
|
33
|
+
if ($null -ne $state.week_pct -and [double]$state.week_pct -ge $weeklyPause -and
|
|
34
|
+
$null -ne $state.week_reset -and [int64]$state.week_reset -gt $now) {
|
|
35
|
+
# forward the user's configured buffer (previously silently reverted to 90)
|
|
36
|
+
& (Join-Path $PSScriptRoot 'arm-resume.ps1') -ResetsAt ([string]$state.week_reset) -Sid $Sid -Proj $Proj -Buffer $Buffer -Skip $Skip
|
|
37
|
+
return # re-armed for the weekly reset; do not relaunch now
|
|
38
|
+
}
|
|
39
|
+
} catch { } # any parse issue -> fall through and just relaunch
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
$prompt = "Read HANDOFF.md and continue the task from where it was paused. Do not redo finished work."
|
|
43
|
+
if ($Skip -eq '1') {
|
|
44
|
+
claude --resume $Sid -p $prompt --dangerously-skip-permissions *>> "$Proj/.claude-resume.log"
|
|
45
|
+
} else {
|
|
46
|
+
claude --resume $Sid -p $prompt *>> "$Proj/.claude-resume.log"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Self-delete - but ONLY if no future trigger exists: the resumed session may have
|
|
50
|
+
# paused again and re-armed this same task name for the next reset; deleting it here
|
|
51
|
+
# would break resume chaining (review finding).
|
|
52
|
+
try {
|
|
53
|
+
$t = Get-ScheduledTask -TaskName $taskName -ErrorAction Stop
|
|
54
|
+
$next = [datetime]::Parse($t.Triggers[0].StartBoundary)
|
|
55
|
+
if ($next -gt (Get-Date)) { return } # re-armed during the resumed session - keep it
|
|
56
|
+
} catch { }
|
|
57
|
+
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Brink — sensor + state writer (Phase 1)
|
|
3
|
+
// A Claude Code statusLine command. Reads the usage JSON Claude pipes on stdin,
|
|
4
|
+
// renders a minimal status string, AND atomically writes a tiny state file that
|
|
5
|
+
// the PreToolUse pause hook reads (hooks can't see rate_limits directly).
|
|
6
|
+
//
|
|
7
|
+
// Node-based on purpose: JSON.parse is built in (no jq/python dependency) and it
|
|
8
|
+
// runs anywhere Claude Code runs. State dir overridable via $BRINK_DIR (tests).
|
|
9
|
+
//
|
|
10
|
+
// Install (later phase) as: "statusLine": { "type": "command",
|
|
11
|
+
// "command": "node \"<path>/statusline-brink.js\"" }
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { spawn } = require('child_process');
|
|
17
|
+
const { detectResets } = require('./core/reset');
|
|
18
|
+
|
|
19
|
+
// Reset-ping defaults (Phase 6): weekly ON, 5h OFF — a 5h reset fires several times
|
|
20
|
+
// a day and trains you to ignore pings; the weekly reset is days of budget coming back.
|
|
21
|
+
// floor = only ping if you were at/above this % before it rolled (else it is noise).
|
|
22
|
+
const DEFAULT_RESET = {
|
|
23
|
+
five_hour: { enabled: false, floor: 75 },
|
|
24
|
+
seven_day: { enabled: true, floor: 80 },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function readJson(p) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
30
|
+
return JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw);
|
|
31
|
+
} catch { return null; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadResetCfg(dir) {
|
|
35
|
+
const c = readJson(path.join(dir, 'config.json')) || {};
|
|
36
|
+
const rp = c.reset_ping || {};
|
|
37
|
+
return {
|
|
38
|
+
five_hour: { ...DEFAULT_RESET.five_hour, ...(rp.five_hour || {}) },
|
|
39
|
+
seven_day: { ...DEFAULT_RESET.seven_day, ...(rp.seven_day || {}) },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let raw = '';
|
|
44
|
+
process.stdin.on('data', (d) => { raw += d; });
|
|
45
|
+
process.stdin.on('end', () => {
|
|
46
|
+
let j = {};
|
|
47
|
+
try { j = JSON.parse(raw || '{}'); } catch { j = {}; }
|
|
48
|
+
|
|
49
|
+
const dir = process.env.BRINK_DIR || path.join(os.homedir(), '.claude', 'brink');
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
51
|
+
|
|
52
|
+
// Read last-seen state BEFORE we overwrite it, so a rollover is detectable.
|
|
53
|
+
const prev = readJson(path.join(dir, 'state.json'));
|
|
54
|
+
|
|
55
|
+
const rl = j.rate_limits || {};
|
|
56
|
+
const five = rl.five_hour || {};
|
|
57
|
+
const week = rl.seven_day || {};
|
|
58
|
+
const num = (v) => (typeof v === 'number' ? v : null);
|
|
59
|
+
|
|
60
|
+
const fivePct = num(five.used_percentage);
|
|
61
|
+
const weekPct = num(week.used_percentage);
|
|
62
|
+
const fiveReset = num(five.resets_at);
|
|
63
|
+
const weekReset = num(week.resets_at);
|
|
64
|
+
const sid = j.session_id || '';
|
|
65
|
+
const cwd = (j.workspace && j.workspace.current_dir) || j.cwd || '';
|
|
66
|
+
|
|
67
|
+
// --- render (minimal; merge into your full status line) ---
|
|
68
|
+
let rate = '';
|
|
69
|
+
if (fivePct !== null) rate = `5h:${Math.round(fivePct)}%`;
|
|
70
|
+
if (fiveReset !== null) {
|
|
71
|
+
const left = fiveReset - Math.floor(Date.now() / 1000);
|
|
72
|
+
if (left > 0) rate += ` (${Math.floor(left / 3600)}h${Math.floor((left % 3600) / 60)}m)`;
|
|
73
|
+
}
|
|
74
|
+
if (weekPct !== null) rate += `${rate ? ' * ' : ''}7d:${Math.round(weekPct)}%`;
|
|
75
|
+
process.stdout.write(rate);
|
|
76
|
+
|
|
77
|
+
// --- persist state atomically (write temp + rename) ---
|
|
78
|
+
const state = {
|
|
79
|
+
five_pct: fivePct, week_pct: weekPct,
|
|
80
|
+
five_reset: fiveReset, week_reset: weekReset,
|
|
81
|
+
session_id: sid, cwd,
|
|
82
|
+
};
|
|
83
|
+
// Per-process tmp name: two concurrent Claude sessions share this dir, and a
|
|
84
|
+
// shared tmp path made ~21% of concurrent refreshes crash on the rename (one
|
|
85
|
+
// writer renames the tmp away under the other — reproduced live). The rename is
|
|
86
|
+
// also guarded: Windows throws EPERM if a long-hold reader (AV/indexer) has the
|
|
87
|
+
// target open, and a lost cycle must not kill the status line.
|
|
88
|
+
try {
|
|
89
|
+
const tmp = path.join(dir, `state.json.tmp.${process.pid}`);
|
|
90
|
+
fs.writeFileSync(tmp, JSON.stringify(state));
|
|
91
|
+
try {
|
|
92
|
+
fs.renameSync(tmp, path.join(dir, 'state.json'));
|
|
93
|
+
} catch (e) {
|
|
94
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
} catch { /* next refresh rewrites; never crash the prompt */ }
|
|
98
|
+
|
|
99
|
+
// Reset ping (Phase 6): a window just rolled over -> "your budget is back".
|
|
100
|
+
// Wrapped so a notifier hiccup can never break the status line. Naturally
|
|
101
|
+
// debounced: we already wrote the new reset epoch above, so the next refresh
|
|
102
|
+
// sees no advance and won't re-fire. BRINK_NO_RESET_PING disables for tests.
|
|
103
|
+
if (!process.env.BRINK_NO_RESET_PING) {
|
|
104
|
+
try {
|
|
105
|
+
const events = detectResets(prev, state, { reset_ping: loadResetCfg(dir) });
|
|
106
|
+
for (const ev of events) {
|
|
107
|
+
const c = spawn('node', [path.join(__dirname, 'notify.js'), ev.message],
|
|
108
|
+
{ detached: true, stdio: 'ignore', windowsHide: true });
|
|
109
|
+
c.on('error', () => {}); // async spawn failure must never crash the sensor
|
|
110
|
+
c.unref();
|
|
111
|
+
}
|
|
112
|
+
} catch { /* never let a ping failure break the prompt */ }
|
|
113
|
+
}
|
|
114
|
+
});
|