Skip to content

Commit d89ed3a

Browse files
OpenSource03claude
andcommitted
feat: event-driven background agent monitoring and SDK Agent tool support
Replace file-polling background agent tracking with SDK task_progress events for real-time metrics (tokens, tool count, duration). Detect task completion from <task-notification> XML in user messages since the SDK doesn't emit system/task_notification events. Background agents register from tool_result with isAsync: true — task_started is ignored since it fires for all agents. Fix subagent step rendering for SDK 0.2.63 which renamed the tool from "Task" to "Agent". All isTask checks now accept both names so parentToolMap routing and subagent steps display correctly for foreground agents. Add detailed task event logging in main process summarizeEvent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5edcbe8 commit d89ed3a

7 files changed

Lines changed: 126 additions & 37 deletions

File tree

electron/src/ipc/claude-sessions.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ function summarizeEvent(event: Record<string, unknown>): string {
5252
if (event.subtype === "init") {
5353
return `system/init session=${(event.session_id as string)?.slice(0, 8)} model=${event.model}`;
5454
}
55+
if (event.subtype === "task_started") {
56+
return `system/task_started task=${(event.task_id as string)?.slice(0, 8)} tool_use=${(event.tool_use_id as string)?.slice(0, 12)} desc="${event.description}"`;
57+
}
58+
if (event.subtype === "task_progress") {
59+
const usage = event.usage as { total_tokens: number; tool_uses: number; duration_ms: number } | undefined;
60+
return `system/task_progress task=${(event.task_id as string)?.slice(0, 8)} tokens=${usage?.total_tokens} tools=${usage?.tool_uses} ${usage?.duration_ms}ms last=${event.last_tool_name ?? "-"}`;
61+
}
62+
if (event.subtype === "task_notification") {
63+
const usage = event.usage as { total_tokens: number; tool_uses: number; duration_ms: number } | undefined;
64+
return `system/task_notification task=${(event.task_id as string)?.slice(0, 8)} status=${event.status} tokens=${usage?.total_tokens} ${usage?.duration_ms}ms`;
65+
}
5566
return `system/${event.subtype}`;
5667
}
5768
case "stream_event": {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "harnss",
3-
"version": "0.11.1",
3+
"version": "0.11.2",
44
"productName": "Harnss",
55
"description": "Harness your AI coding agents — one desktop app for Claude Code, Codex, and any ACP agent",
66
"author": {

src/components/BackgroundAgentsPanel.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,10 @@ function AgentItem({
133133

134134
<CollapsibleContent>
135135
<div className="px-2 ps-9 pb-2 space-y-2">
136-
{/* Activity log (last 15) */}
136+
{/* Activity log — scrollable when long */}
137137
{agent.activity.length > 0 && (
138-
<div className="space-y-0.5">
139-
{agent.activity.slice(-15).map((activity, i) => (
138+
<div className="max-h-48 overflow-y-auto space-y-0.5 scrollbar-none">
139+
{agent.activity.map((activity, i) => (
140140
<ActivityRow key={i} activity={activity} />
141141
))}
142142
</div>

src/components/ToolCall.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ function getToolLabel(toolName: string, type: ToolLabelType): string | null {
168168
// ── Main entry ──
169169

170170
export const ToolCall = memo(function ToolCall({ message }: { message: UIMessage }) {
171-
const isTask = message.toolName === "Task";
171+
const isTask = message.toolName === "Task" || message.toolName === "Agent";
172172

173173
return (
174174
<div className="flex justify-start px-4 py-0.5">

src/hooks/useClaude.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type {
33
ClaudeEvent,
44
SystemInitEvent,
55
SystemCompactBoundaryEvent,
6-
TaskStartedEvent,
76
TaskProgressEvent,
87
TaskNotificationEvent,
98
AuthStatusEvent,
@@ -237,14 +236,12 @@ export function useClaude({ sessionId, initialMessages, initialMeta, initialPerm
237236
// Filter events by sessionId
238237
if (event._sessionId && event._sessionId !== sessionIdRef.current) return;
239238

240-
// Intercept task lifecycle events before parentId routing —
241-
// these are top-level metadata for background agents, not subagent streaming content
239+
// Intercept task progress/notification events before parentId routing —
240+
// these are top-level metadata for background agents, not subagent streaming content.
241+
// Note: task_started fires for ALL agents (foreground + background), so we don't
242+
// register from it. Background agents are registered from the tool_result with isAsync.
242243
if (event.type === "system" && "subtype" in event) {
243244
const sub = (event as { subtype: string }).subtype;
244-
if (sub === "task_started") {
245-
bgAgentStore.handleTaskStarted(sessionIdRef.current!, event as TaskStartedEvent);
246-
return;
247-
}
248245
if (sub === "task_progress") {
249246
bgAgentStore.handleTaskProgress(sessionIdRef.current!, event as TaskProgressEvent);
250247
return;
@@ -370,7 +367,7 @@ export function useClaude({ sessionId, initialMessages, initialMeta, initialPerm
370367
parsedInput = { raw: rawInput };
371368
}
372369

373-
const isTask = toolMeta.name === "Task";
370+
const isTask = toolMeta.name === "Task" || toolMeta.name === "Agent";
374371
const msgId = `tool-${toolMeta.id}`;
375372

376373
setMessages((prev) => {
@@ -493,7 +490,7 @@ export function useClaude({ sessionId, initialMessages, initialMeta, initialPerm
493490

494491
for (const block of event.message.content) {
495492
if (block.type === "tool_use") {
496-
const isTask = block.name === "Task";
493+
const isTask = block.name === "Task" || block.name === "Agent";
497494
const msgId = `tool-${block.id}`;
498495
setMessages((prev) => {
499496
if (prev.some((m) => m.id === msgId)) return prev;
@@ -522,6 +519,12 @@ export function useClaude({ sessionId, initialMessages, initialMeta, initialPerm
522519
case "user": {
523520
const rawContent = event.message.content;
524521

522+
// Task completion arrives as user text with <task-notification> XML,
523+
// not as a system event — parse it and update the bgAgentStore
524+
if (typeof rawContent === "string" && rawContent.includes("<task-notification>")) {
525+
bgAgentStore.handleUserMessage(sessionIdRef.current!, rawContent);
526+
}
527+
525528
// Tool result — update the matching tool_call message
526529
if (Array.isArray(rawContent) && rawContent[0]?.type === "tool_result") {
527530
const toolResult = rawContent[0];
@@ -537,10 +540,20 @@ export function useClaude({ sessionId, initialMessages, initialMeta, initialPerm
537540
isError,
538541
});
539542

543+
// Register background (async) agents in the shared store
544+
if (resultMeta?.isAsync && resultMeta.outputFile && toolUseId) {
545+
bgAgentStore.registerAsyncAgent(sessionIdRef.current!, {
546+
toolUseId,
547+
agentId: resultMeta.agentId ?? toolUseId,
548+
description: String(resultMeta.description ?? "Background agent"),
549+
outputFile: resultMeta.outputFile,
550+
});
551+
}
552+
540553
setMessages((prev) =>
541554
prev.map((m) => {
542555
if (m.id !== toolCallId) return m;
543-
if (m.toolName === "Task" && resultMeta) {
556+
if ((m.toolName === "Task" || m.toolName === "Agent") && resultMeta) {
544557
return {
545558
...m,
546559
toolResult: resultMeta,

src/lib/background-agent-store.ts

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
import type { BackgroundAgent } from "@/types";
2-
import type { TaskStartedEvent, TaskProgressEvent, TaskNotificationEvent } from "@/types";
2+
import type { TaskProgressEvent, TaskNotificationEvent } from "@/types";
33

44
type Listener = (sessionId: string) => void;
55

6+
interface AsyncAgentInfo {
7+
toolUseId: string;
8+
agentId: string;
9+
description: string;
10+
outputFile: string;
11+
}
12+
613
/**
714
* Shared store for event-driven background agent tracking.
815
*
9-
* Both useClaude (active session) and BackgroundSessionStore (backgrounded
10-
* sessions) push SDK task lifecycle events here. The useBackgroundAgents
11-
* hook subscribes via useSyncExternalStore.
16+
* Only tracks BACKGROUND (async) agents — foreground agents use the
17+
* existing parentToolMap/subagentSteps system in useClaude.
18+
*
19+
* Registration: from tool_result with isAsync: true (definitive async signal).
20+
* Updates: from task_progress events (live metrics) and task-notification XML
21+
* in user messages (completion).
1222
*/
1323
class BackgroundAgentStore {
1424
private agents = new Map<string, Map<string, BackgroundAgent>>();
@@ -43,33 +53,37 @@ class BackgroundAgentStore {
4353
this.notify(sessionId);
4454
}
4555

46-
handleTaskStarted(sessionId: string, event: TaskStartedEvent): void {
47-
if (!event.tool_use_id) return;
56+
/**
57+
* Register a background agent from tool_result with isAsync: true.
58+
* This is the only entry point — task_started fires for ALL agents
59+
* (foreground + background), so we don't use it.
60+
*/
61+
registerAsyncAgent(sessionId: string, info: AsyncAgentInfo): void {
4862
let map = this.agents.get(sessionId);
4963
if (!map) {
5064
map = new Map();
5165
this.agents.set(sessionId, map);
5266
}
53-
// Don't overwrite if already exists (e.g. duplicate event)
54-
if (map.has(event.tool_use_id)) return;
67+
if (map.has(info.toolUseId)) return;
5568

56-
map.set(event.tool_use_id, {
57-
agentId: event.task_id,
58-
description: event.description,
69+
map.set(info.toolUseId, {
70+
agentId: info.agentId,
71+
description: info.description,
5972
prompt: "",
60-
outputFile: "",
73+
outputFile: info.outputFile,
6174
launchedAt: Date.now(),
6275
status: "running",
6376
activity: [],
64-
toolUseId: event.tool_use_id,
65-
taskId: event.task_id,
77+
toolUseId: info.toolUseId,
78+
taskId: info.agentId,
6679
});
6780
this.notify(sessionId);
6881
}
6982

7083
handleTaskProgress(sessionId: string, event: TaskProgressEvent): void {
7184
if (!event.tool_use_id) return;
7285
const agent = this.agents.get(sessionId)?.get(event.tool_use_id);
86+
// Only update agents we've registered (i.e. background agents)
7387
if (!agent) return;
7488

7589
agent.usage = {
@@ -109,6 +123,37 @@ class BackgroundAgentStore {
109123
this.notify(sessionId);
110124
}
111125

126+
/**
127+
* Parse task completion from user text messages containing <task-notification> XML.
128+
* The SDK delivers task completion as a user text message, NOT as a system event.
129+
*/
130+
handleUserMessage(sessionId: string, content: string): void {
131+
if (!content.includes("<task-notification>")) return;
132+
133+
const toolUseId = extractXmlTag(content, "tool-use-id");
134+
if (!toolUseId) return;
135+
136+
const agent = this.agents.get(sessionId)?.get(toolUseId);
137+
if (!agent) return;
138+
139+
const status = extractXmlTag(content, "status");
140+
agent.status = status === "completed" ? "completed" : "error";
141+
agent.result = extractXmlTag(content, "summary") || undefined;
142+
143+
const tokens = extractXmlTag(content, "total_tokens");
144+
const tools = extractXmlTag(content, "tool_uses");
145+
const duration = extractXmlTag(content, "duration_ms");
146+
if (tokens) {
147+
agent.usage = {
148+
totalTokens: parseInt(tokens, 10) || 0,
149+
toolUses: parseInt(tools ?? "0", 10) || 0,
150+
durationMs: parseInt(duration ?? "0", 10) || 0,
151+
};
152+
}
153+
154+
this.notify(sessionId);
155+
}
156+
112157
dismissAgent(sessionId: string, agentId: string): void {
113158
const map = this.agents.get(sessionId);
114159
if (!map) return;
@@ -122,4 +167,11 @@ class BackgroundAgentStore {
122167
}
123168
}
124169

170+
/** Extract text content of an XML-like tag from a string. */
171+
function extractXmlTag(text: string, tag: string): string | null {
172+
const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`);
173+
const match = re.exec(text);
174+
return match ? match[1].trim() : null;
175+
}
176+
125177
export const bgAgentStore = new BackgroundAgentStore();

src/lib/background-session-store.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type {
22
ClaudeEvent,
33
StreamEvent,
44
SystemInitEvent,
5-
TaskStartedEvent,
65
TaskProgressEvent,
76
TaskNotificationEvent,
87
AssistantMessageEvent,
@@ -107,13 +106,11 @@ export class BackgroundSessionStore {
107106
const sessionId = event._sessionId;
108107
if (!sessionId) return;
109108

110-
// Route task lifecycle events to the shared background agent store
109+
// Route task progress/notification events to the shared background agent store.
110+
// task_started is NOT handled — it fires for all agents, not just background.
111+
// Background agents are registered from the tool_result with isAsync.
111112
if (event.type === "system" && "subtype" in event) {
112113
const sub = (event as { subtype: string }).subtype;
113-
if (sub === "task_started") {
114-
bgAgentStore.handleTaskStarted(sessionId, event as TaskStartedEvent);
115-
return;
116-
}
117114
if (sub === "task_progress") {
118115
bgAgentStore.handleTaskProgress(sessionId, event as TaskProgressEvent);
119116
return;
@@ -192,7 +189,7 @@ export class BackgroundSessionStore {
192189

193190
for (const block of evt.message.content) {
194191
if (block.type === "tool_use") {
195-
const isTask = block.name === "Task";
192+
const isTask = block.name === "Task" || block.name === "Agent";
196193
const msgId = `tool-${block.id}`;
197194
if (!state.messages.some((m) => m.id === msgId)) {
198195
state.messages.push({
@@ -221,6 +218,12 @@ export class BackgroundSessionStore {
221218
case "user": {
222219
const evt = event as ToolResultEvent;
223220
const uc = evt.message.content;
221+
222+
// Task completion arrives as user text with <task-notification> XML
223+
if (typeof uc === "string" && uc.includes("<task-notification>")) {
224+
bgAgentStore.handleUserMessage(sessionId, uc);
225+
}
226+
224227
if (Array.isArray(uc) && uc[0]?.type === "tool_result") {
225228
const toolResult = uc[0];
226229
const toolUseId = toolResult.tool_use_id;
@@ -231,9 +234,19 @@ export class BackgroundSessionStore {
231234
toolResult.content,
232235
);
233236

237+
// Register background (async) agents in the shared store
238+
if (resultMeta?.isAsync && resultMeta.outputFile && toolUseId) {
239+
bgAgentStore.registerAsyncAgent(sessionId, {
240+
toolUseId,
241+
agentId: resultMeta.agentId ?? toolUseId,
242+
description: String(resultMeta.description ?? "Background agent"),
243+
outputFile: resultMeta.outputFile,
244+
});
245+
}
246+
234247
state.messages = state.messages.map((m) => {
235248
if (m.id !== `tool-${toolUseId}`) return m;
236-
if (m.toolName === "Task" && resultMeta) {
249+
if ((m.toolName === "Task" || m.toolName === "Agent") && resultMeta) {
237250
return {
238251
...m,
239252
toolResult: resultMeta,

0 commit comments

Comments
 (0)