@kvali/cli 0.0.0 → 0.6.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/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/claude-plugin/README.md +37 -0
- package/dist/claude-plugin/hooks/hooks.json +1 -0
- package/dist/claude-plugin/hooks/model.ts +458 -0
- package/dist/claude-plugin/hooks/register.tsx +667 -0
- package/dist/claude-plugin/types/index.d.ts +85 -0
- package/dist/kvali.mjs +5516 -0
- package/package.json +34 -9
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
|
+
}
|