spare10 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -11
- package/dist/spare10.js +29 -23
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -14,16 +14,23 @@ When usage starts eating the reserve (the last 10% by default) it stops Claude C
|
|
|
14
14
|
agent's next tool call — the whole process, subagents and background tasks included — and asks
|
|
15
15
|
you in the terminal whether to carry on:
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
<img width="1080" height="525" alt="spare10" src="https://github.com/user-attachments/assets/7e0107b1-a389-44dd-b5cc-638b6837cf88" />
|
|
18
|
+
|
|
19
|
+
The header above the card is the paused session's own — version, model, effort level and
|
|
20
|
+
directory as that session reported them in the status line payload it was sending spare10 all
|
|
21
|
+
along, not the installed version or the default model. It stands outside the frame because it
|
|
22
|
+
describes the session, not the question.
|
|
23
|
+
|
|
24
|
+
`←`/`→` move between the options and Enter confirms; `y` and `n` still answer outright, and
|
|
25
|
+
Escape leaves the session alone. The selection starts on *Stop here*, so a stray Enter never
|
|
26
|
+
spends the reserve.
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
and spare10 stays quiet
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
Choose **Resume** and the session picks up right where it stopped, with its whole conversation,
|
|
29
|
+
and spare10 stays quiet until the limit resets. Choose **Stop here** and you are back at your
|
|
30
|
+
shell with the session saved; `claude --resume <id>` picks it up later, when the window has
|
|
31
|
+
reset. Terminals that cannot draw the panel — a pipe, `TERM=dumb`, `NO_COLOR`, a narrow
|
|
32
|
+
window — get the plain one-line question instead, and a run with nobody attached carries on
|
|
33
|
+
rather than waiting for an answer that cannot come.
|
|
27
34
|
|
|
28
35
|
For unattended runs, hand it an instruction instead of a question:
|
|
29
36
|
|
|
@@ -128,6 +135,23 @@ The cost is that a turn producing only text is not gated. Before launching, spar
|
|
|
128
135
|
knows your quota from the previous run, so `spare10 claude` asks for confirmation rather than
|
|
129
136
|
starting a session that would stop on its first move.
|
|
130
137
|
|
|
138
|
+
**Background sessions are stopped through the CLI.** `spare10 claude agents` guards the whole
|
|
139
|
+
fleet: the agent view passes spare10's settings to every session it dispatches, so each one
|
|
140
|
+
carries its own sensor and gate. But a background session does not run under the launcher — it
|
|
141
|
+
lives under Claude Code's daemon — so there is no process for the gate to signal. Each kind of
|
|
142
|
+
session offers exactly one handle, and they are different ones:
|
|
143
|
+
|
|
144
|
+
| | listed with | stopped with | resumed with |
|
|
145
|
+
|---|---|---|---|
|
|
146
|
+
| interactive | a `pid` | `SIGTERM` | `claude --resume <session>` |
|
|
147
|
+
| background | a short `id` | `claude stop <id>` | `claude --bg --resume <session>` |
|
|
148
|
+
|
|
149
|
+
Nothing is attached to a background session to ask a question on, so spare10 stops it, keeps the
|
|
150
|
+
record, and asks once the wrapper is back in front of you: one panel listing every session it
|
|
151
|
+
stopped, resuming them all into the reserve if you say so. Anything left stopped is listed by
|
|
152
|
+
`spare10 doctor` with the command to reopen it — which is also how `spare10 claude --bg …` ends,
|
|
153
|
+
since that returns before there is anything to ask about.
|
|
154
|
+
|
|
131
155
|
Three consequences worth knowing:
|
|
132
156
|
|
|
133
157
|
**Nothing you own is modified.** `claude --settings` accepts a raw JSON string, so a run
|
|
@@ -164,7 +188,9 @@ Not in this version, deliberately:
|
|
|
164
188
|
- **Overshoot correction.** The reserve is indicative, not predictive: readings land at 1%
|
|
165
189
|
granularity and up to one refresh interval late, and parallel subagents can land several
|
|
166
190
|
requests at once. Keep back more than you think you need.
|
|
167
|
-
- **Coordination across
|
|
191
|
+
- **Coordination across runs.** Sessions started under one `spare10` command share its state,
|
|
192
|
+
including every background session dispatched from it. Separate runs do not: quota is
|
|
193
|
+
account-wide, but each run tracks it on its own, seeded from the last one.
|
|
168
194
|
|
|
169
195
|
## Known limitations
|
|
170
196
|
|
|
@@ -175,16 +201,26 @@ Not in this version, deliberately:
|
|
|
175
201
|
denies the tool call instead, and the model may retry with another tool before it gives up.
|
|
176
202
|
- Status line chaining reads user-level settings only. A status line configured in project or
|
|
177
203
|
local settings is not detected.
|
|
204
|
+
- Stopping a background session goes through `claude stop`, called from inside the session being
|
|
205
|
+
stopped. If it does not land, the gate falls back to denying the tool call, as it does
|
|
206
|
+
everywhere else.
|
|
178
207
|
|
|
179
208
|
## Development
|
|
180
209
|
|
|
181
210
|
```bash
|
|
182
211
|
npm install
|
|
183
|
-
npm test #
|
|
212
|
+
npm test # 254 tests: unit, plus the hooks and CLI as real subprocesses
|
|
184
213
|
npm run typecheck
|
|
185
214
|
npm run build # single dependency-free bundle in dist/
|
|
215
|
+
npm run demo # the pause prompt at 91% usage, without burning a session
|
|
186
216
|
```
|
|
187
217
|
|
|
218
|
+
`npm run demo` draws the real panel from `src/`, so it cannot drift from what spare10 shows.
|
|
219
|
+
It is interactive by default; `-- --static` prints every state at once (both selections, the
|
|
220
|
+
no-colour fallback and the plain line prompt), `-- --preflight` shows the question asked
|
|
221
|
+
before launch rather than the one after a stop, and `-- --width 64` forces a narrower
|
|
222
|
+
terminal.
|
|
223
|
+
|
|
188
224
|
Test fixtures are real payloads captured from a live Claude Code session, sanitized. When
|
|
189
225
|
Anthropic changes the status line schema, the tolerant parser keeps spare10 failing open and
|
|
190
226
|
`doctor` reports the drift.
|
package/dist/spare10.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var
|
|
2
|
+
var kt={version:null,model:null,effort:null,cwd:null,fastMode:!1},tt={pct:null,resetsAt:null,updatedAt:null,missingStreak:0,blind:!1,disarmedUntil:null,pausePromptInjectedTo:[],halted:null,tick:0,session:null},Me="main",Oe="unknown";function At(t,e){return`${t??Oe}:${e??Me}`}var et=10,Le=2,y={reserve:et,pausePrompt:null,refresh:Le,badge:!0,chain:null},Rt=3,Pt=3,It=3600;function C(t){return 100-t.reserve}function _(t){return 100-t}var b=class extends Error{},nt=`spare10 \u2014 pause Claude Code before the 5-hour quota runs out
|
|
3
3
|
|
|
4
4
|
spare10 [options] <command> [args...]
|
|
5
5
|
|
|
@@ -16,25 +16,31 @@ Commands:
|
|
|
16
16
|
Examples:
|
|
17
17
|
spare10 claude
|
|
18
18
|
spare10 --reserve 15 claude
|
|
19
|
-
spare10 --pause-prompt "Finish this block, commit, then stop." claude --resume`;function
|
|
20
|
-
|
|
21
|
-
User instructions: ${e.pausePrompt??""}`}function
|
|
22
|
-
|
|
23
|
-
${
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
`)}
|
|
28
|
-
|
|
29
|
-
`)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
`);let
|
|
33
|
-
`),
|
|
34
|
-
|
|
35
|
-
`)
|
|
36
|
-
`)
|
|
37
|
-
|
|
38
|
-
${
|
|
39
|
-
`),
|
|
40
|
-
|
|
19
|
+
spare10 --pause-prompt "Finish this block, commit, then stop." claude --resume`;function Ue(t){if(!/^\d+$/.test(t))throw new b(`--reserve must be a whole number: the API reports quota in integer percentages, so "${t}" cannot be honoured.`);let e=Number(t);if(e<1||e>99)throw new b(`--reserve must be between 1 and 99, got ${e}.`);return e}function De(t){if(!/^\d+$/.test(t)||Number(t)<1)throw new b(`--refresh must be a whole number of seconds >= 1, got "${t}".`);return Number(t)}function Et(t){let e={reserve:y.reserve,pausePrompt:y.pausePrompt,refresh:y.refresh,badge:y.badge},n=0,r=o=>{let i=t[n+1];if(i===void 0)throw new b(`${o} requires a value.`);return n+=1,i};for(;n<t.length;n+=1){let o=t[n];if(!o.startsWith("-"))break;switch(o){case"--reserve":e.reserve=Ue(r(o));break;case"--threshold":throw new b('--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":e.pausePrompt=r(o);break;case"--refresh":e.refresh=De(r(o));break;case"--no-badge":e.badge=!1;break;case"-h":case"--help":throw new b("");default:throw new b(`Unknown option "${o}".`)}}let s=t.slice(n);if(s.length===0)throw new b("No command given. Try: spare10 claude");return{config:e,command:s}}import{spawnSync as Xe}from"node:child_process";import{readFileSync as Kt}from"node:fs";import{spawnSync as vt}from"node:child_process";var Ct=1e4,D=t=>typeof t=="string"&&t?t:null;function je(t){let e;try{e=JSON.parse(t)}catch{return[]}if(!Array.isArray(e))return[];let n=[];for(let r of e){if(typeof r!="object"||r===null)continue;let s=r,o=D(s.sessionId);o!==null&&n.push({sessionId:o,backgroundId:D(s.id),kind:D(s.kind),name:D(s.name),cwd:D(s.cwd)})}return n}function _t(t,e){for(let n of t)if(n.sessionId===e)return n.kind!=="background"||n.backgroundId===null?null:{...n,backgroundId:n.backgroundId};return null}function B(t,e){let n=new Set(e.map(r=>r.sessionId));return t.filter(r=>!n.has(r.sessionId))}function Tt(t,e){try{return vt("claude",t,{encoding:"utf8",timeout:Ct,stdio:"ignore",...e===void 0?{}:{cwd:e}}).status===0}catch{return!1}}function T(){try{let t=vt("claude",["agents","--json"],{encoding:"utf8",timeout:Ct});return t.status!==0||typeof t.stdout!="string"?[]:je(t.stdout)}catch{return[]}}function Nt(t){return Tt(["stop",t])}function Fe(t){return["--bg","--resume",t]}function Mt(t,e){return Tt(Fe(t),e??void 0)}var N={kind:"pass"};function rt(t,e,n){return t.resetsAt!==null?n<t.resetsAt:t.updatedAt===null?!1:n-t.updatedAt<=e.refresh*Pt}function H(t){return t===null?"an unknown time":new Date(t*1e3).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}function Ot(t,e){return`into your ${e.reserve}% reserve \xB7 ${_(t.pct??0)}% of quota left \xB7 resets ${H(t.resetsAt)}`}function Be(t,e){return`spare10 \u2014 ${Ot(t,e)}`}function He(t,e){return`spare10 budget guard. You have reached the safe usage limit for this session (${Ot(t,e)}). Immediately wrap up your work and stop. Immediately stop any subagent, unless the user instructs otherwise.
|
|
20
|
+
|
|
21
|
+
User instructions: ${e.pausePrompt??""}`}function Lt(t,e,n){return V({state:t,config:e,now:n,agent:null}).kind!=="pass"}function V({state:t,config:e,now:n,agent:r}){return t.pct===null||t.updatedAt===null||t.blind||!rt(t,e,n)||t.disarmedUntil!==null&&n<t.disarmedUntil||t.pct<C(e)?N:e.pausePrompt!==null?r!==null&&t.pausePromptInjectedTo.includes(r)?N:{kind:"inject",text:He(t,e)}:{kind:"halt",reason:`${Be(t,e)}. Stop now and wait for the user. Do not call any further tools.`}}var Ut={sessionId:null,toolName:null,agentId:null};function Dt(t){try{let e=JSON.parse(t);if(typeof e!="object"||e===null)return Ut;let n=e;return{sessionId:typeof n.session_id=="string"?n.session_id:null,toolName:typeof n.tool_name=="string"?n.tool_name:null,agentId:typeof n.agent_id=="string"?n.agent_id:null}}catch{return Ut}}import{readFileSync as Ve,readdirSync as Ge,writeFileSync as We,renameSync as qe,mkdirSync as Je,unlinkSync as jt}from"node:fs";import{join as I}from"node:path";var Ft=t=>I(t,"state.json"),st=t=>I(t,"config.json"),j=t=>I(t,"claude.pid"),G=t=>I(t,"stopped");function S(){return Math.floor(Date.now()/1e3)}function ot(t){try{let e=JSON.parse(Ve(t,"utf8"));return typeof e=="object"&&e!==null?e:null}catch{return null}}var A=(t,e)=>typeof t=="number"&&Number.isFinite(t)?t:e,Bt=(t,e)=>typeof t=="boolean"?t:e,Ke=t=>Array.isArray(t)?t.filter(e=>typeof e=="string"):[],R=t=>typeof t=="string"&&t?t:null;function Ye(t){if(typeof t!="object"||t===null)return null;let e=t;return{...kt,version:R(e.version),model:R(e.model),effort:R(e.effort),cwd:R(e.cwd),fastMode:e.fastMode===!0}}function $(t){let e=ot(Ft(t));return e?{pct:A(e.pct,null),resetsAt:A(e.resetsAt,null),updatedAt:A(e.updatedAt,null),missingStreak:A(e.missingStreak,0)??0,blind:Bt(e.blind,!1),disarmedUntil:A(e.disarmedUntil,null),pausePromptInjectedTo:Ke(e.pausePromptInjectedTo),halted:typeof e.halted=="string"&&e.halted?e.halted:null,tick:A(e.tick,0)??0,session:Ye(e.session)}:{...tt}}function Ht(t,e,n){let r=`${e}.${process.pid}.tmp`;try{Je(t,{recursive:!0}),We(r,JSON.stringify(n),"utf8"),qe(r,e)}catch{try{jt(r)}catch{}}}function k(t,e){Ht(t,Ft(t),e)}function Vt(t,e){let n=G(t);Ht(n,I(n,`${e.sessionId}.json`),e)}function W(t){let e;try{e=Ge(G(t))}catch{return[]}let n=[];for(let r of e){if(!r.endsWith(".json"))continue;let s=ot(I(G(t),r));if(!s)continue;let o=R(s.sessionId),i=R(s.backgroundId);o===null||i===null||n.push({sessionId:o,backgroundId:i,name:R(s.name),cwd:R(s.cwd),at:A(s.at,0)??0})}return n.sort((r,s)=>r.at-s.at)}function it(t,e){for(let n of e)try{jt(I(G(t),`${n}.json`))}catch{}}function M(t){let e=ot(st(t));if(!e)return{...y};let n=A(e.reserve,y.reserve)??y.reserve,r=A(e.refresh,y.refresh)??y.refresh;return{reserve:Math.min(99,Math.max(1,Math.round(n))),pausePrompt:typeof e.pausePrompt=="string"&&e.pausePrompt?e.pausePrompt:null,refresh:Math.max(1,Math.round(r)),badge:Bt(e.badge,y.badge),chain:typeof e.chain=="string"&&e.chain?e.chain:null}}function q(t,e){return t.resetsAt??e+It}function Gt(t,e){let r=t.filter(s=>s.pct!==null&&s.updatedAt!==null&&s.resetsAt!==null&&s.resetsAt>e).reduce((s,o)=>s===null||o.updatedAt>s.updatedAt?o:s,null);return r===null?null:{...tt,pct:r.pct,resetsAt:r.resetsAt,updatedAt:r.updatedAt}}function Wt(){try{return Kt(0,"utf8")}catch{return""}}function Yt(t){process.stdout.write(JSON.stringify({hookSpecificOutput:t}))}var qt=2e3;function Qe(t){try{let e=Number(Kt(j(t),"utf8").trim());return Number.isInteger(e)&&e>1?e:null}catch{return null}}function ze(t){let e=Xe("ps",["-o","ppid=","-p",String(t)],{encoding:"utf8"}),n=Number(e.stdout.trim());return e.status===0&&Number.isInteger(n)&&n>0?n:null}function Ze(t,e=process.ppid){let n=e;for(let r=0;n!==null&&n>1&&r<32;r+=1){if(n===t)return!0;n=ze(n)}return!1}function Jt(t){Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,t)}function tn(t,e,n,r){let s=n===null?null:Qe(t);if(s!==null&&Ze(s)){k(t,{...e,halted:n});try{process.kill(s,"SIGTERM"),Jt(qt)}catch{}}else if(n!==null){let o=_t(T(),n);o!==null&&(Vt(t,{sessionId:n,backgroundId:o.backgroundId,name:o.name,cwd:o.cwd,at:S()}),Nt(o.backgroundId),Jt(qt))}return Yt({hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:r}),0}function Xt(t){let e=M(t),n=$(t),r=S();if(V({state:n,config:e,now:r,agent:null}).kind==="pass")return Wt(),0;let{sessionId:s,agentId:o}=Dt(Wt()),i=At(s,o),u=V({state:n,config:e,now:r,agent:i});switch(u.kind){case"pass":return 0;case"inject":return k(t,{...n,pausePromptInjectedTo:[...n.pausePromptInjectedTo,i]}),Yt({hookEventName:"PreToolUse",additionalContext:u.text}),0;case"halt":return tn(t,n,s,u.reason)}}import{execFileSync as Ae}from"node:child_process";import{accessSync as er,constants as nr,readdirSync as rr,statSync as bt}from"node:fs";import{join as sr}from"node:path";function Qt(t,e,n){if(t.pct===null||t.updatedAt===null)return{status:"no-data",detail:"no quota reading yet \u2014 start a session and wait a moment"};if(t.blind)return{status:"blind",detail:"Claude Code is not reporting rate_limits on this plan"};if(!rt(t,e,n))return{status:"stale",detail:"the window this reading described has already reset"};if(t.disarmedUntil!==null&&n<t.disarmedUntil)return{status:"disarmed",detail:`consent given; quiet until ${at(t.disarmedUntil)}`};if(t.pct>=C(e)){let r=t.pausePromptInjectedTo.length,s=r>0?`; pause prompt delivered to ${r} agent${r===1?"":"s"}`:"";return{status:"tripped",detail:`into the ${e.reserve}% reserve${s}`}}return{status:"armed",detail:`${_(t.pct)}% left, of which ${e.reserve}% is reserved`}}function ut(t){let e=Math.abs(Math.round(t));if(e<60)return`${e}s`;let n=Math.floor(e/60);return n<60?`${n}m`:`${Math.floor(n/60)}h ${n%60}m`}function at(t){return new Date(t*1e3).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}import{spawn as Pn}from"node:child_process";import{copyFileSync as In,mkdirSync as En,readdirSync as fe,rmSync as vn,statSync as Cn,unlinkSync as _n,writeFileSync as me}from"node:fs";import{homedir as mt}from"node:os";import{basename as Tn,join as E}from"node:path";import{fileURLToPath as Nn}from"node:url";var lt=46,en=78,ne=/\u001b\[[0-9;]*m/g,F=t=>t.replace(ne,"").length,O=[217,119,87],zt=173;var nn=236,rn=245;function re(t){if(!t.color){let r=s=>s;return{brand:r,dim:r,bold:r,chosen:s=>`[${s}]`}}let e=t.truecolor?`\x1B[38;2;${O[0]};${O[1]};${O[2]}m`:`\x1B[38;5;${zt}m`,n=t.truecolor?`\x1B[48;2;${O[0]};${O[1]};${O[2]}m`:`\x1B[48;5;${zt}m`;return{brand:r=>`${e}${r}\x1B[39m`,dim:r=>`\x1B[38;5;${rn}m${r}\x1B[39m`,bold:r=>`\x1B[1m${r}\x1B[22m`,chosen:r=>`${n}\x1B[38;5;${nn}m\x1B[1m${r}\x1B[0m`}}var se=[" \u2590\u259B\u2588\u2588\u2588\u259B\u2588","\u259D\u259C\u2588\u2588\u2588\u2588\u2588\u2588\u2580"," \u259D\u259D \u259D\u259D"],oe=Math.max(...se.map(t=>t.length));function sn(t){if(!t.unicode||!t.color)return[];let e=re(t);return se.map(n=>e.brand(n.padEnd(oe)))}function ct(t,e){return e&&(t===e||t.startsWith(`${e}/`))?`~${t.slice(e.length)}`:t}function on(t,e,n){let r=n.bold("Claude Code"),s=t?.version?` ${n.dim(`v${t.version}`)}`:"",o=[`${r}${s}`],i=t?.model??null;if(i!==null){let u=t?.effort?` with ${t.effort} effort`:"",a=t?.fastMode?" \xB7 fast mode":"";o.push(n.dim(`${i}${u}${a}`))}return t?.cwd&&o.push(n.dim(ct(t.cwd,e))),o}var Zt={unicode:{filled:"\u2588",empty:"\u2592"},ascii:{filled:"#",empty:"-"}};function un(t,e,n,r){let s=r?Zt.unicode:Zt.ascii,o=Math.max(4,e),i=Math.min(o,Math.max(0,Math.round(t/100*o)));return`${n.brand(s.filled.repeat(i))}${n.dim(s.empty.repeat(o-i))}`}function ie(t){return t.pct===null?`quota unknown \xB7 resets ${H(t.resetsAt)}`:`${t.pct}% used \xB7 ${_(t.pct)}% left \xB7 resets ${H(t.resetsAt)}`}function an(t,e){return F(t)<=e?t:`${t.replace(ne,"").slice(0,Math.max(1,e-1))}\u2026`}function te(t,e){let n=[],r="";for(let s of t.split(/\s+/).filter(Boolean))r===""?r=s:r.length+1+s.length<=e?r+=` ${s}`:(n.push(r),r=s);return r!==""&&n.push(r),n}var ee={unicode:{tl:"\u256D",tr:"\u256E",bl:"\u2570",br:"\u256F",h:"\u2500",v:"\u2502"},ascii:{tl:"+",tr:"+",bl:"+",br:"+",h:"-",v:"|"}};function ln(t){return Math.max(lt,Math.min(en,t-2))}function cn(t,e,n){return t.map((r,s)=>s===e?n.chosen(` ${r.label} `):n.dim(` ${r.label} `)).join(" ")}function ue({view:t,state:e,config:n,selected:r,caps:s,home:o}){let i=re(s),u=s.unicode?ee.unicode:ee.ascii,a=ln(s.columns),l=a-6,f=sn(s),w=on(t.session,o,i),g=f.length>0?oe:0,d=g>0?2:0,m=[];for(let c=0;c<Math.max(f.length,w.length);c+=1){let U=(f[c]??"").padEnd(g);m.push(`${U}${" ".repeat(d)}${an(w[c]??"",a-g-d)}`.trimEnd())}let P=[],h=(c="")=>P.push(c);h(i.bold(t.headline));let L=ie(e);if(e.pct!==null){let c=Math.max(6,l-F(L)-2);h(`${un(e.pct,c,i,s.unicode)} ${i.dim(L)}`)}h();for(let c of t.body)for(let U of te(c,l))h(i.dim(U));h(),h(cn(t.choices,r,i));let v=t.choices.map(c=>te(c.hint,l)),Q=Math.max(...v.map(c=>c.length)),x=v[r];for(let c=0;c<Q;c+=1)h(i.dim(x[c]??""));h(),h(i.dim(s.unicode?"\u2190/\u2192 choose \xB7 enter confirm \xB7 esc cancel":"left/right choose, enter confirm, esc cancel"));let z=` ${i.brand(i.bold("spare10"))} `,xt=`${n.reserve}% reserve`,Te=Math.max(1,a-6-F(z)-F(xt)),Ne=`${u.tl}${u.h}${z}${u.h.repeat(Te)} ${i.dim(xt)} ${u.h}${u.tr}`,Z=[...m,"",Ne];for(let c of P){let U=Math.max(0,a-4-F(c));Z.push(`${u.v} ${c}${" ".repeat(U)}${u.v}`)}return Z.push(`${u.bl}${u.h.repeat(a-2)}${u.br}`),Z}function ae(t,e){return`
|
|
22
|
+
spare10 \u2014 ${ie(e)}
|
|
23
|
+
${t.headline}
|
|
24
|
+
${t.choices[0].label}? [y/N] `}import{readFileSync as dn}from"node:fs";function dt(t){return`'${t.replace(/'/g,"'\\''")}'`}function J(t){let e;try{e=JSON.parse(dn(t,"utf8"))}catch{return{command:null,padding:void 0}}if(typeof e!="object"||e===null)return{command:null,padding:void 0};let n=e.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 le(t){let e=s=>`${dt(t.nodePath)} ${dt(t.selfPath)} ${s} --run ${dt(t.runDir)}`,n=s=>[{matcher:"",hooks:[{type:"command",command:e(s)}]}],r={type:"command",command:e("sensor"),refreshInterval:t.refresh};return t.padding!==void 0&&(r.padding=t.padding),{statusLine:r,hooks:{PreToolUse:n("gate")}}}import{closeSync as ce,openSync as pt,readSync as pn,writeSync as fn}from"node:fs";import{homedir as mn}from"node:os";import{ReadStream as gn,WriteStream as hn,isatty as bn}from"node:tty";function Sn(t){switch(t){case"\x1B[D":case"\x1B[A":case"\x1BOD":case"\x1BOA":return"prev";case"\x1B[C":case"\x1B[B":case"\x1BOC":case"\x1BOB":case" ":return"next";case"\r":case`
|
|
25
|
+
`:return"confirm";case"\x1B":case"":case"":return"cancel"}return/^[yY]$/.test(t)?"yes":/^[nNqQ]$/.test(t)?"no":t==="h"||t==="k"?"prev":t==="l"||t==="j"?"next":null}function wn(t){let e=[];for(let n=0;n<t.length;){let r=t[n+1];if(t[n]==="\x1B"&&(r==="["||r==="O")){let s=n+2;for(;s<t.length&&!/[@-~]/.test(t[s]);)s+=1;e.push(t.slice(n,s+1)),n=s+1;continue}e.push(t[n]),n+=1}return e}function yn(t,e){let n=t.TERM==="dumb"||t.TERM===void 0,r=!t.NO_COLOR&&!n,s=t.LC_ALL??t.LC_CTYPE??t.LANG??"";return{color:r,truecolor:r&&/truecolor|24bit/i.test(t.COLORTERM??""),unicode:!n&&(s===""||/utf-?8/i.test(s)),columns:e}}function $n(){let t;try{t=pt("/dev/tty","r")}catch{return null}let e=null,n=2;if(!process.stderr.isTTY)try{n=pt("/dev/tty","w"),e=new hn(n)}catch{n=2}return{fd:t,write:r=>{try{fn(n,r)}catch{}},columns:(e??process.stderr).columns||80,close:()=>{try{ce(t)}catch{}try{e?.destroy()}catch{}}}}var xn="\x1B[?25l",kn="\x1B[?25h",An=t=>`\r${"\x1B[K\x1B[A".repeat(t-1)}\x1B[K`;async function K({view:t,state:e,config:n,initial:r=1}){let s=$n();if(s===null)return null;let o=yn(process.env,s.columns),i=mn(),u=()=>{let m=Rn(s,ae(t,e));return s.close(),m};if(!bn(s.fd)||o.columns<lt+2)return u();let a;try{a=new gn(s.fd),a.setRawMode(!0)}catch{return u()}let l=r,f=0,w=()=>{let m=ue({view:t,state:e,config:n,selected:l,caps:o,home:i});s.write(`${f>0?An(f):""}${m.join(`
|
|
26
|
+
`)}
|
|
27
|
+
`),f=m.length+1},g=()=>{try{a.setRawMode(!1)}catch{}s.write(kn)};process.once("exit",g),s.write(xn),w();let d=await new Promise(m=>{let P=v=>{a.off("data",L),a.off("end",h),a.off("error",h),m(v)},h=()=>P(null),L=v=>{for(let Q of wn(v.toString("utf8"))){let x=Sn(Q);if(x==="prev"||x==="next"){l=((x==="next"?l+1:l-1)+t.choices.length)%t.choices.length,w();continue}if(x==="yes"||x==="no"){l=x==="yes"?0:1,w(),P(x==="yes"?"y":"n");return}if(x==="confirm"){P(l===0?"y":"n");return}if(x==="cancel"){l=1,w(),P("n");return}}};a.on("data",L),a.once("end",h),a.once("error",h),a.resume()});g(),process.off("exit",g);try{a.destroy()}catch{}return d}function Rn(t,e){let n;try{n=pt("/dev/tty","r")}catch{return null}t.write(e);let r=Buffer.alloc(1),s="";try{for(;pn(n,r,0,1,null)>0;){let o=r.toString("utf8");if(o===`
|
|
28
|
+
`||o==="\r")break;s+=o}}catch{return null}finally{ce(n)}return s.trim()}var Mn=7*24*60*60*1e3,Y=()=>E(process.env.SPARE10_HOME??E(mt(),".spare10"),"runs"),gt=()=>E(mt(),".claude","settings.json");function On(t,e){let n;try{n=fe(t)}catch{return}for(let r of n){let s=E(t,r);try{e-Cn(s).mtimeMs>Mn&&vn(s,{recursive:!0,force:!0})}catch{}}}function Ln(t,e){let n;try{n=fe(t)}catch{return}let r=n.map(o=>E(t,o)).filter(o=>o!==e).map($),s=Gt(r,S());s!==null&&k(e,s)}function Un(t){let e=`${Date.now().toString(36)}-${process.pid.toString(36)}`,n=E(t,e);return En(n,{recursive:!0}),n}var ge=t=>/^y(es)?$/i.test(t.trim());function Dn(t,e){return t?e===null?"proceeding":ge(e)?"consented":"declined":"clear"}function he(){return{version:null,model:null,effort:null,cwd:process.cwd(),fastMode:!1}}function jn(t){return{session:he(),headline:"This session would start inside the reserve.",body:[`You have less than the ${t.reserve}% reserve left for this session. If you decide to start anyway, spare10 will stay quiet until the limit resets.`],choices:[{label:"Start anyway",hint:"Runs now on your reserve."},{label:"Do not start",hint:"Back to the shell."}]}}function Fn(t){return{session:t.session,headline:"Reserve reached. The session is paused.",body:["spare10 stopped the agent between tool calls, so nothing is half-written and the whole conversation is saved."],choices:[{label:"Resume",hint:"Continue on the reserve. spare10 stays quiet until the limit resets."},{label:"Stop here",hint:`Back to the shell. You can still resume later with: claude --resume ${t.halted??""}`}]}}var Bn=5,ft=(t,e,n)=>t===1?e:`${t} ${n}`;function Hn(t,e=mt()){let n=t.slice(0,Bn),r=[`spare10 stopped ${ft(t.length,"a background session","background sessions")} at the reserve, between tool calls. Their conversations are kept.`,...n.map(s=>`${s.name??s.backgroundId} \xB7 ${ct(s.cwd??"",e)}`.trim())];return t.length>n.length&&r.push(`\u2026and ${t.length-n.length} more.`),{session:he(),headline:`Reserve reached. ${ft(t.length,"A background session is","background sessions are")} paused.`,body:r,choices:[{label:t.length===1?"Resume":"Resume all",hint:"Back to the background, on your reserve. spare10 stays quiet until the limit resets."},{label:"Leave stopped",hint:"Back to the shell. Each one can still be opened with: claude attach <id>"}]}}async function Vn(t,e){let n=W(t);if(n.length===0)return;let r=B(n,T());if(it(t,n.filter(i=>!r.includes(i)).map(i=>i.sessionId)),r.length===0)return;let s=$(t);if(xe(await K({view:Hn(r),state:s,config:e}))!=="resume"){process.stderr.write(`Left stopped. To pick ${r.length===1?"it":"them"} up later:
|
|
29
|
+
`);for(let i of r)process.stderr.write(` claude attach ${i.backgroundId}
|
|
30
|
+
`);return}k(t,{...s,disarmedUntil:q(s,S())});let o=[];for(let i of r)Mt(i.sessionId,i.cwd)?o.push(i.sessionId):process.stderr.write(`spare10: could not resume ${i.backgroundId}. Try: claude attach ${i.backgroundId}
|
|
31
|
+
`);it(t,o),o.length>0&&process.stderr.write(`Resumed ${ft(o.length,"one session","sessions")} into the reserve for this window.
|
|
32
|
+
`)}async function Gn(t,e){let n=$(t);if(!Lt(n,e,S()))return"clear";let r=await K({view:jn(e),state:n,config:e});return Dn(!0,r)}function ht(){return Nn(import.meta.url)}function Wn(t,e=ht()){let n=E(t,"spare10.js");try{return In(e,n),n}catch{return e}}function qn(t,e=process.env){return!(t.includes("--no-session-persistence")||e.CLAUDE_CODE_CHILD_SESSION&&!e.CLAUDE_CODE_FORCE_SESSION_PERSISTENCE)}var be=new Set(["--agent","--agents","--append-system-prompt","--append-system-prompt-file","--autocompact","--debug-file","--effort","--environment","--fallback-model","--input-format","--json-schema","--max-budget-usd","--model","-n","--name","--output-format","--permission-mode","--permission-prompts","--plugin-dir","--plugin-url","--remote-control-session-name-prefix","--setting-sources","--settings","--system-prompt","--system-prompt-file","--system-prompt-snapshot"]),Se=new Set(["--add-dir","--allowedTools","--allowed-tools","--betas","--disallowedTools","--disallowed-tools","--file","--mcp-config","--tools"]),we=new Set(["-d","--debug","--prompt-suggestions","--remote-control"]),ye=new Set(["-r","--resume","-w","--worktree","--from-pr","--teleport","--cloud"]),$e=new Set(["--session-id"]),Jn=new Set(["-c","--continue","--tmux","--bg","--background"]),Kn=new Set(["agents","attach","auth","auto-mode","doctor","gateway","import","install","logs","mcp","plugin","plugins","project","respawn","rm","setup-token","stop","kill","ultrareview","update","upgrade"]);function Yn(t){for(let e=0;e<t.length;e+=1){let n=t[e];if(n==="--")return null;if(!n.startsWith("-"))return Kn.has(n)?n:null;if(n.includes("="))continue;let r=s=>t[s]!==void 0&&!t[s].startsWith("-");if(be.has(n)||$e.has(n))e+=1;else if(we.has(n)||ye.has(n))r(e+1)&&(e+=1);else if(Se.has(n))for(;r(e+1);)e+=1}return null}function de(t){return t.includes("--bg")||t.includes("--background")}var Xn="spare10 budget guard: the user stopped this session as it entered the reserve and has now chosen to resume it. Continue from where you left off.";function Qn(t,e){let n=[],r=o=>t[o],s=o=>r(o)!==void 0&&!r(o).startsWith("-");for(let o=0;o<t.length;o+=1){let i=r(o);if(i==="--")break;let u=i.startsWith("-")&&i.includes("="),a=u?i.split("=")[0]:i;if(!Jn.has(a)){if($e.has(a)){u||(o+=1);continue}if(ye.has(a)){!u&&s(o+1)&&(o+=1);continue}if(be.has(a)){n.push(i),!u&&r(o+1)!==void 0&&n.push(r(++o));continue}if(we.has(a)){n.push(i),!u&&s(o+1)&&n.push(r(++o));continue}if(Se.has(a)){for(n.push(i);!u&&s(o+1);)n.push(r(++o));continue}if(i.startsWith("-")){n.push(i);continue}}}return["--resume",e,...n,Xn]}function zn(t,e,n,r){return new Promise(s=>{let o=Pn(t,e,{stdio:"inherit"});r&&o.pid!==void 0&&me(j(n),String(o.pid));let i=u=>{try{_n(j(n))}catch{}s(u)};o.once("error",u=>i({status:null,signal:null,error:u})),o.once("exit",(u,a)=>i({status:u,signal:a,error:null}))})}var Zn=t=>t.signal==="SIGTERM"||t.status===143;function pe(t){return t.signal?128+(t.signal==="SIGINT"?2:15):t.status??0}function xe(t){return t===null?"unattended":ge(t)?"resume":"declined"}async function ke({config:t,command:e}){let[n,...r]=e;if(Tn(n)!=="claude")throw new b(`spare10 wraps the "claude" command, but got "${n}". Run it as: ${["spare10",...r,"claude"].join(" ")}`);let s=Y();On(s,Date.now());let o=Un(s);Ln(s,o);let i=await Gn(o,{...t,chain:null});if(i==="declined")return process.stderr.write(`Not started.
|
|
33
|
+
`),0;if(i==="consented"){let m=$(o);k(o,{...m,disarmedUntil:q(m,S())}),process.stderr.write(`Continuing into the reserve for this window.
|
|
34
|
+
|
|
35
|
+
`)}let u=J(gt()),a={...t,chain:u.command};me(st(o),JSON.stringify(a,null,2));let l=JSON.stringify(le({nodePath:process.execPath,selfPath:Wn(o),runDir:o,refresh:t.refresh,padding:u.padding})),f=Yn(r)===null&&!de(r),w=f&&qn(r);de(r)?process.stderr.write(`spare10: --bg returns as soon as the session is dispatched, so spare10 will not be here to ask about resuming it. It still stops the session at the reserve; "spare10 doctor" lists what it stopped.
|
|
36
|
+
`):f&&!w&&process.stderr.write(`spare10: this session will not be saved, so it cannot be stopped and resumed; on trip the gate will deny tool calls instead.
|
|
37
|
+
`);let g=()=>{};process.on("SIGINT",g),process.on("SIGTERM",g);let d=await tr({executable:n,rest:r,settings:l,runDir:o,runConfig:a,canResume:w});return await Vn(o,a),d}async function tr(t){let{executable:e,rest:n,settings:r,runDir:s,runConfig:o,canResume:i}=t,u=n;for(;;){let a=await zn(e,["--settings",r,...u],s,i);if(a.error)return process.stderr.write(`spare10: could not start ${e}: ${a.error.message}
|
|
38
|
+
`),127;let l=$(s);if(!Zn(a)||l.halted===null)return pe(a);let f=xe(await K({view:Fn(l),state:l,config:o}));if(f!=="resume")return process.stderr.write(`Not resumed. To pick it up later: claude --resume ${l.halted}
|
|
39
|
+
`),f==="declined"?0:pe(a);k(s,{...l,halted:null,disarmedUntil:q(l,S())}),process.stderr.write(`Resuming into the reserve for this window.
|
|
40
|
+
|
|
41
|
+
`),u=Qn(n,l.halted)}}var or="0.5.0",p={ok:"\u2713",warn:"\u26A0",info:"\xB7"};function Re(t){try{return rr(t).map(e=>sr(t,e)).filter(e=>{try{return bt(e).isDirectory()}catch{return!1}}).sort((e,n)=>bt(n).mtimeMs-bt(e).mtimeMs)}catch{return[]}}var ir=t=>Re(t)[0]??null;function ur(t){try{return Ae("command",["-v",t],{shell:!0,encoding:"utf8"}).trim()||null}catch{return null}}function ar(){try{return Ae("claude",["--version"],{encoding:"utf8",timeout:1e4}).trim()}catch{return null}}function lr(t){try{return er(t,nr.X_OK),!0}catch{return!1}}function Pe(){let t=(d="")=>process.stdout.write(`${d}
|
|
42
|
+
`),e=S(),n=0;t(`spare10 ${or}`),t(),t("Environment"),t(` ${p.info} node ${process.execPath} (${process.version})`),t(` ${p.info} spare10 ${ht()}`);let r=ur("claude"),s=r?ar():null;r&&s?t(` ${p.ok} claude ${r} (${s})`):(n+=1,t(` ${p.warn} claude not found on PATH \u2014 spare10 has nothing to wrap`)),t(),t("Status line");let o=J(gt());if(o.command===null)t(` ${p.info} nothing to chain \u2014 spare10 will own the status line`);else{let d=o.command.split(/\s+/)[0]??"",m=lr(d);m||(n+=1),t(` ${m?p.ok:p.warn} chains into ${o.command}`),m||t(" that path is not executable; its output will be dropped")}t();let i=ir(Y());if(i===null)return t("No runs yet. Start one with: spare10 claude"),n>0?1:0;let u=M(i),a=$(i),l=Qt(a,u,e);if(t(`Latest run ${i}`),t(` ${p.info} reserve ${u.reserve}% of the 5-hour window`),t(` ${p.info} on trip ${u.pausePrompt===null?"stop the session and ask in the terminal":`inject: ${JSON.stringify(u.pausePrompt)}`}`),t(` ${p.info} refresh ${u.refresh}s`),a.pct!==null&&a.updatedAt!==null){t(` ${p.ok} quota ${a.pct}% used, ${100-a.pct}% left`);let d=e-a.updatedAt;t(` ${p.info} last reading ${ut(d)} ago`+(d>u.refresh*3?" (no session running; still valid for this window)":""))}a.resetsAt!==null&&t(` ${p.info} resets ${at(a.resetsAt)} (in ${ut(a.resetsAt-e)})`);let f=l.status==="blind";f&&(n+=1);let w=f?p.warn:l.status==="no-data"?p.info:p.ok;t(` ${w} state ${l.status.toUpperCase()} \u2014 ${l.detail}`);let g=B(Re(Y()).flatMap(W),T());if(g.length>0){t(),t(`Stopped in the background, waiting to be resumed (${g.length})`);for(let d of g)t(` ${p.info} ${d.name??d.backgroundId}`),t(` claude attach ${d.backgroundId}`)}return n>0?1:0}import{spawnSync as dr}from"node:child_process";import{readFileSync as pr}from"node:fs";function Ie(t){return typeof t=="number"&&Number.isFinite(t)?t:null}var wt=t=>typeof t=="string"&&t?t:null,St=(t,e,n)=>{let r=t[e];return typeof r=="object"&&r!==null?wt(r[n]):null};function cr(t){let e={version:wt(t.version),model:St(t,"model","display_name"),effort:St(t,"effort","level"),cwd:St(t,"workspace","current_dir")??wt(t.cwd),fastMode:t.fast_mode===!0};return e.version!==null||e.model!==null||e.cwd!==null||e.fastMode?e:null}function Ee(t){let e={sessionId:null,fiveHour:null,session:null},n;try{n=JSON.parse(t)}catch{return e}if(typeof n!="object"||n===null)return e;let r=n,s=typeof r.session_id=="string"?r.session_id:null,o=cr(r),i=r.rate_limits;if(typeof i!="object"||i===null)return{sessionId:s,fiveHour:null,session:o};let u=i.five_hour;if(typeof u!="object"||u===null)return{sessionId:s,fiveHour:null,session:o};let a=u,l=Ie(a.used_percentage);return l===null?{sessionId:s,fiveHour:null,session:o}:{sessionId:s,session:o,fiveHour:{usedPercentage:Math.min(100,Math.max(0,l)),resetsAt:Ie(a.resets_at)}}}function fr(t,e,n){let r=e.fiveHour,s=t.tick+1,o=e.session??t.session;if(!r){let u=t.missingStreak+1;return{...t,tick:s,session:o,missingStreak:u,blind:u>=Rt}}let i=r.resetsAt!==null&&t.resetsAt!==null&&r.resetsAt!==t.resetsAt;return{tick:s,session:o,pct:r.usedPercentage,resetsAt:r.resetsAt,updatedAt:n,missingStreak:0,blind:!1,disarmedUntil:i?null:t.disarmedUntil,pausePromptInjectedTo:i?[]:t.pausePromptInjectedTo,halted:t.halted}}var mr="\x1B[38;5;208m",gr="\x1B[38;5;40m",hr="\x1B[38;5;245m",$t="\x1B[39m",yt=t=>`${mr}${t}${$t}`,br=t=>`${gr}${t}${$t}`,Sr=t=>`${hr}${t}${$t}`,X=t=>t.reserve===et?"spare10":`spare10 (${t.reserve}%)`;function wr(t,e,n){if(!e.badge)return"";if(t.blind)return"\u26A0 spare10 quota unavailable";if(t.pct===null)return Sr(`\u29D7 ${X(e)}`);if(t.pct<C(e))return br(`\u25CF ${X(e)}`);if(t.disarmedUntil!==null&&n<t.disarmedUntil)return yt(`\u2A2F ${X(e)}`);if(t.pausePromptInjectedTo.length>0)return yt(`\u23F8 ${X(e)}`);let s=t.tick%2===0?"\u26A0":" ";return yt(`${s} Pausing at next tool call`)}function yr(t,e){if(!t||t.includes("spare10 sensor"))return"";try{return(dr(t,{shell:!0,input:e,encoding:"utf8",timeout:5e3,maxBuffer:1e6}).stdout??"").replace(/\n+$/,"")}catch{return""}}function $r(){try{return pr(0,"utf8")}catch{return""}}function ve(t){let e=$r(),n=M(t),r=fr($(t),Ee(e),S());k(t,r);let s=[wr(r,n,S()),yr(n.chain,e)].filter(Boolean);return s.length>0&&process.stdout.write(s.join(" ")),0}function Ce(t,e){let n=t.indexOf(e);return n===-1?null:t[n+1]??null}function xr(t){let[e,...n]=t;switch(e){case"sensor":{let r=Ce(n,"--run");return r?ve(r):0}case"gate":{let r=Ce(n,"--run");return r?Xt(r):0}case"doctor":return Pe();default:return ke(Et(t))}}function _e(t){throw t instanceof b&&(t.message&&(process.stderr.write(`spare10: ${t.message}
|
|
43
|
+
|
|
44
|
+
${nt}
|
|
45
|
+
`),process.exit(2)),process.stdout.write(`${nt}
|
|
46
|
+
`),process.exit(0)),t}try{Promise.resolve(xr(process.argv.slice(2))).then(t=>process.exit(t),_e)}catch(t){_e(t)}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spare10",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Circuit breaker for Claude Code: pause autonomous sessions before the 5-hour quota runs out",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
25
|
"build": "node scripts/build.mjs",
|
|
26
|
+
"demo": "node scripts/demo.mjs",
|
|
26
27
|
"test": "vitest run",
|
|
27
28
|
"test:watch": "vitest",
|
|
28
29
|
"typecheck": "tsc --noEmit",
|