@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.
@@ -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
+ }