@prowl-ai/prowl-skill 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,45 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * `PreToolUse` — a Prowl call started, and now something knows it.
5
+ *
6
+ * This hook decides nothing. It never returns a permission decision, never blocks a
7
+ * call and never prints: an observer that can refuse is an observer somebody
8
+ * eventually turns off. It opens one record keyed by `tool_use_id` and exits.
9
+ *
10
+ * The matcher in `hooks.json` is a coarse filter and is documented to be
11
+ * best-effort; the precise question — *is this one of Prowl's tools, under either of
12
+ * the two names it can have* — is answered by `lib/prowl.js`, which is pure and
13
+ * fixtured. Nothing here rests on the filter being exact.
14
+ *
15
+ * Fails silent, always exits 0. A widget is never worth costing someone their turn.
16
+ */
17
+
18
+ const path = require('path');
19
+ const os = require('os');
20
+
21
+ const LIB = path.join(__dirname, '..', 'lib');
22
+
23
+ let raw = '';
24
+ process.stdin.setEncoding('utf8');
25
+ process.stdin.on('data', (c) => { raw += c; });
26
+ process.stdin.on('end', () => {
27
+ try {
28
+ // Assert the input arrived before acting on it. A hook that received nothing has
29
+ // observed nothing, and writing a record for it would invent one.
30
+ if (!raw.trim().startsWith('{')) return process.exit(0);
31
+ const data = JSON.parse(raw);
32
+
33
+ const rec = require(path.join(LIB, 'prowl.js')).classify(data);
34
+ if (!rec || rec.kind !== 'open') return process.exit(0);
35
+
36
+ require(path.join(LIB, 'state.js')).openCall(os.homedir(), data.session_id, {
37
+ id: rec.id,
38
+ tool: rec.tool,
39
+ at: new Date().toISOString(),
40
+ });
41
+ } catch (e) {
42
+ /* Silence, deliberately: the turn matters more than the widget. */
43
+ }
44
+ process.exit(0);
45
+ });
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * `SessionStart` — the sweep, and nothing else.
5
+ *
6
+ * One small file per session accumulates forever otherwise: the kind of litter nobody
7
+ * notices until it is thousands of files in a directory that also holds an API token.
8
+ *
9
+ * It prints **nothing**. A plugin that announces itself at the start of every session
10
+ * is the 854-token cost that got another pack switched off on this machine, and this
11
+ * one has nothing to say that the status line does not say better, later, and only
12
+ * when there is something to report.
13
+ *
14
+ * Fails silent, always exits 0.
15
+ */
16
+
17
+ const path = require('path');
18
+ const os = require('os');
19
+
20
+ const RETENTION_MS = 1000 * 60 * 60 * 24 * 7;
21
+
22
+ let raw = '';
23
+ process.stdin.setEncoding('utf8');
24
+ process.stdin.on('data', (c) => { raw += c; });
25
+ process.stdin.on('end', () => {
26
+ try {
27
+ require(path.join(__dirname, '..', 'lib', 'state.js'))
28
+ .prune(os.homedir(), Date.now(), RETENTION_MS);
29
+ } catch (e) {
30
+ /* Nothing to prune is the normal case, and a failed sweep is not worth a word. */
31
+ }
32
+ process.exit(0);
33
+ });
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+ /**
3
+ * The two terminal sequences this plugin emits, and the allowlist that keeps them
4
+ * from being silently dropped.
5
+ *
6
+ * Claude Code honours OSC 0, 1, 2, 9, 99, 777 and a bare BEL in a hook's
7
+ * `terminalSequence`, and **silently ignores** the field when it carries anything
8
+ * else. Silently is the operative word: a module that built a sequence outside that
9
+ * set would ship a feature that never fires and never complains, so this validates
10
+ * before returning rather than trusting itself.
11
+ *
12
+ * Every control byte is written as an escape, never as itself — a literal ESC in a
13
+ * source file survives exactly as long as nothing reformats it.
14
+ *
15
+ * Pure: values in, a string out, `''` when there is nothing safe to emit.
16
+ */
17
+
18
+ const ALLOWED_OSC = [0, 1, 2, 9, 99, 777];
19
+
20
+ const ESC = '\u001b';
21
+ const BEL = '\u0007';
22
+
23
+ /**
24
+ * Text safe to place inside an OSC payload.
25
+ *
26
+ * A semicolon separates an OSC 777 sequence's own fields and a control byte can end
27
+ * the sequence early. Both turn a notification into terminal garbage, and the second
28
+ * is an injection — the message is text that came back from a server.
29
+ */
30
+ function sanitise(text, limit) {
31
+ return String(text || '')
32
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
33
+ .replace(/;/g, ',')
34
+ .replace(/\s+/g, ' ')
35
+ .trim()
36
+ .slice(0, limit || 120);
37
+ }
38
+
39
+ /** Is every sequence in this string inside the allowlist? Every one, not the first. */
40
+ function isAllowed(seq) {
41
+ let rest = String(seq == null ? '' : seq);
42
+ if (!rest) return false;
43
+ while (rest.length) {
44
+ if (rest[0] === BEL) { rest = rest.slice(1); continue; }
45
+ const m = new RegExp(`^${ESC}\\](\\d+);[^${ESC}${BEL}]*${BEL}`).exec(rest);
46
+ if (!m || !ALLOWED_OSC.includes(Number(m[1]))) return false;
47
+ rest = rest.slice(m[0].length);
48
+ }
49
+ return true;
50
+ }
51
+
52
+ /**
53
+ * The taskbar/dock progress bar, from a fraction the server actually reported.
54
+ *
55
+ * State 1 is "normal progress". The percentage is rounded to an integer because the
56
+ * sequence carries one, and clamped only inside 0..100 — a value outside 0..1 never
57
+ * reaches here, because the adapter refuses it rather than guessing whether 42 meant
58
+ * a percent.
59
+ */
60
+ function progress(value) {
61
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) return '';
62
+ const pct = Math.max(0, Math.min(100, Math.round(value * 100)));
63
+ const seq = `${ESC}]9;4;1;${pct}${BEL}`;
64
+ return isAllowed(seq) ? seq : '';
65
+ }
66
+
67
+ /** Take the bar down. Sent when the thing it was describing has finished. */
68
+ function progressClear() {
69
+ const seq = `${ESC}]9;4;0;${BEL}`;
70
+ return isAllowed(seq) ? seq : '';
71
+ }
72
+
73
+ /**
74
+ * A desktop notification.
75
+ *
76
+ * Reserved for the wallet gate. A ping on every finished call is a ping that gets
77
+ * muted, and then the one that matters is muted with it.
78
+ */
79
+ function notify(title, body) {
80
+ const t = sanitise(title, 60);
81
+ const b = sanitise(body, 160);
82
+ if (!b) return '';
83
+ const seq = `${ESC}]777;notify;${t || 'Prowl'};${b}${BEL}`;
84
+ return isAllowed(seq) ? seq : '';
85
+ }
86
+
87
+ module.exports = { progress, progressClear, notify, isAllowed, sanitise, ALLOWED_OSC, ESC, BEL };
@@ -0,0 +1,260 @@
1
+ 'use strict';
2
+ /**
3
+ * A hook payload becomes a record, or becomes nothing.
4
+ *
5
+ * Pure and total. Every decision this plugin makes about a tool call is a function of
6
+ * the payload and lives here, so the suite can fixture it without a `HOME`, without a
7
+ * network and without Claude Code — and so the hook scripts contain no judgement at
8
+ * all, only plumbing.
9
+ *
10
+ * **The server's numbers, and only those.** `usd` appears when the response carried
11
+ * `actual_cost_usd`; `progress` appears when the response carried a fraction. Neither
12
+ * is ever estimated. A number this module could not read is an absence, and an
13
+ * absence renders differently from a zero.
14
+ */
15
+
16
+ /** Tools whose responses are worth reading for a fraction. */
17
+ const PROGRESS_TOOLS = new Set(['prowl_session_status', 'prowl_get_session', 'prowl_start_session']);
18
+
19
+ /** Statuses after which there is nothing left to show progress for. */
20
+ const TERMINAL_STATUS = new Set(['completed', 'complete', 'done', 'failed', 'error', 'cancelled', 'canceled']);
21
+
22
+ /** How deep to look for a `billing` object before giving up. Envelopes are shallow. */
23
+ const MAX_DEPTH = 6;
24
+
25
+ /**
26
+ * Is this one of Prowl's tools, under either name it can have?
27
+ *
28
+ * A directly configured server yields `mcp__prowl__prowl_analyze`; the same server
29
+ * shipped inside this plugin is namespaced by Claude Code and yields
30
+ * `mcp__plugin_prowl_prowl__prowl_analyze`. Both must be recognised, and neither may
31
+ * be recognised by a substring test that would also match somebody else's server
32
+ * called `prowl-mirror`.
33
+ */
34
+ function parseToolName(name) {
35
+ if (typeof name !== 'string') return null;
36
+ const parts = name.split('__');
37
+ if (parts.length < 3 || parts[0] !== 'mcp') return null;
38
+ const server = parts[1];
39
+ const tool = parts.slice(2).join('__');
40
+ const isProwlServer = server === 'prowl' || /(^|_)prowl$/.test(server);
41
+ if (!isProwlServer) return null;
42
+ if (!/^prowl_/.test(tool)) return null;
43
+ return { server, tool };
44
+ }
45
+
46
+ /** `prowl_call_tool` → `call_tool`. The prefix is noise once the segment says `prowl`. */
47
+ function shortName(tool) {
48
+ return typeof tool === 'string' ? tool.replace(/^prowl_/, '') : null;
49
+ }
50
+
51
+ /** Envelope keys whose value is the real body. Verified against the live server. */
52
+ const ENVELOPE_KEYS = ['result', 'content', 'data', 'response', 'output'];
53
+
54
+ /** Largest string this will attempt to parse. A report body can be very large. */
55
+ const MAX_PARSE = 512 * 1024;
56
+
57
+ /** Does this string look like it holds JSON? Cheap test before an expensive parse. */
58
+ function looksJson(s) {
59
+ if (typeof s !== 'string' || s.length > MAX_PARSE) return false;
60
+ const t = s.trim();
61
+ return (t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'));
62
+ }
63
+
64
+ /** Parse if it parses, otherwise `null`. Never throws. */
65
+ function tryParse(s) {
66
+ if (!looksJson(s)) return null;
67
+ try {
68
+ const v = JSON.parse(s);
69
+ return v && typeof v === 'object' ? v : null;
70
+ } catch (e) {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Follow single-key envelopes down to the body that actually says something.
77
+ *
78
+ * **The live server double-encodes.** `prowl_get_stats` and `prowl_list_tools` both
79
+ * answer `{"result": "{\n \"session_id\": …}"}` — an object whose one key holds a
80
+ * JSON *string*. A walker that treats `result` as a leaf stops at the doorstep, which
81
+ * is precisely what this module did until the shape was checked against a real
82
+ * response rather than against a fixture written from the documentation.
83
+ */
84
+ function unwrap(node, depth) {
85
+ let cur = node;
86
+ for (let i = 0; i < (depth || MAX_DEPTH); i += 1) {
87
+ if (!cur || typeof cur !== 'object' || Array.isArray(cur)) return cur;
88
+ const keys = Object.keys(cur);
89
+ if (keys.length !== 1 || !ENVELOPE_KEYS.includes(keys[0])) return cur;
90
+ const inner = cur[keys[0]];
91
+ const parsed = typeof inner === 'string' ? tryParse(inner) : inner;
92
+ if (!parsed || typeof parsed !== 'object') return cur;
93
+ cur = parsed;
94
+ }
95
+ return cur;
96
+ }
97
+
98
+ /** The response body, parsed and unwrapped. A body that is not JSON is not an error. */
99
+ function parseBody(toolResponse) {
100
+ if (!toolResponse) return null;
101
+ const content = typeof toolResponse === 'string' ? toolResponse : toolResponse.content;
102
+ const parsed = typeof content === 'string'
103
+ ? tryParse(content)
104
+ : (content && typeof content === 'object' ? content : null);
105
+ return parsed ? unwrap(parsed, MAX_DEPTH) : null;
106
+ }
107
+
108
+ /**
109
+ * The debited amount, wherever the envelope put it.
110
+ *
111
+ * `actual_cost_usd` is the figure the server says it took; `estimated_cost_usd` sits
112
+ * beside it in the same object and is deliberately NOT read — an estimate displayed
113
+ * next to real debits is the number that gets believed.
114
+ */
115
+ function findUsd(node, depth) {
116
+ if (!node || depth > MAX_DEPTH) return null;
117
+ // A nested JSON string is a branch, not a leaf: the server encodes bodies that way.
118
+ if (typeof node === 'string') return findUsd(tryParse(node), depth + 1);
119
+ if (typeof node !== 'object') return null;
120
+
121
+ const billing = typeof node.billing === 'string' ? tryParse(node.billing) : node.billing;
122
+ if (billing && typeof billing === 'object') {
123
+ const v = billing.actual_cost_usd;
124
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
125
+ }
126
+ if (typeof node.actual_cost_usd === 'number' && Number.isFinite(node.actual_cost_usd)) {
127
+ return node.actual_cost_usd;
128
+ }
129
+ for (const key of Object.keys(node)) {
130
+ const found = findUsd(node[key], depth + 1);
131
+ if (found !== null) return found;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * The server's own accounting, when `prowl_get_stats` is what came back.
138
+ *
139
+ * Strictly better than the sum this plugin keeps: it is what Prowl says it charged
140
+ * and how many tokens its own pipeline burned, rather than what one session happened
141
+ * to observe. Recorded separately and never merged into the sum — two numbers that
142
+ * disagree are information, and averaging them away would destroy it.
143
+ */
144
+ function findServerTotals(body) {
145
+ if (!body || typeof body !== 'object') return null;
146
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
147
+ const out = {
148
+ usd: num(body.tool_cost_usd),
149
+ total_usd: num(body.total_cost_usd),
150
+ tokens: num(body.total_tokens),
151
+ calls: num(body.tool_calls_count),
152
+ };
153
+ return Object.values(out).some((v) => v !== null) ? out : null;
154
+ }
155
+
156
+ /** A fraction, and only if it really is one. `0` is a real progress value. */
157
+ function findProgress(body) {
158
+ if (!body || typeof body !== 'object') return null;
159
+ const v = body.progress;
160
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1) return v;
161
+ return null;
162
+ }
163
+
164
+ /**
165
+ * Did this call fail, and was it the wallet that stopped it?
166
+ *
167
+ * The wallet case is recognised by wording **and** by shape, because either alone is
168
+ * brittle: a code field can be renamed and a message can be rephrased, and a check
169
+ * resting on one of them silently stops firing when that happens.
170
+ */
171
+ function findError(toolResponse, body) {
172
+ const typed = toolResponse && toolResponse.type === 'error';
173
+ const bodyError = body && (body.error || body.isError === true);
174
+ if (!typed && !bodyError) return null;
175
+
176
+ const text = [
177
+ toolResponse && typeof toolResponse.content === 'string' ? toolResponse.content : '',
178
+ body && typeof body.error === 'string' ? body.error : '',
179
+ body && body.error && typeof body.error.message === 'string' ? body.error.message : '',
180
+ body && typeof body.message === 'string' ? body.message : '',
181
+ ].join(' ');
182
+
183
+ const code = (body && (body.code || (body.error && body.error.code))) || '';
184
+ const gate = /insufficient[\s_-]*(balance|funds|credit)/i.test(text)
185
+ || /\bwallet\b[^.]{0,40}\b(empty|balance|insufficient)\b/i.test(text)
186
+ || /insufficient[_-]?balance/i.test(String(code));
187
+
188
+ return { gate, message: text.trim().slice(0, 300) || 'the call failed with no message' };
189
+ }
190
+
191
+ /**
192
+ * A stable fingerprint for "have we already said this?".
193
+ *
194
+ * The tool plus the class of failure, never the message: a message carrying an id or
195
+ * a timestamp would be a new fingerprint every time, and the once-only rule would
196
+ * quietly become a rule that says everything.
197
+ */
198
+ function fingerprint(tool, error) {
199
+ if (!error) return null;
200
+ return `${tool || 'unknown'}:${error.gate ? 'wallet-gate' : 'error'}`;
201
+ }
202
+
203
+ /**
204
+ * The payload, classified.
205
+ *
206
+ * Returns `null` for anything that is not a Prowl tool call — which is almost every
207
+ * payload this process will ever see, and the cheapest possible answer for it.
208
+ */
209
+ function classify(payload) {
210
+ if (!payload || typeof payload !== 'object') return null;
211
+ const parsed = parseToolName(payload.tool_name);
212
+ if (!parsed) return null;
213
+
214
+ const id = typeof payload.tool_use_id === 'string' ? payload.tool_use_id : null;
215
+ if (!id) return null;
216
+
217
+ const base = { tool: parsed.tool, short: shortName(parsed.tool), id };
218
+
219
+ if (payload.hook_event_name === 'PreToolUse') {
220
+ return Object.assign(base, { kind: 'open' });
221
+ }
222
+
223
+ const body = parseBody(payload.tool_response);
224
+ const error = findError(payload.tool_response, body);
225
+ const rec = Object.assign(base, {
226
+ kind: 'close',
227
+ ok: !error,
228
+ usd: findUsd(body, 0),
229
+ error,
230
+ fingerprint: fingerprint(parsed.tool, error),
231
+ });
232
+
233
+ if (parsed.tool === 'prowl_get_stats') {
234
+ const server = findServerTotals(body);
235
+ if (server) rec.server = server;
236
+ }
237
+
238
+ if (PROGRESS_TOOLS.has(parsed.tool)) {
239
+ const p = findProgress(body);
240
+ if (p !== null) {
241
+ rec.progress = p;
242
+ rec.progress_session = (body && typeof body.session_id === 'string') ? body.session_id : null;
243
+ }
244
+ // The bar belongs to the async session, not to the poll that read it. Without a
245
+ // terminal status the caller would have to guess when to take it down, and the
246
+ // obvious guess — "when this tool call ends" — takes it down after every poll.
247
+ const status = body && typeof body.status === 'string' ? body.status.toLowerCase() : null;
248
+ if (status) {
249
+ rec.status = status;
250
+ rec.finished = TERMINAL_STATUS.has(status);
251
+ }
252
+ }
253
+ return rec;
254
+ }
255
+
256
+ module.exports = {
257
+ classify, parseToolName, shortName, parseBody, unwrap, tryParse, looksJson,
258
+ findUsd, findProgress, findError, findServerTotals, fingerprint,
259
+ PROGRESS_TOOLS, TERMINAL_STATUS, ENVELOPE_KEYS,
260
+ };
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+ /**
3
+ * Records become a line, a notice, or nothing.
4
+ *
5
+ * Three rules, inherited rather than invented, because each one has already been got
6
+ * wrong somewhere and the fix is cheaper than the rediscovery:
7
+ *
8
+ * 1. **Every number is borrowed.** Nothing here computes a figure it was not handed.
9
+ * The one arithmetic this module does is formatting.
10
+ * 2. **Absent is a word, never a zero.** `$—` says the server has reported no charge;
11
+ * `$0.00` says it charged nothing. A renderer that prints them alike turns "we do
12
+ * not know" into "it was free".
13
+ * 3. **Nothing to say prints nothing.** An empty string is the honest rendering of a
14
+ * session that has not called Prowl, and it is better than a line that says `0`
15
+ * in a repository where the plugin was never used.
16
+ *
17
+ * Pure: state in, string out.
18
+ */
19
+
20
+ const SEP = ' · ';
21
+
22
+ /** `12s`, `1:12`, `1h 05m` — the coarsest unit that is still true. */
23
+ function elapsed(fromISO, nowMs) {
24
+ const from = Date.parse(fromISO || '');
25
+ if (!Number.isFinite(from) || !Number.isFinite(nowMs) || nowMs < from) return null;
26
+ const s = Math.floor((nowMs - from) / 1000);
27
+ if (s < 60) return `${s}s`;
28
+ const m = Math.floor(s / 60);
29
+ if (m < 60) return `${m}:${String(s % 60).padStart(2, '0')}`;
30
+ return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`;
31
+ }
32
+
33
+ /**
34
+ * Money, at the precision the amount deserves.
35
+ *
36
+ * Two cents of rounding is nothing on a bill and everything on a **unit price**: a
37
+ * metered call at `$0.0325` shown as `$0.03` is off by seven percent, and a hundred of
38
+ * them are off by three dollars. So under a dollar the figure keeps four decimals,
39
+ * trailing zeros trimmed to two so `$0.31` does not become `$0.3100`; above a dollar
40
+ * the cents are the whole story.
41
+ *
42
+ * Caught by a fixture asserting the server's own billed figure and getting `$0.03`
43
+ * back — the same class as printing an absence as a zero, one decimal place down.
44
+ */
45
+ function money(v) {
46
+ if (typeof v !== 'number' || !Number.isFinite(v)) return null;
47
+ if (v === 0) return '$0.00';
48
+ if (v >= 1) return `$${v.toFixed(2)}`;
49
+ const four = v.toFixed(4).replace(/(\.\d\d[1-9]?)0+$/, '$1');
50
+ return `$${four}`;
51
+ }
52
+
53
+ /** The in-flight calls, oldest first — the one that has been waiting longest leads. */
54
+ function inFlight(state) {
55
+ const open = (state && state.open) || {};
56
+ return Object.keys(open)
57
+ .map((id) => ({ id, tool: open[id].tool, at: open[id].at }))
58
+ .sort((a, b) => String(a.at).localeCompare(String(b.at)));
59
+ }
60
+
61
+ /** `call_tool` from `prowl_call_tool`; whatever it is when it is not that. */
62
+ function short(tool) {
63
+ return typeof tool === 'string' ? tool.replace(/^prowl_/, '') : '?';
64
+ }
65
+
66
+ /**
67
+ * The ticker: the last calls, newest first, each with its verdict.
68
+ *
69
+ * Newest first because the question a ticker answers is "what just happened", and
70
+ * the answer should not be at the end of a line that may be truncated.
71
+ */
72
+ function ticker(state, limit) {
73
+ const recent = (state && state.recent) || [];
74
+ const n = typeof limit === 'number' ? limit : recent.length;
75
+ return recent.slice(-Math.max(0, n)).reverse()
76
+ .map((r) => `${short(r.tool)}${r.ok ? '✓' : '✗'}`)
77
+ .join(' ');
78
+ }
79
+
80
+ /**
81
+ * One row, or `''`.
82
+ *
83
+ * `opts.cost` and `opts.context` are the status-line payload's own objects; no hook
84
+ * event carries them, so a caller that has none simply omits those segments rather
85
+ * than filling them in.
86
+ */
87
+ function line(state, opts) {
88
+ const o = opts || {};
89
+ const s = state || {};
90
+ const totals = s.totals || {};
91
+ const running = inFlight(s);
92
+ const calls = typeof totals.calls === 'number' ? totals.calls : 0;
93
+
94
+ // Nothing has happened and nothing is happening: say nothing.
95
+ if (!calls && !running.length) return '';
96
+
97
+ const parts = ['prowl'];
98
+
99
+ if (running.length) {
100
+ const head = running[0];
101
+ const since = elapsed(head.at, o.now);
102
+ const more = running.length > 1 ? ` +${running.length - 1}` : '';
103
+ parts.push(`⟳ ${short(head.tool)}${since ? ` ${since}` : ''}${more}`);
104
+ }
105
+
106
+ if (calls) {
107
+ const ok = typeof totals.ok === 'number' ? totals.ok : 0;
108
+ const failed = typeof totals.failed === 'number' ? totals.failed : 0;
109
+ // "1 calls" is what a widget nobody looked at says. Found by looking at one.
110
+ parts.push(`${calls} ${calls === 1 ? 'call' : 'calls'} ${ok}✓${failed ? ` ${failed}✗` : ''}`);
111
+ // The server's own figure wins when it volunteered one — it is what Prowl says it
112
+ // charged, against what this session happened to observe. Marked, so the two are
113
+ // never read as the same claim.
114
+ const server = s.server && typeof s.server.usd === 'number' ? money(s.server.usd) : null;
115
+ // `$—` is not `$0.00`: the first says no response has reported a charge, the
116
+ // second says a response reported one and it was nothing.
117
+ parts.push(server ? `${server} billed` : (money(totals.usd) || '$—'));
118
+ }
119
+
120
+ if (s.progress && typeof s.progress.value === 'number') {
121
+ parts.push(`${Math.round(s.progress.value * 100)}% done`);
122
+ }
123
+
124
+ const used = o.context && typeof o.context.used_percentage === 'number'
125
+ ? Math.round(o.context.used_percentage) : null;
126
+ if (used !== null) parts.push(`ctx ${used}%`);
127
+
128
+ const claude = o.cost && typeof o.cost.total_cost_usd === 'number' ? money(o.cost.total_cost_usd) : null;
129
+ if (claude) parts.push(`claude ${claude}`);
130
+
131
+ const tick = ticker(s, o.tickerLimit);
132
+ if (tick) parts.push(`▸ ${tick}`);
133
+
134
+ return fit(parts, o.columns);
135
+ }
136
+
137
+ /**
138
+ * Join, and drop from the end until it fits the terminal.
139
+ *
140
+ * The status-line reference is explicit that a script cannot read the terminal width
141
+ * itself and must use `COLUMNS`. Where the caller knows it, the ticker is shed first:
142
+ * it is the segment whose absence costs the least, and the segments before it answer
143
+ * questions the ticker does not.
144
+ */
145
+ function fit(parts, columns) {
146
+ const width = typeof columns === 'number' && columns > 20 ? columns : null;
147
+ let out = parts.join(SEP);
148
+ if (!width) return out;
149
+ const kept = parts.slice();
150
+ while (kept.length > 1 && visibleLength(out) > width) {
151
+ kept.pop();
152
+ out = kept.join(SEP);
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /** Length as the terminal sees it: the glyphs used here are all single-width. */
158
+ function visibleLength(text) {
159
+ return Array.from(String(text || '')).length;
160
+ }
161
+
162
+ /**
163
+ * The chat notice for the two things worth interrupting for.
164
+ *
165
+ * Everything else this plugin knows belongs on the status line, where it costs the
166
+ * reader nothing. A notice that fires on every finished call is a notice that gets
167
+ * skimmed, and then the wallet gate is skimmed with it.
168
+ */
169
+ function block(record) {
170
+ if (!record || record.ok !== false || !record.error) return '';
171
+ const tool = short(record.tool);
172
+ if (record.error.gate) {
173
+ return `[prowl] \`${tool}\` was stopped before it ran: the wallet has no balance for it. `
174
+ + 'Nothing was charged. Top up at MCP Home (https://prowl.chat) — or run the free '
175
+ + 'catalogue tools (`prowl_list_tools`, `prowl_search_tools`, `prowl_tool_info`) meanwhile.';
176
+ }
177
+ return `[prowl] \`${tool}\` failed: ${record.error.message}`;
178
+ }
179
+
180
+ module.exports = { line, block, ticker, elapsed, money, inFlight, short, fit, visibleLength, SEP };