@@ -57,6 +57,7 @@ const MAIN_AGENT_TRANSCRIPT_FRAMES = new Set<string>([
5757 'tool.result' ,
5858 'agent.status.updated' ,
5959 'prompt.completed' ,
60+ 'prompt.aborted' ,
6061 'error' ,
6162] ) ;
6263
@@ -126,6 +127,10 @@ interface SessionState {
126127 // Subagent lifecycle deltas after spawned only carry subagentId. Keep the
127128 // spawned metadata here so later updates can replace the full AppTask.
128129 subagentMeta : Map < string , AppTask > ;
130+
131+ // Bubble cleared by turn.step.retrying, to be reused by the retried
132+ // step.started (same turn) instead of stacking a new bubble.
133+ retryReuseMsgId : string | undefined ;
129134}
130135
131136function createSessionState ( ) : SessionState {
@@ -146,6 +151,7 @@ function createSessionState(): SessionState {
146151 model : '' ,
147152 messages : [ ] ,
148153 subagentMeta : new Map ( ) ,
154+ retryReuseMsgId : undefined ,
149155 } ;
150156}
151157
@@ -696,12 +702,11 @@ export function createAgentProjector(): AgentProjector {
696702 // -----------------------------------------------------------------------
697703 case 'turn.started' : {
698704 // Bind turnId → promptId. Generate a synthetic one if none was pre-bound.
699- // Session status is intentionally NOT projected here — the daemon's
700- // `event.session.status_changed` is the single source of status
701- // transitions (it carries the authoritative previousStatus /
702- // currentPromptId and dedupes per real transition); projecting a
703- // second running/idle event per turn from the raw stream made every
704- // turn-end consumer (notifications, sounds) fire twice.
705+ // Session busy is intentionally NOT projected here — the daemon's
706+ // `event.session.work_changed` is the single source of the busy fact
707+ // (it re-reads the authoritative drain registry and dedupes per real
708+ // transition); projecting a second busy flip per turn from the raw
709+ // stream made every turn-end consumer fire twice.
705710 const turnId : number = p ?. turnId ;
706711 const existingPromptId = s . currentPromptId ?? ulid ( 'pr_' ) ;
707712 s . currentPromptId = existingPromptId ;
@@ -711,6 +716,9 @@ export function createAgentProjector(): AgentProjector {
711716 // Fresh turn → fresh step stream offsets.
712717 s . turnTextLen = 0 ;
713718 s . turnThinkLen = 0 ;
719+ // Main-conversation liveness (the moon) keys off the main agent's turn
720+ // boundary directly — only main-agent frames reach this switch arm.
721+ out . push ( { type : 'turnActiveChanged' , sessionId, active : true } ) ;
714722 break ;
715723 }
716724
@@ -733,6 +741,17 @@ export function createAgentProjector(): AgentProjector {
733741 s . turnTextLen = 0 ;
734742 s . turnThinkLen = 0 ;
735743
744+ // A retry continuation: refill the bubble turn.step.retrying cleared,
745+ // instead of creating a second bubble with the same step's content.
746+ if ( s . retryReuseMsgId !== undefined ) {
747+ const reuseId = s . retryReuseMsgId ;
748+ s . retryReuseMsgId = undefined ;
749+ if ( getMsgById ( s , reuseId ) !== undefined ) {
750+ s . currentAssistantMsgId = reuseId ;
751+ break ;
752+ }
753+ }
754+
736755 // Create a new pending assistant message
737756 const msg = startAssistantMessage ( s , sessionId , promptId ) ;
738757 s . currentAssistantMsgId = msg . id ;
@@ -953,6 +972,16 @@ export function createAgentProjector(): AgentProjector {
953972 const reason : string = p ?. reason ?? 'completed' ;
954973 const durationMs = numberField ( p ?? { } , 'durationMs' ) ;
955974
975+ // Main-conversation liveness: the prompt this turn served is done.
976+ // This — not the session-busy status — is what ends the working moon.
977+ // It MUST be emitted first in this arm: the onMainTurnEnd side effect
978+ // gates on `seq > lastSeqBySession`, and sibling events in this arm
979+ // advance that cursor — emitted after them, this event would compare
980+ // equal and the prompt-finish cleanup (moon, queue drain) would never
981+ // fire (observed: moon stuck when a turn ends with background tasks
982+ // still running, where no work_changed(busy:false) fallback exists).
983+ out . push ( { type : 'turnActiveChanged' , sessionId, active : false , reason : p ?. reason } ) ;
984+
956985 if ( msgId ) {
957986 finishAssistantMessage ( s , msgId ) ;
958987 const msg = getMsgById ( s , msgId ) ;
@@ -972,30 +1001,86 @@ export function createAgentProjector(): AgentProjector {
9721001 const usageSnapshot = buildUsageSnapshot ( s ) ;
9731002 out . push ( { type : 'sessionUsageUpdated' , sessionId, usage : usageSnapshot } ) ;
9741003
975- // No sessionStatusChanged here — see turn.started. The daemon's
976- // `event.session.status_changed ` flips the session to idle/aborted .
1004+ // No busy projection here — see turn.started. The daemon's
1005+ // `event.session.work_changed ` flips the session busy fact .
9771006
9781007 // Clear per-turn state. Reset the stream offsets too so a stale length
9791008 // from this turn can't wedge the next turn's delta alignment into a
980- // silent skip if its turn.started is missed across a reconnect.
1009+ // silent skip if its turn.started is missed across a reconnect. The
1010+ // retry reuse target is per-turn as well: if the turn died between
1011+ // turn.step.retrying and the retried step.started, the next prompt
1012+ // must open a fresh bubble, not refill this turn's emptied one.
9811013 s . currentAssistantMsgId = undefined ;
9821014 s . currentPromptId = undefined ;
9831015 s . turnTextLen = 0 ;
9841016 s . turnThinkLen = 0 ;
1017+ s . retryReuseMsgId = undefined ;
9851018 break ;
9861019 }
9871020
9881021 // -----------------------------------------------------------------------
9891022 case 'prompt.completed' : {
990- // No-op at AppEvent level — turn.ended already handles the transition to idle
1023+ // No state change at AppEvent level — turn.ended / the session
1024+ // status_changed ahead of this event already finished the prompt. The
1025+ // event rides along so the web layer can spot the one case that has no
1026+ // turn-level signal: a prompt blocked before any turn started (reason
1027+ // 'blocked'), which would otherwise pin the in-flight state forever.
1028+ const promptId : string | undefined = p ?. promptId ;
1029+ if ( typeof promptId === 'string' && promptId . length > 0 ) {
1030+ out . push ( { type : 'promptCompleted' , sessionId, promptId, reason : p ?. reason ?? 'completed' } ) ;
1031+ }
9911032 break ;
9921033 }
9931034
9941035 // -----------------------------------------------------------------------
995- case 'turn.step.retrying' :
1036+ case 'prompt.aborted' : {
1037+ // Fires both for an active-turn abort (a turn.ended + status_changed
1038+ // precede it — the prompt is already finished) and for a QUEUED prompt
1039+ // that never started a turn (no turn events, no status flip). The web
1040+ // layer keys on promptId to clear the in-flight state in the latter case.
1041+ const promptId : string | undefined = p ?. promptId ;
1042+ if ( typeof promptId === 'string' && promptId . length > 0 ) {
1043+ out . push ( { type : 'promptAborted' , sessionId, promptId } ) ;
1044+ }
1045+ break ;
1046+ }
1047+
1048+ // -----------------------------------------------------------------------
1049+ case 'turn.step.retrying' : {
1050+ // The step's stream restarts from offset 0. Reuse the abandoned
1051+ // bubble instead of stacking a new one: strip its streamed parts and
1052+ // keep the id in retryReuseMsgId so the retried step.started refills
1053+ // it in place. Otherwise the failed attempt's partial bubble stays
1054+ // rendered next to the retry's full stream — the "text/tool shown
1055+ // twice" duplication (far more visible since the retry budget grew).
1056+ const msgId = s . currentAssistantMsgId ;
1057+ if ( msgId !== undefined ) {
1058+ const msg = getMsgById ( s , msgId ) ;
1059+ if ( msg !== undefined ) {
1060+ msg . content = msg . content . filter (
1061+ ( c ) => c . type !== 'text' && c . type !== 'thinking' && c . type !== 'toolUse' ,
1062+ ) ;
1063+ out . push ( {
1064+ type : 'messageUpdated' ,
1065+ sessionId,
1066+ messageId : msgId ,
1067+ content : msg . content . map ( ( c ) => ( { ...c } ) ) ,
1068+ status : 'pending' ,
1069+ } ) ;
1070+ s . retryReuseMsgId = msgId ;
1071+ }
1072+ }
1073+ s . turnTextLen = 0 ;
1074+ s . turnThinkLen = 0 ;
1075+ s . toolStartTimes . clear ( ) ;
1076+ break ;
1077+ }
1078+
9961079 case 'turn.step.interrupted' : {
997- // Discard current assistant message; next step.started will create a new one
1080+ // Discard current assistant message; next step.started will create a
1081+ // new one. Drop any pending retry reuse target for the same reason.
9981082 s . currentAssistantMsgId = undefined ;
1083+ s . retryReuseMsgId = undefined ;
9991084 break ;
10001085 }
10011086
@@ -1366,6 +1451,7 @@ const KNOWN_AGENT_CORE_TYPES = new Set([
13661451 'agent.status.updated' ,
13671452 'prompt.submitted' ,
13681453 'prompt.completed' ,
1454+ 'prompt.aborted' ,
13691455 'session.meta.updated' ,
13701456 'compaction.started' ,
13711457 'compaction.completed' ,
0 commit comments