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/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
+ });