spare10 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 +180 -0
- package/dist/spare10.js +34 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alessandro Diano
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# spare10
|
|
2
|
+
|
|
3
|
+
**A circuit breaker for Claude Code.** It keeps a slice of your 5-hour quota in reserve and
|
|
4
|
+
stops the agent before that reserve is spent — so you still have budget left to steer it,
|
|
5
|
+
instead of the session dying mid-edit.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
spare10 claude
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
That's the whole integration. spare10 configures the session and gets out of the way.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## The problem
|
|
16
|
+
|
|
17
|
+
A long autonomous session burns quota invisibly. When it hits 100%, Claude Code stops
|
|
18
|
+
immediately: a half-finished refactor, uncommitted, and no agent available to finish it or
|
|
19
|
+
back it out. Your context isn't lost — `claude --continue` restores it — but the hours until
|
|
20
|
+
the window resets are, and so is any chance to say "wait, commit that first."
|
|
21
|
+
|
|
22
|
+
spare10 spends the last slice of your quota on **you**.
|
|
23
|
+
|
|
24
|
+
## What it does
|
|
25
|
+
|
|
26
|
+
When usage starts eating the reserve (the last 10% by default) it hands control back:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
spare10 — 9% of the 5-hour window left, which is your 10% reserve.
|
|
30
|
+
Resets at 14:00. Continue anyway?
|
|
31
|
+
❯ Yes No (Esc)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Approve and it stays quiet for the rest of the window. Press Esc and the turn ends there,
|
|
35
|
+
with your working tree in a state you chose.
|
|
36
|
+
|
|
37
|
+
For unattended runs, hand it an instruction instead of a question:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
spare10 --pause-prompt "Finish this block, commit, then stop." claude
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That text is injected into the running agent **without blocking it**, so the agent can
|
|
44
|
+
actually carry out the wind-down you asked for.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
> **Node.js 20+ is required on every install path**, including Homebrew and the install
|
|
49
|
+
> script. spare10 is a Node program; brew and curl are conveniences for fetching it, not a way
|
|
50
|
+
> to avoid the runtime. Bundling one would cost 60–110 MB and make the hot path slower.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install -g spare10 # or, with no install at all: npx spare10 claude
|
|
54
|
+
curl -fsSL https://raw.githubusercontent.com/alesdi/spare10/main/install.sh | sh
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The install script fetches the same published bundle and puts `spare10` in
|
|
58
|
+
`~/.local/bin`. It checks for Node 20+ first and tells you plainly if it is missing, rather
|
|
59
|
+
than installing something that cannot run.
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
spare10 [options] <command> [args...]
|
|
65
|
+
|
|
66
|
+
--reserve <1-99> Keep this much of the 5-hour window back for yourself (default: 10)
|
|
67
|
+
--pause-prompt <text> Inject this instruction instead of asking
|
|
68
|
+
--refresh <seconds> Quota poll interval (default: 2)
|
|
69
|
+
--no-badge Never draw the spare10 marker in the status line
|
|
70
|
+
doctor Report what spare10 detected and what it would do
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Everything after the command passes through untouched, so `spare10 claude --resume` works as
|
|
74
|
+
you'd expect.
|
|
75
|
+
|
|
76
|
+
`--reserve` is the quota you keep, not the level that trips — `--reserve 20` stops the agent
|
|
77
|
+
with a fifth of the window still in hand. It takes whole numbers only: Claude Code reports
|
|
78
|
+
quota in integer percentages, so `--reserve 10.5` is rejected rather than silently rounded.
|
|
79
|
+
|
|
80
|
+
The status line shows nothing until the reserve is reached, then an orange
|
|
81
|
+
`⚠ Pausing at next tool call`, its icon pulsing once per refresh. Once you have consented it
|
|
82
|
+
drops back to a quiet `▶ spare10`, with a non-default reserve spelled out as `▶ spare10 (20%)`
|
|
83
|
+
— the name already accounts for 10.
|
|
84
|
+
|
|
85
|
+
The pulse is driven by spare10's own render cadence rather than the ANSI blink attribute,
|
|
86
|
+
which most terminals ignore.
|
|
87
|
+
|
|
88
|
+
Consenting at the pre-flight prompt counts for the whole window: the session starts disarmed
|
|
89
|
+
rather than asking the same question again on the first tool call.
|
|
90
|
+
|
|
91
|
+
## How it works
|
|
92
|
+
|
|
93
|
+
Claude Code exposes quota in exactly one place: the `rate_limits` object handed to your
|
|
94
|
+
**status line** command. It is not available to hooks. Hooks, meanwhile, are the only thing
|
|
95
|
+
that can *stop* anything. So spare10 splits in two and joins them through a state file:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
spare10 claude
|
|
99
|
+
└─ exec claude --settings '{ statusLine: …, hooks: { PreToolUse: …, PostToolUse: … } }'
|
|
100
|
+
|
|
101
|
+
sensor (status line, every 2s) reads rate_limits → writes state
|
|
102
|
+
gate (PreToolUse, every call) reads state → passes, asks, injects, or denies
|
|
103
|
+
post (PostToolUse) the tool ran, so the user approved → disarm
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**It stops between operations, not mid-write.** The gate fires on tool calls, and that is the
|
|
107
|
+
point rather than a limitation: it interrupts in the gap between one call and the next, where
|
|
108
|
+
nothing is half-written and no command is in flight. A keystroke-level interrupt would land
|
|
109
|
+
wherever the agent happened to be — which is the mess spare10 exists to avoid.
|
|
110
|
+
|
|
111
|
+
The cost is that a turn producing only text is not gated. Before launching, spare10 already
|
|
112
|
+
knows your quota from the previous run, so `spare10 claude` asks for confirmation rather than
|
|
113
|
+
starting a session that would stop on its first move. Once a session is running, the status
|
|
114
|
+
line badge is the signal — a hook's only user-visible channel is exit code 2, which on
|
|
115
|
+
`UserPromptSubmit` erases what you typed, and plain output there becomes model context that
|
|
116
|
+
Claude paraphrases rather than a message you can rely on.
|
|
117
|
+
|
|
118
|
+
Three consequences worth knowing:
|
|
119
|
+
|
|
120
|
+
**Nothing you own is modified.** `claude --settings` accepts a raw JSON string, so a run
|
|
121
|
+
configures exactly one session. No file in `~/.claude` is touched. Close the terminal and
|
|
122
|
+
spare10 is gone.
|
|
123
|
+
|
|
124
|
+
**Your existing setup keeps working.** Hooks merge across settings levels, so your own hooks
|
|
125
|
+
still fire. The status line does *not* merge — ours replaces it — so spare10 runs your
|
|
126
|
+
original status line command with the untouched payload and prints its output. If you have
|
|
127
|
+
none, spare10 draws nothing at all until the reserve is reached.
|
|
128
|
+
|
|
129
|
+
**It knows the quota from the first tool call.** Claude Code omits `rate_limits` from the
|
|
130
|
+
first status line payload of every session, so a fresh run is briefly blind — long enough for
|
|
131
|
+
the opening turn to slip past. spare10 opens each run with the most recent reading from the
|
|
132
|
+
previous run in the same window. Quota is account-wide and only rises within a window, so that
|
|
133
|
+
figure is a lower bound: it can bring a stop forward, never invent one. Only the very first run
|
|
134
|
+
of a window starts with nothing.
|
|
135
|
+
|
|
136
|
+
**It fails open, always.** No reading, a stale reading, a plan that doesn't report
|
|
137
|
+
`rate_limits`, a corrupt state file, a missing run directory — every one of those lets the
|
|
138
|
+
tool call through. A quota guard that blocks your session because it went blind is worse than
|
|
139
|
+
no guard. When it can't see quota for three consecutive polls it says so in the status line.
|
|
140
|
+
|
|
141
|
+
Run `spare10 doctor` to see exactly what it detected.
|
|
142
|
+
|
|
143
|
+
## What it doesn't do
|
|
144
|
+
|
|
145
|
+
Not in this version, deliberately:
|
|
146
|
+
|
|
147
|
+
- **Headless / `-p` mode.** No status line renders, so there's no sensor. spare10 does nothing.
|
|
148
|
+
- **The weekly limit.** Only the 5-hour window is watched.
|
|
149
|
+
- **Auto-resume** when the window resets.
|
|
150
|
+
- **Model downgrade** (Opus → Sonnet) as an alternative to stopping.
|
|
151
|
+
- **Overshoot correction.** The reserve is indicative, not predictive: readings land at 1%
|
|
152
|
+
granularity and up to one refresh interval late, and parallel subagents can land several
|
|
153
|
+
requests at once. Keep back more than you think you need.
|
|
154
|
+
- **Coordination across sessions.** Quota is account-wide, but each run tracks its own state.
|
|
155
|
+
|
|
156
|
+
## Known limitations
|
|
157
|
+
|
|
158
|
+
- In `bypassPermissions` and `dontAsk` modes an `ask` dialog would be auto-approved, so
|
|
159
|
+
spare10 falls back to `deny`. The agent may then retry with a different tool and be denied
|
|
160
|
+
again, burning tokens in a small loop. The deny message tells it to stop; there is no retry
|
|
161
|
+
counter yet.
|
|
162
|
+
- Status line chaining reads user-level settings only. A status line configured in project or
|
|
163
|
+
local settings is not detected.
|
|
164
|
+
|
|
165
|
+
## Development
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
npm install
|
|
169
|
+
npm test # 122 tests: unit, plus the hooks as real subprocesses
|
|
170
|
+
npm run typecheck
|
|
171
|
+
npm run build # single dependency-free bundle in dist/
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Test fixtures are real payloads captured from a live Claude Code session, sanitized. When
|
|
175
|
+
Anthropic changes the status line schema, the tolerant parser keeps spare10 failing open and
|
|
176
|
+
`doctor` reports the drift.
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
package/dist/spare10.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var N={pct:null,resetsAt:null,updatedAt:null,missingStreak:0,blind:!1,disarmedUntil:null,pausePromptInjected:!1,awaitingApproval:!1,tick:0},C=10,he=2,c={reserve:C,pausePrompt:null,refresh:he,badge:!0,chain:null},V=3,K=3,W=3600;function w(e){return 100-e.reserve}function $(e){return 100-e}var u=class extends Error{},T=`spare10 \u2014 pause Claude Code before the 5-hour quota runs out
|
|
3
|
+
|
|
4
|
+
spare10 [options] <command> [args...]
|
|
5
|
+
|
|
6
|
+
Options:
|
|
7
|
+
--reserve <1-99> Keep this much of the 5-hour window back for yourself (default: 10)
|
|
8
|
+
--pause-prompt <text> Instead of asking, inject this instruction into the running agent
|
|
9
|
+
--refresh <seconds> Status line poll interval, also the staleness unit (default: 5)
|
|
10
|
+
--no-badge Never draw the spare10 marker in the status line
|
|
11
|
+
-h, --help Show this message
|
|
12
|
+
|
|
13
|
+
Commands:
|
|
14
|
+
doctor Report what spare10 detected and what it would do
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
spare10 claude
|
|
18
|
+
spare10 --reserve 15 claude
|
|
19
|
+
spare10 --pause-prompt "Finish this block, commit, then stop." claude --resume`;function be(e){if(!/^\d+$/.test(e))throw new u(`--reserve must be a whole number: the API reports quota in integer percentages, so "${e}" cannot be honoured.`);let t=Number(e);if(t<1||t>99)throw new u(`--reserve must be between 1 and 99, got ${t}.`);return t}function Se(e){if(!/^\d+$/.test(e)||Number(e)<1)throw new u(`--refresh must be a whole number of seconds >= 1, got "${e}".`);return Number(e)}function Q(e){let t={reserve:c.reserve,pausePrompt:c.pausePrompt,refresh:c.refresh,badge:c.badge},n=0,r=o=>{let i=e[n+1];if(i===void 0)throw new u(`${o} requires a value.`);return n+=1,i};for(;n<e.length;n+=1){let o=e[n];if(!o.startsWith("-"))break;switch(o){case"--reserve":t.reserve=be(r(o));break;case"--threshold":throw new u('--threshold was replaced by --reserve, which is the quota kept back rather than the level that trips. "--threshold 90" is now "--reserve 10".');case"--pause-prompt":t.pausePrompt=r(o);break;case"--refresh":t.refresh=Se(r(o));break;case"--no-badge":t.badge=!1;break;case"-h":case"--help":throw new u("");default:throw new u(`Unknown option "${o}".`)}}let s=e.slice(n);if(s.length===0)throw new u("No command given. Try: spare10 claude");return{config:t,command:s}}import{readFileSync as Re}from"node:fs";var we=new Set(["bypassPermissions","dontAsk"]),k={kind:"pass"};function I(e,t,n){return e.resetsAt!==null?n<e.resetsAt:e.updatedAt===null?!1:n-e.updatedAt<=t.refresh*K}function ye(e){return e===null?"an unknown time":new Date(e*1e3).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}function X(e,t){return`spare10 \u2014 into your ${t.reserve}% reserve \xB7 ${$(e.pct??0)}% of quota left \xB7 resets ${ye(e.resetsAt)}`}var U=X;function z(e,t,n){return R({state:e,config:t,now:n,permissionMode:null}).kind!=="pass"}function Y(e,t){return`${X(e,t)}
|
|
20
|
+
The agent will pause safely at its first tool call \u2014 between operations, with nothing left half-written.`}function R({state:e,config:t,now:n,permissionMode:r}){return e.pct===null||e.updatedAt===null||e.blind||!I(e,t,n)||e.disarmedUntil!==null&&n<e.disarmedUntil||e.pct<w(t)?k:t.pausePrompt!==null&&!e.pausePromptInjected?{kind:"inject",text:`${U(e,t)}
|
|
21
|
+
|
|
22
|
+
${t.pausePrompt}`}:r!==null&&we.has(r)?{kind:"deny",reason:`${U(e,t)}. Stop now and wait for the user. Do not call any further tools.`}:{kind:"ask",reason:U(e,t)}}function Z(e){try{let t=JSON.parse(e);if(typeof t!="object"||t===null)return{permissionMode:null,toolName:null};let n=t;return{permissionMode:typeof n.permission_mode=="string"?n.permission_mode:null,toolName:typeof n.tool_name=="string"?n.tool_name:null}}catch{return{permissionMode:null,toolName:null}}}import{readFileSync as ke,writeFileSync as Ae,renameSync as xe,mkdirSync as Pe,unlinkSync as $e}from"node:fs";import{join as ee}from"node:path";var te=e=>ee(e,"state.json"),D=e=>ee(e,"config.json");function d(){return Math.floor(Date.now()/1e3)}function ne(e){try{let t=JSON.parse(ke(e,"utf8"));return typeof t=="object"&&t!==null?t:null}catch{return null}}var h=(e,t)=>typeof e=="number"&&Number.isFinite(e)?e:t,v=(e,t)=>typeof e=="boolean"?e:t;function m(e){let t=ne(te(e));return t?{pct:h(t.pct,null),resetsAt:h(t.resetsAt,null),updatedAt:h(t.updatedAt,null),missingStreak:h(t.missingStreak,0)??0,blind:v(t.blind,!1),disarmedUntil:h(t.disarmedUntil,null),pausePromptInjected:v(t.pausePromptInjected,!1),awaitingApproval:v(t.awaitingApproval,!1),tick:h(t.tick,0)??0}:{...N}}function f(e,t){let n=te(e),r=`${n}.${process.pid}.tmp`;try{Pe(e,{recursive:!0}),Ae(r,JSON.stringify(t),"utf8"),xe(r,n)}catch{try{$e(r)}catch{}}}function y(e){let t=ne(D(e));if(!t)return{...c};let n=h(t.reserve,c.reserve)??c.reserve,r=h(t.refresh,c.refresh)??c.refresh;return{reserve:Math.min(99,Math.max(1,Math.round(n))),pausePrompt:typeof t.pausePrompt=="string"&&t.pausePrompt?t.pausePrompt:null,refresh:Math.max(1,Math.round(r)),badge:v(t.badge,c.badge),chain:typeof t.chain=="string"&&t.chain?t.chain:null}}function A(e,t){return e.resetsAt??t+W}function re(e,t){let r=e.filter(s=>s.pct!==null&&s.updatedAt!==null&&s.resetsAt!==null&&s.resetsAt>t).reduce((s,o)=>s===null||o.updatedAt>s.updatedAt?o:s,null);return r===null?null:{...N,pct:r.pct,resetsAt:r.resetsAt,updatedAt:r.updatedAt}}function F(){try{return Re(0,"utf8")}catch{return""}}function j(e){process.stdout.write(JSON.stringify({hookSpecificOutput:e}))}function se(e){let t=y(e),n=m(e),r=d();if(R({state:n,config:t,now:r,permissionMode:null}).kind==="pass")return F(),0;let{permissionMode:s}=Z(F()),o=R({state:n,config:t,now:r,permissionMode:s});switch(o.kind){case"pass":return 0;case"inject":return f(e,{...n,pausePromptInjected:!0,disarmedUntil:A(n,r)}),j({hookEventName:"PreToolUse",additionalContext:o.text}),0;case"ask":return f(e,{...n,awaitingApproval:!0}),j({hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:o.reason}),0;case"deny":return j({hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:o.reason}),0}}function oe(e){F();let t=m(e);return t.awaitingApproval&&f(e,{...t,awaitingApproval:!1,disarmedUntil:A(t,d())}),0}import{execFileSync as de}from"node:child_process";import{accessSync as Je,constants as Ve,readdirSync as Ke,statSync as B}from"node:fs";import{join as We}from"node:path";function ie(e,t,n){return e.pct===null||e.updatedAt===null?{status:"no-data",detail:"no quota reading yet \u2014 start a session and wait a moment"}:e.blind?{status:"blind",detail:"Claude Code is not reporting rate_limits on this plan"}:I(e,t,n)?e.disarmedUntil!==null&&n<e.disarmedUntil?{status:"disarmed",detail:`consent given; quiet until ${O(e.disarmedUntil)}`}:e.pct>=w(t)?{status:"tripped",detail:`into the ${t.reserve}% reserve`}:{status:"armed",detail:`${$(e.pct)}% left, of which ${t.reserve}% is reserved`}:{status:"stale",detail:"the window this reading described has already reset"}}function M(e){let t=Math.abs(Math.round(e));if(t<60)return`${t}s`;let n=Math.floor(t/60);return n<60?`${n}m`:`${Math.floor(n/60)}h ${n%60}m`}function O(e){return new Date(e*1e3).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}import{spawnSync as Ee}from"node:child_process";import{closeSync as _e,mkdirSync as Ne,openSync as Ce,readSync as Te,readdirSync as ue,rmSync as Ue,statSync as Ie,writeFileSync as De}from"node:fs";import{homedir as le}from"node:os";import{basename as je,join as x}from"node:path";import{fileURLToPath as Fe}from"node:url";import{readFileSync as ve}from"node:fs";function L(e){return`'${e.replace(/'/g,"'\\''")}'`}function E(e){let t;try{t=JSON.parse(ve(e,"utf8"))}catch{return{command:null,padding:void 0}}if(typeof t!="object"||t===null)return{command:null,padding:void 0};let n=t.statusLine;if(typeof n!="object"||n===null)return{command:null,padding:void 0};let r=n,s=typeof r.command=="string"?r.command:null,o=typeof r.padding=="number"?r.padding:void 0;return s!==null&&s.includes("spare10")?{command:null,padding:o}:{command:s,padding:o}}function ae(e){let t=s=>`${L(e.nodePath)} ${L(e.selfPath)} ${s} --run ${L(e.runDir)}`,n=s=>[{matcher:"",hooks:[{type:"command",command:t(s)}]}],r={type:"command",command:t("sensor"),refreshInterval:e.refresh};return e.padding!==void 0&&(r.padding=e.padding),{statusLine:r,hooks:{PreToolUse:n("gate"),PostToolUse:n("post")}}}var Me=7*24*60*60*1e3,H=()=>x(le(),".spare10","runs"),G=()=>x(le(),".claude","settings.json");function Oe(e,t){let n;try{n=ue(e)}catch{return}for(let r of n){let s=x(e,r);try{t-Ie(s).mtimeMs>Me&&Ue(s,{recursive:!0,force:!0})}catch{}}}function Le(e,t){let n;try{n=ue(e)}catch{return}let r=n.map(o=>x(e,o)).filter(o=>o!==t).map(m),s=re(r,d());s!==null&&f(t,s)}function He(e){let t=`${Date.now().toString(36)}-${process.pid.toString(36)}`,n=x(e,t);return Ne(n,{recursive:!0}),n}function Ge(e){let t;try{t=Ce("/dev/tty","r")}catch{return null}process.stderr.write(e);let n=Buffer.alloc(1),r="";try{for(;Te(t,n,0,1,null)>0;){let s=n.toString("utf8");if(s===`
|
|
23
|
+
`||s==="\r")break;r+=s}}catch{return null}finally{_e(t)}return r.trim()}function qe(e,t,n){return e?t||n===null?"proceeding":/^y(es)?$/i.test(n.trim())?"consented":"declined":"clear"}function Be(e,t){let n=m(e);if(!z(n,t,d()))return"clear";process.stderr.write(`
|
|
24
|
+
${Y(n,t)}
|
|
25
|
+
`);let r=t.pausePrompt!==null?null:Ge("Start anyway? [y/N] ");return qe(!0,t.pausePrompt!==null,r)}function q(){return Fe(import.meta.url)}function ce({config:e,command:t}){let[n,...r]=t;if(je(n)!=="claude")throw new u(`spare10 wraps the "claude" command, but got "${n}". Run it as: ${["spare10",...r,"claude"].join(" ")}`);let s=H();Oe(s,Date.now());let o=He(s);Le(s,o);let i=Be(o,{...e,chain:null});if(i==="declined")return process.stderr.write(`Not started.
|
|
26
|
+
`),0;if(i==="consented"){let P=m(o);f(o,{...P,disarmedUntil:A(P,d())}),process.stderr.write(`Continuing into the reserve for this window.
|
|
27
|
+
|
|
28
|
+
`)}let l=E(G());De(D(o),JSON.stringify({...e,chain:l.command},null,2));let p=ae({nodePath:process.execPath,selfPath:q(),runDir:o,refresh:e.refresh,padding:l.padding}),b=()=>{};process.on("SIGINT",b),process.on("SIGTERM",b);let g=Ee(n,["--settings",JSON.stringify(p),...r],{stdio:"inherit"});return g.error?(process.stderr.write(`spare10: could not start ${n}: ${g.error.message}
|
|
29
|
+
`),127):g.signal?128+(g.signal==="SIGINT"?2:15):g.status??0}var Qe="0.1.0",a={ok:"\u2713",warn:"\u26A0",info:"\xB7"};function Xe(e){try{return Ke(e).map(n=>We(e,n)).filter(n=>{try{return B(n).isDirectory()}catch{return!1}}).sort((n,r)=>B(r).mtimeMs-B(n).mtimeMs)[0]??null}catch{return null}}function ze(e){try{return de("command",["-v",e],{shell:!0,encoding:"utf8"}).trim()||null}catch{return null}}function Ye(){try{return de("claude",["--version"],{encoding:"utf8",timeout:1e4}).trim()}catch{return null}}function Ze(e){try{return Je(e,Ve.X_OK),!0}catch{return!1}}function pe(){let e=(S="")=>process.stdout.write(`${S}
|
|
30
|
+
`),t=d(),n=0;e(`spare10 ${Qe}`),e(),e("Environment"),e(` ${a.info} node ${process.execPath} (${process.version})`),e(` ${a.info} spare10 ${q()}`);let r=ze("claude"),s=r?Ye():null;r&&s?e(` ${a.ok} claude ${r} (${s})`):(n+=1,e(` ${a.warn} claude not found on PATH \u2014 spare10 has nothing to wrap`)),e(),e("Status line");let o=E(G());if(o.command===null)e(` ${a.info} nothing to chain \u2014 spare10 will own the status line`);else{let S=o.command.split(/\s+/)[0]??"",_=Ze(S);_||(n+=1),e(` ${_?a.ok:a.warn} chains into ${o.command}`),_||e(" that path is not executable; its output will be dropped")}e();let i=Xe(H());if(i===null)return e("No runs yet. Start one with: spare10 claude"),n>0?1:0;let l=y(i),p=m(i),b=ie(p,l,t);if(e(`Latest run ${i}`),e(` ${a.info} reserve ${l.reserve}% of the 5-hour window`),e(` ${a.info} on trip ${l.pausePrompt===null?"ask for confirmation":`inject: ${JSON.stringify(l.pausePrompt)}`}`),e(` ${a.info} refresh ${l.refresh}s`),p.pct!==null&&p.updatedAt!==null){e(` ${a.ok} quota ${p.pct}% used, ${100-p.pct}% left`);let S=t-p.updatedAt;e(` ${a.info} last reading ${M(S)} ago`+(S>l.refresh*3?" (no session running; still valid for this window)":""))}p.resetsAt!==null&&e(` ${a.info} resets ${O(p.resetsAt)} (in ${M(p.resetsAt-t)})`);let g=b.status==="blind";g&&(n+=1);let P=g?a.warn:b.status==="no-data"?a.info:a.ok;return e(` ${P} state ${b.status.toUpperCase()} \u2014 ${b.detail}`),n>0?1:0}import{spawnSync as et}from"node:child_process";import{readFileSync as tt}from"node:fs";function me(e){return typeof e=="number"&&Number.isFinite(e)?e:null}function fe(e){let t;try{t=JSON.parse(e)}catch{return{sessionId:null,fiveHour:null}}if(typeof t!="object"||t===null)return{sessionId:null,fiveHour:null};let n=t,r=typeof n.session_id=="string"?n.session_id:null,s=n.rate_limits;if(typeof s!="object"||s===null)return{sessionId:r,fiveHour:null};let o=s.five_hour;if(typeof o!="object"||o===null)return{sessionId:r,fiveHour:null};let i=o,l=me(i.used_percentage);return l===null?{sessionId:r,fiveHour:null}:{sessionId:r,fiveHour:{usedPercentage:Math.min(100,Math.max(0,l)),resetsAt:me(i.resets_at)}}}function nt(e,t,n){let r=t.fiveHour,s=e.tick+1;if(!r){let i=e.missingStreak+1;return{...e,tick:s,missingStreak:i,blind:i>=V}}let o=r.resetsAt!==null&&e.resetsAt!==null&&r.resetsAt!==e.resetsAt;return{tick:s,pct:r.usedPercentage,resetsAt:r.resetsAt,updatedAt:n,missingStreak:0,blind:!1,disarmedUntil:o?null:e.disarmedUntil,pausePromptInjected:o?!1:e.pausePromptInjected,awaitingApproval:o?!1:e.awaitingApproval}}var rt="\x1B[38;5;208m",st="\x1B[39m",ot=e=>`${rt}${e}${st}`;function it(e,t,n){if(!t.badge)return"";if(e.blind)return"\u26A0 spare10 quota unavailable";if(e.pct===null||e.pct<w(t))return"";if(e.disarmedUntil!==null&&n<e.disarmedUntil)return`\u25B6 spare10${t.reserve===C?"":` (${t.reserve}%)`}`;let s=e.tick%2===0?"\u26A0":" ";return ot(`${s} Pausing at next tool call`)}function at(e,t){if(!e||e.includes("spare10 sensor"))return"";try{return(et(e,{shell:!0,input:t,encoding:"utf8",timeout:5e3,maxBuffer:1e6}).stdout??"").replace(/\n+$/,"")}catch{return""}}function ut(){try{return tt(0,"utf8")}catch{return""}}function ge(e){let t=ut(),n=y(e),r=nt(m(e),fe(t),d());f(e,r);let s=[it(r,n,d()),at(n.chain,t)].filter(Boolean);return s.length>0&&process.stdout.write(s.join(" ")),0}function J(e,t){let n=e.indexOf(t);return n===-1?null:e[n+1]??null}function lt(e){let[t,...n]=e;switch(t){case"sensor":{let r=J(n,"--run");return r?ge(r):0}case"gate":{let r=J(n,"--run");return r?se(r):0}case"post":{let r=J(n,"--run");return r?oe(r):0}case"doctor":return pe();default:return ce(Q(e))}}try{process.exit(lt(process.argv.slice(2)))}catch(e){throw e instanceof u&&(process.stderr.write(e.message?`spare10: ${e.message}
|
|
31
|
+
|
|
32
|
+
${T}
|
|
33
|
+
`:`${T}
|
|
34
|
+
`),process.exit(e.message?2:0)),e}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spare10",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Circuit breaker for Claude Code: pause autonomous sessions before the 5-hour quota runs out",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"claude",
|
|
8
|
+
"rate-limit",
|
|
9
|
+
"quota",
|
|
10
|
+
"circuit-breaker",
|
|
11
|
+
"cli"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"spare10": "dist/spare10.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "node scripts/build.mjs",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:watch": "vitest",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.10.2",
|
|
33
|
+
"esbuild": "^0.24.2",
|
|
34
|
+
"typescript": "^5.7.2",
|
|
35
|
+
"vitest": "^2.1.8"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/alesdi/spare10.git"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/alesdi/spare10#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/alesdi/spare10/issues"
|
|
44
|
+
},
|
|
45
|
+
"author": "Alessandro Diano"
|
|
46
|
+
}
|