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.
@@ -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
+ });
@@ -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 => { input += 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(JSON.stringify({
33
- hookSpecificOutput: {
34
- hookEventName: 'PostToolUse',
35
- systemMessage: `⚠️ TokenGolf: $${updated.spent.toFixed(4)} of $${updated.budget.toFixed(2)} spent (${Math.round(pct * 100)}%). Only $${remaining} left. Be concise and targeted.`,
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
@@ -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 { stdin = fs.readFileSync('/dev/stdin', 'utf8'); } catch {}
10
+ try {
11
+ stdin = fs.readFileSync('/dev/stdin', 'utf8');
12
+ } catch {}
11
13
 
12
14
  let event = {};
13
- try { event = JSON.parse(stdin); } catch {}
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 { run = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch {}
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 || [];
@@ -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(path.join(__dir, '../src/lib/score.js'));
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', G = '\x1b[32m', Y = '\x1b[33m', C = '\x1b[36m';
28
- const M = '\x1b[35m', DIM = '\x1b[2m', RESET = '\x1b[0m', BOLD = '\x1b[1m';
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 = '╔', tr = '╗', bl = '╚', br = '╝';
32
- const h = '', v = '║';
33
- const ml = '', mr = '╣';
34
-
35
- function bar() { return bc + ml + h.repeat(W) + mr + RESET; }
36
- function top() { return bc + tl + h.repeat(W) + tr + RESET; }
37
- function bot() { return bc + bl + h.repeat(W) + br + RESET; }
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
- // Strip ANSI for length calculation
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
- ? `${BOLD}${Y}💤 FAINTED — Run Continues${RESET}`
54
- : `${BOLD}${R}💀 BUDGET BUSTED${RESET}`;
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 = `${won ? G : R}$${run.spent.toFixed(4)}${RESET}` +
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 = eff.color === 'magenta' ? M : eff.color === 'cyan' ? C : eff.color === 'green' ? G : eff.color === 'yellow' ? Y : R;
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 && run.effort !== 'medium' && effortInfo ? effortInfo.label : null,
134
+ run.effort && effortInfo ? effortInfo.label : null,
78
135
  run.fastMode ? '⚡Fast' : null,
79
- ].filter(Boolean).join('·');
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 achStr = achievements.map(a => `${a.emoji} ${a.key}`).join(' ');
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 = ti > 0
89
- ? `${M}🔮 ${ti} ultrathink${ti > 1 ? ' invocations' : ' invocation'}${RESET}`
90
- : null;
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 (achievements.length > 0) {
170
+ if (achLines.length > 0) {
106
171
  lines.push(bar());
107
- lines.push(row(achStr));
172
+ for (const line of achLines) {
173
+ lines.push(row(line));
174
+ }
108
175
  }
109
176
 
110
177
  lines.push(bar());
111
- lines.push(row(`${DIM}tokengolf scorecard${RESET} · ${DIM}tokengolf start${RESET} · ${DIM}tokengolf stats${RESET}`));
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 { stdin = fs.readFileSync('/dev/stdin', 'utf8'); } catch {}
190
+ try {
191
+ stdin = fs.readFileSync('/dev/stdin', 'utf8');
192
+ } catch {}
120
193
 
121
194
  let event = {};
122
- try { event = JSON.parse(stdin); } catch {}
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
- const result = autoDetectCost(run);
129
- if (!result) process.exit(0); // no transcripts found, nothing to save
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) status = 'resting'; // hit limit, run continues next session
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 = { ...run, spent: result.spent, modelBreakdown: result.modelBreakdown, status, fainted: true, ...thinkingFields };
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 {
@@ -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 = path.join(os.homedir(), '.tokengolf');
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 { const s = JSON.parse(fs.readFileSync(p, 'utf8')); if (s.effortLevel) return s.effortLevel; } catch {}
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(fs.readFileSync(path.join(os.homedir(), '.claude', 'settings.json'), 'utf8'));
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 { return false; }
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 { run = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch { /* no run */ }
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(JSON.stringify({
92
- hookSpecificOutput: {
93
- hookEventName: 'SessionStart',
94
- additionalContext: context,
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
  }
@@ -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 => { input += 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 { /* no run or no cost data */ }
25
+ } catch {
26
+ /* no run or no cost data */
27
+ }
24
28
  process.exit(0);
25
29
  });
@@ -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 = run.get('model', '').lower()
23
- if 'haiku' in m: model, model_emoji = 'Haiku', '🏹'
24
- elif 'sonnet' in m: model, model_emoji = 'Sonnet', '⚔️'
25
- elif 'opus' in m: model, model_emoji = 'Opus', '🧙'
26
- else: model, model_emoji = '?', '?'
27
- effort = run.get('effort')
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 and effort != 'medium': label_parts.append(effort.capitalize())
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(JSON.stringify({
20
- hookSpecificOutput: {
21
- hookEventName: 'UserPromptSubmit',
22
- additionalContext: `[TokenGolf] Halfway point. $${updated.spent.toFixed(4)} of $${updated.budget.toFixed(2)} spent. Quest: "${updated.quest}" — stay focused.`,
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