@kvali/cli 0.0.0 → 0.6.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Giorgi Khiladze
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # kvali
2
+
3
+ **One progress bar for every AI coding agent, in your terminal.** Live task progress, the current step and what the agent is doing right now, tokens and phone alerts for Claude Code, Codex, Gemini CLI, Antigravity and any agent that reads `AGENTS.md`. Grouped by project. Local and read-only.
4
+
5
+ ```bash
6
+ npx @kvali/cli # live dashboard (q to quit)
7
+ npx @kvali/cli --here # only the project in this folder
8
+ npx @kvali/cli --json # one snapshot as JSON, for scripts
9
+ ```
10
+
11
+ Installed once with `npm install -g @kvali/cli`, the command is just `kvali`, as below.
12
+
13
+ ## One-time setup
14
+
15
+ ```bash
16
+ kvali setup # alerts and automatic plans for every agent found
17
+ kvali phone # progress and alerts on your phone (shows a QR code for the free ntfy app)
18
+ ```
19
+
20
+ `kvali setup` installs Kvali's Claude Code plugin with Claude Code's own command (a live progress pane beside the chat, exact status, alerts) and asks Codex and Gemini CLI / Antigravity to keep a plan, so every task gets a real progress bar. `kvali setup off` removes all of it.
21
+
22
+ ## Without a dashboard
23
+
24
+ ```bash
25
+ kvali watch # hooks and phone notifications only, e.g. in the background
26
+ ```
27
+
28
+ ## More
29
+
30
+ - `kvali phone test` / `kvali phone off`; options `--private` (no project or step names in notifications), `--no-steps` (alerts only, no progress notifications), `--server <url>` for your own ntfy server.
31
+ - `kvali protocol [dir]` adds the progress protocol to a repository's `AGENTS.md` for agents Kvali has no adapter for (Cursor, Copilot, …).
32
+ - `kvali --help` lists everything.
33
+
34
+ Kvali also comes as an editor extension (VS Code, Antigravity, Cursor, Windsurf): search for "Kvali" in the Extensions view. Source and docs: https://github.com/webGrandpa/kvali
35
+
36
+ MIT © Giorgi Khiladze
@@ -0,0 +1,37 @@
1
+ # Kvali for Claude Code
2
+
3
+ Live progress for long Claude Code tasks, in a pane beside the chat:
4
+
5
+ - **The plan** as a progress bar (Claude Code's Tasks or TodoWrite), with the step in progress.
6
+ - **The current step on a clock**: "4m 12s on this step · usually ~6m", from how long this plan's earlier steps took.
7
+ - **What Claude is doing right now**, straight from Claude Code's own events, so nothing is guessed: thinking, writing the answer, preparing a tool call, running “pnpm test” for 41s, waiting for your answer, or "no data from the API for 1m 30s" when the connection stalls.
8
+ - **The latest actions** with their durations, and the **tokens** the session spent with how full the context window is.
9
+
10
+ The status line shows the short version (`Kvali · 3/7 · 43% · Writing tests · thinking`), and a long turn ends with a notification.
11
+
12
+ It works wherever Claude Code runs: the terminal, the desktop app, the VS Code extension. The pane opens by itself where it docks beside the chat (desktop app, VS Code); type `/kvali` to open it anywhere else.
13
+
14
+ ## On your phone
15
+
16
+ With phone notifications set up (`kvali phone` or **Kvali: Send Notifications to My Phone** in the editor), the plugin sends this session's pushes through ntfy by itself, no editor needed: the turn's progress through its plan as one silent notification that each finished step updates (at most once a minute) and the turn's end replaces, and alerts when a question has waited a minute for your answer (one answered at the computer sends nothing), a turn of two minutes or more (or one whose progress you followed) finished, or stopped mid-plan and waited a minute, an error, or no data from the API. It reads the shared setup in `~/.kvali/config.json` once a minute.
17
+
18
+ ## With the Kvali editor extension
19
+
20
+ The plugin also writes a small heartbeat file per session to `~/.kvali/live/claude-code/<session>.mod.json`. The [Kvali extension](../../README.md) reads it, so its sidebar shows the same exact state for every Claude Code session on your machine, and it can tell "thinking for 6 minutes" from "Claude Code was closed or froze". The heartbeat never leaves your computer.
21
+
22
+ ## Install
23
+
24
+ - **With Kvali's editor extension** (VS Code, Antigravity, Cursor, Windsurf): click **Turn on** in the Kvali view. Kvali installs this plugin with Claude Code's own command and keeps it updated.
25
+ - **With Kvali's CLI**: `npx @kvali/cli setup`.
26
+ - **On its own**, at the prompt of a terminal Claude Code session: `/plugin install kvali --marketplace webGrandpa/kvali` (from a shell: `claude plugin install kvali --marketplace webGrandpa/kvali`). In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with `webGrandpa/kvali`, then Add plugin.
27
+
28
+ Then start a new Claude Code session (or run `/reload-plugins`).
29
+
30
+ ## Development
31
+
32
+ ```bash
33
+ claude plugin validate packages/claude-plugin
34
+ claude plugin test packages/claude-plugin
35
+ ```
36
+
37
+ `hooks/model.ts` holds the logic (plan, step timing, the "is it working?" line), `hooks/register.tsx` connects it to Claude Code's events and draws the pane, `types/index.d.ts` declares the state it keeps.
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,458 @@
1
+ // Pure logic of the Kvali mod: no `$`, so the tests can call it directly.
2
+
3
+ import type { KvAction, KvLive, KvPhase, KvPlan, KvTask, KvUsage } from '../types'
4
+
5
+ /** No piece of the response for this long while a request is open: say so (a fact, not a guess). */
6
+ export const STALL_MS = 90_000
7
+ /** Completions closer than this are one batch update, not a step's duration. */
8
+ const BATCH_MS = 10_000
9
+
10
+ export const PLAN_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'])
11
+
12
+ /** Tools whose call waits for the person while it runs. */
13
+ export const ASKS_PERSON: Record<string, string> = {
14
+ AskUserQuestion: 'Claude asked you a question',
15
+ ExitPlanMode: 'A plan is waiting for your approval',
16
+ }
17
+
18
+ export const EMPTY_PLAN: KvPlan = { tasks: [], durations: [], fileDurations: [] }
19
+ export const EMPTY_USAGE: KvUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, isWholeSession: false }
20
+ export const IDLE: KvLive = { phase: 'idle', since: 0 }
21
+
22
+ export const keyOf = (t: KvTask): string => t.title.trim().toLowerCase() || `#${t.id}`
23
+
24
+ export function median(xs: readonly number[]): number | undefined {
25
+ if (!xs.length) return undefined
26
+ const s = [...xs].sort((a, b) => a - b)
27
+ const m = Math.floor(s.length / 2)
28
+ return s.length % 2 ? s[m] : (s[m - 1]! + s[m]!) / 2
29
+ }
30
+
31
+ export function progress(tasks: readonly KvTask[]) {
32
+ const total = tasks.length
33
+ const done = tasks.filter(t => t.status === 'completed').length
34
+ const inProgress = tasks.filter(t => t.status === 'in_progress').length
35
+ const current = tasks.find(t => t.status === 'in_progress')
36
+ return { total, done, inProgress, percent: total ? Math.round((done / total) * 100) : 0, current }
37
+ }
38
+
39
+ /** How long a step of this plan usually takes: the larger sample wins. */
40
+ export function typicalMs(plan: KvPlan): number | undefined {
41
+ return median(plan.durations.length >= plan.fileDurations.length ? plan.durations : plan.fileDurations)
42
+ }
43
+
44
+ function normStatus(s: unknown): KvTask['status'] {
45
+ return s === 'completed' ? 'completed' : s === 'in_progress' ? 'in_progress' : 'pending'
46
+ }
47
+
48
+ /** One task file of Claude Code's Tasks (`<config>/tasks/<session>/<id>.json`). */
49
+ export function parseTaskFile(text: string, fallbackId: string): KvTask | undefined {
50
+ try {
51
+ const t = JSON.parse(text) as Record<string, unknown>
52
+ if (t.status === 'deleted') return undefined
53
+ return {
54
+ id: String(t.id ?? fallbackId),
55
+ title: String(t.subject ?? t.title ?? ''),
56
+ active: typeof t.activeForm === 'string' ? t.activeForm : undefined,
57
+ status: normStatus(t.status),
58
+ }
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+
64
+ export function sortTasks(tasks: KvTask[]): KvTask[] {
65
+ return tasks.sort((a, b) => Number(a.id) - Number(b.id) || a.id.localeCompare(b.id))
66
+ }
67
+
68
+ /**
69
+ * Applies a plan tool call to the task list. `input` is the call's arguments,
70
+ * `result` the tool's structured result (TaskCreate answers the new id).
71
+ */
72
+ export function applyPlanTool(tasks: readonly KvTask[], tool: string, input: Record<string, unknown>, result: unknown): KvTask[] {
73
+ if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
74
+ return (input.todos as Record<string, unknown>[]).map((t, i) => ({
75
+ id: `todo-${i + 1}`,
76
+ title: String(t.content ?? ''),
77
+ active: typeof t.activeForm === 'string' ? t.activeForm : undefined,
78
+ status: normStatus(t.status),
79
+ }))
80
+ }
81
+ if (tool === 'TaskCreate') {
82
+ const task = (result as { task?: { id?: unknown; subject?: unknown } } | undefined)?.task
83
+ if (task?.id === undefined) return [...tasks]
84
+ const id = String(task.id)
85
+ if (tasks.some(t => t.id === id)) return [...tasks]
86
+ return sortTasks([
87
+ ...tasks,
88
+ { id, title: String(task.subject ?? input.subject ?? ''), active: typeof input.activeForm === 'string' ? input.activeForm : undefined, status: 'pending' },
89
+ ])
90
+ }
91
+ if (tool === 'TaskUpdate' && input.taskId !== undefined) {
92
+ const id = String(input.taskId)
93
+ if (input.status === 'deleted') return tasks.filter(t => t.id !== id)
94
+ return tasks.map(t =>
95
+ t.id !== id
96
+ ? t
97
+ : {
98
+ ...t,
99
+ status: input.status === undefined ? t.status : normStatus(input.status),
100
+ title: typeof input.subject === 'string' ? input.subject : t.title,
101
+ active: typeof input.activeForm === 'string' ? input.activeForm : t.active,
102
+ },
103
+ )
104
+ }
105
+ return [...tasks]
106
+ }
107
+
108
+ /**
109
+ * Moves the plan to a new task list: closes the step that finished (its time joins
110
+ * `durations`) and starts timing the one now in progress. `fileTimes` (task id to the
111
+ * mtime of its file) gives the true start of a step that began before Kvali saw it.
112
+ */
113
+ export function advance(prev: KvPlan, tasks: KvTask[], now: number, fileTimes?: ReadonlyMap<string, number>): KvPlan {
114
+ const durations = [...prev.durations]
115
+ if (prev.step) {
116
+ const before = prev.tasks.find(t => keyOf(t) === prev.step!.key)
117
+ const after = tasks.find(t => keyOf(t) === prev.step!.key)
118
+ if (after?.status === 'completed' && before?.status !== 'completed' && now >= prev.step.startedAt) {
119
+ durations.push(now - prev.step.startedAt)
120
+ }
121
+ }
122
+ const cur = tasks.find(t => t.status === 'in_progress')
123
+ let step = prev.step
124
+ if (!cur) step = undefined
125
+ else {
126
+ const fileStart = fileTimes?.get(cur.id)
127
+ if (!step || step.key !== keyOf(cur)) step = { key: keyOf(cur), startedAt: fileStart !== undefined ? Math.min(fileStart, now) : now, actions: 0 }
128
+ else if (fileStart !== undefined && fileStart < step.startedAt) step = { ...step, startedAt: fileStart }
129
+ }
130
+ let fileDurations = prev.fileDurations
131
+ if (fileTimes) {
132
+ const done = tasks
133
+ .filter(t => t.status === 'completed')
134
+ .map(t => fileTimes.get(t.id))
135
+ .filter((x): x is number => x !== undefined)
136
+ .sort((a, b) => a - b)
137
+ fileDurations = []
138
+ for (let i = 1; i < done.length; i += 1) if (done[i]! - done[i - 1]! >= BATCH_MS) fileDurations.push(done[i]! - done[i - 1]!)
139
+ }
140
+ return { tasks, step, durations: durations.slice(-50), fileDurations }
141
+ }
142
+
143
+ function truncate(text: string, max: number): string {
144
+ const one = text.replace(/\s+/g, ' ').trim()
145
+ return one.length <= max ? one : `${one.slice(0, max - 1).trimEnd()}…`
146
+ }
147
+
148
+ function relPath(p: unknown, cwd: string): string | undefined {
149
+ if (typeof p !== 'string' || !p) return undefined
150
+ if (cwd && p.startsWith(`${cwd}/`)) return p.slice(cwd.length + 1)
151
+ return p
152
+ }
153
+
154
+ /** A short label and detail for a tool call, the way a person would say it. */
155
+ export function describeTool(tool: string, input: Record<string, unknown>, cwd: string): { label: string; detail?: string } {
156
+ const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : undefined)
157
+ switch (tool) {
158
+ case 'Bash': {
159
+ const cmd = str('command')
160
+ const desc = str('description')
161
+ return { label: desc ? truncate(desc, 60) : 'Run a command', detail: cmd ? truncate(cmd.split('\n')[0] ?? cmd, 80) : undefined }
162
+ }
163
+ case 'Read':
164
+ case 'Edit':
165
+ case 'Write':
166
+ case 'MultiEdit':
167
+ case 'NotebookEdit':
168
+ return { label: tool, detail: relPath(input.file_path ?? input.notebook_path, cwd) }
169
+ case 'Grep':
170
+ case 'Glob':
171
+ return { label: tool === 'Grep' ? 'Search' : 'Find files', detail: str('pattern') && truncate(str('pattern')!, 60) }
172
+ case 'WebFetch':
173
+ return { label: 'Read a web page', detail: str('url') && truncate(str('url')!, 70) }
174
+ case 'WebSearch':
175
+ return { label: 'Search the web', detail: str('query') && truncate(str('query')!, 70) }
176
+ case 'Task':
177
+ case 'Agent':
178
+ return { label: 'Subagent', detail: str('description') && truncate(str('description')!, 70) }
179
+ default: {
180
+ if (PLAN_TOOLS.has(tool)) return { label: 'Updating the plan' }
181
+ if (ASKS_PERSON[tool]) return { label: ASKS_PERSON[tool]! }
182
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
183
+ if (mcp) return { label: `${mcp[1]}: ${mcp[2]}` }
184
+ return { label: tool }
185
+ }
186
+ }
187
+ }
188
+
189
+ export function fmtDuration(ms: number): string {
190
+ const s = Math.max(0, Math.round(ms / 1_000))
191
+ if (s < 60) return `${s}s`
192
+ const m = Math.floor(s / 60)
193
+ if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
194
+ const h = Math.floor(m / 60)
195
+ if (h < 24) return `${h}h ${String(m % 60).padStart(2, '0')}m`
196
+ return `${Math.floor(h / 24)}d ${h % 24}h`
197
+ }
198
+
199
+ /** Minutes only, for places that should not change every second. */
200
+ export function fmtMinutes(ms: number): string {
201
+ const m = Math.floor(Math.max(0, ms) / 60_000)
202
+ if (m < 1) return '<1m'
203
+ if (m < 60) return `${m}m`
204
+ return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
205
+ }
206
+
207
+ export function fmtTokens(n: number): string {
208
+ if (n < 1_000) return String(Math.round(n))
209
+ if (n < 1_000_000) return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}k`
210
+ return `${(n / 1_000_000).toFixed(n < 10_000_000 ? 2 : 1)}M`
211
+ }
212
+
213
+ export const totalTokens = (u: KvUsage): number => u.input + u.output + u.cacheRead + u.cacheWrite
214
+
215
+ /** The context window the last request filled: 1M for `[1m]` models or once a request went past 200k. */
216
+ export function contextPercent(u: KvUsage): number | undefined {
217
+ if (!u.context) return undefined
218
+ const window = /\[1m\]/i.test(u.model ?? '') || u.context > 200_000 ? 1_000_000 : 200_000
219
+ return Math.min(100, Math.round((u.context / window) * 100))
220
+ }
221
+
222
+ /** A text progress bar: done, in progress, the rest. Text draws the same on every surface. */
223
+ export function barCells(done: number, inProgress: number, total: number, width: number): { done: string; active: string; rest: string } {
224
+ const w = Math.max(4, Math.floor(width))
225
+ if (!total) return { done: '', active: '', rest: '░'.repeat(w) }
226
+ const d = Math.min(w, Math.round((done / total) * w))
227
+ const a = Math.min(w - d, inProgress ? Math.max(1, Math.round((inProgress / total) * w)) : 0)
228
+ return { done: '█'.repeat(d), active: '▒'.repeat(a), rest: '░'.repeat(Math.max(0, w - d - a)) }
229
+ }
230
+
231
+ export type Tone = 'ok' | 'busy' | 'warn' | 'error' | 'muted'
232
+
233
+ /** The one-line answer to "is it working?", from what the engine reported. */
234
+ export function liveLine(live: KvLive, now: number): { icon: string; text: string; tone: Tone } {
235
+ const for_ = fmtDuration(now - live.since)
236
+ const what = live.label ? `“${live.label}”` : ''
237
+ switch (live.phase) {
238
+ case 'requesting':
239
+ return { icon: '●', text: `Waiting for the model · ${for_}`, tone: 'ok' }
240
+ case 'thinking':
241
+ return { icon: '●', text: `Thinking · ${for_}`, tone: 'ok' }
242
+ case 'writing':
243
+ return { icon: '●', text: `Writing the answer · ${for_}`, tone: 'ok' }
244
+ case 'tool-input':
245
+ return { icon: '●', text: `Preparing ${live.label ?? 'a tool call'} · ${for_}`, tone: 'ok' }
246
+ case 'running':
247
+ return { icon: '◌', text: `Running ${what || 'a tool'} · ${for_}`, tone: 'busy' }
248
+ case 'needs-you':
249
+ return { icon: '!', text: `Needs you: ${live.label ?? 'waiting for your answer'}`, tone: 'warn' }
250
+ case 'stalled':
251
+ return {
252
+ icon: '⚠',
253
+ text: `No data from the API for ${fmtDuration(now - (live.lastDataAt ?? live.since))}. The connection may be down; Claude Code retries on its own.`,
254
+ tone: 'warn',
255
+ }
256
+ case 'done':
257
+ return { icon: '✓', text: live.lastTurnMs ? `Done in ${fmtDuration(live.lastTurnMs)}` : 'Done', tone: 'ok' }
258
+ case 'interrupted':
259
+ return { icon: '■', text: 'Stopped by you', tone: 'muted' }
260
+ case 'error':
261
+ return { icon: '✕', text: `Stopped: ${live.label ?? 'an API error ended the turn'}`, tone: 'error' }
262
+ case 'ended':
263
+ return { icon: '○', text: 'Session ended', tone: 'muted' }
264
+ default:
265
+ return { icon: '○', text: 'Idle', tone: 'muted' }
266
+ }
267
+ }
268
+
269
+ export function isActivePhase(p: KvPhase): boolean {
270
+ return p !== 'idle' && p !== 'done' && p !== 'interrupted' && p !== 'error' && p !== 'ended'
271
+ }
272
+
273
+ /** The status line text: short, and it changes only when something real changes. */
274
+ export function statusText(plan: KvPlan, live: KvLive, now: number): string | undefined {
275
+ const p = progress(plan.tasks)
276
+ const step = p.current ? truncate(p.current.active ?? p.current.title, 32) : undefined
277
+ const head = p.total ? `${p.done}/${p.total} · ${p.percent}%` : undefined
278
+ const active = isActivePhase(live.phase)
279
+ const state = (() => {
280
+ switch (live.phase) {
281
+ case 'needs-you':
282
+ return 'needs you'
283
+ case 'stalled':
284
+ return '⚠ no data from the API'
285
+ case 'running':
286
+ return `running ${fmtMinutes(now - live.since)}`
287
+ case 'requesting':
288
+ case 'thinking':
289
+ case 'writing':
290
+ case 'tool-input':
291
+ return live.phase === 'writing' ? 'writing' : 'thinking'
292
+ case 'error':
293
+ return 'stopped: error'
294
+ default:
295
+ return undefined
296
+ }
297
+ })()
298
+ if (!head && !active) return undefined
299
+ return ['Kvali', head, step, state].filter(Boolean).join(' · ')
300
+ }
301
+
302
+ /** Keeps the latest actions, newest first. */
303
+ export function pushAction(list: readonly KvAction[], a: KvAction, max = 6): KvAction[] {
304
+ return [a, ...list.filter(x => x.id !== a.id)].slice(0, max)
305
+ }
306
+
307
+ export function endAction(list: readonly KvAction[], id: string, at: number, isError: boolean): KvAction[] {
308
+ return list.map(x => (x.id === id ? { ...x, endedAt: at, isError: isError || undefined } : x))
309
+ }
310
+
311
+ /**
312
+ * Reads what Kvali missed before it loaded from a Claude Code transcript: the tokens
313
+ * spent (once per API response; Claude Code repeats the usage on every block's line)
314
+ * and the last TodoWrite list, for sessions that plan without the Tasks tools.
315
+ */
316
+ export function scanTranscript(text: string): { usage: KvUsage; todos?: KvTask[] } {
317
+ const seen = new Set<string>()
318
+ const u: KvUsage = { ...EMPTY_USAGE, isWholeSession: true }
319
+ let todos: KvTask[] | undefined
320
+ for (const line of text.split('\n')) {
321
+ if (!line.startsWith('{') || !line.includes('"assistant"')) continue
322
+ let r: Record<string, any>
323
+ try {
324
+ r = JSON.parse(line)
325
+ } catch {
326
+ continue
327
+ }
328
+ const m = r.message
329
+ if (r.type !== 'assistant' || !m) continue
330
+ for (const b of Array.isArray(m.content) ? m.content : []) {
331
+ if (b?.type === 'tool_use' && b.name === 'TodoWrite' && !r.isSidechain) todos = applyPlanTool([], 'TodoWrite', b.input ?? {}, undefined)
332
+ }
333
+ if (!m.usage) continue
334
+ const key = `${m.id ?? ''}:${r.requestId ?? ''}`
335
+ if (seen.has(key)) continue
336
+ seen.add(key)
337
+ u.input += m.usage.input_tokens ?? 0
338
+ u.output += m.usage.output_tokens ?? 0
339
+ u.cacheRead += m.usage.cache_read_input_tokens ?? 0
340
+ u.cacheWrite += m.usage.cache_creation_input_tokens ?? 0
341
+ if (!r.isSidechain && m.model !== '<synthetic>') {
342
+ u.context = (m.usage.input_tokens ?? 0) + (m.usage.cache_read_input_tokens ?? 0) + (m.usage.cache_creation_input_tokens ?? 0)
343
+ if (typeof m.model === 'string') u.model = m.model
344
+ }
345
+ }
346
+ return { usage: u, todos }
347
+ }
348
+
349
+ /** Claude Code's folder name for a project: every character but letters and digits becomes `-`. */
350
+ export const projectSlug = (dir: string): string => dir.replace(/[^a-zA-Z0-9]/g, '-')
351
+
352
+ // ---------- phone notifications (shared setup in ~/.kvali/config.json) ----------
353
+
354
+ export type PhoneEvent = 'needs-you' | 'done' | 'error' | 'stuck' | 'steps'
355
+ export type PhoneCfg = { server: string; topic: string; token?: string; events: PhoneEvent[]; details: boolean }
356
+ const PHONE_EVENTS: readonly PhoneEvent[] = ['needs-you', 'done', 'error', 'stuck', 'steps']
357
+
358
+ /** Kvali 0.5 saved this list as its default, before progress existed: it reads as today's default. */
359
+ const DEFAULT_EVENTS_V1: readonly PhoneEvent[] = ['needs-you', 'done', 'error', 'stuck']
360
+
361
+ /** The `phone` section of Kvali's config file, as the editor extension and the CLI write it. */
362
+ export function parsePhoneConfig(text: string): PhoneCfg | undefined {
363
+ let r: Record<string, unknown>
364
+ try {
365
+ r = (JSON.parse(text) as { phone?: Record<string, unknown> })?.phone ?? {}
366
+ } catch {
367
+ return undefined
368
+ }
369
+ if (typeof r.topic !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(r.topic)) return undefined
370
+ const server = typeof r.server === 'string' && /^https?:\/\/[^\s/]/.test(r.server) ? r.server.replace(/\/+$/, '') : 'https://ntfy.sh'
371
+ let events = Array.isArray(r.events) ? r.events.filter((e): e is PhoneEvent => PHONE_EVENTS.includes(e as PhoneEvent)) : [...PHONE_EVENTS]
372
+ const v1Default = events.length === DEFAULT_EVENTS_V1.length && DEFAULT_EVENTS_V1.every(e => events.includes(e))
373
+ if (r.v === undefined && v1Default) events = [...PHONE_EVENTS]
374
+ return { server, topic: r.topic, token: typeof r.token === 'string' && r.token ? r.token : undefined, events, details: r.details !== false }
375
+ }
376
+
377
+ /** `sequence`: the notification it replaces (ntfy's sequence ID), so a turn's progress and its end are one notification. */
378
+ export type PhonePush = { title: string; message: string; priority: 2 | 3 | 4; tags: string[]; sequence?: string }
379
+ export type PushKind = 'needs-you' | 'finished' | 'paused' | 'error' | 'stalled' | 'step'
380
+
381
+ /** The words of a push, the same as the extension's. Without details no project or step names leave the computer. */
382
+ export function phonePush(
383
+ kind: PushKind,
384
+ info: { project: string; plan: KvPlan; label?: string; workedMs?: number; tokens?: number; quietMs?: number },
385
+ details: boolean,
386
+ ): PhonePush {
387
+ const where = details && info.project ? ` · ${info.project}` : ''
388
+ const p = progress(info.plan.tasks)
389
+ const step = p.current ? (p.current.active ?? p.current.title) : undefined
390
+ const planLine = p.total ? `${p.done}/${p.total} tasks${details && step ? ` · ${truncate(step, 80)}` : ''}` : undefined
391
+ const lines = (...xs: (string | undefined)[]) => xs.filter(Boolean).join('\n')
392
+ switch (kind) {
393
+ case 'needs-you':
394
+ return { title: `Claude Code needs you${where}`, message: lines(details && info.label ? truncate(info.label, 200) : 'Waiting for your answer', planLine), priority: 4, tags: ['bell'] }
395
+ case 'finished': {
396
+ const facts = [info.workedMs ? `in ${fmtDuration(info.workedMs)}` : undefined, p.total ? `${p.done}/${p.total} tasks` : undefined, info.tokens ? `${fmtTokens(info.tokens)} tokens` : undefined]
397
+ .filter(Boolean)
398
+ .join(' · ')
399
+ return { title: `Claude Code finished${where}`, message: facts ? `Done ${facts}` : 'Done', priority: 3, tags: ['white_check_mark'] }
400
+ }
401
+ case 'paused':
402
+ return { title: `Claude Code stopped and is waiting for you${where}`, message: planLine ? `Stopped at ${planLine}` : 'Its turn ended before the plan was done', priority: 4, tags: ['raised_hand'] }
403
+ case 'error':
404
+ return { title: `Claude Code stopped with an error${where}`, message: lines(details && info.label ? info.label : 'The turn ended with an error', planLine), priority: 4, tags: ['x'] }
405
+ case 'stalled':
406
+ return {
407
+ title: `Claude Code: no data from the API${where}`,
408
+ message: `Nothing has arrived for ${fmtDuration(info.quietMs ?? STALL_MS)} while it was answering. The connection may be down; Claude Code retries on its own.`,
409
+ priority: 3,
410
+ tags: ['warning'],
411
+ }
412
+ case 'step':
413
+ {
414
+ const next = step ? undefined : info.plan.tasks.find(t => t.status === 'pending')?.title
415
+ const line = details ? (step ? `Now: ${truncate(step, 120)}` : next ? `Next: ${truncate(next, 120)}` : undefined) : undefined
416
+ return { title: `Claude Code${where} · ${p.done}/${p.total}`, message: lines(`${phoneBar(p.done, p.total)} ${p.percent}%`, line), priority: 2, tags: ['arrow_forward'] }
417
+ }
418
+ }
419
+ }
420
+
421
+ export function ntfyBody(cfg: PhoneCfg, push: PhonePush): string {
422
+ return JSON.stringify({ topic: cfg.topic, title: push.title, message: push.message, priority: push.priority, tags: push.tags, ...(push.sequence ? { sequence_id: push.sequence } : {}) })
423
+ }
424
+
425
+ /** A text progress bar that reads on any phone, the same as the extension's: `███░░░░░░░`. */
426
+ export function phoneBar(done: number, total: number, cells = 10): string {
427
+ const filled = total > 0 ? Math.min(cells, Math.round((done / total) * cells)) : 0
428
+ return `${'█'.repeat(filled)}${'░'.repeat(cells - filled)}`
429
+ }
430
+
431
+ /** The ntfy sequence ID of one turn's notification: letters, digits and `-` only. */
432
+ export function turnSequence(sessionId: string, startedAt: number): string {
433
+ return `kvali-${sessionId.replace(/[^A-Za-z0-9]/g, '').slice(0, 12) || 'session'}-${Math.max(0, Math.floor(startedAt)).toString(36)}`
434
+ }
435
+
436
+ export const baseName = (p: string): string => p.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
437
+
438
+ /** Sent beside a prompt while the session has no unfinished plan: the same words as Kvali's hook. */
439
+ export const PLAN_CONTEXT =
440
+ 'Progress tracking (Kvali): if this request takes more than two steps, start by creating a task list with TaskCreate ' +
441
+ '(or TodoWrite), keep exactly one task in_progress, and mark each task completed as soon as it is done. ' +
442
+ 'The user follows your progress from that list, so keep it current.'
443
+
444
+ /** `autoPlan` in Kvali's config file; on unless set to false. */
445
+ export function autoPlanEnabled(text: string | undefined): boolean {
446
+ if (!text) return true
447
+ try {
448
+ return (JSON.parse(text) as { autoPlan?: unknown })?.autoPlan !== false
449
+ } catch {
450
+ return true
451
+ }
452
+ }
453
+
454
+ /** Whether a prompt should carry the planning reminder. */
455
+ export function wantsPlanContext(plan: KvPlan): boolean {
456
+ const p = progress(plan.tasks)
457
+ return !(p.total > 0 && p.done < p.total)
458
+ }