@kvali/cli 0.6.1 → 0.6.3

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/README.md CHANGED
@@ -27,10 +27,14 @@ kvali watch # hooks and phone notifications only, e.g. in the backgro
27
27
 
28
28
  ## More
29
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.
30
+ - `kvali phone test` / `kvali phone off`; options `--events needs-you,done,steps,actions,error,stuck` (what reaches the phone), `--done-after <seconds>` (the shortest task worth a notification; 0: every task), `--private` (no project or step names in notifications), `--server <url>` for your own ntfy server.
31
31
  - `kvali protocol [dir]` adds the progress protocol to a repository's `AGENTS.md` for agents Kvali has no adapter for (Cursor, Copilot, …).
32
32
  - `kvali --help` lists everything.
33
33
 
34
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
35
 
36
+ Everything stays on your computer, with no telemetry; only phone notifications, when you set them up, go to the ntfy server you choose. [Privacy](https://github.com/webGrandpa/kvali/blob/main/PRIVACY.md) · [Security policy](https://github.com/webGrandpa/kvali/blob/main/SECURITY.md) · [Third-party notices](https://github.com/webGrandpa/kvali/blob/main/THIRD_PARTY_NOTICES.md)
37
+
36
38
  MIT © Giorgi Khiladze
39
+
40
+ Kvali is an independent open-source project, not affiliated with or endorsed by Anthropic, OpenAI, Google, Microsoft or the makers of the other tools it works with. Claude Code, Codex, Gemini CLI, Antigravity, VS Code, Cursor, Windsurf and the other product names here are trademarks of their owners, used only to say what Kvali works with.
@@ -0,0 +1,64 @@
1
+ # Third-party notices
2
+
3
+ Kvali's own code is released under the [MIT License](LICENSE). The editor extension and the `kvali` command (npm package `@kvali/cli`) also bundle the open-source code listed below. Each keeps its own license, reproduced here as its terms require.
4
+
5
+ | Package | Version | License | Where it is used |
6
+ | --- | --- | --- | --- |
7
+ | [Preact](https://github.com/preactjs/preact) | 10.29.8 | MIT | The editor extension's Agents view |
8
+ | [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) | 2.0.4 | MIT | The QR code for phone setup, in the extension and the CLI |
9
+
10
+ The release build checks this list: it stops when the bundles contain a package, or a version, that is missing here.
11
+
12
+ ## preact 10.29.8
13
+
14
+ The MIT License (MIT)
15
+
16
+ Copyright (c) 2015-present Jason Miller
17
+
18
+ Permission is hereby granted, free of charge, to any person obtaining a copy
19
+ of this software and associated documentation files (the "Software"), to deal
20
+ in the Software without restriction, including without limitation the rights
21
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
22
+ copies of the Software, and to permit persons to whom the Software is
23
+ furnished to do so, subject to the following conditions:
24
+
25
+ The above copyright notice and this permission notice shall be included in all
26
+ copies or substantial portions of the Software.
27
+
28
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
29
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
30
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
31
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
32
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
33
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
34
+ SOFTWARE.
35
+
36
+ ## qrcode-generator 2.0.4
37
+
38
+ QR Code Generator for JavaScript
39
+
40
+ Copyright (c) 2009 Kazuhiko Arase
41
+
42
+ URL: http://www.d-project.com/
43
+
44
+ Licensed under the MIT license:
45
+
46
+ Permission is hereby granted, free of charge, to any person obtaining a copy
47
+ of this software and associated documentation files (the "Software"), to deal
48
+ in the Software without restriction, including without limitation the rights
49
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
50
+ copies of the Software, and to permit persons to whom the Software is
51
+ furnished to do so, subject to the following conditions:
52
+
53
+ The above copyright notice and this permission notice shall be included in all
54
+ copies or substantial portions of the Software.
55
+
56
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
57
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
58
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
59
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
60
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
61
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
62
+ SOFTWARE.
63
+
64
+ The word "QR Code" is a registered trademark of DENSO WAVE INCORPORATED.
@@ -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.
@@ -13,7 +13,7 @@ It works wherever Claude Code runs: the terminal, the desktop app, the VS Code e
13
13
 
14
14
  ## On your phone
15
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.
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 follows the choices made in Kvali's phone card or with `kvali phone --events` (each finished step, every action live, every task or only longer ones) and reads the shared setup in `~/.kvali/config.json` once a minute.
17
17
 
18
18
  ## With the Kvali editor extension
19
19
 
@@ -35,3 +35,9 @@ claude plugin test packages/claude-plugin
35
35
  ```
36
36
 
37
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.
38
+
39
+ ## Privacy
40
+
41
+ The plugin keeps its state on your computer and has no telemetry. It sends something only when you set up phone notifications: short status lines to the ntfy server you chose, never code or prompts. [Privacy](https://github.com/webGrandpa/kvali/blob/main/PRIVACY.md) · [Security policy](https://github.com/webGrandpa/kvali/blob/main/SECURITY.md) · MIT License
42
+
43
+ Kvali is an independent open-source project, not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic, used only to say what this plugin works with.
@@ -351,9 +351,11 @@ export const projectSlug = (dir: string): string => dir.replace(/[^a-zA-Z0-9]/g,
351
351
 
352
352
  // ---------- phone notifications (shared setup in ~/.kvali/config.json) ----------
353
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']
354
+ export type PhoneEvent = 'needs-you' | 'done' | 'error' | 'stuck' | 'steps' | 'actions'
355
+ /** `doneAfterSec`: a turn's end is pushed when it took at least this long (0: every turn), or when the phone followed it. */
356
+ export type PhoneCfg = { server: string; topic: string; token?: string; events: PhoneEvent[]; details: boolean; doneAfterSec: number }
357
+ const PHONE_EVENTS: readonly PhoneEvent[] = ['needs-you', 'done', 'steps', 'actions', 'error', 'stuck']
358
+ const DEFAULT_EVENTS: readonly PhoneEvent[] = ['needs-you', 'done', 'error', 'stuck', 'steps']
357
359
 
358
360
  /** Kvali 0.5 saved this list as its default, before progress existed: it reads as today's default. */
359
361
  const DEFAULT_EVENTS_V1: readonly PhoneEvent[] = ['needs-you', 'done', 'error', 'stuck']
@@ -368,10 +370,26 @@ export function parsePhoneConfig(text: string): PhoneCfg | undefined {
368
370
  }
369
371
  if (typeof r.topic !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(r.topic)) return undefined
370
372
  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]
373
+ let events = Array.isArray(r.events) ? r.events.filter((e): e is PhoneEvent => PHONE_EVENTS.includes(e as PhoneEvent)) : [...DEFAULT_EVENTS]
372
374
  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
+ if (r.v === undefined && v1Default) events = [...DEFAULT_EVENTS]
376
+ const doneAfterSec = typeof r.doneAfter === 'number' && Number.isFinite(r.doneAfter) && r.doneAfter >= 0 ? Math.min(Math.round(r.doneAfter), 86_400) : 120
377
+ return { server, topic: r.topic, token: typeof r.token === 'string' && r.token ? r.token : undefined, events, details: r.details !== false, doneAfterSec }
378
+ }
379
+
380
+ /** Silent updates pause while fewer messages than this are left for the day, so alerts still get through. */
381
+ export const QUIET_RESERVE = 50
382
+
383
+ /** Messages left for today from ntfy's `GET /v1/account`, when the server limits them (ntfy.sh: 250 a day per address). */
384
+ export function messagesLeftOf(text: string): number | undefined {
385
+ try {
386
+ const a = JSON.parse(text) as { limits?: { messages?: number }; stats?: { messages_remaining?: number } }
387
+ const limit = a?.limits?.messages
388
+ const left = a?.stats?.messages_remaining
389
+ return typeof limit === 'number' && limit > 0 && typeof left === 'number' ? left : undefined
390
+ } catch {
391
+ return undefined
392
+ }
375
393
  }
376
394
 
377
395
  /** `sequence`: the notification it replaces (ntfy's sequence ID), so a turn's progress and its end are one notification. */
@@ -381,7 +399,7 @@ export type PushKind = 'needs-you' | 'finished' | 'paused' | 'error' | 'stalled'
381
399
  /** The words of a push, the same as the extension's. Without details no project or step names leave the computer. */
382
400
  export function phonePush(
383
401
  kind: PushKind,
384
- info: { project: string; plan: KvPlan; label?: string; workedMs?: number; tokens?: number; quietMs?: number },
402
+ info: { project: string; plan: KvPlan; label?: string; workedMs?: number; tokens?: number; quietMs?: number; doing?: string },
385
403
  details: boolean,
386
404
  ): PhonePush {
387
405
  const where = details && info.project ? ` · ${info.project}` : ''
@@ -411,9 +429,16 @@ export function phonePush(
411
429
  }
412
430
  case 'step':
413
431
  {
432
+ // The turn's live notification: the plan's progress and, when chosen, the action running now.
414
433
  const next = step ? undefined : info.plan.tasks.find(t => t.status === 'pending')?.title
415
434
  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'] }
435
+ const doing = details && info.doing ? `Doing: ${truncate(info.doing, 120)}` : undefined
436
+ return {
437
+ title: p.total ? `Claude Code${where} · ${p.done}/${p.total}` : `Claude Code${where} · working`,
438
+ message: lines(p.total ? `${phoneBar(p.done, p.total)} ${p.percent}%` : undefined, line, doing) || 'Working',
439
+ priority: 2,
440
+ tags: ['arrow_forward'],
441
+ }
417
442
  }
418
443
  }
419
444
  }
@@ -36,6 +36,8 @@ import {
36
36
  totalTokens,
37
37
  turnSequence,
38
38
  typicalMs,
39
+ messagesLeftOf,
40
+ QUIET_RESERVE,
39
41
  type PhoneCfg,
40
42
  type PushKind,
41
43
  } from './model'
@@ -114,6 +116,12 @@ type Runtime = {
114
116
  progressSent: boolean
115
117
  /** A turn that stopped in the middle of its plan: pushed once it has waited like a question. */
116
118
  pendingPause: { at: number; workedMs: number; sequence?: string } | undefined
119
+ /** The last live update (a step or an action) and the action it showed. */
120
+ lastLivePushAt: number
121
+ sentAction: string | undefined
122
+ /** Messages the ntfy server still allows today (checked once a minute), to keep room for alerts. */
123
+ budgetAt: number
124
+ budgetLeft: number | undefined
117
125
  /** Ask Claude for a plan while there is none (`autoPlan` in ~/.kvali/config.json). */
118
126
  autoPlan: boolean
119
127
  }
@@ -151,6 +159,10 @@ const fresh = (): Runtime => ({
151
159
  progressSeenAt: 0,
152
160
  progressSent: false,
153
161
  pendingPause: undefined,
162
+ lastLivePushAt: 0,
163
+ sentAction: undefined,
164
+ budgetAt: -Infinity,
165
+ budgetLeft: undefined,
154
166
  autoPlan: true,
155
167
  })
156
168
 
@@ -209,7 +221,8 @@ async function doRefresh($: Engine, force: boolean): Promise<void> {
209
221
  quiet(push($, 'paused', { workedMs, sequence }))
210
222
  }
211
223
  const current = await read($, plan)
212
- if (progressDue(current, now)) quiet(push($, 'step', { sequence: turnSeq(now) }))
224
+ const news = liveDue(current, now)
225
+ if (news) quiet(push($, 'step', { sequence: turnSeq(now), doing: news.doing }))
213
226
  const key = [l.phase, l.since, l.label ?? '', l.detail ?? ''].join('|')
214
227
  if (key !== rt.lastLiveKey || force) {
215
228
  rt.lastLiveKey = key
@@ -230,20 +243,53 @@ async function doRefresh($: Engine, force: boolean): Promise<void> {
230
243
  * count waits a moment first: Claude marks a task done and the next one in progress in two
231
244
  * calls, and the update should name the next one.
232
245
  */
233
- function progressDue(p: KvPlan, now: number): boolean {
246
+ function liveDue(p: KvPlan, now: number): { doing?: string } | undefined {
234
247
  const pr = progress(p.tasks)
235
248
  if (pr.done < rt.progressSentDone) rt.progressSentDone = pr.done // a new plan
236
249
  if (pr.done !== rt.progressSeenDone) {
237
250
  rt.progressSeenDone = pr.done
238
251
  rt.progressSeenAt = now
239
252
  }
240
- if (!rt.turnActive || !rt.phone?.events.includes('steps')) return false
241
- if (pr.total === 0 || pr.done <= rt.progressSentDone || pr.done >= pr.total) return false
242
- if (now - rt.lastStepPushAt < 60_000 || now - rt.progressSeenAt < 1_500) return false
243
- rt.lastStepPushAt = now
244
- rt.progressSentDone = pr.done
253
+ const events = rt.phone?.events ?? []
254
+ if (!rt.turnActive) return undefined
255
+ const stepNews =
256
+ events.includes('steps') &&
257
+ pr.total > 0 &&
258
+ pr.done > rt.progressSentDone &&
259
+ pr.done < pr.total &&
260
+ now - rt.lastStepPushAt >= 60_000 &&
261
+ now - rt.progressSeenAt >= 1_500
262
+ // With actions chosen: what runs now, at most twice a minute.
263
+ const action = [...rt.running.values()].filter(r => !r.asks).pop()
264
+ const actionKey = action ? `${action.label}|${action.startedAt}` : undefined
265
+ const actionNews = events.includes('actions') && actionKey !== undefined && actionKey !== rt.sentAction && now - rt.lastLivePushAt >= 30_000
266
+ if (!stepNews && !actionNews) return undefined
267
+ rt.lastLivePushAt = now
268
+ if (stepNews) {
269
+ rt.lastStepPushAt = now
270
+ rt.progressSentDone = pr.done
271
+ }
272
+ if (actionKey !== undefined) rt.sentAction = actionKey
245
273
  rt.progressSent = true
246
- return true
274
+ return { doing: events.includes('actions') ? action?.label : undefined }
275
+ }
276
+
277
+ /** Silent updates are worth a message only while the day's allowance keeps room for alerts. */
278
+ async function quietAllowed($: Engine, cfg: PhoneCfg): Promise<boolean> {
279
+ const now = await $.clock.now()
280
+ if (now - rt.budgetAt >= 60_000) {
281
+ rt.budgetAt = now
282
+ rt.budgetLeft = undefined
283
+ const headers: Record<string, string> = { Accept: 'application/json' }
284
+ if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`
285
+ try {
286
+ const res = await $.http.fetch(`${cfg.server}/v1/account`, { headers })
287
+ if (res.ok) rt.budgetLeft = messagesLeftOf(res.text)
288
+ } catch {
289
+ // unknown: no limit to keep to
290
+ }
291
+ }
292
+ return rt.budgetLeft === undefined || rt.budgetLeft > QUIET_RESERVE
247
293
  }
248
294
 
249
295
  function turnSeq(now: number): string {
@@ -359,9 +405,12 @@ const PUSH_EVENT: Record<PushKind, 'needs-you' | 'done' | 'error' | 'stuck' | 's
359
405
  }
360
406
 
361
407
  /** Sends one push to the phone; a failure only costs that push. */
362
- async function push($: Engine, kind: PushKind, extra: { label?: string; workedMs?: number; quietMs?: number; sequence?: string } = {}): Promise<void> {
408
+ async function push($: Engine, kind: PushKind, extra: { label?: string; workedMs?: number; quietMs?: number; sequence?: string; doing?: string } = {}): Promise<void> {
363
409
  const cfg = rt.phone
364
- if (!cfg || !cfg.events.includes(PUSH_EVENT[kind])) return
410
+ if (!cfg) return
411
+ const wanted = kind === 'step' ? cfg.events.includes('steps') || cfg.events.includes('actions') : cfg.events.includes(PUSH_EVENT[kind])
412
+ if (!wanted) return
413
+ if (kind === 'step' && !(await quietAllowed($, cfg))) return
365
414
  const p = await read($, plan)
366
415
  const u = await read($, usage)
367
416
  const { sequence, ...info } = extra
@@ -369,7 +418,8 @@ async function push($: Engine, kind: PushKind, extra: { label?: string; workedMs
369
418
  const headers: Record<string, string> = { 'Content-Type': 'application/json' }
370
419
  if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`
371
420
  try {
372
- await $.http.fetch(cfg.server, { method: 'POST', headers, body })
421
+ const res = await $.http.fetch(cfg.server, { method: 'POST', headers, body })
422
+ if (res.ok && rt.budgetLeft !== undefined) rt.budgetLeft -= 1
373
423
  } catch {
374
424
  // offline or refused by policy: the pane still shows it
375
425
  }
@@ -558,7 +608,7 @@ export const register: Register = on => {
558
608
  if (phase === 'error') quiet(push($, 'error', { label: rt.ended.label, sequence }))
559
609
  // A turn the phone followed always gets its end; other turns only when they were long.
560
610
  // A stop in the middle of the plan asks the person something, so it waits like a question.
561
- if (phase === 'done' && (e.durationMs >= LONG_TURN_MS || followed)) {
611
+ if (phase === 'done' && (e.durationMs >= (rt.phone?.doneAfterSec ?? 120) * 1000 || followed)) {
562
612
  const pr = progress((await read($, plan)).tasks)
563
613
  if (pr.total > 0 && pr.done < pr.total) rt.pendingPause = { at: now, workedMs: e.durationMs, sequence }
564
614
  else quiet(push($, 'finished', { workedMs: e.durationMs, sequence }))
package/dist/kvali.mjs CHANGED
@@ -905,6 +905,7 @@ var AntigravityAdapter = class {
905
905
  agentLabel: this.label,
906
906
  id: st.id,
907
907
  title: st.task?.title || st.firstInput || current?.title || "Antigravity conversation",
908
+ titleFromPrompt: !st.task?.title && !!st.firstInput ? true : void 0,
908
909
  projectPath: this.projectOf(st, ctx.home),
909
910
  model: st.model,
910
911
  turn: st.turnStartedAt !== void 0 ? { steps: st.turnSteps, startedAt: st.turnStartedAt } : void 0,
@@ -1476,7 +1477,8 @@ var ClaudeCodeAdapter = class {
1476
1477
  }
1477
1478
  const activeSubagents = [...st.subagents.values()].filter((s) => now - s.updatedAt < 6e4).length;
1478
1479
  const window = /\[1m\]/i.test(st.model ?? "") || st.maxContext > 2e5 ? 1e6 : 2e5;
1479
- const title = st.customTitle || st.aiTitle || st.summary || st.firstPrompt || "Untitled session";
1480
+ const ownTitle = st.customTitle || st.aiTitle || st.summary;
1481
+ const title = ownTitle || st.firstPrompt || "Untitled session";
1480
1482
  let activity = status === "working" ? st.activity : void 0;
1481
1483
  if (status === "working" && st.phase === "tool" && st.open.size > 0) activity = [...st.open.values()].pop();
1482
1484
  return {
@@ -1485,6 +1487,7 @@ var ClaudeCodeAdapter = class {
1485
1487
  agentLabel: this.label,
1486
1488
  id: st.sessionId,
1487
1489
  title: truncate(title, 100),
1490
+ titleFromPrompt: !ownTitle && !!st.firstPrompt ? true : void 0,
1488
1491
  projectPath: st.launchCwd ? findGitRoot(st.launchCwd) ?? st.launchCwd : void 0,
1489
1492
  cwd: st.launchCwd,
1490
1493
  model: st.model,
@@ -1824,6 +1827,7 @@ var CodexAdapter = class extends JsonlSessionAdapter {
1824
1827
  agentLabel: this.label,
1825
1828
  id: st.id,
1826
1829
  title: st.title ?? "Untitled session",
1830
+ titleFromPrompt: st.title !== void 0 ? true : void 0,
1827
1831
  projectPath: st.cwd ? findGitRoot(st.cwd) ?? st.cwd : void 0,
1828
1832
  cwd: st.cwd,
1829
1833
  model: st.model,
@@ -2106,6 +2110,7 @@ var GeminiCliAdapter = class {
2106
2110
  agentLabel: this.label,
2107
2111
  id: st.sessionId ?? path5.basename(st.file),
2108
2112
  title: st.summary ? truncate(st.summary, 100) : title ?? "Untitled session",
2113
+ titleFromPrompt: !st.summary && title !== void 0 ? true : void 0,
2109
2114
  projectPath: st.projectPath,
2110
2115
  model,
2111
2116
  status: phase === "idle" && tasksProg.total > 0 && tasksProg.done === tasksProg.total ? "done" : phase,
@@ -2444,7 +2449,22 @@ var HookHub = class {
2444
2449
  this.setHub(true);
2445
2450
  });
2446
2451
  }
2452
+ /**
2453
+ * A web page can make a browser call this port by pointing its own host name at
2454
+ * 127.0.0.1 (DNS rebinding); such requests name that host, so only local names pass.
2455
+ * Hooks call `http://127.0.0.1:<port>`.
2456
+ */
2457
+ localHost(req) {
2458
+ const host = req.headers.host?.toLowerCase();
2459
+ if (!host) return true;
2460
+ const port = this.server?.address()?.port ?? this.port;
2461
+ return host === `127.0.0.1:${port}` || host === `localhost:${port}` || host === `[::1]:${port}`;
2462
+ }
2447
2463
  async handle(req, res) {
2464
+ if (!this.localHost(req)) {
2465
+ res.writeHead(403).end();
2466
+ return;
2467
+ }
2448
2468
  const url = req.url ?? "/";
2449
2469
  if (req.method === "GET" && url === "/v1/health") {
2450
2470
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -3018,11 +3038,12 @@ async function removeSetup(home, env = {}, opts = {}) {
3018
3038
  import { randomBytes } from "node:crypto";
3019
3039
  import { promises as fsp8 } from "node:fs";
3020
3040
  import path14 from "node:path";
3021
- var PHONE_EVENTS = ["needs-you", "done", "error", "stuck", "steps"];
3041
+ var PHONE_EVENTS = ["needs-you", "done", "steps", "actions", "error", "stuck"];
3022
3042
  var DEFAULT_PHONE_EVENTS = ["needs-you", "done", "error", "stuck", "steps"];
3023
3043
  var DEFAULT_EVENTS_V1 = ["needs-you", "done", "error", "stuck"];
3024
3044
  var PHONE_CONFIG_VERSION = 2;
3025
3045
  var DEFAULT_NTFY_SERVER = "https://ntfy.sh";
3046
+ var DEFAULT_DONE_AFTER_SEC = 120;
3026
3047
  var kvaliConfigFile = (home) => path14.join(home, ".kvali", "config.json");
3027
3048
  var TOPIC_RE = /^[A-Za-z0-9_-]{1,64}$/;
3028
3049
  function normalizePhoneConfig(raw) {
@@ -3033,7 +3054,8 @@ function normalizePhoneConfig(raw) {
3033
3054
  let events = Array.isArray(r.events) ? r.events.filter((e) => PHONE_EVENTS.includes(e)) : [...DEFAULT_PHONE_EVENTS];
3034
3055
  const v1Default = events.length === DEFAULT_EVENTS_V1.length && DEFAULT_EVENTS_V1.every((e) => events.includes(e));
3035
3056
  if (r.v === void 0 && v1Default) events = [...DEFAULT_PHONE_EVENTS];
3036
- return { server, topic: r.topic, token: typeof r.token === "string" && r.token ? r.token : void 0, events, details: r.details !== false };
3057
+ const doneAfterSec = typeof r.doneAfter === "number" && Number.isFinite(r.doneAfter) && r.doneAfter >= 0 ? Math.min(Math.round(r.doneAfter), 86400) : DEFAULT_DONE_AFTER_SEC;
3058
+ return { server, topic: r.topic, token: typeof r.token === "string" && r.token ? r.token : void 0, events, details: r.details !== false, doneAfterSec };
3037
3059
  }
3038
3060
  async function readPhoneConfig(home) {
3039
3061
  try {
@@ -3051,7 +3073,16 @@ async function writePhoneConfig(home, cfg) {
3051
3073
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) all = parsed;
3052
3074
  } catch {
3053
3075
  }
3054
- if (cfg) all.phone = { v: PHONE_CONFIG_VERSION, server: cfg.server, topic: cfg.topic, ...cfg.token ? { token: cfg.token } : {}, events: cfg.events, details: cfg.details };
3076
+ if (cfg)
3077
+ all.phone = {
3078
+ v: PHONE_CONFIG_VERSION,
3079
+ server: cfg.server,
3080
+ topic: cfg.topic,
3081
+ ...cfg.token ? { token: cfg.token } : {},
3082
+ events: cfg.events,
3083
+ details: cfg.details,
3084
+ doneAfter: cfg.doneAfterSec ?? DEFAULT_DONE_AFTER_SEC
3085
+ };
3055
3086
  else delete all.phone;
3056
3087
  await fsp8.mkdir(path14.dirname(file), { recursive: true });
3057
3088
  await fsp8.writeFile(file, `${JSON.stringify(all, null, 2)}
@@ -3130,7 +3161,7 @@ function formatPush(kind, s, details, extra = {}) {
3130
3161
  const stepName = step ? step.activeTitle ?? step.title : void 0;
3131
3162
  const nextName = step ? void 0 : s.tasks.find((t) => t.status === "pending")?.title;
3132
3163
  const planLine = p2.total ? `${p2.done}/${p2.total} tasks${details && stepName ? ` \xB7 ${clip(stepName, 80)}` : ""}` : void 0;
3133
- const title = details ? clip(s.title, 120) : void 0;
3164
+ const title = details && !s.titleFromPrompt ? clip(s.title, 120) : void 0;
3134
3165
  const lines = (...xs) => xs.filter(Boolean).join("\n");
3135
3166
  switch (kind) {
3136
3167
  case "needs-you":
@@ -3189,14 +3220,17 @@ function formatPush(kind, s, details, extra = {}) {
3189
3220
  priority: 3,
3190
3221
  tags: ["hourglass"]
3191
3222
  };
3192
- case "step":
3223
+ case "step": {
3224
+ const doing = extra.action && s.activity && !s.activity.endedAt ? s.activity.label : void 0;
3225
+ const stepLine = details ? stepName ? `Now: ${clip(stepName, 120)}` : nextName ? `Next: ${clip(nextName, 120)}` : void 0 : void 0;
3193
3226
  return {
3194
- event: "steps",
3195
- title: `${agent}${where} \xB7 ${p2.done}/${p2.total}`,
3196
- message: lines(`${phoneBar(p2.done, p2.total)} ${p2.percent}%`, details ? stepName ? `Now: ${clip(stepName, 120)}` : nextName ? `Next: ${clip(nextName, 120)}` : void 0 : void 0),
3227
+ event: extra.action ? "actions" : "steps",
3228
+ title: p2.total ? `${agent}${where} \xB7 ${p2.done}/${p2.total}` : `${agent}${where} \xB7 working`,
3229
+ message: lines(p2.total ? `${phoneBar(p2.done, p2.total)} ${p2.percent}%` : void 0, stepLine, details && doing ? `Doing: ${clip(doing, 120)}` : void 0) || "Working",
3197
3230
  priority: 2,
3198
3231
  tags: ["arrow_forward"]
3199
3232
  };
3233
+ }
3200
3234
  }
3201
3235
  }
3202
3236
  var PushPlanner = class {
@@ -3210,6 +3244,7 @@ var PushPlanner = class {
3210
3244
  troubleMs;
3211
3245
  stepEveryMs;
3212
3246
  progressSettleMs;
3247
+ actionEveryMs;
3213
3248
  constructor(opts = {}) {
3214
3249
  this.now = opts.now ?? Date.now;
3215
3250
  this.startedAt = this.now();
@@ -3220,11 +3255,13 @@ var PushPlanner = class {
3220
3255
  this.troubleMs = opts.troubleMs ?? 2 * 6e4;
3221
3256
  this.stepEveryMs = opts.stepEveryMs ?? 6e4;
3222
3257
  this.progressSettleMs = opts.progressSettleMs ?? 2e3;
3258
+ this.actionEveryMs = opts.actionEveryMs ?? 3e4;
3223
3259
  }
3224
3260
  observe(snapshot, cfg) {
3225
3261
  const now = this.now();
3226
3262
  const out = [];
3227
3263
  const want = (e) => cfg.events.includes(e);
3264
+ const minWork = cfg.doneAfterSec !== void 0 ? cfg.doneAfterSec * 1e3 : this.minWorkMs;
3228
3265
  const seen = /* @__PURE__ */ new Set();
3229
3266
  for (const s of snapshot.sessions) {
3230
3267
  seen.add(s.key);
@@ -3263,7 +3300,7 @@ var PushPlanner = class {
3263
3300
  if (t.stoppedAt !== void 0 && (s.status === "done" || s.status === "idle") && now - t.stoppedAt >= this.settleMs) {
3264
3301
  const worked = t.workStart !== void 0 ? t.stoppedAt - t.workStart : 0;
3265
3302
  const interrupted = s.statusDetail === "Interrupted";
3266
- const worth = (worked >= this.minWorkMs || t.progressSent) && !interrupted && mine && want("done");
3303
+ const worth = (worked >= minWork || t.progressSent) && !interrupted && mine && want("done");
3267
3304
  const unfinished = prog.total > 0 && prog.done < prog.total;
3268
3305
  if (!(worth && unfinished && now - t.stoppedAt < this.needsYouAfterMs)) {
3269
3306
  if (worth) out.push({ ...formatPush(unfinished ? "paused" : "finished", s, cfg.details, { workedMs: worked }), sequence: t.episode });
@@ -3286,8 +3323,8 @@ var PushPlanner = class {
3286
3323
  t.troublePushed = true;
3287
3324
  out.push(formatPush("trouble", s, cfg.details));
3288
3325
  } else if (mine && !s.pulse && t.troubleSince === void 0) {
3289
- const a = s.activity;
3290
- const running = !!a && !a.endedAt && a.kind !== "thinking" && a.kind !== "message";
3326
+ const a2 = s.activity;
3327
+ const running = !!a2 && !a2.endedAt && a2.kind !== "thinking" && a2.kind !== "message";
3291
3328
  const quietFor = now - Math.max(s.updatedAt, this.startedAt);
3292
3329
  if (!running && quietFor >= this.quietMs && t.quietPushedFor !== s.updatedAt) {
3293
3330
  t.quietPushedFor = s.updatedAt;
@@ -3300,12 +3337,19 @@ var PushPlanner = class {
3300
3337
  t.seenDone = prog.done;
3301
3338
  t.seenDoneAt = now;
3302
3339
  }
3303
- const stepDue = (t.lastStepPush === void 0 || now - t.lastStepPush >= this.stepEveryMs) && now - t.seenDoneAt >= this.progressSettleMs;
3304
- if (want("steps") && mine && active && prog.total > 0 && prog.done > t.sentDone && prog.done < prog.total && stepDue) {
3305
- t.lastStepPush = now;
3306
- t.sentDone = prog.done;
3340
+ const stepNews = want("steps") && prog.total > 0 && prog.done > t.sentDone && prog.done < prog.total && now - t.seenDoneAt >= this.progressSettleMs && (t.lastStepPush === void 0 || now - t.lastStepPush >= this.stepEveryMs);
3341
+ const a = s.activity;
3342
+ const actionKey = a && !a.endedAt && a.kind !== "thinking" && a.kind !== "message" ? `${a.label}|${a.startedAt}` : void 0;
3343
+ const actionNews = want("actions") && actionKey !== void 0 && actionKey !== t.sentAction && (t.lastLivePush === void 0 || now - t.lastLivePush >= this.actionEveryMs);
3344
+ if (mine && active && (stepNews || actionNews)) {
3345
+ t.lastLivePush = now;
3346
+ if (stepNews) {
3347
+ t.lastStepPush = now;
3348
+ t.sentDone = prog.done;
3349
+ }
3350
+ if (actionKey !== void 0) t.sentAction = actionKey;
3307
3351
  t.progressSent = true;
3308
- out.push({ ...formatPush("step", s, cfg.details), sequence: t.episode });
3352
+ out.push({ ...formatPush("step", s, cfg.details, { action: want("actions") }), sequence: t.episode });
3309
3353
  }
3310
3354
  t.status = s.status;
3311
3355
  }
@@ -3325,6 +3369,17 @@ function end(t, done) {
3325
3369
  t.progressSent = false;
3326
3370
  t.sentDone = done;
3327
3371
  }
3372
+ var QUIET_RESERVE = 50;
3373
+ async function messagesLeft(cfg, fetchImpl = fetch) {
3374
+ const headers = { Accept: "application/json" };
3375
+ if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`;
3376
+ const res = await fetchImpl(`${cfg.server}/v1/account`, { headers, signal: AbortSignal.timeout(1e4) });
3377
+ if (!res.ok) return void 0;
3378
+ const a = await res.json().catch(() => void 0);
3379
+ const limit = a?.limits?.messages;
3380
+ const left = a?.stats?.messages_remaining;
3381
+ return typeof limit === "number" && limit > 0 && typeof left === "number" ? left : void 0;
3382
+ }
3328
3383
  var PhoneNotifier = class {
3329
3384
  cfg;
3330
3385
  mtime = -1;
@@ -3332,6 +3387,9 @@ var PhoneNotifier = class {
3332
3387
  timer;
3333
3388
  running;
3334
3389
  again = false;
3390
+ left;
3391
+ leftAt = -Infinity;
3392
+ quietPaused = false;
3335
3393
  planner;
3336
3394
  opts;
3337
3395
  constructor(opts) {
@@ -3392,10 +3450,29 @@ var PhoneNotifier = class {
3392
3450
  if (!pushes.length || !this.opts.canSend()) return;
3393
3451
  const send = this.opts.send ?? sendPush;
3394
3452
  for (const p2 of pushes) {
3453
+ if (p2.priority <= 2 && !await this.quietAllowed(cfg)) continue;
3395
3454
  const r = await send(cfg, p2);
3455
+ if (r.ok && this.left !== void 0) this.left -= 1;
3396
3456
  this.opts.report?.(r.ok ? p2.title : `${p2.title}: ${r.error}`, r.ok);
3397
3457
  }
3398
3458
  }
3459
+ /** Silent updates are worth a message only while the day's allowance keeps room for alerts. */
3460
+ async quietAllowed(cfg) {
3461
+ const now = Date.now();
3462
+ if (now - this.leftAt >= 6e4) {
3463
+ this.leftAt = now;
3464
+ this.left = await (this.opts.budget ?? messagesLeft)(cfg).catch(() => void 0);
3465
+ }
3466
+ const ok = this.left === void 0 || this.left > QUIET_RESERVE;
3467
+ if (ok === this.quietPaused) {
3468
+ this.quietPaused = !ok;
3469
+ this.opts.report?.(
3470
+ ok ? "progress updates resumed" : `progress updates paused: ${this.left} notifications left today on ${new URL(cfg.server).host}, kept for alerts`,
3471
+ ok
3472
+ );
3473
+ }
3474
+ return ok;
3475
+ }
3399
3476
  };
3400
3477
 
3401
3478
  // ../../node_modules/.pnpm/qrcode-generator@2.0.4/node_modules/qrcode-generator/dist/qrcode.mjs
@@ -5066,7 +5143,7 @@ function qrTerminal(text, color = true) {
5066
5143
  }
5067
5144
 
5068
5145
  // src/index.ts
5069
- var VERSION = "0.6.1";
5146
+ var VERSION = "0.6.3";
5070
5147
  function parse(argv) {
5071
5148
  const opts = {
5072
5149
  json: false,
@@ -5078,7 +5155,9 @@ function parse(argv) {
5078
5155
  port: DEFAULT_HOOK_PORT,
5079
5156
  color: process2.stdout.isTTY && !process2.env.NO_COLOR,
5080
5157
  private: false,
5081
- steps: void 0
5158
+ steps: void 0,
5159
+ events: void 0,
5160
+ doneAfter: void 0
5082
5161
  };
5083
5162
  const cmd = [];
5084
5163
  for (let i = 0; i < argv.length; i += 1) {
@@ -5136,6 +5215,19 @@ function parse(argv) {
5136
5215
  case "--no-steps":
5137
5216
  opts.steps = false;
5138
5217
  break;
5218
+ case "--events": {
5219
+ const list = next().split(",").map((e) => e.trim()).filter(Boolean);
5220
+ const bad = list.filter((e) => !PHONE_EVENTS.includes(e));
5221
+ if (bad.length) fail(`unknown event ${bad.join(", ")}: choose from ${PHONE_EVENTS.join(", ")}`);
5222
+ opts.events = PHONE_EVENTS.filter((e) => list.includes(e));
5223
+ break;
5224
+ }
5225
+ case "--done-after": {
5226
+ const sec = Number(next());
5227
+ if (!Number.isFinite(sec) || sec < 0) fail("--done-after needs a number of seconds (0: every task)");
5228
+ opts.doneAfter = Math.round(sec);
5229
+ break;
5230
+ }
5139
5231
  case "--version":
5140
5232
  case "-v":
5141
5233
  console.log(VERSION);
@@ -5178,7 +5270,8 @@ Options
5178
5270
  --port <n> hook hub port (default ${DEFAULT_HOOK_PORT})
5179
5271
  --no-hub do not listen for hook events (and send no phone notifications)
5180
5272
  --no-color
5181
- kvali phone options: --server <url> (self-hosted ntfy) --token <token> --private (no project or step names) --no-steps (no progress notifications)`;
5273
+ kvali phone options: --server <url> (self-hosted ntfy) --token <token> --private (no project or step names)
5274
+ --events needs-you,done,steps,actions,error,stuck (what to hear about) --done-after <seconds> (shortest task worth a notification; 0: every task) --no-steps`;
5182
5275
  function fail(msg) {
5183
5276
  console.error(`kvali: ${msg}`);
5184
5277
  process2.exit(2);
@@ -5352,6 +5445,18 @@ function startPhone(engine, hub, home, report) {
5352
5445
  notifier.start();
5353
5446
  return notifier;
5354
5447
  }
5448
+ function phoneEventsText(cfg) {
5449
+ const done = cfg.doneAfterSec ?? DEFAULT_DONE_AFTER_SEC;
5450
+ const words = {
5451
+ "needs-you": "a question that waited a minute",
5452
+ done: done ? `tasks done after ${fmtDuration(done * 1e3)} or longer` : "every task done",
5453
+ steps: "each finished step (silent)",
5454
+ actions: "every action, live (silent)",
5455
+ error: "errors",
5456
+ stuck: "an agent gone silent"
5457
+ };
5458
+ return cfg.events.length ? PHONE_EVENTS.filter((e) => cfg.events.includes(e)).map((e) => words[e]).join(", ") : "nothing (no event chosen)";
5459
+ }
5355
5460
  async function phone(sub, opts) {
5356
5461
  const c = paint(opts.color);
5357
5462
  if (sub === "off") {
@@ -5373,8 +5478,10 @@ async function phone(sub, opts) {
5373
5478
  if (opts.server) cfg.server = opts.server.replace(/\/+$/, "");
5374
5479
  if (opts.token) cfg.token = opts.token;
5375
5480
  if (opts.private) cfg.details = false;
5481
+ if (opts.events) cfg.events = opts.events;
5376
5482
  if (opts.steps === true && !cfg.events.includes("steps")) cfg.events.push("steps");
5377
5483
  if (opts.steps === false) cfg.events = cfg.events.filter((e) => e !== "steps");
5484
+ if (opts.doneAfter !== void 0) cfg.doneAfterSec = opts.doneAfter;
5378
5485
  await writePhoneConfig(opts.home, cfg);
5379
5486
  const self = cfg.server !== "https://ntfy.sh";
5380
5487
  console.log(
@@ -5395,8 +5502,8 @@ async function phone(sub, opts) {
5395
5502
  console.log(
5396
5503
  [
5397
5504
  "",
5398
- " You get an alert when an agent finishes, fails, goes quiet or has waited a minute for your answer.",
5399
- cfg.events.includes("steps") ? " Each task also gets one silent notification that its finished steps update (--no-steps turns it off)." : " Progress notifications are off (kvali phone --steps turns them on).",
5505
+ ` You will hear about: ${phoneEventsText(cfg)}.`,
5506
+ " Choose with --events needs-you,done,steps,actions,error,stuck and --done-after <seconds>.",
5400
5507
  " They are sent while Kvali runs: the editor extension, `kvali`, or `kvali watch` in the background.",
5401
5508
  " Kvali's Claude Code plugin sends them for its own sessions by itself.",
5402
5509
  ` ${c.gray("Test: kvali phone test \xB7 Stop: kvali phone off")}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kvali/cli",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Live progress, tokens and phone alerts for AI coding agents (Claude Code, Codex, Gemini CLI, Antigravity), in your terminal.",
5
5
  "license": "MIT",
6
6
  "author": "Giorgi Khiladze (https://github.com/webGrandpa)",
@@ -17,7 +17,8 @@
17
17
  "kvali": "dist/kvali.mjs"
18
18
  },
19
19
  "files": [
20
- "dist"
20
+ "dist",
21
+ "THIRD_PARTY_NOTICES.md"
21
22
  ],
22
23
  "engines": {
23
24
  "node": ">=20"