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/src/lib/cost.js CHANGED
@@ -4,9 +4,9 @@ import os from 'os';
4
4
 
5
5
  // Pricing per million tokens (Anthropic list prices)
6
6
  const PRICING = {
7
- 'claude-opus-4': { input: 15.00, output: 75.00, cacheWrite: 18.75, cacheRead: 1.50 },
8
- 'claude-sonnet-4': { input: 3.00, output: 15.00, cacheWrite: 3.75, cacheRead: 0.30 },
9
- 'claude-haiku-4': { input: 0.80, output: 4.00, cacheWrite: 1.00, cacheRead: 0.08 },
7
+ 'claude-opus-4': { input: 15.0, output: 75.0, cacheWrite: 18.75, cacheRead: 1.5 },
8
+ 'claude-sonnet-4': { input: 3.0, output: 15.0, cacheWrite: 3.75, cacheRead: 0.3 },
9
+ 'claude-haiku-4': { input: 0.8, output: 4.0, cacheWrite: 1.0, cacheRead: 0.08 },
10
10
  };
11
11
 
12
12
  function getPrice(model) {
@@ -33,14 +33,17 @@ export function parseCostFromTranscript(transcriptPath) {
33
33
  const model = entry.message.model;
34
34
  const p = getPrice(model);
35
35
  const u = entry.message.usage;
36
- const cost = (u.input_tokens || 0) / 1e6 * p.input
37
- + (u.output_tokens || 0) / 1e6 * p.output
38
- + (u.cache_creation_input_tokens || 0) / 1e6 * p.cacheWrite
39
- + (u.cache_read_input_tokens || 0) / 1e6 * p.cacheRead;
36
+ const cost =
37
+ ((u.input_tokens || 0) / 1e6) * p.input +
38
+ ((u.output_tokens || 0) / 1e6) * p.output +
39
+ ((u.cache_creation_input_tokens || 0) / 1e6) * p.cacheWrite +
40
+ ((u.cache_read_input_tokens || 0) / 1e6) * p.cacheRead;
40
41
  total += cost;
41
42
  byModel[model] = (byModel[model] || 0) + cost;
42
43
  }
43
- } catch { /* skip malformed lines */ }
44
+ } catch {
45
+ /* skip malformed lines */
46
+ }
44
47
  }
45
48
  return total > 0 ? { total, byModel } : null;
46
49
  } catch {
@@ -51,9 +54,13 @@ export function parseCostFromTranscript(transcriptPath) {
51
54
  // Returns all transcript paths modified at or after sinceMs
52
55
  function findTranscriptsSince(projectDir, sinceMs) {
53
56
  try {
54
- return fs.readdirSync(projectDir)
55
- .filter(f => f.endsWith('.jsonl'))
56
- .map(f => ({ p: path.join(projectDir, f), mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
57
+ return fs
58
+ .readdirSync(projectDir)
59
+ .filter((f) => f.endsWith('.jsonl'))
60
+ .map((f) => ({
61
+ p: path.join(projectDir, f),
62
+ mtime: fs.statSync(path.join(projectDir, f)).mtimeMs,
63
+ }))
57
64
  .filter(({ mtime }) => mtime >= sinceMs)
58
65
  .map(({ p }) => p);
59
66
  } catch {
@@ -71,7 +78,7 @@ export function parseThinkingFromTranscripts(paths) {
71
78
  try {
72
79
  const entry = JSON.parse(line);
73
80
  if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
74
- const thinkBlocks = entry.message.content.filter(b => b.type === 'thinking');
81
+ const thinkBlocks = entry.message.content.filter((b) => b.type === 'thinking');
75
82
  if (thinkBlocks.length > 0) {
76
83
  invocations++;
77
84
  for (const block of thinkBlocks) {
@@ -79,9 +86,13 @@ export function parseThinkingFromTranscripts(paths) {
79
86
  }
80
87
  }
81
88
  }
82
- } catch { /* skip malformed lines */ }
89
+ } catch {
90
+ /* skip malformed lines */
91
+ }
83
92
  }
84
- } catch { /* skip unreadable files */ }
93
+ } catch {
94
+ /* skip unreadable files */
95
+ }
85
96
  }
86
97
  return invocations > 0 ? { thinkingInvocations: invocations, thinkingTokens: tokens } : null;
87
98
  }
@@ -101,19 +112,53 @@ function parseAllTranscripts(paths) {
101
112
  return total > 0 ? { total, byModel } : null;
102
113
  }
103
114
 
115
+ export function modelFamily(model) {
116
+ const m = (model || '').toLowerCase();
117
+ if (m.includes('haiku')) return 'haiku';
118
+ if (m.includes('sonnet')) return 'sonnet';
119
+ if (m.includes('opus')) return 'opus';
120
+ return 'unknown';
121
+ }
122
+
123
+ export function parseModelSwitches(transcriptPath) {
124
+ try {
125
+ const lines = fs.readFileSync(transcriptPath, 'utf8').trim().split('\n');
126
+ let lastFamily = null;
127
+ let switches = 0;
128
+ for (const line of lines) {
129
+ try {
130
+ const entry = JSON.parse(line);
131
+ if (entry.type === 'assistant' && entry.message?.model) {
132
+ const family = modelFamily(entry.message.model);
133
+ if (lastFamily !== null && family !== lastFamily) switches++;
134
+ lastFamily = family;
135
+ }
136
+ } catch {
137
+ /* skip */
138
+ }
139
+ }
140
+ return { switches };
141
+ } catch {
142
+ return { switches: 0 };
143
+ }
144
+ }
145
+
104
146
  export function findTranscript(sessionId, projectDir) {
105
147
  if (sessionId) {
106
148
  try {
107
149
  const p = path.join(projectDir, `${sessionId}.jsonl`);
108
150
  fs.accessSync(p);
109
151
  return p;
110
- } catch { /* fall through */ }
152
+ } catch {
153
+ /* fall through */
154
+ }
111
155
  }
112
156
  // Fall back to most recently modified transcript
113
157
  try {
114
- const files = fs.readdirSync(projectDir)
115
- .filter(f => f.endsWith('.jsonl'))
116
- .map(f => ({ f, mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
158
+ const files = fs
159
+ .readdirSync(projectDir)
160
+ .filter((f) => f.endsWith('.jsonl'))
161
+ .map((f) => ({ f, mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
117
162
  .sort((a, b) => b.mtime - a.mtime);
118
163
  return files.length ? path.join(projectDir, files[0].f) : null;
119
164
  } catch {
@@ -127,9 +172,10 @@ export function autoDetectCost(run) {
127
172
 
128
173
  // Scan all transcripts modified since session start to capture subagent sidechains
129
174
  const sinceMs = run.startedAt ? new Date(run.startedAt).getTime() : 0;
130
- const paths = sinceMs > 0
131
- ? findTranscriptsSince(projectDir, sinceMs)
132
- : [findTranscript(run.sessionId, projectDir)].filter(Boolean);
175
+ const paths =
176
+ sinceMs > 0
177
+ ? findTranscriptsSince(projectDir, sinceMs)
178
+ : [findTranscript(run.sessionId, projectDir)].filter(Boolean);
133
179
 
134
180
  const parsed = paths.length > 0 ? parseAllTranscripts(paths) : null;
135
181
 
@@ -140,10 +186,27 @@ export function autoDetectCost(run) {
140
186
  // Always use parsed model breakdown (Stop hook doesn't capture it)
141
187
  const modelBreakdown = parsed?.byModel ?? run.modelBreakdown ?? null;
142
188
  const thinking = parseThinkingFromTranscripts(paths);
189
+
190
+ // Model switch detection: only on primary transcript (user-initiated switches)
191
+ const primaryPath = findTranscript(run.sessionId, projectDir);
192
+ const { switches: modelSwitches } = primaryPath
193
+ ? parseModelSwitches(primaryPath)
194
+ : { switches: 0 };
195
+
196
+ // Distinct model families across ALL transcripts (includes subagents)
197
+ const families = new Set(
198
+ Object.keys(parsed?.byModel ?? {})
199
+ .map(modelFamily)
200
+ .filter((f) => f !== 'unknown')
201
+ );
202
+ const distinctModels = families.size;
203
+
143
204
  return {
144
205
  spent,
145
206
  modelBreakdown,
146
207
  thinkingInvocations: thinking?.thinkingInvocations ?? 0,
147
208
  thinkingTokens: thinking?.thinkingTokens ?? 0,
209
+ modelSwitches,
210
+ distinctModels,
148
211
  };
149
212
  }
@@ -0,0 +1,186 @@
1
+ const R = '\x1b[31m';
2
+ const G = '\x1b[32m';
3
+ const Y = '\x1b[33m';
4
+ const M = '\x1b[35m';
5
+ const C = '\x1b[36m';
6
+ const DIM = '\x1b[2m';
7
+ const BOLD = '\x1b[1m';
8
+ const RESET = '\x1b[0m';
9
+
10
+ function hudLine({ quest, model, cost, budget, ctxPct, effort, fainted, floor }) {
11
+ const m = (model || '').toLowerCase();
12
+ let modelName, modelEmoji;
13
+ if (m.includes('haiku')) {
14
+ modelName = 'Haiku';
15
+ modelEmoji = '🏹';
16
+ } else if (m.includes('sonnet')) {
17
+ modelName = 'Sonnet';
18
+ modelEmoji = '⚔️';
19
+ } else if (m.includes('opus')) {
20
+ modelName = 'Opus';
21
+ modelEmoji = '🧙';
22
+ } else {
23
+ modelName = '?';
24
+ modelEmoji = '?';
25
+ }
26
+
27
+ const labelParts = [`${modelEmoji} ${modelName}`];
28
+ if (effort) labelParts.push(effort.charAt(0).toUpperCase() + effort.slice(1));
29
+ const modelLabel = labelParts.join('·');
30
+
31
+ let tierEmoji;
32
+ if (cost < 0.1) tierEmoji = '💎';
33
+ else if (cost < 0.3) tierEmoji = '🥇';
34
+ else if (cost < 1.0) tierEmoji = '🥈';
35
+ else if (cost < 3.0) tierEmoji = '🥉';
36
+ else tierEmoji = '💸';
37
+
38
+ const sep = ` ${DIM}|${RESET} `;
39
+ let costStr, ratingStr;
40
+
41
+ if (budget) {
42
+ const pct = (cost / budget) * 100;
43
+ let rating, rc;
44
+ if (pct <= 25) {
45
+ rating = 'LEGENDARY';
46
+ rc = M;
47
+ } else if (pct <= 50) {
48
+ rating = 'EFFICIENT';
49
+ rc = C;
50
+ } else if (pct <= 75) {
51
+ rating = 'SOLID';
52
+ rc = G;
53
+ } else if (pct <= 100) {
54
+ rating = 'CLOSE CALL';
55
+ rc = Y;
56
+ } else {
57
+ rating = 'BUSTED';
58
+ rc = R;
59
+ }
60
+ costStr = `${tierEmoji} $${cost.toFixed(4)}/$${budget.toFixed(2)} ${pct.toFixed(0)}%`;
61
+ ratingStr = `${rc}${rating}${RESET}`;
62
+ } else {
63
+ costStr = `${tierEmoji} $${cost.toFixed(4)}`;
64
+ ratingStr = null;
65
+ }
66
+
67
+ let ctxStr = null;
68
+ if (ctxPct != null) {
69
+ if (ctxPct >= 90) ctxStr = `${R}📦 ${ctxPct}%${RESET}`;
70
+ else if (ctxPct >= 75) ctxStr = `${Y}🎒 ${ctxPct}%${RESET}`;
71
+ else if (ctxPct >= 50) ctxStr = `${G}🪶 ${ctxPct}%${RESET}`;
72
+ }
73
+
74
+ const icon = fainted ? '💤' : '⛳';
75
+ const prefix = `${BOLD}${C}${icon}${RESET}`;
76
+ const parts = [`${prefix} ${quest}`, costStr];
77
+ if (ratingStr) parts.push(ratingStr);
78
+ if (ctxStr) parts.push(ctxStr);
79
+ parts.push(`${C}${modelLabel}${RESET}`);
80
+ if (budget && floor) parts.push(`Floor ${floor}`);
81
+
82
+ return `${DIM} ───────────────${RESET}\n${parts.join(sep)}\n${DIM} ───────────────${RESET}`;
83
+ }
84
+
85
+ const SCENARIOS = [
86
+ {
87
+ title: 'Flow mode (passive — no quest, no budget)',
88
+ hud: { quest: 'Flow', model: 'claude-sonnet-4-6', cost: 0.0034 },
89
+ },
90
+ {
91
+ title: 'Roguelike · Sonnet · EFFICIENT',
92
+ hud: {
93
+ quest: 'add pagination to /users',
94
+ model: 'claude-sonnet-4-6',
95
+ cost: 0.54,
96
+ budget: 1.5,
97
+ ctxPct: 34,
98
+ floor: '2/5',
99
+ },
100
+ },
101
+ {
102
+ title: 'Roguelike · Sonnet·High · LEGENDARY',
103
+ hud: {
104
+ quest: 'implement SSO with SAML',
105
+ model: 'claude-sonnet-4-6',
106
+ cost: 0.41,
107
+ budget: 2.0,
108
+ ctxPct: 29,
109
+ effort: 'high',
110
+ floor: '1/5',
111
+ },
112
+ },
113
+ {
114
+ title: 'Roguelike · Opus · LEGENDARY · 🪶 context',
115
+ hud: {
116
+ quest: 'refactor auth middleware',
117
+ model: 'claude-opus-4-6',
118
+ cost: 0.82,
119
+ budget: 4.0,
120
+ ctxPct: 52,
121
+ floor: '3/5',
122
+ },
123
+ },
124
+ {
125
+ title: 'Roguelike · Haiku · CLOSE CALL · 🎒 context',
126
+ hud: {
127
+ quest: 'fix N+1 query in dashboard',
128
+ model: 'claude-haiku-4-5-20251001',
129
+ cost: 0.46,
130
+ budget: 0.5,
131
+ ctxPct: 78,
132
+ floor: '4/5',
133
+ },
134
+ },
135
+ {
136
+ title: 'Roguelike · BUSTED — over budget',
137
+ hud: {
138
+ quest: 'migrate postgres schema',
139
+ model: 'claude-sonnet-4-6',
140
+ cost: 2.41,
141
+ budget: 2.0,
142
+ floor: '2/5',
143
+ },
144
+ },
145
+ {
146
+ title: 'Roguelike · Opus · EFFICIENT · 📦 overencumbered',
147
+ hud: {
148
+ quest: 'refactor entire API layer',
149
+ model: 'claude-opus-4-6',
150
+ cost: 3.1,
151
+ budget: 10.0,
152
+ ctxPct: 91,
153
+ floor: '3/5',
154
+ },
155
+ },
156
+ {
157
+ title: 'Fainted 💤 — usage limit hit, run resumes next session',
158
+ hud: {
159
+ quest: 'write test suite for payments',
160
+ model: 'claude-sonnet-4-6',
161
+ cost: 1.22,
162
+ budget: 3.0,
163
+ ctxPct: 67,
164
+ fainted: true,
165
+ floor: '2/5',
166
+ },
167
+ },
168
+ ];
169
+
170
+ export function runDemo() {
171
+ console.log('');
172
+ console.log(`${BOLD}${C}⛳ TokenGolf — HUD Demo${RESET}`);
173
+ console.log(`${DIM}Live statusline shown in every Claude Code session${RESET}`);
174
+ console.log('');
175
+
176
+ for (const { title, hud } of SCENARIOS) {
177
+ console.log(`${DIM}${title}${RESET}`);
178
+ console.log(hudLine(hud));
179
+ console.log('');
180
+ }
181
+
182
+ console.log(
183
+ `${DIM}Run ${RESET}tokengolf start${DIM} to begin a roguelike run, or just open Claude Code — flow mode tracks automatically.${RESET}`
184
+ );
185
+ console.log('');
186
+ }
@@ -1,30 +1,30 @@
1
- import fs from "fs";
2
- import path from "path";
3
- import os from "os";
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
4
 
5
5
  // Follow symlinks (npm link creates a symlink in the nvm/node bin dir)
6
6
  // to find the actual project directory, then resolve hooks/ relative to it.
7
7
  const realEntry = fs.realpathSync(process.argv[1]);
8
- const HOOKS_DIR = path.resolve(path.dirname(realEntry), "../hooks");
9
- const STATUSLINE_PATH = path.join(HOOKS_DIR, "statusline.sh");
10
- const WRAPPER_PATH = path.join(HOOKS_DIR, "statusline-wrapper.sh");
11
- const CLAUDE_DIR = path.join(os.homedir(), ".claude");
12
- const CLAUDE_SETTINGS = path.join(CLAUDE_DIR, "settings.json");
8
+ const HOOKS_DIR = path.resolve(path.dirname(realEntry), '../hooks');
9
+ const STATUSLINE_PATH = path.join(HOOKS_DIR, 'statusline.sh');
10
+ const WRAPPER_PATH = path.join(HOOKS_DIR, 'statusline-wrapper.sh');
11
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
12
+ const CLAUDE_SETTINGS = path.join(CLAUDE_DIR, 'settings.json');
13
13
 
14
14
  export function installHooks() {
15
- console.log("\n⛳ TokenGolf — Installing Claude Code hooks\n");
15
+ console.log('\n⛳ TokenGolf — Installing Claude Code hooks\n');
16
16
 
17
17
  let settings = {};
18
18
  if (fs.existsSync(CLAUDE_SETTINGS)) {
19
19
  try {
20
- settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, "utf8"));
21
- console.log(" ✓ Found ~/.claude/settings.json");
20
+ settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, 'utf8'));
21
+ console.log(' ✓ Found ~/.claude/settings.json');
22
22
  } catch {
23
- console.log(" ⚠️ Could not parse settings.json — starting fresh");
23
+ console.log(' ⚠️ Could not parse settings.json — starting fresh');
24
24
  }
25
25
  } else {
26
26
  fs.mkdirSync(CLAUDE_DIR, { recursive: true });
27
- console.log(" ℹ️ Creating ~/.claude/settings.json");
27
+ console.log(' ℹ️ Creating ~/.claude/settings.json');
28
28
  }
29
29
 
30
30
  if (!settings.hooks) settings.hooks = {};
@@ -37,73 +37,106 @@ export function installHooks() {
37
37
  !h._tg &&
38
38
  !h.hooks?.some(
39
39
  (e) =>
40
- e.command?.includes("tokengolf") ||
41
- e.command?.includes("session-start.js") ||
42
- e.command?.includes("session-stop.js") ||
43
- e.command?.includes("session-end.js") ||
44
- e.command?.includes("pre-compact.js") ||
45
- e.command?.includes("post-tool-use.js") ||
46
- e.command?.includes("user-prompt-submit.js"),
47
- ),
40
+ e.command?.includes('tokengolf') ||
41
+ e.command?.includes('session-start.js') ||
42
+ e.command?.includes('session-stop.js') ||
43
+ e.command?.includes('session-end.js') ||
44
+ e.command?.includes('pre-compact.js') ||
45
+ e.command?.includes('post-tool-use.js') ||
46
+ e.command?.includes('post-tool-use-failure.js') ||
47
+ e.command?.includes('subagent-start.js') ||
48
+ e.command?.includes('stop.js') ||
49
+ e.command?.includes('user-prompt-submit.js')
50
+ )
48
51
  );
49
52
  settings.hooks[event] = [...filtered, { _tg: true, ...entry }];
50
53
  }
51
54
 
52
- // Remove Stop hook if present (replaced by SessionEnd)
55
+ // Remove old session-stop.js Stop hook if present (superseded by session-end.js)
53
56
  if (settings.hooks.Stop) {
54
57
  settings.hooks.Stop = (settings.hooks.Stop || []).filter(
55
- (h) =>
56
- !h._tg && !h.hooks?.some((e) => e.command?.includes("session-stop.js")),
58
+ (h) => !h._tg && !h.hooks?.some((e) => e.command?.includes('session-stop.js'))
57
59
  );
58
60
  if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
59
61
  }
60
62
 
61
- upsertHook("SessionStart", {
63
+ upsertHook('SessionStart', {
62
64
  hooks: [
63
65
  {
64
- type: "command",
65
- command: `node ${path.join(HOOKS_DIR, "session-start.js")}`,
66
+ type: 'command',
67
+ command: `node ${path.join(HOOKS_DIR, 'session-start.js')}`,
66
68
  timeout: 5,
67
69
  },
68
70
  ],
69
71
  });
70
72
 
71
- upsertHook("PostToolUse", {
72
- matcher: "",
73
+ upsertHook('PostToolUse', {
74
+ matcher: '',
73
75
  hooks: [
74
76
  {
75
- type: "command",
76
- command: `node ${path.join(HOOKS_DIR, "post-tool-use.js")}`,
77
+ type: 'command',
78
+ command: `node ${path.join(HOOKS_DIR, 'post-tool-use.js')}`,
77
79
  timeout: 5,
78
80
  },
79
81
  ],
80
82
  });
81
83
 
82
- upsertHook("UserPromptSubmit", {
84
+ upsertHook('UserPromptSubmit', {
83
85
  hooks: [
84
86
  {
85
- type: "command",
86
- command: `node ${path.join(HOOKS_DIR, "user-prompt-submit.js")}`,
87
+ type: 'command',
88
+ command: `node ${path.join(HOOKS_DIR, 'user-prompt-submit.js')}`,
87
89
  timeout: 5,
88
90
  },
89
91
  ],
90
92
  });
91
93
 
92
- upsertHook("SessionEnd", {
94
+ upsertHook('SessionEnd', {
93
95
  hooks: [
94
96
  {
95
- type: "command",
96
- command: `node ${path.join(HOOKS_DIR, "session-end.js")}`,
97
+ type: 'command',
98
+ command: `node ${path.join(HOOKS_DIR, 'session-end.js')}`,
97
99
  timeout: 30,
98
100
  },
99
101
  ],
100
102
  });
101
103
 
102
- upsertHook("PreCompact", {
104
+ upsertHook('PreCompact', {
105
+ hooks: [
106
+ {
107
+ type: 'command',
108
+ command: `node ${path.join(HOOKS_DIR, 'pre-compact.js')}`,
109
+ timeout: 5,
110
+ },
111
+ ],
112
+ });
113
+
114
+ upsertHook('PostToolUseFailure', {
115
+ matcher: '',
103
116
  hooks: [
104
117
  {
105
- type: "command",
106
- command: `node ${path.join(HOOKS_DIR, "pre-compact.js")}`,
118
+ type: 'command',
119
+ command: `node ${path.join(HOOKS_DIR, 'post-tool-use-failure.js')}`,
120
+ timeout: 5,
121
+ },
122
+ ],
123
+ });
124
+
125
+ upsertHook('SubagentStart', {
126
+ hooks: [
127
+ {
128
+ type: 'command',
129
+ command: `node ${path.join(HOOKS_DIR, 'subagent-start.js')}`,
130
+ timeout: 5,
131
+ },
132
+ ],
133
+ });
134
+
135
+ upsertHook('Stop', {
136
+ hooks: [
137
+ {
138
+ type: 'command',
139
+ command: `node ${path.join(HOOKS_DIR, 'stop.js')}`,
107
140
  timeout: 5,
108
141
  },
109
142
  ],
@@ -115,49 +148,46 @@ export function installHooks() {
115
148
  } catch {}
116
149
 
117
150
  const existing = settings.statusLine;
118
- const existingCmd =
119
- typeof existing === "string" ? existing : (existing?.command ?? null);
120
- const alreadyOurs =
121
- existingCmd === STATUSLINE_PATH || existingCmd === WRAPPER_PATH;
151
+ const existingCmd = typeof existing === 'string' ? existing : (existing?.command ?? null);
152
+ const alreadyOurs = existingCmd === STATUSLINE_PATH || existingCmd === WRAPPER_PATH;
122
153
 
123
154
  if (!alreadyOurs && existingCmd) {
124
155
  fs.writeFileSync(
125
156
  WRAPPER_PATH,
126
157
  [
127
- "#!/usr/bin/env bash",
128
- "SESSION_JSON=$(cat)",
158
+ '#!/usr/bin/env bash',
159
+ 'SESSION_JSON=$(cat)',
129
160
  `echo "$SESSION_JSON" | ${existingCmd} 2>/dev/null || true`,
130
161
  `echo "$SESSION_JSON" | bash ${STATUSLINE_PATH}`,
131
- ].join("\n") + "\n",
162
+ ].join('\n') + '\n'
132
163
  );
133
164
  fs.chmodSync(WRAPPER_PATH, 0o755);
134
165
  settings.statusLine = {
135
- type: "command",
166
+ type: 'command',
136
167
  command: WRAPPER_PATH,
137
168
  padding: 1,
138
169
  };
139
- console.log(
140
- " ✓ statusLine → wrapped your existing statusline + tokengolf HUD",
141
- );
170
+ console.log(' ✓ statusLine → wrapped your existing statusline + tokengolf HUD');
142
171
  } else if (!alreadyOurs) {
143
172
  settings.statusLine = {
144
- type: "command",
173
+ type: 'command',
145
174
  command: STATUSLINE_PATH,
146
175
  padding: 1,
147
176
  };
148
- console.log(" ✓ statusLine → live HUD in every Claude session");
177
+ console.log(' ✓ statusLine → live HUD in every Claude session');
149
178
  } else {
150
- console.log(" ✓ statusLine → already installed");
179
+ console.log(' ✓ statusLine → already installed');
151
180
  }
152
181
 
153
182
  fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
154
183
 
155
- console.log(" ✓ SessionStart → injects run context into Claude");
156
- console.log(" ✓ PostToolUse → tracks tool calls + 80% budget warning");
157
- console.log(" ✓ UserPromptSubmit → counts prompts + 50% nudge");
158
- console.log(" ✓ SessionEnd → auto-displays scorecard on /exit");
159
- console.log(
160
- "PreCompact → tracks compaction events for gear achievements",
161
- );
162
- console.log("\n Done! Start a run: tokengolf start\n");
184
+ console.log(' ✓ SessionStart → injects run context into Claude');
185
+ console.log(' ✓ PostToolUse → tracks tool calls + 80% budget warning');
186
+ console.log(' ✓ UserPromptSubmit → counts prompts + 50% nudge');
187
+ console.log(' ✓ SessionEnd → auto-displays scorecard on /exit');
188
+ console.log(' ✓ PreCompact → tracks compaction events for gear achievements');
189
+ console.log(' PostToolUseFailure → tracks failed tool calls');
190
+ console.log(' ✓ SubagentStart → tracks subagent spawns');
191
+ console.log(' Stop → tracks turn count');
192
+ console.log('\n ✅ Done! Start a run: tokengolf start\n');
163
193
  }