tokengolf 0.3.0 → 0.4.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/.husky/pre-commit +4 -0
- package/.prettierignore +2 -0
- package/.prettierrc +6 -0
- package/.vscode/settings.json +15 -0
- package/CHANGELOG.md +254 -0
- package/CLAUDE.md +136 -10
- package/README.md +89 -47
- package/assets/demo-hud.png +0 -0
- package/assets/scorecard.png +0 -0
- package/dist/cli.js +790 -103
- package/docs/assets/demo-hud.png +0 -0
- package/docs/assets/scorecard.png +0 -0
- package/docs/assets/tokengolf-bg-min.jpg +0 -0
- package/docs/index.html +1080 -0
- package/eslint.config.js +39 -0
- package/hooks/post-tool-use-failure.js +27 -0
- package/hooks/post-tool-use.js +11 -7
- package/hooks/pre-compact.js +9 -3
- package/hooks/session-end.js +168 -42
- package/hooks/session-start.js +31 -11
- package/hooks/session-stop.js +6 -2
- package/hooks/statusline.sh +16 -7
- package/hooks/stop.js +27 -0
- package/hooks/subagent-start.js +27 -0
- package/hooks/user-prompt-submit.js +8 -6
- package/package.json +16 -3
- package/src/cli.js +23 -6
- package/src/components/ActiveRun.js +76 -24
- package/src/components/ScoreCard.js +132 -37
- package/src/components/StartRun.js +156 -53
- package/src/components/StatsView.js +89 -37
- package/src/lib/__tests__/score.test.js +596 -0
- package/src/lib/cost.js +84 -21
- package/src/lib/demo.js +186 -0
- package/src/lib/install.js +92 -62
- package/src/lib/score.js +433 -136
- package/src/lib/store.js +11 -11
- package/.claude/settings.local.json +0 -36
package/eslint.config.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import js from '@eslint/js';
|
|
2
|
+
import prettier from 'eslint-config-prettier';
|
|
3
|
+
|
|
4
|
+
const sharedGlobals = {
|
|
5
|
+
process: 'readonly',
|
|
6
|
+
console: 'readonly',
|
|
7
|
+
setTimeout: 'readonly',
|
|
8
|
+
clearTimeout: 'readonly',
|
|
9
|
+
setInterval: 'readonly',
|
|
10
|
+
clearInterval: 'readonly',
|
|
11
|
+
Buffer: 'readonly',
|
|
12
|
+
URL: 'readonly',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const sharedRules = {
|
|
16
|
+
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
|
17
|
+
'no-console': 'off',
|
|
18
|
+
'no-empty': ['error', { allowEmptyCatch: true }],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default [
|
|
22
|
+
js.configs.recommended,
|
|
23
|
+
prettier,
|
|
24
|
+
{
|
|
25
|
+
files: ['src/**/*.js', 'hooks/**/*.js'],
|
|
26
|
+
languageOptions: {
|
|
27
|
+
ecmaVersion: 'latest',
|
|
28
|
+
sourceType: 'module',
|
|
29
|
+
parserOptions: {
|
|
30
|
+
ecmaFeatures: { jsx: true },
|
|
31
|
+
},
|
|
32
|
+
globals: sharedGlobals,
|
|
33
|
+
},
|
|
34
|
+
rules: sharedRules,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
ignores: ['dist/**', 'node_modules/**'],
|
|
38
|
+
},
|
|
39
|
+
];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const STATE_FILE = path.join(os.homedir(), '.tokengolf', 'current-run.json');
|
|
7
|
+
|
|
8
|
+
let input = '';
|
|
9
|
+
process.stdin.setEncoding('utf8');
|
|
10
|
+
process.stdin.on('data', (chunk) => {
|
|
11
|
+
input += chunk;
|
|
12
|
+
});
|
|
13
|
+
process.stdin.on('end', () => {
|
|
14
|
+
try {
|
|
15
|
+
const run = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
16
|
+
if (!run || run.status !== 'active') process.exit(0);
|
|
17
|
+
|
|
18
|
+
const updated = {
|
|
19
|
+
...run,
|
|
20
|
+
failedToolCalls: (run.failedToolCalls || 0) + 1,
|
|
21
|
+
};
|
|
22
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(updated, null, 2));
|
|
23
|
+
} catch {
|
|
24
|
+
// silent fail
|
|
25
|
+
}
|
|
26
|
+
process.exit(0);
|
|
27
|
+
});
|
package/hooks/post-tool-use.js
CHANGED
|
@@ -7,7 +7,9 @@ const STATE_FILE = path.join(os.homedir(), '.tokengolf', 'current-run.json');
|
|
|
7
7
|
|
|
8
8
|
let input = '';
|
|
9
9
|
process.stdin.setEncoding('utf8');
|
|
10
|
-
process.stdin.on('data', chunk => {
|
|
10
|
+
process.stdin.on('data', (chunk) => {
|
|
11
|
+
input += chunk;
|
|
12
|
+
});
|
|
11
13
|
process.stdin.on('end', () => {
|
|
12
14
|
try {
|
|
13
15
|
const run = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
@@ -29,12 +31,14 @@ process.stdin.on('end', () => {
|
|
|
29
31
|
const pct = updated.spent / updated.budget;
|
|
30
32
|
if (pct >= 0.8 && pct < 1.0) {
|
|
31
33
|
const remaining = (updated.budget - updated.spent).toFixed(4);
|
|
32
|
-
process.stdout.write(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
process.stdout.write(
|
|
35
|
+
JSON.stringify({
|
|
36
|
+
hookSpecificOutput: {
|
|
37
|
+
hookEventName: 'PostToolUse',
|
|
38
|
+
systemMessage: `⚠️ TokenGolf: $${updated.spent.toFixed(4)} of $${updated.budget.toFixed(2)} spent (${Math.round(pct * 100)}%). Only $${remaining} left. Be concise and targeted.`,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
);
|
|
38
42
|
}
|
|
39
43
|
} catch {
|
|
40
44
|
// silent fail
|
package/hooks/pre-compact.js
CHANGED
|
@@ -7,15 +7,21 @@ const STATE_FILE = path.join(os.homedir(), '.tokengolf', 'current-run.json');
|
|
|
7
7
|
|
|
8
8
|
try {
|
|
9
9
|
let stdin = '';
|
|
10
|
-
try {
|
|
10
|
+
try {
|
|
11
|
+
stdin = fs.readFileSync('/dev/stdin', 'utf8');
|
|
12
|
+
} catch {}
|
|
11
13
|
|
|
12
14
|
let event = {};
|
|
13
|
-
try {
|
|
15
|
+
try {
|
|
16
|
+
event = JSON.parse(stdin);
|
|
17
|
+
} catch {}
|
|
14
18
|
|
|
15
19
|
const trigger = event.trigger || 'auto'; // 'manual' or 'auto'
|
|
16
20
|
|
|
17
21
|
let run = null;
|
|
18
|
-
try {
|
|
22
|
+
try {
|
|
23
|
+
run = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
24
|
+
} catch {}
|
|
19
25
|
if (!run || run.status !== 'active') process.exit(0);
|
|
20
26
|
|
|
21
27
|
const compactionEvents = run.compactionEvents || [];
|
package/hooks/session-end.js
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import fs from 'fs';
|
|
5
|
+
import os from 'os';
|
|
5
6
|
|
|
6
7
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
7
8
|
const { autoDetectCost } = await import(path.join(__dir, '../src/lib/cost.js'));
|
|
8
9
|
const { getCurrentRun, clearCurrentRun } = await import(path.join(__dir, '../src/lib/state.js'));
|
|
9
10
|
const { saveRun } = await import(path.join(__dir, '../src/lib/store.js'));
|
|
10
|
-
const { getTier, getModelClass, getEffortLevel, getEfficiencyRating, getBudgetPct } = await import(
|
|
11
|
+
const { getTier, getModelClass, getEffortLevel, getEfficiencyRating, getBudgetPct } = await import(
|
|
12
|
+
path.join(__dir, '../src/lib/score.js')
|
|
13
|
+
);
|
|
11
14
|
|
|
12
15
|
function writeTTY(text) {
|
|
13
16
|
try {
|
|
@@ -19,26 +22,70 @@ function writeTTY(text) {
|
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
function termWidth(str) {
|
|
26
|
+
// Compute display width of a string, handling emoji variation selectors and surrogates.
|
|
27
|
+
// - Supplementary plane chars (> U+FFFF) → 2 cols
|
|
28
|
+
// - U+FE0F (emoji variation selector after BMP char) → upgrades prev from 1→2, adds 0 itself
|
|
29
|
+
// - U+FE0F after supplementary → 0 (already 2)
|
|
30
|
+
// - U+FE0E, ZWJ, zero-width chars → 0
|
|
31
|
+
// - Everything else → 1
|
|
32
|
+
/* eslint-disable no-control-regex */
|
|
33
|
+
const plain = str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
34
|
+
/* eslint-enable no-control-regex */
|
|
35
|
+
const cps = [...plain].map((c) => c.codePointAt(0));
|
|
36
|
+
let width = 0;
|
|
37
|
+
for (let i = 0; i < cps.length; i++) {
|
|
38
|
+
const cp = cps[i];
|
|
39
|
+
if (cp === 0xfe0f) {
|
|
40
|
+
// Emoji presentation selector: if previous was a narrow BMP char, upgrade it to 2
|
|
41
|
+
if (i > 0 && cps[i - 1] <= 0xffff && cps[i - 1] !== 0x200d) width += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (cp === 0xfe0e || cp === 0x200d || (cp >= 0x200b && cp <= 0x200f)) continue;
|
|
45
|
+
if (cp > 0xffff) {
|
|
46
|
+
width += 2;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
width += 1;
|
|
50
|
+
}
|
|
51
|
+
return width;
|
|
52
|
+
}
|
|
53
|
+
|
|
22
54
|
function renderScorecard(run) {
|
|
23
55
|
const W = Math.min(Math.max((process.stdout.columns || 88) - 4, 72), 120);
|
|
24
56
|
const won = run.status === 'won';
|
|
25
57
|
const flowMode = !run.budget;
|
|
26
58
|
|
|
27
|
-
const R = '\x1b[31m',
|
|
28
|
-
|
|
59
|
+
const R = '\x1b[31m',
|
|
60
|
+
G = '\x1b[32m',
|
|
61
|
+
Y = '\x1b[33m',
|
|
62
|
+
C = '\x1b[36m';
|
|
63
|
+
const M = '\x1b[35m',
|
|
64
|
+
DIM = '\x1b[2m',
|
|
65
|
+
RESET = '\x1b[0m',
|
|
66
|
+
BOLD = '\x1b[1m';
|
|
29
67
|
const bc = won ? Y : R;
|
|
30
68
|
|
|
31
|
-
const tl = '╔',
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
69
|
+
const tl = '╔',
|
|
70
|
+
tr = '╗',
|
|
71
|
+
bl = '╚',
|
|
72
|
+
br = '╝';
|
|
73
|
+
const h = '═',
|
|
74
|
+
v = '║';
|
|
75
|
+
const ml = '╠',
|
|
76
|
+
mr = '╣';
|
|
77
|
+
|
|
78
|
+
function bar() {
|
|
79
|
+
return bc + ml + h.repeat(W) + mr + RESET;
|
|
80
|
+
}
|
|
81
|
+
function top() {
|
|
82
|
+
return bc + tl + h.repeat(W) + tr + RESET;
|
|
83
|
+
}
|
|
84
|
+
function bot() {
|
|
85
|
+
return bc + bl + h.repeat(W) + br + RESET;
|
|
86
|
+
}
|
|
38
87
|
function row(content) {
|
|
39
|
-
|
|
40
|
-
const plain = content.replace(/\x1b\[[0-9;]*m/g, '');
|
|
41
|
-
const pad = Math.max(0, W - plain.length - 2);
|
|
88
|
+
const pad = Math.max(0, W - termWidth(content) - 2);
|
|
42
89
|
return bc + v + RESET + ' ' + content + ' '.repeat(pad) + ' ' + bc + v + RESET;
|
|
43
90
|
}
|
|
44
91
|
|
|
@@ -50,8 +97,8 @@ function renderScorecard(run) {
|
|
|
50
97
|
const header = won
|
|
51
98
|
? `${BOLD}${Y}🏆 SESSION COMPLETE${RESET}`
|
|
52
99
|
: fainted
|
|
53
|
-
|
|
54
|
-
|
|
100
|
+
? `${BOLD}${Y}💤 FAINTED — Run Continues${RESET}`
|
|
101
|
+
: `${BOLD}${R}💀 BUDGET BUSTED${RESET}`;
|
|
55
102
|
|
|
56
103
|
const questStr = run.quest
|
|
57
104
|
? `${BOLD}${run.quest.slice(0, 60)}${RESET}`
|
|
@@ -61,54 +108,78 @@ function renderScorecard(run) {
|
|
|
61
108
|
const spentThisSession = run.spent - spentBefore;
|
|
62
109
|
const multiSession = sessions > 1 && spentBefore > 0;
|
|
63
110
|
|
|
64
|
-
const spentStr =
|
|
111
|
+
const spentStr =
|
|
112
|
+
`${won ? G : R}$${run.spent.toFixed(4)}${RESET}` +
|
|
65
113
|
(multiSession ? ` ${DIM}(+$${spentThisSession.toFixed(4)} this session)${RESET}` : '');
|
|
66
114
|
|
|
67
115
|
let midRow = spentStr;
|
|
68
116
|
if (!flowMode) {
|
|
69
117
|
const pct = getBudgetPct(run.spent, run.budget);
|
|
70
118
|
const eff = getEfficiencyRating(run.spent, run.budget);
|
|
71
|
-
const effC =
|
|
119
|
+
const effC =
|
|
120
|
+
eff.color === 'magenta'
|
|
121
|
+
? M
|
|
122
|
+
: eff.color === 'cyan'
|
|
123
|
+
? C
|
|
124
|
+
: eff.color === 'green'
|
|
125
|
+
? G
|
|
126
|
+
: eff.color === 'yellow'
|
|
127
|
+
? Y
|
|
128
|
+
: R;
|
|
72
129
|
midRow += ` ${DIM}/${RESET}$${run.budget.toFixed(2)} ${pct}% ${effC}${eff.emoji} ${eff.label}${RESET}`;
|
|
73
130
|
}
|
|
74
131
|
|
|
75
132
|
const effortInfo = run.effort ? getEffortLevel(run.effort) : null;
|
|
76
133
|
const modelSuffix = [
|
|
77
|
-
run.effort &&
|
|
134
|
+
run.effort && effortInfo ? effortInfo.label : null,
|
|
78
135
|
run.fastMode ? '⚡Fast' : null,
|
|
79
|
-
]
|
|
136
|
+
]
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.join('·');
|
|
80
139
|
midRow += ` ${C}${mc.emoji} ${mc.name}${modelSuffix ? '·' + modelSuffix : ''}${RESET}`;
|
|
81
140
|
midRow += ` ${tier.emoji} ${tier.label}`;
|
|
82
141
|
if (multiSession) midRow += ` ${DIM}${sessions} sessions${RESET}`;
|
|
83
142
|
|
|
84
143
|
const achievements = run.achievements || [];
|
|
85
|
-
const
|
|
144
|
+
const achTokens = achievements.map((a) => `${a.emoji} ${a.key}`);
|
|
145
|
+
const achLines = [];
|
|
146
|
+
let currentLine = '';
|
|
147
|
+
for (const token of achTokens) {
|
|
148
|
+
const sep = currentLine ? ' ' : '';
|
|
149
|
+
const testLen = termWidth(currentLine + sep + token);
|
|
150
|
+
if (currentLine && testLen > W - 2) {
|
|
151
|
+
achLines.push(currentLine);
|
|
152
|
+
currentLine = token;
|
|
153
|
+
} else {
|
|
154
|
+
currentLine += sep + token;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (currentLine) achLines.push(currentLine);
|
|
86
158
|
|
|
87
159
|
const ti = run.thinkingInvocations || 0;
|
|
88
|
-
const thinkRow =
|
|
89
|
-
? `${M}🔮 ${ti} ultrathink${ti > 1 ? ' invocations' : ' invocation'}${RESET}`
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const lines = [
|
|
93
|
-
top(),
|
|
94
|
-
row(header),
|
|
95
|
-
row(questStr),
|
|
96
|
-
bar(),
|
|
97
|
-
row(midRow),
|
|
98
|
-
];
|
|
160
|
+
const thinkRow =
|
|
161
|
+
ti > 0 ? `${M}🔮 ${ti} ultrathink${ti > 1 ? ' invocations' : ' invocation'}${RESET}` : null;
|
|
162
|
+
|
|
163
|
+
const lines = [top(), row(header), row(questStr), bar(), row(midRow)];
|
|
99
164
|
|
|
100
165
|
if (thinkRow) {
|
|
101
166
|
lines.push(bar());
|
|
102
167
|
lines.push(row(thinkRow));
|
|
103
168
|
}
|
|
104
169
|
|
|
105
|
-
if (
|
|
170
|
+
if (achLines.length > 0) {
|
|
106
171
|
lines.push(bar());
|
|
107
|
-
|
|
172
|
+
for (const line of achLines) {
|
|
173
|
+
lines.push(row(line));
|
|
174
|
+
}
|
|
108
175
|
}
|
|
109
176
|
|
|
110
177
|
lines.push(bar());
|
|
111
|
-
lines.push(
|
|
178
|
+
lines.push(
|
|
179
|
+
row(
|
|
180
|
+
`${DIM}tokengolf scorecard${RESET} · ${DIM}tokengolf start${RESET} · ${DIM}tokengolf stats${RESET}`
|
|
181
|
+
)
|
|
182
|
+
);
|
|
112
183
|
lines.push(bot());
|
|
113
184
|
|
|
114
185
|
return lines.join('\n');
|
|
@@ -116,27 +187,71 @@ function renderScorecard(run) {
|
|
|
116
187
|
|
|
117
188
|
try {
|
|
118
189
|
let stdin = '';
|
|
119
|
-
try {
|
|
190
|
+
try {
|
|
191
|
+
stdin = fs.readFileSync('/dev/stdin', 'utf8');
|
|
192
|
+
} catch {}
|
|
120
193
|
|
|
121
194
|
let event = {};
|
|
122
|
-
try {
|
|
195
|
+
try {
|
|
196
|
+
event = JSON.parse(stdin);
|
|
197
|
+
} catch {}
|
|
123
198
|
const reason = event.reason || 'other';
|
|
124
199
|
|
|
200
|
+
// Read authoritative cost from StatusLine sidecar (same source as the HUD)
|
|
201
|
+
let liveCost = null;
|
|
202
|
+
try {
|
|
203
|
+
const raw = fs
|
|
204
|
+
.readFileSync(path.join(os.homedir(), '.tokengolf', 'session-cost'), 'utf8')
|
|
205
|
+
.trim();
|
|
206
|
+
const parsed = parseFloat(raw);
|
|
207
|
+
if (!isNaN(parsed) && parsed > 0) liveCost = parsed;
|
|
208
|
+
} catch {}
|
|
209
|
+
// SessionEnd event may also carry cost (future-proofing)
|
|
210
|
+
const eventCost = event.cost?.total_cost_usd ?? null;
|
|
211
|
+
// Priority: liveCost (StatusLine sidecar) > eventCost > transcript parsing
|
|
212
|
+
const authoritativeCost = liveCost ?? eventCost;
|
|
213
|
+
|
|
125
214
|
const run = getCurrentRun();
|
|
126
215
|
if (!run || run.status !== 'active') process.exit(0);
|
|
127
216
|
|
|
128
|
-
|
|
129
|
-
if (!result) process.exit(0); // no
|
|
217
|
+
let result = autoDetectCost(run);
|
|
218
|
+
if (!result && authoritativeCost === null) process.exit(0); // no data at all
|
|
219
|
+
if (!result) result = { spent: 0, modelBreakdown: {}, thinkingInvocations: 0, thinkingTokens: 0 };
|
|
220
|
+
// Always prefer authoritative cost over manual transcript recomputation
|
|
221
|
+
if (authoritativeCost !== null) {
|
|
222
|
+
// Scale model breakdown to match authoritative total (transcript ratios are correct, amounts are not)
|
|
223
|
+
if (result.modelBreakdown && Object.keys(result.modelBreakdown).length > 0) {
|
|
224
|
+
const parsedTotal = Object.values(result.modelBreakdown).reduce((s, v) => s + v, 0);
|
|
225
|
+
if (parsedTotal > 0) {
|
|
226
|
+
const scale = authoritativeCost / parsedTotal;
|
|
227
|
+
for (const model of Object.keys(result.modelBreakdown)) {
|
|
228
|
+
result.modelBreakdown[model] *= scale;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
result.spent = authoritativeCost;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Merge model breakdown by family (e.g. claude-opus-4-6 + claude-opus-4-20250514 → Opus)
|
|
236
|
+
if (result.modelBreakdown && Object.keys(result.modelBreakdown).length > 0) {
|
|
237
|
+
const merged = {};
|
|
238
|
+
for (const [model, cost] of Object.entries(result.modelBreakdown)) {
|
|
239
|
+
const m = model.toLowerCase();
|
|
240
|
+
const family = m.includes('haiku') ? 'Haiku' : m.includes('sonnet') ? 'Sonnet' : 'Opus';
|
|
241
|
+
merged[family] = (merged[family] || 0) + cost;
|
|
242
|
+
}
|
|
243
|
+
result.modelBreakdown = merged;
|
|
244
|
+
}
|
|
130
245
|
|
|
131
246
|
// reason 'other' = unexpected exit (usage limit hit = Fainted)
|
|
132
247
|
// clean exits: 'clear', 'logout', 'prompt_input_exit', 'bypass_permissions_disabled'
|
|
133
248
|
const cleanExits = ['clear', 'logout', 'prompt_input_exit', 'bypass_permissions_disabled'];
|
|
134
|
-
const fainted = !cleanExits.includes(reason) && reason !== 'other' ? false
|
|
135
|
-
: reason === 'other';
|
|
249
|
+
const fainted = !cleanExits.includes(reason) && reason !== 'other' ? false : reason === 'other';
|
|
136
250
|
|
|
137
251
|
let status;
|
|
138
252
|
if (run.budget && result.spent > run.budget) status = 'died';
|
|
139
|
-
else if (fainted)
|
|
253
|
+
else if (fainted)
|
|
254
|
+
status = 'resting'; // hit limit, run continues next session
|
|
140
255
|
else status = 'won';
|
|
141
256
|
|
|
142
257
|
const thinkingFields = {
|
|
@@ -148,7 +263,14 @@ try {
|
|
|
148
263
|
if (status === 'resting') {
|
|
149
264
|
const { setCurrentRun } = await import(path.join(__dir, '../src/lib/state.js'));
|
|
150
265
|
setCurrentRun({ ...run, spent: result.spent, fainted: true, ...thinkingFields });
|
|
151
|
-
const saved = {
|
|
266
|
+
const saved = {
|
|
267
|
+
...run,
|
|
268
|
+
spent: result.spent,
|
|
269
|
+
modelBreakdown: result.modelBreakdown,
|
|
270
|
+
status,
|
|
271
|
+
fainted: true,
|
|
272
|
+
...thinkingFields,
|
|
273
|
+
};
|
|
152
274
|
writeTTY('\n' + renderScorecard({ ...saved, achievements: [] }) + '\n\n');
|
|
153
275
|
process.exit(0);
|
|
154
276
|
}
|
|
@@ -163,6 +285,10 @@ try {
|
|
|
163
285
|
});
|
|
164
286
|
|
|
165
287
|
clearCurrentRun();
|
|
288
|
+
// Clean up sidecar cost file
|
|
289
|
+
try {
|
|
290
|
+
fs.unlinkSync(path.join(os.homedir(), '.tokengolf', 'session-cost'));
|
|
291
|
+
} catch {}
|
|
166
292
|
|
|
167
293
|
writeTTY('\n' + renderScorecard(saved) + '\n\n');
|
|
168
294
|
} catch {
|
package/hooks/session-start.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'path';
|
|
|
4
4
|
import os from 'os';
|
|
5
5
|
|
|
6
6
|
const STATE_FILE = path.join(os.homedir(), '.tokengolf', 'current-run.json');
|
|
7
|
-
const STATE_DIR
|
|
7
|
+
const STATE_DIR = path.join(os.homedir(), '.tokengolf');
|
|
8
8
|
|
|
9
9
|
function detectEffort() {
|
|
10
10
|
const fromEnv = process.env.CLAUDE_CODE_EFFORT_LEVEL;
|
|
@@ -13,28 +13,40 @@ function detectEffort() {
|
|
|
13
13
|
path.join(os.homedir(), '.claude', 'settings.json'),
|
|
14
14
|
path.join(process.env.PWD || process.cwd(), '.claude', 'settings.json'),
|
|
15
15
|
]) {
|
|
16
|
-
try {
|
|
16
|
+
try {
|
|
17
|
+
const s = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
18
|
+
if (s.effortLevel) return s.effortLevel;
|
|
19
|
+
} catch {}
|
|
17
20
|
}
|
|
18
21
|
return null;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
function detectFastMode() {
|
|
22
25
|
try {
|
|
23
|
-
const s = JSON.parse(
|
|
26
|
+
const s = JSON.parse(
|
|
27
|
+
fs.readFileSync(path.join(os.homedir(), '.claude', 'settings.json'), 'utf8')
|
|
28
|
+
);
|
|
24
29
|
return s.fastMode === true;
|
|
25
|
-
} catch {
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
try {
|
|
29
36
|
const cwd = process.env.PWD || process.cwd();
|
|
30
37
|
|
|
31
38
|
let run = null;
|
|
32
|
-
try {
|
|
39
|
+
try {
|
|
40
|
+
run = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
41
|
+
} catch {
|
|
42
|
+
/* no run */
|
|
43
|
+
}
|
|
33
44
|
|
|
34
45
|
if (!run || run.status !== 'active') {
|
|
35
46
|
// Flow mode: auto-start a tracking run for this session
|
|
36
47
|
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
37
48
|
run = {
|
|
49
|
+
id: `run_${Date.now()}`,
|
|
38
50
|
quest: null,
|
|
39
51
|
model: 'claude-sonnet-4-6',
|
|
40
52
|
budget: null,
|
|
@@ -48,6 +60,12 @@ try {
|
|
|
48
60
|
promptCount: 0,
|
|
49
61
|
totalToolCalls: 0,
|
|
50
62
|
toolCalls: {},
|
|
63
|
+
failedToolCalls: 0,
|
|
64
|
+
subagentSpawns: 0,
|
|
65
|
+
turnCount: 0,
|
|
66
|
+
thinkingInvocations: 0,
|
|
67
|
+
thinkingTokens: 0,
|
|
68
|
+
fainted: false,
|
|
51
69
|
cwd,
|
|
52
70
|
sessionCount: 1,
|
|
53
71
|
compactionEvents: [],
|
|
@@ -88,12 +106,14 @@ Efficiency tips:
|
|
|
88
106
|
- Be specific — avoid exploratory reads when you know the target
|
|
89
107
|
- Scope bash commands tightly`;
|
|
90
108
|
|
|
91
|
-
process.stdout.write(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
109
|
+
process.stdout.write(
|
|
110
|
+
JSON.stringify({
|
|
111
|
+
hookSpecificOutput: {
|
|
112
|
+
hookEventName: 'SessionStart',
|
|
113
|
+
additionalContext: context,
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
);
|
|
97
117
|
} catch {
|
|
98
118
|
// silent fail
|
|
99
119
|
}
|
package/hooks/session-stop.js
CHANGED
|
@@ -7,7 +7,9 @@ const STATE_FILE = path.join(os.homedir(), '.tokengolf', 'current-run.json');
|
|
|
7
7
|
|
|
8
8
|
let input = '';
|
|
9
9
|
process.stdin.setEncoding('utf8');
|
|
10
|
-
process.stdin.on('data', chunk => {
|
|
10
|
+
process.stdin.on('data', (chunk) => {
|
|
11
|
+
input += chunk;
|
|
12
|
+
});
|
|
11
13
|
process.stdin.on('end', () => {
|
|
12
14
|
try {
|
|
13
15
|
const event = JSON.parse(input);
|
|
@@ -20,6 +22,8 @@ process.stdin.on('end', () => {
|
|
|
20
22
|
if (!run || run.status !== 'active') process.exit(0);
|
|
21
23
|
|
|
22
24
|
fs.writeFileSync(STATE_FILE, JSON.stringify({ ...run, spent: cost }, null, 2));
|
|
23
|
-
} catch {
|
|
25
|
+
} catch {
|
|
26
|
+
/* no run or no cost data */
|
|
27
|
+
}
|
|
24
28
|
process.exit(0);
|
|
25
29
|
});
|
package/hooks/statusline.sh
CHANGED
|
@@ -15,21 +15,30 @@ try:
|
|
|
15
15
|
except: sys.exit(0)
|
|
16
16
|
|
|
17
17
|
cost = (session.get('cost') or {}).get('total_cost_usd') or run.get('spent', 0)
|
|
18
|
+
# Persist CC's authoritative cost so session-end can read it (SessionEnd doesn't receive cost in stdin)
|
|
19
|
+
try:
|
|
20
|
+
with open(os.path.join(os.path.expanduser('~'), '.tokengolf', 'session-cost'), 'w') as _cf: _cf.write(str(cost))
|
|
21
|
+
except: pass
|
|
18
22
|
ctx_pct = (session.get('context_window') or {}).get('used_percentage') or None
|
|
19
23
|
quest = (run.get('quest') or 'Flow')[:32]
|
|
20
24
|
budget = run.get('budget')
|
|
21
25
|
floor = f"{run.get('floor',1)}/{run.get('totalFloors',5)}"
|
|
22
|
-
m
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
elif '
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
sm = session.get('model') or {}; m = (sm.get('id','') or run.get('model','') if isinstance(sm,dict) else sm or run.get('model','')).lower()
|
|
27
|
+
# opusplan must be checked before opus (opusplan contains 'opus' as substring)
|
|
28
|
+
if 'opusplan' in m: model, model_emoji = 'Paladin', '⚜️'
|
|
29
|
+
elif 'haiku' in m: model, model_emoji = 'Haiku', '🏹'
|
|
30
|
+
elif 'sonnet' in m: model, model_emoji = 'Sonnet', '⚔️'
|
|
31
|
+
elif 'opus' in m: model, model_emoji = 'Opus', '🧙'
|
|
32
|
+
else: model, model_emoji = '?', '?'
|
|
33
|
+
try:
|
|
34
|
+
with open(os.path.expanduser('~/.claude/settings.json')) as _sf: _s = json.load(_sf)
|
|
35
|
+
except: _s = {}
|
|
36
|
+
effort = _s.get('effortLevel')
|
|
28
37
|
fast = run.get('fastMode', False)
|
|
29
38
|
fainted = run.get('fainted', False)
|
|
30
39
|
|
|
31
40
|
label_parts = [f'{model_emoji} {model}']
|
|
32
|
-
if effort
|
|
41
|
+
if effort: label_parts.append(effort.capitalize())
|
|
33
42
|
if fast: label_parts.append('⚡Fast')
|
|
34
43
|
model_label = '·'.join(label_parts)
|
|
35
44
|
|
package/hooks/stop.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const STATE_FILE = path.join(os.homedir(), '.tokengolf', 'current-run.json');
|
|
7
|
+
|
|
8
|
+
let input = '';
|
|
9
|
+
process.stdin.setEncoding('utf8');
|
|
10
|
+
process.stdin.on('data', (chunk) => {
|
|
11
|
+
input += chunk;
|
|
12
|
+
});
|
|
13
|
+
process.stdin.on('end', () => {
|
|
14
|
+
try {
|
|
15
|
+
const run = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
16
|
+
if (!run || run.status !== 'active') process.exit(0);
|
|
17
|
+
|
|
18
|
+
const updated = {
|
|
19
|
+
...run,
|
|
20
|
+
turnCount: (run.turnCount || 0) + 1,
|
|
21
|
+
};
|
|
22
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(updated, null, 2));
|
|
23
|
+
} catch {
|
|
24
|
+
// silent fail
|
|
25
|
+
}
|
|
26
|
+
process.exit(0);
|
|
27
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const STATE_FILE = path.join(os.homedir(), '.tokengolf', 'current-run.json');
|
|
7
|
+
|
|
8
|
+
let input = '';
|
|
9
|
+
process.stdin.setEncoding('utf8');
|
|
10
|
+
process.stdin.on('data', (chunk) => {
|
|
11
|
+
input += chunk;
|
|
12
|
+
});
|
|
13
|
+
process.stdin.on('end', () => {
|
|
14
|
+
try {
|
|
15
|
+
const run = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
16
|
+
if (!run || run.status !== 'active') process.exit(0);
|
|
17
|
+
|
|
18
|
+
const updated = {
|
|
19
|
+
...run,
|
|
20
|
+
subagentSpawns: (run.subagentSpawns || 0) + 1,
|
|
21
|
+
};
|
|
22
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(updated, null, 2));
|
|
23
|
+
} catch {
|
|
24
|
+
// silent fail
|
|
25
|
+
}
|
|
26
|
+
process.exit(0);
|
|
27
|
+
});
|
|
@@ -16,12 +16,14 @@ try {
|
|
|
16
16
|
|
|
17
17
|
// Nudge at 50% — once (between 50-60%)
|
|
18
18
|
if (pct >= 0.5 && pct < 0.6) {
|
|
19
|
-
process.stdout.write(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
process.stdout.write(
|
|
20
|
+
JSON.stringify({
|
|
21
|
+
hookSpecificOutput: {
|
|
22
|
+
hookEventName: 'UserPromptSubmit',
|
|
23
|
+
additionalContext: `[TokenGolf] Halfway point. $${updated.spent.toFixed(4)} of $${updated.budget.toFixed(2)} spent. Quest: "${updated.quest}" — stay focused.`,
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
);
|
|
25
27
|
}
|
|
26
28
|
} catch {
|
|
27
29
|
// silent fail
|