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
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export function getClaudeGlobalSettingsJsonContent(
| "SessionEnd"
| "UserPromptSubmit"
| "Stop"
| "StopFailure"
| "PostToolUse"
| "PostToolUseFailure"
| "PermissionRequest";
Expand All @@ -194,6 +195,11 @@ export function getClaudeGlobalSettingsJsonContent(
eventName: "Stop",
definition: { hooks: [{ type: "command", command: managedHookCommand }] },
},
{
// API-error hook; fires while the session stays alive, unlike Stop.
eventName: "StopFailure",
definition: { hooks: [{ type: "command", command: managedHookCommand }] },
},
{
eventName: "PostToolUse",
definition: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ describe("deriveTerminalAgentStatus", () => {
).toBe("idle");
});

it("maps Failed to failed regardless of seen timestamp", () => {
expect(
deriveTerminalAgentStatus({
lastEventType: "Failed",
lastEventAt: 100,
lastSeenAt: 200,
}),
).toBe("failed");
});

it("maps Attached to idle", () => {
expect(
deriveTerminalAgentStatus({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function deriveTerminalAgentStatus({
}): PaneStatus {
if (lastEventType === "Start") return "working";
if (lastEventType === "PermissionRequest") return "permission";
if (lastEventType === "Failed") return "failed";
if (lastEventType === "Stop") {
return lastEventAt > (lastSeenAt ?? 0) ? "review" : "idle";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export function useV2WorkspaceIsUnread(workspaceId: string): boolean {
);
if (manualUnread) return true;
for (const status of statuses.values()) {
if (status === "review") return true;
if (status === "review" || status === "failed") return true;
}
return false;
}
Expand Down Expand Up @@ -140,7 +140,11 @@ export function useV2AttentionWorkspaceCount(): number {
lastEventAt: binding.lastEventAt,
lastSeenAt: terminalSeenAt[binding.terminalId],
});
if (status === "permission" || status === "review") {
if (
status === "permission" ||
status === "review" ||
status === "failed"
) {
workspaceIds.add(binding.workspaceId);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ const STATUS_CONFIG = {
pulse: true,
tooltip: "Needs input",
},
failed: {
pingColor: "bg-red-400",
dotColor: "bg-red-500",
pulse: true,
tooltip: "Agent failed",
},
working: {
pingColor: "bg-amber-400",
dotColor: "bg-amber-500",
Expand Down
14 changes: 12 additions & 2 deletions apps/desktop/src/shared/tabs-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,31 @@ export type PaneType =
* - working: Agent actively processing (amber)
* - permission: Agent blocked, needs user action (red)
* - review: Agent completed, ready for review (green)
* - failed: Agent turn/process ended in failure, needs attention (red)
*/
export type PaneStatus = "idle" | "working" | "permission" | "review";
export type PaneStatus =
| "idle"
| "working"
| "permission"
| "review"
| "failed";

/** Non-idle status for UI indicators */
export type ActivePaneStatus = Exclude<PaneStatus, "idle">;

/**
* Status priority order (higher = more urgent).
* Single source of truth for aggregation logic.
*
* `failed` sits just below `permission`: both demand attention, but a live
* permission prompt is actionable right now, whereas a failure is terminal.
*/
export const STATUS_PRIORITY = {
idle: 0,
review: 1,
working: 2,
permission: 3,
failed: 3,
permission: 4,
} as const satisfies Record<PaneStatus, number>;

/**
Expand Down
7 changes: 7 additions & 0 deletions packages/host-service/src/events/map-event-type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ describe("mapEventType", () => {
expect(mapEventType("agent-turn-complete")).toBe("Stop");
});

it("routes failure events to Failed, distinct from Stop", () => {
expect(mapEventType("StopFailure")).toBe("Failed");
expect(mapEventType("stop_failure")).toBe("Failed");
expect(mapEventType("Failed")).toBe("Failed");
expect(mapEventType("Stop")).toBe("Stop");
});

it("routes permission events", () => {
expect(mapEventType("PermissionRequest")).toBe("PermissionRequest");
expect(mapEventType("Notification")).toBe("PermissionRequest");
Expand Down
13 changes: 13 additions & 0 deletions packages/host-service/src/events/map-event-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
* - `Start` / `Stop`: per-turn working-state cadence — drives the working
* indicator and the completion chime.
* - `PermissionRequest`: agent is blocked waiting for a tool/exec decision.
* - `Failed`: the agent reported its turn ended in failure (e.g. Claude Code's
* `StopFailure` API-error hook). Distinct from `Stop` so the UI can surface
* it instead of showing a clean completion — this is what stops failures
* from being silent.
* - `Attached` / `Detached`: session-lifetime signal — drives the pane icon
* binding only. NOT working state: SessionStart fires on agent boot when
* the agent is still idle waiting for input.
Expand All @@ -12,6 +16,7 @@ export type AgentLifecycleEventType =
| "Start"
| "Stop"
| "PermissionRequest"
| "Failed"
| "Attached"
| "Detached";

Expand Down Expand Up @@ -67,6 +72,14 @@ export function mapEventType(
) {
return "PermissionRequest";
}
if (
eventType === "StopFailure" ||
eventType === "stop_failure" ||
eventType === "Failed" ||
eventType === "failed"
) {
return "Failed";
}
if (
eventType === "Stop" ||
eventType === "stop" ||
Expand Down
21 changes: 21 additions & 0 deletions packages/host-service/src/terminal-agents/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,27 @@ describe("TerminalAgentStore", () => {
expect(store.listByWorkspace(WORKSPACE)).toHaveLength(0);
});

it("records a Failed event on the binding instead of deleting it", () => {
store.recordEvent({
terminalId: "t1",
workspaceId: WORKSPACE,
eventType: "Attached",
agentId: "claude",
occurredAt: 100,
});
store.recordEvent({
terminalId: "t1",
workspaceId: WORKSPACE,
eventType: "Failed",
occurredAt: 200,
});

const binding = store.get("t1");
expect(binding?.lastEventType).toBe("Failed");
expect(binding?.lastEventAt).toBe(200);
expect(store.listByWorkspace(WORKSPACE)).toHaveLength(1);
});

it("drops stale identity metadata on agent swap even when the new event omits it", () => {
store.recordEvent({
terminalId: "t1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,37 @@ describe("notificationsRouter.hook", () => {
expect(binding?.lastEventType).toBe("Attached");
});

it("maps Claude Code's StopFailure API-error hook to a Failed event and records it", async () => {
const { ctx, broadcastAgentLifecycle, terminalAgentStore } =
createContext("workspace-1");
const caller = notificationsRouter.createCaller(ctx);

await caller.hook({
terminalId: "terminal-1",
eventType: "SessionStart",
agent: { agentId: "claude", sessionId: "session-abc" },
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
});
const result = await caller.hook({
terminalId: "terminal-1",
eventType: "StopFailure",
agent: { agentId: "claude", sessionId: "session-abc" },
});

expect(result).toEqual({ success: true, ignored: false });
const failedBroadcast = broadcastAgentLifecycle.mock.calls.at(-1)?.[0];
expect(failedBroadcast).toMatchObject({
workspaceId: "workspace-1",
eventType: "Failed",
terminalId: "terminal-1",
// Failed keeps the agent identifiable, unlike an exit that drops it.
agent: { agentId: "claude", sessionId: "session-abc" },
});
const binding = terminalAgentStore.get("terminal-1");
expect(binding?.lastEventType).toBe("Failed");
expect(binding?.agentId).toBe("claude");
expect(binding?.agentSessionId).toBe("session-abc");
});

it("drops agent identity entirely when agentId is missing", async () => {
const { ctx, broadcastAgentLifecycle } = createContext("workspace-1");

Expand Down
Loading