@kvali/cli 0.0.0-stage → 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 +35 -2
- 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 +36 -4
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import type { KvAction, KvLive, KvPhase, KvPlan, KvTask } from '../types'
|
|
5
|
+
import {
|
|
6
|
+
ASKS_PERSON,
|
|
7
|
+
EMPTY_PLAN,
|
|
8
|
+
EMPTY_USAGE,
|
|
9
|
+
IDLE,
|
|
10
|
+
PLAN_TOOLS,
|
|
11
|
+
STALL_MS,
|
|
12
|
+
advance,
|
|
13
|
+
applyPlanTool,
|
|
14
|
+
baseName,
|
|
15
|
+
autoPlanEnabled,
|
|
16
|
+
ntfyBody,
|
|
17
|
+
parsePhoneConfig,
|
|
18
|
+
PLAN_CONTEXT,
|
|
19
|
+
wantsPlanContext,
|
|
20
|
+
phonePush,
|
|
21
|
+
barCells,
|
|
22
|
+
contextPercent,
|
|
23
|
+
describeTool,
|
|
24
|
+
endAction,
|
|
25
|
+
fmtDuration,
|
|
26
|
+
fmtTokens,
|
|
27
|
+
isActivePhase,
|
|
28
|
+
liveLine,
|
|
29
|
+
parseTaskFile,
|
|
30
|
+
progress,
|
|
31
|
+
projectSlug,
|
|
32
|
+
pushAction,
|
|
33
|
+
scanTranscript,
|
|
34
|
+
sortTasks,
|
|
35
|
+
statusText,
|
|
36
|
+
totalTokens,
|
|
37
|
+
turnSequence,
|
|
38
|
+
typicalMs,
|
|
39
|
+
type PhoneCfg,
|
|
40
|
+
type PushKind,
|
|
41
|
+
} from './model'
|
|
42
|
+
|
|
43
|
+
type Engine = EngineInterface
|
|
44
|
+
|
|
45
|
+
const PANE = 'kvali'
|
|
46
|
+
const TICK_MS = 2_000
|
|
47
|
+
const PULSE_EVERY_MS = 10_000
|
|
48
|
+
const LONG_TURN_MS = 120_000
|
|
49
|
+
/** A question to the person is worth a push only after this long unanswered: sooner, they were at the computer. */
|
|
50
|
+
const ASK_PUSH_AFTER_MS = 60_000
|
|
51
|
+
/** `$.fs.read` copies at most 4 MiB into the plugin. */
|
|
52
|
+
const MAX_TRANSCRIPT = 4 * 1024 * 1024
|
|
53
|
+
|
|
54
|
+
// Change a tag when that value's fields change: a reload declines what an older version stored.
|
|
55
|
+
const plan = atom({ plugin: 'kvali', key: 'plan' } as const, EMPTY_PLAN, { shape: 'plan-1' })
|
|
56
|
+
const live = atom({ plugin: 'kvali', key: 'live' } as const, IDLE, { shape: 'live-1' })
|
|
57
|
+
const recent = atom({ plugin: 'kvali', key: 'recent' } as const, [] as KvAction[], { shape: 'recent-1' })
|
|
58
|
+
const usage = atom({ plugin: 'kvali', key: 'usage' } as const, EMPTY_USAGE, { shape: 'usage-1' })
|
|
59
|
+
const showTasks = atom({ plugin: 'kvali', key: 'showTasks' } as const, false)
|
|
60
|
+
|
|
61
|
+
type Running = {
|
|
62
|
+
label: string
|
|
63
|
+
detail?: string
|
|
64
|
+
startedAt: number
|
|
65
|
+
asks?: string
|
|
66
|
+
/** The same question for a phone push: no command text leaves the computer. */
|
|
67
|
+
askPush?: string
|
|
68
|
+
askedAt?: number
|
|
69
|
+
}
|
|
70
|
+
type StreamPhase = 'requesting' | 'thinking' | 'writing' | 'tool-input'
|
|
71
|
+
|
|
72
|
+
const quiet = <T,>(p: Promise<T>): void => {
|
|
73
|
+
p.catch(() => undefined)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type Runtime = {
|
|
77
|
+
running: Map<string, Running>
|
|
78
|
+
stream: { phase: StreamPhase; since: number; label?: string } | undefined
|
|
79
|
+
streamSeq: number
|
|
80
|
+
seenSeq: number
|
|
81
|
+
pendingPhase: { phase: StreamPhase; label?: string } | undefined
|
|
82
|
+
lastDataAt: number
|
|
83
|
+
turnActive: boolean
|
|
84
|
+
turnStartedAt: number
|
|
85
|
+
betweenSince: number
|
|
86
|
+
ended: { phase: KvPhase; since: number; label?: string; lastTurnMs?: number } | undefined
|
|
87
|
+
cwd: string
|
|
88
|
+
sessionId: string
|
|
89
|
+
tasksDir: string | undefined
|
|
90
|
+
pulseFile: string | undefined
|
|
91
|
+
lastPulseAt: number
|
|
92
|
+
lastPulseKey: string
|
|
93
|
+
lastLiveKey: string
|
|
94
|
+
lastStatus: string | undefined
|
|
95
|
+
/** Refreshes run one after another, so an older state never lands after a newer one. */
|
|
96
|
+
refreshing: Promise<void>
|
|
97
|
+
/** Phone notifications, set up in ~/.kvali/config.json by the extension or `kvali phone`. */
|
|
98
|
+
phone: PhoneCfg | undefined
|
|
99
|
+
phoneFile: string | undefined
|
|
100
|
+
phoneCheckedAt: number
|
|
101
|
+
project: string
|
|
102
|
+
/** The episode (its `since`) each kind of push was last sent for: one push per event. */
|
|
103
|
+
pushedNeedsFor: number
|
|
104
|
+
pushedStallFor: number
|
|
105
|
+
lastStepPushAt: number
|
|
106
|
+
/** This turn's phone notification (an ntfy sequence ID): its progress updates and its end replace it. */
|
|
107
|
+
turnSeq: string | undefined
|
|
108
|
+
/** Tasks done when the last progress update went out, or when the turn began. */
|
|
109
|
+
progressSentDone: number
|
|
110
|
+
/** The tasks-done count last seen and since when: an update waits until it holds a moment. */
|
|
111
|
+
progressSeenDone: number
|
|
112
|
+
progressSeenAt: number
|
|
113
|
+
/** The phone followed this turn's progress, so its end is sent however short the turn was. */
|
|
114
|
+
progressSent: boolean
|
|
115
|
+
/** A turn that stopped in the middle of its plan: pushed once it has waited like a question. */
|
|
116
|
+
pendingPause: { at: number; workedMs: number; sequence?: string } | undefined
|
|
117
|
+
/** Ask Claude for a plan while there is none (`autoPlan` in ~/.kvali/config.json). */
|
|
118
|
+
autoPlan: boolean
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const fresh = (): Runtime => ({
|
|
122
|
+
running: new Map(),
|
|
123
|
+
stream: undefined,
|
|
124
|
+
streamSeq: 0,
|
|
125
|
+
seenSeq: 0,
|
|
126
|
+
pendingPhase: undefined,
|
|
127
|
+
lastDataAt: 0,
|
|
128
|
+
turnActive: false,
|
|
129
|
+
turnStartedAt: 0,
|
|
130
|
+
betweenSince: 0,
|
|
131
|
+
ended: undefined,
|
|
132
|
+
cwd: '',
|
|
133
|
+
sessionId: '',
|
|
134
|
+
tasksDir: undefined,
|
|
135
|
+
pulseFile: undefined,
|
|
136
|
+
lastPulseAt: 0,
|
|
137
|
+
lastPulseKey: '',
|
|
138
|
+
lastLiveKey: '',
|
|
139
|
+
lastStatus: undefined,
|
|
140
|
+
refreshing: Promise.resolve(),
|
|
141
|
+
phone: undefined,
|
|
142
|
+
phoneFile: undefined,
|
|
143
|
+
phoneCheckedAt: 0,
|
|
144
|
+
project: '',
|
|
145
|
+
pushedNeedsFor: 0,
|
|
146
|
+
pushedStallFor: 0,
|
|
147
|
+
lastStepPushAt: 0,
|
|
148
|
+
turnSeq: undefined,
|
|
149
|
+
progressSentDone: 0,
|
|
150
|
+
progressSeenDone: 0,
|
|
151
|
+
progressSeenAt: 0,
|
|
152
|
+
progressSent: false,
|
|
153
|
+
pendingPause: undefined,
|
|
154
|
+
autoPlan: true,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What happens right now, from the engine's events. It lives with the module: a reload
|
|
159
|
+
* starts it over and the engine's next event fills it in again.
|
|
160
|
+
*/
|
|
161
|
+
let rt: Runtime = fresh()
|
|
162
|
+
|
|
163
|
+
function derive(now: number): KvLive {
|
|
164
|
+
const base = { turnStartedAt: rt.turnActive ? rt.turnStartedAt : undefined, lastDataAt: rt.lastDataAt || undefined }
|
|
165
|
+
const all = [...rt.running.values()]
|
|
166
|
+
const asking = all.filter(r => r.asks).pop()
|
|
167
|
+
if (asking) return { ...base, phase: 'needs-you', since: asking.askedAt ?? asking.startedAt, label: asking.asks }
|
|
168
|
+
const last = all.pop()
|
|
169
|
+
if (last) return { ...base, phase: 'running', since: last.startedAt, label: last.label, detail: last.detail }
|
|
170
|
+
if (rt.stream) {
|
|
171
|
+
const quietFrom = Math.max(rt.lastDataAt, rt.stream.since)
|
|
172
|
+
if (now - quietFrom > STALL_MS) return { ...base, phase: 'stalled', since: quietFrom, lastDataAt: quietFrom }
|
|
173
|
+
return { ...base, phase: rt.stream.phase, since: rt.stream.since, label: rt.stream.label }
|
|
174
|
+
}
|
|
175
|
+
if (rt.turnActive) return { ...base, phase: 'requesting', since: rt.betweenSince || rt.turnStartedAt }
|
|
176
|
+
if (rt.ended) return { phase: rt.ended.phase, since: rt.ended.since, label: rt.ended.label, lastTurnMs: rt.ended.lastTurnMs }
|
|
177
|
+
return { phase: 'idle', since: now }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Brings the drawn state, the status line and the editor's heartbeat file up to date, in order. */
|
|
181
|
+
function refresh($: Engine, force = false): Promise<void> {
|
|
182
|
+
const run = rt.refreshing.then(() => doRefresh($, force))
|
|
183
|
+
rt.refreshing = run.catch(() => undefined)
|
|
184
|
+
return run
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function doRefresh($: Engine, force: boolean): Promise<void> {
|
|
188
|
+
const now = await $.clock.now()
|
|
189
|
+
if (rt.stream && rt.streamSeq !== rt.seenSeq) {
|
|
190
|
+
rt.seenSeq = rt.streamSeq
|
|
191
|
+
rt.lastDataAt = now
|
|
192
|
+
}
|
|
193
|
+
if (rt.stream && rt.pendingPhase && (rt.pendingPhase.phase !== rt.stream.phase || rt.pendingPhase.label !== rt.stream.label)) {
|
|
194
|
+
rt.stream = { phase: rt.pendingPhase.phase, since: now, label: rt.pendingPhase.label }
|
|
195
|
+
}
|
|
196
|
+
const l = derive(now)
|
|
197
|
+
await loadPhone($, now)
|
|
198
|
+
if (l.phase === 'needs-you' && rt.pushedNeedsFor !== l.since && now - l.since >= ASK_PUSH_AFTER_MS) {
|
|
199
|
+
rt.pushedNeedsFor = l.since
|
|
200
|
+
const asking = [...rt.running.values()].filter(r => r.asks).pop()
|
|
201
|
+
quiet(push($, 'needs-you', { label: asking?.askPush ?? asking?.asks ?? l.label }))
|
|
202
|
+
} else if (l.phase === 'stalled' && rt.pushedStallFor !== l.since) {
|
|
203
|
+
rt.pushedStallFor = l.since
|
|
204
|
+
quiet(push($, 'stalled', { quietMs: now - l.since }))
|
|
205
|
+
}
|
|
206
|
+
if (rt.pendingPause && !rt.turnActive && now - rt.pendingPause.at >= ASK_PUSH_AFTER_MS) {
|
|
207
|
+
const { workedMs, sequence } = rt.pendingPause
|
|
208
|
+
rt.pendingPause = undefined
|
|
209
|
+
quiet(push($, 'paused', { workedMs, sequence }))
|
|
210
|
+
}
|
|
211
|
+
const current = await read($, plan)
|
|
212
|
+
if (progressDue(current, now)) quiet(push($, 'step', { sequence: turnSeq(now) }))
|
|
213
|
+
const key = [l.phase, l.since, l.label ?? '', l.detail ?? ''].join('|')
|
|
214
|
+
if (key !== rt.lastLiveKey || force) {
|
|
215
|
+
rt.lastLiveKey = key
|
|
216
|
+
await update($, live, () => l)
|
|
217
|
+
}
|
|
218
|
+
if (isActivePhase(l.phase)) $.ui.invalidate('ui.render')
|
|
219
|
+
const status = statusText(current, l, now)
|
|
220
|
+
if (status !== rt.lastStatus) {
|
|
221
|
+
rt.lastStatus = status
|
|
222
|
+
$.ui.status(status)
|
|
223
|
+
}
|
|
224
|
+
await pulse($, l, now, force)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Progress on the phone: the turn's notification shows the latest finished step, at most
|
|
229
|
+
* once a minute, so a step finished sooner goes out with the next refresh after that. A new
|
|
230
|
+
* count waits a moment first: Claude marks a task done and the next one in progress in two
|
|
231
|
+
* calls, and the update should name the next one.
|
|
232
|
+
*/
|
|
233
|
+
function progressDue(p: KvPlan, now: number): boolean {
|
|
234
|
+
const pr = progress(p.tasks)
|
|
235
|
+
if (pr.done < rt.progressSentDone) rt.progressSentDone = pr.done // a new plan
|
|
236
|
+
if (pr.done !== rt.progressSeenDone) {
|
|
237
|
+
rt.progressSeenDone = pr.done
|
|
238
|
+
rt.progressSeenAt = now
|
|
239
|
+
}
|
|
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
|
|
245
|
+
rt.progressSent = true
|
|
246
|
+
return true
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function turnSeq(now: number): string {
|
|
250
|
+
rt.turnSeq ??= turnSequence(rt.sessionId, rt.turnStartedAt || now)
|
|
251
|
+
return rt.turnSeq
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The bridge to Kvali's editor extension and terminal dashboard: one small file per
|
|
256
|
+
* session, rewritten while a turn runs. Its age is the proof of life: a working session
|
|
257
|
+
* whose file stops changing was closed or froze, which nothing else can tell.
|
|
258
|
+
*/
|
|
259
|
+
async function pulse($: Engine, l: KvLive, now: number, force: boolean): Promise<void> {
|
|
260
|
+
if (!rt.pulseFile) return
|
|
261
|
+
const key = [l.phase, l.since, l.label ?? ''].join('|')
|
|
262
|
+
const due = isActivePhase(l.phase) ? now - rt.lastPulseAt >= PULSE_EVERY_MS : false
|
|
263
|
+
if (!force && key === rt.lastPulseKey && !due) return
|
|
264
|
+
rt.lastPulseKey = key
|
|
265
|
+
rt.lastPulseAt = now
|
|
266
|
+
const p = await read($, plan)
|
|
267
|
+
const pr = progress(p.tasks)
|
|
268
|
+
const body = {
|
|
269
|
+
v: 1,
|
|
270
|
+
agent: 'claude-code',
|
|
271
|
+
sessionId: rt.sessionId,
|
|
272
|
+
at: now,
|
|
273
|
+
push: rt.phone !== undefined,
|
|
274
|
+
phase: l.phase,
|
|
275
|
+
since: l.since,
|
|
276
|
+
label: l.label,
|
|
277
|
+
detail: l.detail,
|
|
278
|
+
lastDataAt: l.lastDataAt,
|
|
279
|
+
turnStartedAt: l.turnStartedAt,
|
|
280
|
+
step: pr.current && p.step ? { title: pr.current.title, startedAt: p.step.startedAt, actions: p.step.actions, typicalMs: typicalMs(p) } : undefined,
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
await $.fs.write(rt.pulseFile, `${JSON.stringify(body)}\n`)
|
|
284
|
+
} catch {
|
|
285
|
+
rt.pulseFile = undefined // a read-only home: the pane still works
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function taskFiles($: Engine): Promise<{ tasks: KvTask[]; times: Map<string, number> } | undefined> {
|
|
290
|
+
if (!rt.tasksDir) return undefined
|
|
291
|
+
let entries
|
|
292
|
+
try {
|
|
293
|
+
entries = await $.fs.list(rt.tasksDir)
|
|
294
|
+
} catch {
|
|
295
|
+
return undefined
|
|
296
|
+
}
|
|
297
|
+
const tasks: KvTask[] = []
|
|
298
|
+
const times = new Map<string, number>()
|
|
299
|
+
for (const f of entries) {
|
|
300
|
+
if (f.kind !== 'file' || !f.name.endsWith('.json')) continue
|
|
301
|
+
let text: string
|
|
302
|
+
try {
|
|
303
|
+
text = await $.fs.read(`${rt.tasksDir}/${f.name}`)
|
|
304
|
+
} catch {
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
const t = parseTaskFile(text, f.name.slice(0, -5))
|
|
308
|
+
if (!t) continue
|
|
309
|
+
tasks.push(t)
|
|
310
|
+
times.set(t.id, f.mtimeMs)
|
|
311
|
+
}
|
|
312
|
+
return tasks.length ? { tasks: sortTasks(tasks), times } : undefined
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Reads the plan and the tokens spent before Kvali loaded (a reload, or Kvali added mid-session). */
|
|
316
|
+
async function catchUp($: Engine, config: string): Promise<void> {
|
|
317
|
+
const now = await $.clock.now()
|
|
318
|
+
const files = await taskFiles($)
|
|
319
|
+
let todos: KvTask[] | undefined
|
|
320
|
+
const candidates = [...new Set([await $.session.root().catch(() => rt.cwd), rt.cwd])].map(d => `${config}/projects/${projectSlug(d)}/${rt.sessionId}.jsonl`)
|
|
321
|
+
for (const file of candidates) {
|
|
322
|
+
try {
|
|
323
|
+
const st = await $.fs.stat(file)
|
|
324
|
+
if (st.size > MAX_TRANSCRIPT) break
|
|
325
|
+
const scan = scanTranscript(await $.fs.read(file))
|
|
326
|
+
todos = scan.todos
|
|
327
|
+
await update($, usage, () => scan.usage)
|
|
328
|
+
break
|
|
329
|
+
} catch {
|
|
330
|
+
// not this one
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (files) await update($, plan, p => advance(p, files.tasks, now, files.times))
|
|
334
|
+
else if (todos?.length) await update($, plan, p => (p.tasks.length ? p : advance(p, todos!, now)))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
/** Re-reads the phone setup at most once a minute: it may be turned on or off while the session runs. */
|
|
339
|
+
async function loadPhone($: Engine, now: number, force = false): Promise<void> {
|
|
340
|
+
if (!rt.phoneFile || (!force && now - rt.phoneCheckedAt < 60_000)) return
|
|
341
|
+
rt.phoneCheckedAt = now
|
|
342
|
+
let text: string | undefined
|
|
343
|
+
try {
|
|
344
|
+
text = await $.fs.read(rt.phoneFile)
|
|
345
|
+
} catch {
|
|
346
|
+
text = undefined
|
|
347
|
+
}
|
|
348
|
+
rt.phone = text ? parsePhoneConfig(text) : undefined
|
|
349
|
+
rt.autoPlan = autoPlanEnabled(text)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const PUSH_EVENT: Record<PushKind, 'needs-you' | 'done' | 'error' | 'stuck' | 'steps'> = {
|
|
353
|
+
'needs-you': 'needs-you',
|
|
354
|
+
finished: 'done',
|
|
355
|
+
paused: 'done',
|
|
356
|
+
error: 'error',
|
|
357
|
+
stalled: 'stuck',
|
|
358
|
+
step: 'steps',
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** 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> {
|
|
363
|
+
const cfg = rt.phone
|
|
364
|
+
if (!cfg || !cfg.events.includes(PUSH_EVENT[kind])) return
|
|
365
|
+
const p = await read($, plan)
|
|
366
|
+
const u = await read($, usage)
|
|
367
|
+
const { sequence, ...info } = extra
|
|
368
|
+
const body = ntfyBody(cfg, { ...phonePush(kind, { project: rt.project, plan: p, tokens: totalTokens(u) || undefined, ...info }, cfg.details), sequence })
|
|
369
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
370
|
+
if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`
|
|
371
|
+
try {
|
|
372
|
+
await $.http.fetch(cfg.server, { method: 'POST', headers, body })
|
|
373
|
+
} catch {
|
|
374
|
+
// offline or refused by policy: the pane still shows it
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export const register: Register = on => {
|
|
379
|
+
on('session.start', async ($, e, next) => {
|
|
380
|
+
const result = await next(e)
|
|
381
|
+
rt = fresh()
|
|
382
|
+
rt.cwd = e.cwd
|
|
383
|
+
rt.sessionId = await $.session.id()
|
|
384
|
+
const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
|
|
385
|
+
const config = ((await $.env.get('CLAUDE_CONFIG_DIR'))?.split(',')[0]?.trim() || (home ? `${home}/.claude` : '')).replace(/\\/g, '/')
|
|
386
|
+
rt.tasksDir = config && rt.sessionId ? `${config}/tasks/${rt.sessionId}` : undefined
|
|
387
|
+
rt.pulseFile = home && rt.sessionId ? `${home.replace(/\\/g, '/')}/.kvali/live/claude-code/${rt.sessionId}.mod.json` : undefined
|
|
388
|
+
rt.phoneFile = home ? `${home.replace(/\\/g, '/')}/.kvali/config.json` : undefined
|
|
389
|
+
rt.project = baseName(await $.session.root().catch(() => e.cwd)) || baseName(e.cwd)
|
|
390
|
+
await loadPhone($, await $.clock.now(), true)
|
|
391
|
+
await $.command.register({ name: 'kvali', description: 'Show live progress: plan, current step, what the agent is doing, tokens' })
|
|
392
|
+
if (config && rt.sessionId) await catchUp($, config).catch(() => undefined)
|
|
393
|
+
// A pane opens by itself only where it docks beside the chat.
|
|
394
|
+
if (e.surface === 'desktop' || e.surface === 'vscode') quiet($.ui.open({ id: PANE, title: 'Kvali' }))
|
|
395
|
+
$.clock.every(TICK_MS, () => quiet(refresh($)))
|
|
396
|
+
await refresh($, true)
|
|
397
|
+
return result
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
on('command.run', { command: 'kvali' }, async $ => {
|
|
401
|
+
const opened = await $.ui.open({ id: PANE, title: 'Kvali' })
|
|
402
|
+
const p = progress((await read($, plan)).tasks)
|
|
403
|
+
const where = opened.isPlaced ? 'Kvali pane opened.' : 'Kvali:'
|
|
404
|
+
const what = p.total ? `${p.done}/${p.total} tasks done (${p.percent}%)${p.current ? `, now: ${p.current.title}` : ''}.` : 'no plan in this session yet.'
|
|
405
|
+
return { text: `${where} ${what}` }
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
// Automatic planning: while the session has no unfinished plan, a prompt asks Claude to keep one.
|
|
409
|
+
// The words are fixed and never contain anything from the prompt.
|
|
410
|
+
on('prompt.submit', async ($, e, next) => {
|
|
411
|
+
if (e.command || e.isMeta) return next(e)
|
|
412
|
+
await loadPhone($, await $.clock.now())
|
|
413
|
+
if (!rt.autoPlan || !wantsPlanContext(await read($, plan))) return next(e)
|
|
414
|
+
return next({ ...e, context: [...(e.context ?? []), PLAN_CONTEXT] })
|
|
415
|
+
}).catch(($, e, next) => next(e))
|
|
416
|
+
|
|
417
|
+
on('turn.start', async ($, e, next) => {
|
|
418
|
+
if (!e.agentId) {
|
|
419
|
+
const now = await $.clock.now()
|
|
420
|
+
rt.turnActive = true
|
|
421
|
+
rt.turnStartedAt = now
|
|
422
|
+
rt.betweenSince = now
|
|
423
|
+
rt.ended = undefined
|
|
424
|
+
rt.stream = undefined
|
|
425
|
+
rt.running.clear()
|
|
426
|
+
rt.turnSeq = turnSequence(rt.sessionId, now)
|
|
427
|
+
rt.progressSent = false
|
|
428
|
+
rt.pendingPause = undefined // the person answered
|
|
429
|
+
rt.progressSentDone = progress((await read($, plan)).tasks).done
|
|
430
|
+
quiet(refresh($))
|
|
431
|
+
}
|
|
432
|
+
return next(e)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
on('turn.step', async function* ($, e, next) {
|
|
436
|
+
if (e.agentId) {
|
|
437
|
+
// A subagent's request: it shows the session is alive, the main line says what runs.
|
|
438
|
+
for await (const c of next(e)) {
|
|
439
|
+
rt.streamSeq += 1
|
|
440
|
+
yield c
|
|
441
|
+
}
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
const now = await $.clock.now()
|
|
445
|
+
rt.turnActive = true
|
|
446
|
+
if (!rt.turnStartedAt) rt.turnStartedAt = now
|
|
447
|
+
rt.stream = { phase: 'requesting', since: now }
|
|
448
|
+
rt.pendingPhase = undefined
|
|
449
|
+
rt.lastDataAt = now
|
|
450
|
+
let stepUsage: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number; model: string } | undefined
|
|
451
|
+
quiet(refresh($))
|
|
452
|
+
try {
|
|
453
|
+
for await (const c of next(e)) {
|
|
454
|
+
rt.streamSeq += 1
|
|
455
|
+
if (c.kind === 'thinking') rt.pendingPhase = { phase: 'thinking' }
|
|
456
|
+
else if (c.kind === 'text') rt.pendingPhase = { phase: 'writing' }
|
|
457
|
+
else if (c.kind === 'tool') rt.pendingPhase = { phase: 'tool-input', label: c.name }
|
|
458
|
+
else if (c.kind === 'stop' && c.usage) stepUsage = c.usage
|
|
459
|
+
yield c
|
|
460
|
+
}
|
|
461
|
+
} finally {
|
|
462
|
+
rt.stream = undefined
|
|
463
|
+
rt.pendingPhase = undefined
|
|
464
|
+
rt.betweenSince = await $.clock.now()
|
|
465
|
+
if (stepUsage) {
|
|
466
|
+
const su = stepUsage
|
|
467
|
+
await update($, usage, u => ({
|
|
468
|
+
...u,
|
|
469
|
+
input: u.input + su.input_tokens,
|
|
470
|
+
output: u.output + su.output_tokens,
|
|
471
|
+
cacheRead: u.cacheRead + su.cache_read_input_tokens,
|
|
472
|
+
cacheWrite: u.cacheWrite + su.cache_creation_input_tokens,
|
|
473
|
+
context: su.input_tokens + su.cache_read_input_tokens + su.cache_creation_input_tokens,
|
|
474
|
+
model: su.model || u.model,
|
|
475
|
+
}))
|
|
476
|
+
}
|
|
477
|
+
quiet(refresh($))
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
on('tool.call', async ($, e, next) => {
|
|
482
|
+
const now = await $.clock.now()
|
|
483
|
+
const input = e as unknown as Record<string, unknown>
|
|
484
|
+
const isPlan = PLAN_TOOLS.has(e.tool)
|
|
485
|
+
const id = e.tool_use_id
|
|
486
|
+
if (!e.agentId) rt.turnActive = true
|
|
487
|
+
if (!isPlan) {
|
|
488
|
+
const d = describeTool(e.tool, input, rt.cwd)
|
|
489
|
+
rt.running.set(id, { ...d, startedAt: now, asks: ASKS_PERSON[e.tool], askedAt: ASKS_PERSON[e.tool] ? now : undefined })
|
|
490
|
+
await update($, recent, list => pushAction(list, { id, label: d.label, detail: d.detail, startedAt: now }))
|
|
491
|
+
await update($, plan, p => (p.step ? { ...p, step: { ...p.step, actions: p.step.actions + 1 } } : p))
|
|
492
|
+
quiet(refresh($))
|
|
493
|
+
}
|
|
494
|
+
let ran: Awaited<ReturnType<typeof next>>
|
|
495
|
+
try {
|
|
496
|
+
ran = await next(e)
|
|
497
|
+
} finally {
|
|
498
|
+
rt.running.delete(id)
|
|
499
|
+
rt.betweenSince = await $.clock.now()
|
|
500
|
+
}
|
|
501
|
+
const end = await $.clock.now()
|
|
502
|
+
if (!isPlan) await update($, recent, list => endAction(list, id, end, ran.deny !== undefined || ran.isError === true))
|
|
503
|
+
else if (ran.deny === undefined && !ran.isError && e.tool !== 'TaskList' && e.tool !== 'TaskGet') {
|
|
504
|
+
const files = e.tool === 'TodoWrite' ? undefined : await taskFiles($)
|
|
505
|
+
const current = await read($, plan)
|
|
506
|
+
const tasks = files?.tasks ?? applyPlanTool(current.tasks, e.tool, input, ran.result)
|
|
507
|
+
await update($, plan, p => advance(p, tasks, end, files?.times))
|
|
508
|
+
}
|
|
509
|
+
await refresh($)
|
|
510
|
+
return ran
|
|
511
|
+
}).catch(($, e, next) => next(e)) // an observer: if it fails, the call goes on as if it were not here
|
|
512
|
+
|
|
513
|
+
// A permission prompt: the engine asks the person before the tool runs.
|
|
514
|
+
on('tool.check', async ($, e, next) => {
|
|
515
|
+
const verdict = await next(e)
|
|
516
|
+
if (verdict.decision === 'ask' && e.approver === 'person' && e.tool_use_id) {
|
|
517
|
+
const r = rt.running.get(e.tool_use_id)
|
|
518
|
+
if (r) {
|
|
519
|
+
r.asks = `allow ${r.label}${r.detail ? ` (${r.detail})` : ''}?`
|
|
520
|
+
r.askPush = `allow ${r.label}?`
|
|
521
|
+
r.askedAt = await $.clock.now()
|
|
522
|
+
quiet(refresh($))
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return verdict
|
|
526
|
+
}).catch(($, e, next) => next(e))
|
|
527
|
+
|
|
528
|
+
on('session.compact', async ($, e, next) => {
|
|
529
|
+
rt.running.set('kvali:compact', { label: 'Compacting the conversation', startedAt: await $.clock.now() })
|
|
530
|
+
quiet(refresh($))
|
|
531
|
+
try {
|
|
532
|
+
return await next(e)
|
|
533
|
+
} finally {
|
|
534
|
+
rt.running.delete('kvali:compact')
|
|
535
|
+
quiet(refresh($))
|
|
536
|
+
}
|
|
537
|
+
}).catch(($, e, next) => next(e))
|
|
538
|
+
|
|
539
|
+
on('turn.complete', async ($, e, next) => {
|
|
540
|
+
const result = await next(e)
|
|
541
|
+
if (e.agentId) return result
|
|
542
|
+
const now = await $.clock.now()
|
|
543
|
+
rt.turnActive = false
|
|
544
|
+
rt.stream = undefined
|
|
545
|
+
rt.running.clear()
|
|
546
|
+
const phase: KvPhase = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'interrupted' : 'error'
|
|
547
|
+
rt.ended = {
|
|
548
|
+
phase,
|
|
549
|
+
since: now,
|
|
550
|
+
lastTurnMs: e.durationMs,
|
|
551
|
+
label: e.reason === 'refusal' ? 'the model declined' : e.reason === 'error' ? 'an API error ended the turn' : undefined,
|
|
552
|
+
}
|
|
553
|
+
const sequence = rt.turnSeq
|
|
554
|
+
const followed = rt.progressSent
|
|
555
|
+
rt.turnSeq = undefined
|
|
556
|
+
rt.progressSent = false
|
|
557
|
+
await refresh($, true)
|
|
558
|
+
if (phase === 'error') quiet(push($, 'error', { label: rt.ended.label, sequence }))
|
|
559
|
+
// A turn the phone followed always gets its end; other turns only when they were long.
|
|
560
|
+
// 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)) {
|
|
562
|
+
const pr = progress((await read($, plan)).tasks)
|
|
563
|
+
if (pr.total > 0 && pr.done < pr.total) rt.pendingPause = { at: now, workedMs: e.durationMs, sequence }
|
|
564
|
+
else quiet(push($, 'finished', { workedMs: e.durationMs, sequence }))
|
|
565
|
+
}
|
|
566
|
+
if (e.durationMs >= LONG_TURN_MS && phase !== 'interrupted') {
|
|
567
|
+
const p = progress((await read($, plan)).tasks)
|
|
568
|
+
const text = phase === 'done' ? `Done in ${fmtDuration(e.durationMs)}${p.total ? ` · ${p.done}/${p.total} tasks` : ''}` : `Stopped: ${rt.ended.label}`
|
|
569
|
+
$.ui.toast(text)
|
|
570
|
+
quiet($.ui.notify(text, { title: 'Claude Code' }))
|
|
571
|
+
}
|
|
572
|
+
return result
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
on('session.end', async ($, e, next) => {
|
|
576
|
+
rt.turnActive = false
|
|
577
|
+
rt.stream = undefined
|
|
578
|
+
rt.running.clear()
|
|
579
|
+
rt.ended = { phase: 'ended', since: await $.clock.now() }
|
|
580
|
+
rt.pendingPause = undefined
|
|
581
|
+
await refresh($, true).catch(() => undefined)
|
|
582
|
+
$.ui.status(undefined)
|
|
583
|
+
return next(e)
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
587
|
+
const { Box, Text, Button } = $.ui.resolve(e)
|
|
588
|
+
const p = await read($, plan)
|
|
589
|
+
const l = await read($, live)
|
|
590
|
+
const acts = await read($, recent)
|
|
591
|
+
const u = await read($, usage)
|
|
592
|
+
const isOpen = await read($, showTasks)
|
|
593
|
+
const now = await $.clock.now()
|
|
594
|
+
const cols = Math.max(24, e.props.bodyColumns ?? 40)
|
|
595
|
+
const pr = progress(p.tasks)
|
|
596
|
+
const cells = barCells(pr.done, pr.inProgress, pr.total, Math.min(28, cols - 12))
|
|
597
|
+
const typical = typicalMs(p)
|
|
598
|
+
const line = liveLine(l, now)
|
|
599
|
+
const toneColor = { ok: 'success', busy: 'suggestion', warn: 'warning', error: 'error', muted: 'inactive' }[line.tone]
|
|
600
|
+
const onStep = p.step ? now - p.step.startedAt : 0
|
|
601
|
+
const slow = typical !== undefined && onStep > typical * 1.5
|
|
602
|
+
const tokens = totalTokens(u)
|
|
603
|
+
const ctx = contextPercent(u)
|
|
604
|
+
const showRecent = isActivePhase(l.phase) || (l.phase === 'done' && now - l.since < 10 * 60_000)
|
|
605
|
+
|
|
606
|
+
return (
|
|
607
|
+
<Box flexDirection="column">
|
|
608
|
+
{pr.total > 0 ? (
|
|
609
|
+
<Box flexDirection="column">
|
|
610
|
+
<Box flexDirection="row">
|
|
611
|
+
<Text mono color="success">{cells.done}</Text>
|
|
612
|
+
<Text mono color="suggestion">{cells.active}</Text>
|
|
613
|
+
<Text mono dimColor>{cells.rest}</Text>
|
|
614
|
+
<Text bold>{` ${pr.done}/${pr.total}`}</Text>
|
|
615
|
+
<Text dimColor>{` · ${pr.percent}%`}</Text>
|
|
616
|
+
</Box>
|
|
617
|
+
{pr.current ? <Text bold wrap="wrap">{pr.current.active ?? pr.current.title}</Text> : null}
|
|
618
|
+
{pr.current && p.step ? (
|
|
619
|
+
<Text color={slow ? 'warning' : 'inactive'}>
|
|
620
|
+
{typical !== undefined
|
|
621
|
+
? `${fmtDuration(onStep)} on this step · usually ~${fmtDuration(typical)}${slow ? ' · slower than usual' : ''}`
|
|
622
|
+
: `${fmtDuration(onStep)} on this step${p.step.actions ? ` · ${p.step.actions} action${p.step.actions === 1 ? '' : 's'}` : ''}`}
|
|
623
|
+
</Text>
|
|
624
|
+
) : null}
|
|
625
|
+
</Box>
|
|
626
|
+
) : (
|
|
627
|
+
<Text dimColor wrap="wrap">No plan yet. For multi-step work Claude keeps a task list, and its progress shows here.</Text>
|
|
628
|
+
)}
|
|
629
|
+
<Box marginTop={1}>
|
|
630
|
+
<Text color={toneColor} wrap="wrap">{`${line.icon} ${line.text}`}</Text>
|
|
631
|
+
</Box>
|
|
632
|
+
{l.phase === 'running' && l.detail ? <Text mono dimColor wrap="truncate-end">{l.detail}</Text> : null}
|
|
633
|
+
{showRecent && acts.length > 0 ? (
|
|
634
|
+
<Box flexDirection="column" marginTop={1}>
|
|
635
|
+
{acts.slice(0, 4).map(a => (
|
|
636
|
+
<Box key={`a-${a.id}`} flexDirection="row" justifyContent="space-between">
|
|
637
|
+
<Text wrap="truncate-end" {...(a.isError ? { color: 'error' } : { dimColor: !!a.endedAt })}>
|
|
638
|
+
{`${a.endedAt ? (a.isError ? '✕' : '✓') : '›'} ${a.label}${a.detail ? ` · ${a.detail}` : ''}`}
|
|
639
|
+
</Text>
|
|
640
|
+
<Text dimColor>{` ${fmtDuration((a.endedAt ?? now) - a.startedAt)}`}</Text>
|
|
641
|
+
</Box>
|
|
642
|
+
))}
|
|
643
|
+
</Box>
|
|
644
|
+
) : null}
|
|
645
|
+
{tokens > 0 ? (
|
|
646
|
+
<Box marginTop={1}>
|
|
647
|
+
<Text dimColor wrap="wrap">
|
|
648
|
+
{`${fmtTokens(tokens)} tokens${u.isWholeSession ? '' : ' since Kvali started'}${ctx !== undefined ? ` · context ${ctx}%` : ''}`}
|
|
649
|
+
</Text>
|
|
650
|
+
</Box>
|
|
651
|
+
) : null}
|
|
652
|
+
{pr.total > 0 ? (
|
|
653
|
+
<Box flexDirection="column" marginTop={1}>
|
|
654
|
+
<Button key="tasks" plain label={isOpen ? 'Hide tasks' : `Show all ${pr.total} tasks`} onPress={() => update($, showTasks, v => !v)} />
|
|
655
|
+
{isOpen
|
|
656
|
+
? p.tasks.map(t => (
|
|
657
|
+
<Text wrap="wrap" bold={t.status === 'in_progress'} dimColor={t.status === 'completed'}>
|
|
658
|
+
{`${t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '◐' : '○'} ${t.title}`}
|
|
659
|
+
</Text>
|
|
660
|
+
))
|
|
661
|
+
: null}
|
|
662
|
+
</Box>
|
|
663
|
+
) : null}
|
|
664
|
+
</Box>
|
|
665
|
+
)
|
|
666
|
+
})
|
|
667
|
+
}
|