spare10 0.2.0 → 0.3.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 +49 -45
- package/dist/spare10.js +24 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,38 +1,29 @@
|
|
|
1
1
|
# spare10
|
|
2
2
|
|
|
3
|
-
**A circuit breaker for Claude Code.**
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
**A circuit breaker for Claude Code.** Use spare10 when you run low-priority agents on Claude Code and you want to keep a reserve of quota for important stuff. Spare10 watches your session limits and stops the agent when you reach 90%. Then it's up to you to decide whether to continue or not.
|
|
4
|
+
|
|
5
|
+
To launch a session with spare10, just prefix the `claude` command with `spare10`:
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
spare10 claude
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
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
|
|
11
|
+
## How it works
|
|
25
12
|
|
|
26
|
-
When usage starts eating the reserve (the last 10% by default) it
|
|
13
|
+
When usage starts eating the reserve (the last 10% by default) it stops Claude Code at the
|
|
14
|
+
agent's next tool call — the whole process, subagents and background tasks included — and asks
|
|
15
|
+
you in the terminal whether to carry on:
|
|
27
16
|
|
|
28
17
|
```
|
|
29
|
-
spare10 — 9% of
|
|
30
|
-
|
|
31
|
-
|
|
18
|
+
spare10 — into your 10% reserve · 9% of quota left · resets 14:00
|
|
19
|
+
spare10 stopped the agent between operations — nothing is left half-written.
|
|
20
|
+
Resume anyway? [y/N]
|
|
32
21
|
```
|
|
33
22
|
|
|
34
|
-
|
|
35
|
-
|
|
23
|
+
Answer `y` and the session is resumed right where it stopped, with its whole conversation,
|
|
24
|
+
and spare10 stays quiet for the rest of the window. Answer anything else and you are back at
|
|
25
|
+
your shell with the session saved; `claude --resume <id>` picks it up later, when the window
|
|
26
|
+
has reset.
|
|
36
27
|
|
|
37
28
|
For unattended runs, hand it an instruction instead of a question:
|
|
38
29
|
|
|
@@ -41,8 +32,10 @@ spare10 --pause-prompt "Finish this block, commit, then stop." claude
|
|
|
41
32
|
```
|
|
42
33
|
|
|
43
34
|
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
|
-
|
|
35
|
+
actually carry out the wind-down you asked for. Every agent gets it — the main thread and each
|
|
36
|
+
subagent, on its own next tool call — because hook context only reaches the agent whose call
|
|
37
|
+
it rode in on, and a subagent left untold would keep working. It arrives with the situation
|
|
38
|
+
attached, since an instruction turning up mid-turn otherwise has no context:
|
|
46
39
|
|
|
47
40
|
```
|
|
48
41
|
spare10 budget guard. You have reached the safe usage limit for this session
|
|
@@ -73,7 +66,7 @@ than installing something that cannot run.
|
|
|
73
66
|
spare10 [options] <command> [args...]
|
|
74
67
|
|
|
75
68
|
--reserve <1-99> Keep this much of the 5-hour window back for yourself (default: 10)
|
|
76
|
-
--pause-prompt <text> Inject this instruction instead of
|
|
69
|
+
--pause-prompt <text> Inject this instruction instead of stopping
|
|
77
70
|
--refresh <seconds> Quota poll interval (default: 2)
|
|
78
71
|
--no-badge Never draw the spare10 marker in the status line
|
|
79
72
|
doctor Report what spare10 detected and what it would do
|
|
@@ -89,16 +82,19 @@ quota in integer percentages, so `--reserve 10.5` is rejected rather than silent
|
|
|
89
82
|
The status line shows a gray `⧗ spare10` until the first quota reading arrives, a green
|
|
90
83
|
`● spare10` while the reserve is untouched, then an orange
|
|
91
84
|
`⚠ Pausing at next tool call` once it is reached, its icon pulsing once per refresh. Once you
|
|
92
|
-
have consented it drops back to a quiet orange `⨯ spare10
|
|
93
|
-
out
|
|
85
|
+
have consented it drops back to a quiet orange `⨯ spare10`; once a `--pause-prompt` has gone
|
|
86
|
+
out it shows `⏸ spare10`. A non-default reserve is spelled out either way, as
|
|
87
|
+
`● spare10 (20%)` — the name already accounts for 10.
|
|
94
88
|
|
|
95
89
|
The pulse is driven by spare10's own render cadence rather than the ANSI blink attribute,
|
|
96
90
|
which most terminals ignore.
|
|
97
91
|
|
|
98
92
|
Consenting at the pre-flight prompt counts for the whole window: the session starts disarmed
|
|
99
|
-
rather than
|
|
93
|
+
rather than stopping again on the first tool call.
|
|
100
94
|
|
|
101
|
-
|
|
95
|
+
State lives under `~/.spare10`; set `SPARE10_HOME` to put it somewhere else.
|
|
96
|
+
|
|
97
|
+
## Under the hood
|
|
102
98
|
|
|
103
99
|
Claude Code exposes quota in exactly one place: the `rate_limits` object handed to your
|
|
104
100
|
**status line** command. It is not available to hooks. Hooks, meanwhile, are the only thing
|
|
@@ -106,24 +102,31 @@ that can *stop* anything. So spare10 splits in two and joins them through a stat
|
|
|
106
102
|
|
|
107
103
|
```
|
|
108
104
|
spare10 claude
|
|
109
|
-
└─
|
|
105
|
+
└─ spawn claude --settings '{ statusLine: …, hooks: { PreToolUse: … } }'
|
|
110
106
|
|
|
111
|
-
sensor
|
|
112
|
-
gate
|
|
113
|
-
|
|
107
|
+
sensor (status line, every 2s) reads rate_limits → writes state
|
|
108
|
+
gate (PreToolUse, every call) reads state → passes, injects, or stops Claude Code
|
|
109
|
+
launcher (after Claude Code exits) sees the stop → asks → claude --resume <session>
|
|
114
110
|
```
|
|
115
111
|
|
|
116
112
|
**It stops between operations, not mid-write.** The gate fires on tool calls, and that is the
|
|
117
113
|
point rather than a limitation: it interrupts in the gap between one call and the next, where
|
|
118
|
-
nothing is half-written and no command is in flight.
|
|
119
|
-
|
|
114
|
+
nothing is half-written and no command is in flight. Claude Code runs write-capable tools one
|
|
115
|
+
at a time, so when the gate fires nothing else that writes can be running either. A
|
|
116
|
+
keystroke-level interrupt would land wherever the agent happened to be — which is the mess
|
|
117
|
+
spare10 exists to avoid.
|
|
118
|
+
|
|
119
|
+
**It stops the process, not the turn.** A hook on its own can only deny a tool call or ask
|
|
120
|
+
through Claude Code's permission dialog — which non-interactive modes auto-approve, and which
|
|
121
|
+
the model can keep retrying around. So the gate sends Claude Code `SIGTERM` instead. Claude
|
|
122
|
+
Code exits cleanly, the transcript is already on disk, and spare10 asks its own question on
|
|
123
|
+
the terminal it now owns; `y` starts `claude --resume` on the same session. The question is the
|
|
124
|
+
same one in every permission mode, and subagents go down with the process rather than each
|
|
125
|
+
raising a dialog of their own.
|
|
120
126
|
|
|
121
127
|
The cost is that a turn producing only text is not gated. Before launching, spare10 already
|
|
122
128
|
knows your quota from the previous run, so `spare10 claude` asks for confirmation rather than
|
|
123
|
-
starting a session that would stop on its first move.
|
|
124
|
-
line badge is the signal — a hook's only user-visible channel is exit code 2, which on
|
|
125
|
-
`UserPromptSubmit` erases what you typed, and plain output there becomes model context that
|
|
126
|
-
Claude paraphrases rather than a message you can rely on.
|
|
129
|
+
starting a session that would stop on its first move.
|
|
127
130
|
|
|
128
131
|
Three consequences worth knowing:
|
|
129
132
|
|
|
@@ -165,10 +168,11 @@ Not in this version, deliberately:
|
|
|
165
168
|
|
|
166
169
|
## Known limitations
|
|
167
170
|
|
|
168
|
-
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
- Stopping the process drops what only lived in it: permissions granted for the session,
|
|
172
|
+
anything queued or half-typed in the prompt. The resumed session asks again.
|
|
173
|
+
- A session that cannot be resumed — started with `--no-session-persistence`, or inside
|
|
174
|
+
another Claude Code session, where transcripts are not saved — is not stopped. The gate
|
|
175
|
+
denies the tool call instead, and the model may retry with another tool before it gives up.
|
|
172
176
|
- Status line chaining reads user-level settings only. A status line configured in project or
|
|
173
177
|
local settings is not detected.
|
|
174
178
|
|
|
@@ -176,7 +180,7 @@ Not in this version, deliberately:
|
|
|
176
180
|
|
|
177
181
|
```bash
|
|
178
182
|
npm install
|
|
179
|
-
npm test #
|
|
183
|
+
npm test # 151 tests: unit, plus the hooks and CLI as real subprocesses
|
|
180
184
|
npm run typecheck
|
|
181
185
|
npm run build # single dependency-free bundle in dist/
|
|
182
186
|
```
|
package/dist/spare10.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var
|
|
2
|
+
var T={pct:null,resetsAt:null,updatedAt:null,missingStreak:0,blind:!1,disarmedUntil:null,pausePromptInjectedTo:[],halted:null,tick:0},_e="main";function Y(e){return e??_e}var N=10,ve=2,p={reserve:N,pausePrompt:null,refresh:ve,badge:!0,chain:null},Q=3,X=3,z=3600;function w(e){return 100-e.reserve}function E(e){return 100-e}var c=class extends Error{},U=`spare10 \u2014 pause Claude Code before the 5-hour quota runs out
|
|
3
3
|
|
|
4
4
|
spare10 [options] <command> [args...]
|
|
5
5
|
|
|
6
6
|
Options:
|
|
7
7
|
--reserve <1-99> Keep this much of the 5-hour window back for yourself (default: 10)
|
|
8
|
-
--pause-prompt <text> Instead of
|
|
8
|
+
--pause-prompt <text> Instead of stopping, inject this instruction into the running agent
|
|
9
9
|
--refresh <seconds> Status line poll interval, also the staleness unit (default: 5)
|
|
10
10
|
--no-badge Never draw the spare10 marker in the status line
|
|
11
11
|
-h, --help Show this message
|
|
@@ -16,19 +16,25 @@ 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: ${t.pausePrompt??""}`}var
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
`)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
`)
|
|
29
|
-
`),127
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
${
|
|
33
|
-
`),
|
|
34
|
-
|
|
19
|
+
spare10 --pause-prompt "Finish this block, commit, then stop." claude --resume`;function Ie(e){if(!/^\d+$/.test(e))throw new c(`--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 c(`--reserve must be between 1 and 99, got ${t}.`);return t}function Ce(e){if(!/^\d+$/.test(e)||Number(e)<1)throw new c(`--refresh must be a whole number of seconds >= 1, got "${e}".`);return Number(e)}function Z(e){let t={reserve:p.reserve,pausePrompt:p.pausePrompt,refresh:p.refresh,badge:p.badge},n=0,r=s=>{let i=e[n+1];if(i===void 0)throw new c(`${s} requires a value.`);return n+=1,i};for(;n<e.length;n+=1){let s=e[n];if(!s.startsWith("-"))break;switch(s){case"--reserve":t.reserve=Ie(r(s));break;case"--threshold":throw new c('--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(s);break;case"--refresh":t.refresh=Ce(r(s));break;case"--no-badge":t.badge=!1;break;case"-h":case"--help":throw new c("");default:throw new c(`Unknown option "${s}".`)}}let o=e.slice(n);if(o.length===0)throw new c("No command given. Try: spare10 claude");return{config:t,command:o}}import{spawnSync as Me}from"node:child_process";import{readFileSync as le}from"node:fs";var A={kind:"pass"};function D(e,t,n){return e.resetsAt!==null?n<e.resetsAt:e.updatedAt===null?!1:n-e.updatedAt<=t.refresh*X}function Te(e){return e===null?"an unknown time":new Date(e*1e3).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}function ee(e,t){return`into your ${t.reserve}% reserve \xB7 ${E(e.pct??0)}% of quota left \xB7 resets ${Te(e.resetsAt)}`}function _(e,t){return`spare10 \u2014 ${ee(e,t)}`}function Ne(e,t){return`spare10 budget guard. You have reached the safe usage limit for this session (${ee(e,t)}). Immediately wrap up your work and stop. Immediately stop any subagent, unless the user instructs otherwise.
|
|
20
|
+
|
|
21
|
+
User instructions: ${t.pausePrompt??""}`}function te(e,t,n){return v({state:e,config:t,now:n,agent:null}).kind!=="pass"}function v({state:e,config:t,now:n,agent:r}){return e.pct===null||e.updatedAt===null||e.blind||!D(e,t,n)||e.disarmedUntil!==null&&n<e.disarmedUntil||e.pct<w(t)?A:t.pausePrompt!==null?r!==null&&e.pausePromptInjectedTo.includes(r)?A:{kind:"inject",text:Ne(e,t)}:{kind:"halt",reason:`${_(e,t)}. Stop now and wait for the user. Do not call any further tools.`}}var ne={sessionId:null,toolName:null,agentId:null};function re(e){try{let t=JSON.parse(e);if(typeof t!="object"||t===null)return ne;let n=t;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 ne}}import{readFileSync as Ue,writeFileSync as De,renameSync as Le,mkdirSync as Oe,unlinkSync as Fe}from"node:fs";import{join as L}from"node:path";var se=e=>L(e,"state.json"),O=e=>L(e,"config.json"),R=e=>L(e,"claude.pid");function m(){return Math.floor(Date.now()/1e3)}function oe(e){try{let t=JSON.parse(Ue(e,"utf8"));return typeof t=="object"&&t!==null?t:null}catch{return null}}var b=(e,t)=>typeof e=="number"&&Number.isFinite(e)?e:t,ie=(e,t)=>typeof e=="boolean"?e:t,je=e=>Array.isArray(e)?e.filter(t=>typeof t=="string"):[];function f(e){let t=oe(se(e));return t?{pct:b(t.pct,null),resetsAt:b(t.resetsAt,null),updatedAt:b(t.updatedAt,null),missingStreak:b(t.missingStreak,0)??0,blind:ie(t.blind,!1),disarmedUntil:b(t.disarmedUntil,null),pausePromptInjectedTo:je(t.pausePromptInjectedTo),halted:typeof t.halted=="string"&&t.halted?t.halted:null,tick:b(t.tick,0)??0}:{...T}}function h(e,t){let n=se(e),r=`${n}.${process.pid}.tmp`;try{Oe(e,{recursive:!0}),De(r,JSON.stringify(t),"utf8"),Le(r,n)}catch{try{Fe(r)}catch{}}}function x(e){let t=oe(O(e));if(!t)return{...p};let n=b(t.reserve,p.reserve)??p.reserve,r=b(t.refresh,p.refresh)??p.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:ie(t.badge,p.badge),chain:typeof t.chain=="string"&&t.chain?t.chain:null}}function F(e,t){return e.resetsAt??t+z}function ue(e,t){let r=e.filter(o=>o.pct!==null&&o.updatedAt!==null&&o.resetsAt!==null&&o.resetsAt>t).reduce((o,s)=>o===null||s.updatedAt>o.updatedAt?s:o,null);return r===null?null:{...T,pct:r.pct,resetsAt:r.resetsAt,updatedAt:r.updatedAt}}function ae(){try{return le(0,"utf8")}catch{return""}}function ce(e){process.stdout.write(JSON.stringify({hookSpecificOutput:e}))}var He=2e3;function Ge(e){try{let t=Number(le(R(e),"utf8").trim());return Number.isInteger(t)&&t>1?t:null}catch{return null}}function Ve(e){let t=Me("ps",["-o","ppid=","-p",String(e)],{encoding:"utf8"}),n=Number(t.stdout.trim());return t.status===0&&Number.isInteger(n)&&n>0?n:null}function qe(e,t=process.ppid){let n=t;for(let r=0;n!==null&&n>1&&r<32;r+=1){if(n===e)return!0;n=Ve(n)}return!1}function Be(e){Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,e)}function Je(e,t,n,r){let o=n===null?null:Ge(e);if(o!==null&&qe(o)){h(e,{...t,halted:n});try{process.kill(o,"SIGTERM"),Be(He)}catch{}}return ce({hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:r}),0}function de(e){let t=x(e),n=f(e),r=m();if(v({state:n,config:t,now:r,agent:null}).kind==="pass")return ae(),0;let{sessionId:o,agentId:s}=re(ae()),i=Y(s),u=v({state:n,config:t,now:r,agent:i});switch(u.kind){case"pass":return 0;case"inject":return h(e,{...n,pausePromptInjectedTo:[...n.pausePromptInjectedTo,i]}),ce({hookEventName:"PreToolUse",additionalContext:u.text}),0;case"halt":return Je(e,n,o,u.reason)}}import{execFileSync as Ae}from"node:child_process";import{accessSync as xt,constants as Pt,readdirSync as kt,statSync as B}from"node:fs";import{join as Rt}from"node:path";function pe(e,t,n){if(e.pct===null||e.updatedAt===null)return{status:"no-data",detail:"no quota reading yet \u2014 start a session and wait a moment"};if(e.blind)return{status:"blind",detail:"Claude Code is not reporting rate_limits on this plan"};if(!D(e,t,n))return{status:"stale",detail:"the window this reading described has already reset"};if(e.disarmedUntil!==null&&n<e.disarmedUntil)return{status:"disarmed",detail:`consent given; quiet until ${M(e.disarmedUntil)}`};if(e.pct>=w(t)){let r=e.pausePromptInjectedTo.length,o=r>0?`; pause prompt delivered to ${r} agent${r===1?"":"s"}`:"";return{status:"tripped",detail:`into the ${t.reserve}% reserve${o}`}}return{status:"armed",detail:`${E(e.pct)}% left, of which ${t.reserve}% is reserved`}}function j(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 M(e){return new Date(e*1e3).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}import{spawn as We}from"node:child_process";import{closeSync as Ye,mkdirSync as Qe,openSync as Xe,readSync as ze,readdirSync as ge,rmSync as Ze,statSync as et,unlinkSync as tt,writeFileSync as he}from"node:fs";import{homedir as be}from"node:os";import{basename as nt,join as P}from"node:path";import{fileURLToPath as rt}from"node:url";import{readFileSync as Ke}from"node:fs";function H(e){return`'${e.replace(/'/g,"'\\''")}'`}function I(e){let t;try{t=JSON.parse(Ke(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,o=typeof r.command=="string"?r.command:null,s=typeof r.padding=="number"?r.padding:void 0;return o!==null&&o.includes("spare10")?{command:null,padding:s}:{command:o,padding:s}}function me(e){let t=o=>`${H(e.nodePath)} ${H(e.selfPath)} ${o} --run ${H(e.runDir)}`,n=o=>[{matcher:"",hooks:[{type:"command",command:t(o)}]}],r={type:"command",command:t("sensor"),refreshInterval:e.refresh};return e.padding!==void 0&&(r.padding=e.padding),{statusLine:r,hooks:{PreToolUse:n("gate")}}}var st=7*24*60*60*1e3,G=()=>P(process.env.SPARE10_HOME??P(be(),".spare10"),"runs"),V=()=>P(be(),".claude","settings.json");function ot(e,t){let n;try{n=ge(e)}catch{return}for(let r of n){let o=P(e,r);try{t-et(o).mtimeMs>st&&Ze(o,{recursive:!0,force:!0})}catch{}}}function it(e,t){let n;try{n=ge(e)}catch{return}let r=n.map(s=>P(e,s)).filter(s=>s!==t).map(f),o=ue(r,m());o!==null&&h(t,o)}function ut(e){let t=`${Date.now().toString(36)}-${process.pid.toString(36)}`,n=P(e,t);return Qe(n,{recursive:!0}),n}function Se(e){let t;try{t=Xe("/dev/tty","r")}catch{return null}process.stderr.write(e);let n=Buffer.alloc(1),r="";try{for(;ze(t,n,0,1,null)>0;){let o=n.toString("utf8");if(o===`
|
|
22
|
+
`||o==="\r")break;r+=o}}catch{return null}finally{Ye(t)}return r.trim()}var ye=e=>/^y(es)?$/i.test(e.trim());function at(e,t){return e?t===null?"proceeding":ye(t)?"consented":"declined":"clear"}function lt(e,t){let n=f(e);return te(n,t,m())?(process.stderr.write(`
|
|
23
|
+
${_(n,t)}
|
|
24
|
+
`),at(!0,Se("Start anyway? [y/N] "))):"clear"}function q(){return rt(import.meta.url)}function ct(e,t=process.env){return!(e.includes("--no-session-persistence")||t.CLAUDE_CODE_CHILD_SESSION&&!t.CLAUDE_CODE_FORCE_SESSION_PERSISTENCE)}var dt=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"]),pt=new Set(["--add-dir","--allowedTools","--allowed-tools","--betas","--disallowedTools","--disallowed-tools","--file","--mcp-config","--tools"]),mt=new Set(["-d","--debug","--prompt-suggestions","--remote-control"]),ft=new Set(["-r","--resume","-w","--worktree","--from-pr","--teleport","--cloud"]),gt=new Set(["--session-id"]),ht=new Set(["-c","--continue","--tmux","--bg","--background"]),bt="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 St(e,t){let n=[],r=s=>e[s],o=s=>r(s)!==void 0&&!r(s).startsWith("-");for(let s=0;s<e.length;s+=1){let i=r(s);if(i==="--")break;let u=i.startsWith("-")&&i.includes("="),a=u?i.split("=")[0]:i;if(!ht.has(a)){if(gt.has(a)){u||(s+=1);continue}if(ft.has(a)){!u&&o(s+1)&&(s+=1);continue}if(dt.has(a)){n.push(i),!u&&r(s+1)!==void 0&&n.push(r(++s));continue}if(mt.has(a)){n.push(i),!u&&o(s+1)&&n.push(r(++s));continue}if(pt.has(a)){for(n.push(i);!u&&o(s+1);)n.push(r(++s));continue}if(i.startsWith("-")){n.push(i);continue}}}return["--resume",t,...n,bt]}function yt(e,t,n,r){return new Promise(o=>{let s=We(e,t,{stdio:"inherit"});r&&s.pid!==void 0&&he(R(n),String(s.pid));let i=u=>{try{tt(R(n))}catch{}o(u)};s.once("error",u=>i({status:null,signal:null,error:u})),s.once("exit",(u,a)=>i({status:u,signal:a,error:null}))})}var wt=e=>e.signal==="SIGTERM"||e.status===143;function fe(e){return e.signal?128+(e.signal==="SIGINT"?2:15):e.status??0}function At(e){return e===null?"unattended":ye(e)?"resume":"declined"}async function we({config:e,command:t}){let[n,...r]=t;if(nt(n)!=="claude")throw new c(`spare10 wraps the "claude" command, but got "${n}". Run it as: ${["spare10",...r,"claude"].join(" ")}`);let o=G();ot(o,Date.now());let s=ut(o);it(o,s);let i=lt(s,{...e,chain:null});if(i==="declined")return process.stderr.write(`Not started.
|
|
25
|
+
`),0;if(i==="consented"){let d=f(s);h(s,{...d,disarmedUntil:F(d,m())}),process.stderr.write(`Continuing into the reserve for this window.
|
|
26
|
+
|
|
27
|
+
`)}let u=I(V()),a={...e,chain:u.command};he(O(s),JSON.stringify(a,null,2));let S=JSON.stringify(me({nodePath:process.execPath,selfPath:q(),runDir:s,refresh:e.refresh,padding:u.padding})),k=ct(r);k||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.
|
|
28
|
+
`);let $=()=>{};process.on("SIGINT",$),process.on("SIGTERM",$);let g=r;for(;;){let d=await yt(n,["--settings",S,...g],s,k);if(d.error)return process.stderr.write(`spare10: could not start ${n}: ${d.error.message}
|
|
29
|
+
`),127;let y=f(s);if(!wt(d)||y.halted===null)return fe(d);process.stderr.write(`
|
|
30
|
+
${_(y,a)}
|
|
31
|
+
spare10 stopped the agent between operations \u2014 nothing is left half-written.
|
|
32
|
+
`);let W=At(Se("Resume anyway? [y/N] "));if(W!=="resume")return process.stderr.write(`Not resumed. To pick it up later: claude --resume ${y.halted}
|
|
33
|
+
`),W==="declined"?0:fe(d);h(s,{...y,halted:null,disarmedUntil:F(y,m())}),process.stderr.write(`Resuming into the reserve for this window.
|
|
34
|
+
|
|
35
|
+
`),g=St(r,y.halted)}}var $t="0.3.0",l={ok:"\u2713",warn:"\u26A0",info:"\xB7"};function Et(e){try{return kt(e).map(n=>Rt(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 _t(e){try{return Ae("command",["-v",e],{shell:!0,encoding:"utf8"}).trim()||null}catch{return null}}function vt(){try{return Ae("claude",["--version"],{encoding:"utf8",timeout:1e4}).trim()}catch{return null}}function It(e){try{return xt(e,Pt.X_OK),!0}catch{return!1}}function xe(){let e=(g="")=>process.stdout.write(`${g}
|
|
36
|
+
`),t=m(),n=0;e(`spare10 ${$t}`),e(),e("Environment"),e(` ${l.info} node ${process.execPath} (${process.version})`),e(` ${l.info} spare10 ${q()}`);let r=_t("claude"),o=r?vt():null;r&&o?e(` ${l.ok} claude ${r} (${o})`):(n+=1,e(` ${l.warn} claude not found on PATH \u2014 spare10 has nothing to wrap`)),e(),e("Status line");let s=I(V());if(s.command===null)e(` ${l.info} nothing to chain \u2014 spare10 will own the status line`);else{let g=s.command.split(/\s+/)[0]??"",d=It(g);d||(n+=1),e(` ${d?l.ok:l.warn} chains into ${s.command}`),d||e(" that path is not executable; its output will be dropped")}e();let i=Et(G());if(i===null)return e("No runs yet. Start one with: spare10 claude"),n>0?1:0;let u=x(i),a=f(i),S=pe(a,u,t);if(e(`Latest run ${i}`),e(` ${l.info} reserve ${u.reserve}% of the 5-hour window`),e(` ${l.info} on trip ${u.pausePrompt===null?"stop the session and ask in the terminal":`inject: ${JSON.stringify(u.pausePrompt)}`}`),e(` ${l.info} refresh ${u.refresh}s`),a.pct!==null&&a.updatedAt!==null){e(` ${l.ok} quota ${a.pct}% used, ${100-a.pct}% left`);let g=t-a.updatedAt;e(` ${l.info} last reading ${j(g)} ago`+(g>u.refresh*3?" (no session running; still valid for this window)":""))}a.resetsAt!==null&&e(` ${l.info} resets ${M(a.resetsAt)} (in ${j(a.resetsAt-t)})`);let k=S.status==="blind";k&&(n+=1);let $=k?l.warn:S.status==="no-data"?l.info:l.ok;return e(` ${$} state ${S.status.toUpperCase()} \u2014 ${S.detail}`),n>0?1:0}import{spawnSync as Ct}from"node:child_process";import{readFileSync as Tt}from"node:fs";function Pe(e){return typeof e=="number"&&Number.isFinite(e)?e:null}function ke(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,o=n.rate_limits;if(typeof o!="object"||o===null)return{sessionId:r,fiveHour:null};let s=o.five_hour;if(typeof s!="object"||s===null)return{sessionId:r,fiveHour:null};let i=s,u=Pe(i.used_percentage);return u===null?{sessionId:r,fiveHour:null}:{sessionId:r,fiveHour:{usedPercentage:Math.min(100,Math.max(0,u)),resetsAt:Pe(i.resets_at)}}}function Nt(e,t,n){let r=t.fiveHour,o=e.tick+1;if(!r){let i=e.missingStreak+1;return{...e,tick:o,missingStreak:i,blind:i>=Q}}let s=r.resetsAt!==null&&e.resetsAt!==null&&r.resetsAt!==e.resetsAt;return{tick:o,pct:r.usedPercentage,resetsAt:r.resetsAt,updatedAt:n,missingStreak:0,blind:!1,disarmedUntil:s?null:e.disarmedUntil,pausePromptInjectedTo:s?[]:e.pausePromptInjectedTo,halted:e.halted}}var Ut="\x1B[38;5;208m",Dt="\x1B[38;5;40m",Lt="\x1B[38;5;245m",K="\x1B[39m",J=e=>`${Ut}${e}${K}`,Ot=e=>`${Dt}${e}${K}`,Ft=e=>`${Lt}${e}${K}`,C=e=>e.reserve===N?"spare10":`spare10 (${e.reserve}%)`;function jt(e,t,n){if(!t.badge)return"";if(e.blind)return"\u26A0 spare10 quota unavailable";if(e.pct===null)return Ft(`\u29D7 ${C(t)}`);if(e.pct<w(t))return Ot(`\u25CF ${C(t)}`);if(e.disarmedUntil!==null&&n<e.disarmedUntil)return J(`\u2A2F ${C(t)}`);if(e.pausePromptInjectedTo.length>0)return J(`\u23F8 ${C(t)}`);let o=e.tick%2===0?"\u26A0":" ";return J(`${o} Pausing at next tool call`)}function Mt(e,t){if(!e||e.includes("spare10 sensor"))return"";try{return(Ct(e,{shell:!0,input:t,encoding:"utf8",timeout:5e3,maxBuffer:1e6}).stdout??"").replace(/\n+$/,"")}catch{return""}}function Ht(){try{return Tt(0,"utf8")}catch{return""}}function Re(e){let t=Ht(),n=x(e),r=Nt(f(e),ke(t),m());h(e,r);let o=[jt(r,n,m()),Mt(n.chain,t)].filter(Boolean);return o.length>0&&process.stdout.write(o.join(" ")),0}function $e(e,t){let n=e.indexOf(t);return n===-1?null:e[n+1]??null}function Gt(e){let[t,...n]=e;switch(t){case"sensor":{let r=$e(n,"--run");return r?Re(r):0}case"gate":{let r=$e(n,"--run");return r?de(r):0}case"doctor":return xe();default:return we(Z(e))}}function Ee(e){throw e instanceof c&&(e.message&&(process.stderr.write(`spare10: ${e.message}
|
|
37
|
+
|
|
38
|
+
${U}
|
|
39
|
+
`),process.exit(2)),process.stdout.write(`${U}
|
|
40
|
+
`),process.exit(0)),e}try{Promise.resolve(Gt(process.argv.slice(2))).then(e=>process.exit(e),Ee)}catch(e){Ee(e)}
|