@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 +5 -1
- package/THIRD_PARTY_NOTICES.md +64 -0
- package/dist/claude-plugin/LICENSE +21 -0
- package/dist/claude-plugin/README.md +7 -1
- package/dist/claude-plugin/hooks/model.ts +33 -8
- package/dist/claude-plugin/hooks/register.tsx +62 -12
- package/dist/kvali.mjs +129 -22
- package/package.json +3 -2
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 `--
|
|
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
|
-
|
|
356
|
-
|
|
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)) : [...
|
|
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 = [...
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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
|
|
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 >=
|
|
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
|
|
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", "
|
|
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
|
-
|
|
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)
|
|
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}
|
|
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 >=
|
|
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
|
|
3290
|
-
const running = !!
|
|
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
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
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.
|
|
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)
|
|
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
|
-
|
|
5399
|
-
|
|
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.
|
|
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"
|