Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ async function main() {
onWebappEvent: (event: SyncEvent) => syncEngine?.handleRealtimeEvent(event),
onSessionAlive: (payload) => syncEngine?.handleSessionAlive(payload),
onSessionEnd: (payload) => syncEngine?.handleSessionEnd(payload),
onMachineAlive: (payload) => syncEngine?.handleMachineAlive(payload)
onMachineAlive: (payload) => syncEngine?.handleMachineAlive(payload),
onBackgroundTaskDelta: (sessionId, delta) => syncEngine?.handleBackgroundTaskDelta(sessionId, delta)
})

syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager)
Expand Down
6 changes: 4 additions & 2 deletions hub/src/socket/handlers/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ export type CliHandlersDeps = {
onSessionEnd?: (payload: SessionEndPayload) => void
onMachineAlive?: (payload: MachineAlivePayload) => void
onWebappEvent?: (event: SyncEvent) => void
onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void
}

export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlersDeps): void {
const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent } = deps
const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTaskDelta } = deps
const terminalNamespace = io.of('/terminal')
const namespace = typeof socket.data.namespace === 'string' ? socket.data.namespace : null

Expand Down Expand Up @@ -102,7 +103,8 @@ export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlers
emitAccessError,
onSessionAlive,
onSessionEnd,
onWebappEvent
onWebappEvent,
onBackgroundTaskDelta
})
registerMachineHandlers(socket, {
store,
Expand Down
9 changes: 8 additions & 1 deletion hub/src/socket/handlers/cli/sessionHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Store, StoredSession } from '../../../store'
import type { SyncEvent } from '../../../sync/syncEngine'
import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos'
import { extractTeamStateFromMessageContent, applyTeamStateDelta } from '../../../sync/teams'
import { extractBackgroundTaskDelta } from '../../../sync/backgroundTasks'
import type { CliSocketWithData } from '../../socketTypes'
import type { AccessErrorReason, AccessResult } from './types'

Expand Down Expand Up @@ -57,10 +58,11 @@ export type SessionHandlersDeps = {
onSessionAlive?: (payload: SessionAlivePayload) => void
onSessionEnd?: (payload: SessionEndPayload) => void
onWebappEvent?: (event: SyncEvent) => void
onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void
}

export function registerSessionHandlers(socket: CliSocketWithData, deps: SessionHandlersDeps): void {
const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionEnd, onWebappEvent } = deps
const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionEnd, onWebappEvent, onBackgroundTaskDelta } = deps

socket.on('message', (data: unknown) => {
const parsed = messageSchema.safeParse(data)
Expand Down Expand Up @@ -109,6 +111,11 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session
}
}

const bgDelta = extractBackgroundTaskDelta(content)
if (bgDelta) {
onBackgroundTaskDelta?.(sid, bgDelta)
}

const update = {
id: randomUUID(),
seq: msg.seq,
Expand Down
4 changes: 3 additions & 1 deletion hub/src/socket/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type SocketServerDeps = {
onSessionAlive?: (payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }) => void
onSessionEnd?: (payload: { sid: string; time: number }) => void
onMachineAlive?: (payload: { machineId: string; time: number }) => void
onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void
}

export function createSocketServer(deps: SocketServerDeps): {
Expand Down Expand Up @@ -113,7 +114,8 @@ export function createSocketServer(deps: SocketServerDeps): {
onSessionAlive: deps.onSessionAlive,
onSessionEnd: deps.onSessionEnd,
onMachineAlive: deps.onMachineAlive,
onWebappEvent: deps.onWebappEvent
onWebappEvent: deps.onWebappEvent,
onBackgroundTaskDelta: deps.onBackgroundTaskDelta
}))

terminalNs.use(async (socket, next) => {
Expand Down
91 changes: 91 additions & 0 deletions hub/src/sync/backgroundTasks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { isObject } from '@hapi/protocol'
import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages'

/**
* Extract background task start/completion signals from a message.
*
* Uses role-aware parsing to avoid false positives:
* - Started: agent-role output with tool_result containing
* "Command running in background with ID:"
* - Completed: agent-role output wrapping a user-type message (system-injected)
* starting with "<task-notification>"
*
* Both signals arrive as { role: 'agent', content: { type: 'output', data: {...} } }
* because the CLI wraps all messages in agent envelopes.
*/
export function extractBackgroundTaskDelta(messageContent: unknown): { started: number; completed: number } | null {
const record = unwrapRoleWrappedRecordEnvelope(messageContent)
if (!record || record.role !== 'agent') return null
if (!isObject(record.content) || record.content.type !== 'output') return null

const data = isObject(record.content.data) ? record.content.data : null
if (!data) return null

const started = countTaskStarts(record.content)
const completed = data.type === 'user' ? countTaskCompletions(data) : 0

if (started === 0 && completed === 0) return null
return { started, completed }
}

/**
* Count background task starts from tool_result blocks.
*/
function countTaskStarts(content: Record<string, unknown>): number {
const data = isObject(content.data) ? content.data : null
if (!data) return 0

// Direct tool_result
if (data.type === 'tool_result') {
return isBackgroundStartResult(data) ? 1 : 0
}

// Assistant message with content array containing tool_result blocks
if (data.type === 'assistant') {
const message = isObject(data.message) ? data.message : null
const modelContent = message?.content
if (!Array.isArray(modelContent)) return 0

let count = 0
for (const block of modelContent) {
if (isObject(block) && block.type === 'tool_result' && isBackgroundStartResult(block)) {
count++
}
}
return count
}

return 0
}

function isBackgroundStartResult(block: Record<string, unknown>): boolean {
const text = typeof block.content === 'string'
? block.content
: Array.isArray(block.content)
? block.content.map((c: unknown) => isObject(c) && typeof c.text === 'string' ? c.text : '').join('')
: ''
return text.includes('Command running in background with ID:')
}

/**
* Count task completions from system-injected user messages.
*
* These arrive as: { type: 'user', message: { content: '<task-notification>...' } }
* inside the agent output envelope.
*/
function countTaskCompletions(data: Record<string, unknown>): number {
// { type: 'user', message: { content: '<task-notification>...' } }
if (isObject(data.message)) {
const msg = data.message as Record<string, unknown>
if (typeof msg.content === 'string' && msg.content.trimStart().startsWith('<task-notification>')) {
return 1
}
}

// { type: 'user', uuid: '...', content: '<task-notification>...' }
if (typeof data.content === 'string' && data.content.trimStart().startsWith('<task-notification>')) {
return 1
}

return 0
}
21 changes: 20 additions & 1 deletion hub/src/sync/sessionCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Store } from '../store'
import { clampAliveTime } from './aliveTime'
import { EventPublisher } from './eventPublisher'
import { extractTodoWriteTodosFromMessageContent, TodosSchema } from './todos'
import { extractBackgroundTaskDelta } from './backgroundTasks'

export class SessionCache {
private readonly sessions: Map<string, Session> = new Map()
Expand Down Expand Up @@ -131,6 +132,7 @@ export class SessionCache {
agentStateVersion: stored.agentStateVersion,
thinking: existing?.thinking ?? false,
thinkingAt: existing?.thinkingAt ?? 0,
backgroundTaskCount: existing?.backgroundTaskCount ?? 0,
todos,
teamState,
model: stored.model,
Expand Down Expand Up @@ -230,6 +232,22 @@ export class SessionCache {
}
}

applyBackgroundTaskDelta(sessionId: string, delta: { started: number; completed: number }): void {
const session = this.sessions.get(sessionId)
if (!session) return

const prev = session.backgroundTaskCount ?? 0
const next = Math.max(0, prev + delta.started - delta.completed)
if (next === prev) return

session.backgroundTaskCount = next
this.publisher.emit({
type: 'session-updated',
sessionId,
data: { backgroundTaskCount: next }
})
}

handleSessionEnd(payload: { sid: string; time: number }): void {
const t = clampAliveTime(payload.time) ?? Date.now()

Expand All @@ -243,8 +261,9 @@ export class SessionCache {
session.active = false
session.thinking = false
session.thinkingAt = t
session.backgroundTaskCount = 0

this.publisher.emit({ type: 'session-updated', sessionId: session.id, data: { active: false, thinking: false } })
this.publisher.emit({ type: 'session-updated', sessionId: session.id, data: { active: false, thinking: false, backgroundTaskCount: 0 } })
}

expireInactive(now: number = Date.now()): void {
Expand Down
4 changes: 4 additions & 0 deletions hub/src/sync/syncEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ export class SyncEngine {
this.sessionCache.handleSessionEnd(payload)
}

handleBackgroundTaskDelta(sessionId: string, delta: { started: number; completed: number }): void {
this.sessionCache.applyBackgroundTaskDelta(sessionId, delta)
}

handleMachineAlive(payload: { machineId: string; time: number }): void {
this.machineCache.handleMachineAlive(payload)
}
Expand Down
1 change: 1 addition & 0 deletions shared/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export const SessionSchema = z.object({
agentStateVersion: z.number(),
thinking: z.boolean(),
thinkingAt: z.number(),
backgroundTaskCount: z.number().optional(),
todos: TodosSchema.optional(),
teamState: TeamStateSchema.optional(),
model: z.string().nullable(),
Expand Down
3 changes: 3 additions & 0 deletions web/src/components/AssistantChat/HappyComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function HappyComposer(props: {
allowSendWhenInactive?: boolean
thinking?: boolean
agentState?: AgentState | null
backgroundTaskCount?: number
contextSize?: number
controlledByUser?: boolean
agentFlavor?: string | null
Expand Down Expand Up @@ -77,6 +78,7 @@ export function HappyComposer(props: {
allowSendWhenInactive = false,
thinking = false,
agentState,
backgroundTaskCount,
contextSize,
controlledByUser = false,
agentFlavor,
Expand Down Expand Up @@ -668,6 +670,7 @@ export function HappyComposer(props: {
active={active}
thinking={thinking}
agentState={agentState}
backgroundTaskCount={backgroundTaskCount}
contextSize={contextSize}
model={model}
permissionMode={permissionMode}
Expand Down
15 changes: 13 additions & 2 deletions web/src/components/AssistantChat/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function getConnectionStatus(
thinking: boolean,
agentState: AgentState | null | undefined,
voiceStatus: ConversationStatus | undefined,
backgroundTaskCount: number,
t: (key: string) => string
): { text: string; color: string; dotColor: string; isPulsing: boolean } {
const hasPermissions = agentState?.requests && Object.keys(agentState.requests).length > 0
Expand Down Expand Up @@ -84,6 +85,15 @@ function getConnectionStatus(
}
}

if (backgroundTaskCount > 0) {
return {
text: `${backgroundTaskCount} background task${backgroundTaskCount > 1 ? 's' : ''} running`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] This new status text is hardcoded in English, while the rest of the connection-state labels already go through useTranslation(). In zh-CN, users will now see an English-only status as soon as a background task is running.

Suggested fix:

text: t('misc.backgroundTasksRunning', {
    count: backgroundTaskCount,
    s: backgroundTaskCount > 1 ? 's' : ''
})

Add the matching misc.backgroundTasksRunning entry in web/src/lib/locales/en.ts and web/src/lib/locales/zh-CN.ts.

color: 'text-[#007AFF]',
dotColor: 'bg-[#007AFF]',
isPulsing: true
}
}

return {
text: t('misc.online'),
color: 'text-[#34C759]',
Expand All @@ -110,6 +120,7 @@ export function StatusBar(props: {
active: boolean
thinking: boolean
agentState: AgentState | null | undefined
backgroundTaskCount?: number
contextSize?: number
model?: string | null
permissionMode?: PermissionMode
Expand All @@ -119,8 +130,8 @@ export function StatusBar(props: {
}) {
const { t } = useTranslation()
const connectionStatus = useMemo(
() => getConnectionStatus(props.active, props.thinking, props.agentState, props.voiceStatus, t),
[props.active, props.thinking, props.agentState, props.voiceStatus, t]
() => getConnectionStatus(props.active, props.thinking, props.agentState, props.voiceStatus, props.backgroundTaskCount ?? 0, t),
[props.active, props.thinking, props.agentState, props.voiceStatus, props.backgroundTaskCount, t]
)

const contextWarning = useMemo(
Expand Down
1 change: 1 addition & 0 deletions web/src/components/SessionChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ export function SessionChat(props: {
allowSendWhenInactive
thinking={props.session.thinking}
agentState={props.session.agentState}
backgroundTaskCount={props.session.backgroundTaskCount}
contextSize={reduced.latestUsage?.contextSize}
controlledByUser={controlledByUser}
onCollaborationModeChange={
Expand Down
Loading