mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat: add todo tracking and custom tool views
Adds todo tracking infrastructure with TodoWrite tool integration to extract and persist todos in sessions. Refactors ToolCard component to support custom tool view rendering and displays todo progress in SessionList.
This commit is contained in:
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { Store } from '../../store'
|
||||
import { RpcRegistry } from '../rpcRegistry'
|
||||
import type { SyncEvent } from '../../sync/syncEngine'
|
||||
import { extractTodoWriteTodosFromMessageContent } from '../../sync/todos'
|
||||
|
||||
type SessionAlivePayload = {
|
||||
sid: string
|
||||
@@ -125,6 +126,14 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void
|
||||
|
||||
const msg = store.addMessage(sid, content, localId)
|
||||
|
||||
const todos = extractTodoWriteTodosFromMessageContent(content)
|
||||
if (todos) {
|
||||
const updated = store.setSessionTodos(sid, todos, msg.createdAt)
|
||||
if (updated) {
|
||||
onWebappEvent?.({ type: 'session-updated', sessionId: sid, data: { sid } })
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast to other CLI sockets interested in this session (skip sender).
|
||||
const update = {
|
||||
id: randomUUID(),
|
||||
|
||||
@@ -13,6 +13,8 @@ export type StoredSession = {
|
||||
metadataVersion: number
|
||||
agentState: unknown | null
|
||||
agentStateVersion: number
|
||||
todos: unknown | null
|
||||
todosUpdatedAt: number | null
|
||||
active: boolean
|
||||
activeAt: number | null
|
||||
seq: number
|
||||
@@ -55,6 +57,8 @@ type DbSessionRow = {
|
||||
metadata_version: number
|
||||
agent_state: string | null
|
||||
agent_state_version: number
|
||||
todos: string | null
|
||||
todos_updated_at: number | null
|
||||
active: number
|
||||
active_at: number | null
|
||||
seq: number
|
||||
@@ -102,6 +106,8 @@ function toStoredSession(row: DbSessionRow): StoredSession {
|
||||
metadataVersion: row.metadata_version,
|
||||
agentState: safeJsonParse(row.agent_state),
|
||||
agentStateVersion: row.agent_state_version,
|
||||
todos: safeJsonParse(row.todos),
|
||||
todosUpdatedAt: row.todos_updated_at,
|
||||
active: row.active === 1,
|
||||
activeAt: row.active_at,
|
||||
seq: row.seq
|
||||
@@ -184,6 +190,8 @@ export class Store {
|
||||
metadata_version INTEGER DEFAULT 1,
|
||||
agent_state TEXT,
|
||||
agent_state_version INTEGER DEFAULT 1,
|
||||
todos TEXT,
|
||||
todos_updated_at INTEGER,
|
||||
active INTEGER DEFAULT 0,
|
||||
active_at INTEGER,
|
||||
seq INTEGER DEFAULT 0
|
||||
@@ -215,6 +223,16 @@ export class Store {
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_local_id ON messages(session_id, local_id) WHERE local_id IS NOT NULL;
|
||||
`)
|
||||
|
||||
const sessionColumns = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
|
||||
const sessionColumnNames = new Set(sessionColumns.map((c) => c.name))
|
||||
|
||||
if (!sessionColumnNames.has('todos')) {
|
||||
this.db.exec('ALTER TABLE sessions ADD COLUMN todos TEXT')
|
||||
}
|
||||
if (!sessionColumnNames.has('todos_updated_at')) {
|
||||
this.db.exec('ALTER TABLE sessions ADD COLUMN todos_updated_at INTEGER')
|
||||
}
|
||||
}
|
||||
|
||||
getOrCreateSession(tag: string, metadata: unknown, agentState: unknown): StoredSession {
|
||||
@@ -237,11 +255,13 @@ export class Store {
|
||||
id, tag, machine_id, created_at, updated_at,
|
||||
metadata, metadata_version,
|
||||
agent_state, agent_state_version,
|
||||
todos, todos_updated_at,
|
||||
active, active_at, seq
|
||||
) VALUES (
|
||||
@id, @tag, NULL, @created_at, @updated_at,
|
||||
@metadata, 1,
|
||||
@agent_state, 1,
|
||||
NULL, NULL,
|
||||
0, NULL, 0
|
||||
)
|
||||
`).run({
|
||||
@@ -326,6 +346,29 @@ export class Store {
|
||||
}
|
||||
}
|
||||
|
||||
setSessionTodos(id: string, todos: unknown, todosUpdatedAt: number): boolean {
|
||||
try {
|
||||
const json = todos === null || todos === undefined ? null : JSON.stringify(todos)
|
||||
const result = this.db.prepare(`
|
||||
UPDATE sessions
|
||||
SET todos = @todos,
|
||||
todos_updated_at = @todos_updated_at,
|
||||
updated_at = CASE WHEN updated_at > @updated_at THEN updated_at ELSE @updated_at END,
|
||||
seq = seq + 1
|
||||
WHERE id = @id AND (todos_updated_at IS NULL OR todos_updated_at < @todos_updated_at)
|
||||
`).run({
|
||||
id,
|
||||
todos: json,
|
||||
todos_updated_at: todosUpdatedAt,
|
||||
updated_at: todosUpdatedAt
|
||||
})
|
||||
|
||||
return result.changes === 1
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
getSession(id: string): StoredSession | null {
|
||||
const row = this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as DbSessionRow | undefined
|
||||
return row ? toStoredSession(row) : null
|
||||
|
||||
@@ -11,6 +11,7 @@ import { z } from 'zod'
|
||||
import type { Server } from 'socket.io'
|
||||
import type { Store } from '../store'
|
||||
import type { RpcRegistry } from '../socket/rpcRegistry'
|
||||
import { extractTodoWriteTodosFromMessageContent, TodosSchema, type TodoItem } from './todos'
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connected'
|
||||
|
||||
@@ -73,6 +74,7 @@ export interface Session {
|
||||
agentStateVersion: number
|
||||
thinking: boolean
|
||||
thinkingAt: number
|
||||
todos?: TodoItem[]
|
||||
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | null
|
||||
modelMode?: 'default' | 'sonnet' | 'opus' | null
|
||||
}
|
||||
@@ -143,6 +145,7 @@ export class SyncEngine {
|
||||
|
||||
private readonly lastBroadcastAtBySessionId: Map<string, number> = new Map()
|
||||
private readonly lastBroadcastAtByMachineId: Map<string, number> = new Map()
|
||||
private readonly todoBackfillAttemptedSessionIds: Set<string> = new Set()
|
||||
private inactivityTimer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(
|
||||
@@ -382,7 +385,7 @@ export class SyncEngine {
|
||||
}
|
||||
|
||||
private refreshSession(sessionId: string): Session | null {
|
||||
const stored = this.store.getSession(sessionId)
|
||||
let stored = this.store.getSession(sessionId)
|
||||
if (!stored) {
|
||||
const existed = this.sessions.delete(sessionId)
|
||||
if (existed) {
|
||||
@@ -393,6 +396,22 @@ export class SyncEngine {
|
||||
|
||||
const existing = this.sessions.get(sessionId)
|
||||
|
||||
if (stored.todos === null && !this.todoBackfillAttemptedSessionIds.has(sessionId)) {
|
||||
this.todoBackfillAttemptedSessionIds.add(sessionId)
|
||||
const messages = this.store.getMessages(sessionId, 200)
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i]
|
||||
const todos = extractTodoWriteTodosFromMessageContent(message.content)
|
||||
if (todos) {
|
||||
const updated = this.store.setSessionTodos(sessionId, todos, message.createdAt)
|
||||
if (updated) {
|
||||
stored = this.store.getSession(sessionId) ?? stored
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = (() => {
|
||||
const parsed = MetadataSchema.safeParse(stored.metadata)
|
||||
return parsed.success ? parsed.data : null
|
||||
@@ -403,6 +422,12 @@ export class SyncEngine {
|
||||
return parsed.success ? parsed.data : null
|
||||
})()
|
||||
|
||||
const todos = (() => {
|
||||
if (stored.todos === null) return undefined
|
||||
const parsed = TodosSchema.safeParse(stored.todos)
|
||||
return parsed.success ? parsed.data : undefined
|
||||
})()
|
||||
|
||||
const session: Session = {
|
||||
id: stored.id,
|
||||
seq: stored.seq,
|
||||
@@ -416,6 +441,7 @@ export class SyncEngine {
|
||||
agentStateVersion: stored.agentStateVersion,
|
||||
thinking: existing?.thinking ?? false,
|
||||
thinkingAt: existing?.thinkingAt ?? 0,
|
||||
todos,
|
||||
permissionMode: existing?.permissionMode ?? null,
|
||||
modelMode: existing?.modelMode ?? null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const TodoItemSchema = z.object({
|
||||
content: z.string(),
|
||||
status: z.enum(['pending', 'in_progress', 'completed']),
|
||||
priority: z.enum(['high', 'medium', 'low']),
|
||||
id: z.string()
|
||||
}).passthrough()
|
||||
|
||||
export type TodoItem = z.infer<typeof TodoItemSchema>
|
||||
|
||||
export const TodosSchema = z.array(TodoItemSchema)
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object'
|
||||
}
|
||||
|
||||
type RoleWrappedRecord = {
|
||||
role: string
|
||||
content: unknown
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
function isRoleWrappedRecord(value: unknown): value is RoleWrappedRecord {
|
||||
if (!isObject(value)) return false
|
||||
return typeof value.role === 'string' && 'content' in value
|
||||
}
|
||||
|
||||
function unwrapRoleWrappedRecordEnvelope(value: unknown): RoleWrappedRecord | null {
|
||||
if (isRoleWrappedRecord(value)) return value
|
||||
if (!isObject(value)) return null
|
||||
|
||||
const direct = value.message
|
||||
if (isRoleWrappedRecord(direct)) return direct
|
||||
|
||||
const data = value.data
|
||||
if (isObject(data) && isRoleWrappedRecord(data.message)) return data.message as RoleWrappedRecord
|
||||
|
||||
const payload = value.payload
|
||||
if (isObject(payload) && isRoleWrappedRecord(payload.message)) return payload.message as RoleWrappedRecord
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractTodosFromClaudeOutput(content: Record<string, unknown>): TodoItem[] | null {
|
||||
if (content.type !== 'output') return null
|
||||
|
||||
const data = isObject(content.data) ? content.data : null
|
||||
if (!data || data.type !== 'assistant') return null
|
||||
|
||||
const message = isObject(data.message) ? data.message : null
|
||||
if (!message) return null
|
||||
|
||||
const modelContent = message.content
|
||||
if (!Array.isArray(modelContent)) return null
|
||||
|
||||
for (const block of modelContent) {
|
||||
if (!isObject(block) || block.type !== 'tool_use') continue
|
||||
const name = typeof block.name === 'string' ? block.name : null
|
||||
if (name !== 'TodoWrite') continue
|
||||
const input = 'input' in block ? (block as Record<string, unknown>).input : null
|
||||
if (!isObject(input)) continue
|
||||
|
||||
const todosCandidate = input.todos
|
||||
const parsed = TodosSchema.safeParse(todosCandidate)
|
||||
if (parsed.success) {
|
||||
return parsed.data
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractTodosFromCodexMessage(content: Record<string, unknown>): TodoItem[] | null {
|
||||
if (content.type !== 'codex') return null
|
||||
|
||||
const data = isObject(content.data) ? content.data : null
|
||||
if (!data || data.type !== 'tool-call') return null
|
||||
|
||||
const name = typeof data.name === 'string' ? data.name : null
|
||||
if (name !== 'TodoWrite') return null
|
||||
|
||||
const input = 'input' in data ? (data as Record<string, unknown>).input : null
|
||||
if (!isObject(input)) return null
|
||||
|
||||
const todosCandidate = input.todos
|
||||
const parsed = TodosSchema.safeParse(todosCandidate)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
|
||||
export function extractTodoWriteTodosFromMessageContent(messageContent: unknown): TodoItem[] | null {
|
||||
const record = unwrapRoleWrappedRecordEnvelope(messageContent)
|
||||
if (!record) return null
|
||||
|
||||
if (record.role !== 'agent' && record.role !== 'assistant') return null
|
||||
|
||||
if (!isObject(record.content) || typeof record.content.type !== 'string') return null
|
||||
|
||||
return extractTodosFromClaudeOutput(record.content) ?? extractTodosFromCodexMessage(record.content)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type SessionSummary = {
|
||||
permissionMode: Session['permissionMode']
|
||||
modelMode: Session['modelMode']
|
||||
metadata: Session['metadata']
|
||||
todos?: Session['todos']
|
||||
pendingRequestsCount: number
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ function toSessionSummary(session: Session): SessionSummary {
|
||||
permissionMode: session.permissionMode,
|
||||
modelMode: session.modelMode,
|
||||
metadata: session.metadata,
|
||||
todos: session.todos,
|
||||
pendingRequestsCount
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user