bingocode 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/bin/bingo-win.cjs +34 -3
  2. package/desktop/README.md +30 -0
  3. package/desktop/bunfig.toml +1 -0
  4. package/desktop/index.html +17 -0
  5. package/desktop/package.json +55 -0
  6. package/desktop/pnpm-lock.yaml +3832 -0
  7. package/desktop/public/app-icon.jpg +0 -0
  8. package/desktop/public/fonts/inter-latin-ext.woff2 +0 -0
  9. package/desktop/public/fonts/inter-latin.woff2 +0 -0
  10. package/desktop/public/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  11. package/desktop/public/fonts/jetbrains-mono-latin.woff2 +0 -0
  12. package/desktop/public/fonts/manrope-latin-ext.woff2 +0 -0
  13. package/desktop/public/fonts/manrope-latin.woff2 +0 -0
  14. package/desktop/public/fonts/material-symbols-outlined.woff2 +0 -0
  15. package/desktop/public/icons/bilibili.svg +1 -0
  16. package/desktop/public/icons/douyin.svg +1 -0
  17. package/desktop/public/icons/github.svg +3 -0
  18. package/desktop/public/icons/xiaohongshu.svg +1 -0
  19. package/desktop/scripts/build-macos-arm64.sh +270 -0
  20. package/desktop/scripts/build-sidecars.ts +183 -0
  21. package/desktop/scripts/build-windows-x64.ps1 +295 -0
  22. package/desktop/scripts/scan-missing-imports.ts +235 -0
  23. package/desktop/sidecars/claude-sidecar.ts +156 -0
  24. package/desktop/src/App.tsx +5 -0
  25. package/desktop/src/__tests__/agentsSettings.test.tsx +349 -0
  26. package/desktop/src/__tests__/pages.test.tsx +290 -0
  27. package/desktop/src/__tests__/skillsSettings.test.tsx +205 -0
  28. package/desktop/src/api/adapters.ts +12 -0
  29. package/desktop/src/api/agents.ts +36 -0
  30. package/desktop/src/api/cliTasks.ts +28 -0
  31. package/desktop/src/api/client.ts +63 -0
  32. package/desktop/src/api/computerUse.ts +76 -0
  33. package/desktop/src/api/filesystem.ts +30 -0
  34. package/desktop/src/api/hahaOAuth.ts +38 -0
  35. package/desktop/src/api/models.ts +28 -0
  36. package/desktop/src/api/providers.ts +63 -0
  37. package/desktop/src/api/search.ts +29 -0
  38. package/desktop/src/api/sessions.ts +56 -0
  39. package/desktop/src/api/settings.ts +20 -0
  40. package/desktop/src/api/skills.ts +19 -0
  41. package/desktop/src/api/tasks.ts +36 -0
  42. package/desktop/src/api/teams.ts +44 -0
  43. package/desktop/src/api/websocket.ts +164 -0
  44. package/desktop/src/components/chat/AskUserQuestion.tsx +268 -0
  45. package/desktop/src/components/chat/AssistantMessage.tsx +29 -0
  46. package/desktop/src/components/chat/AttachmentGallery.tsx +113 -0
  47. package/desktop/src/components/chat/ChatInput.tsx +622 -0
  48. package/desktop/src/components/chat/CodeViewer.tsx +161 -0
  49. package/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx +174 -0
  50. package/desktop/src/components/chat/ComputerUsePermissionModal.tsx +311 -0
  51. package/desktop/src/components/chat/DiffViewer.tsx +157 -0
  52. package/desktop/src/components/chat/FileSearchMenu.tsx +198 -0
  53. package/desktop/src/components/chat/ImageGalleryModal.tsx +91 -0
  54. package/desktop/src/components/chat/InlineImageGallery.tsx +106 -0
  55. package/desktop/src/components/chat/InlineTaskSummary.tsx +60 -0
  56. package/desktop/src/components/chat/MermaidRenderer.test.tsx +98 -0
  57. package/desktop/src/components/chat/MermaidRenderer.tsx +361 -0
  58. package/desktop/src/components/chat/MessageActionBar.tsx +27 -0
  59. package/desktop/src/components/chat/MessageList.test.tsx +313 -0
  60. package/desktop/src/components/chat/MessageList.tsx +249 -0
  61. package/desktop/src/components/chat/PermissionDialog.tsx +262 -0
  62. package/desktop/src/components/chat/SessionTaskBar.test.tsx +99 -0
  63. package/desktop/src/components/chat/SessionTaskBar.tsx +159 -0
  64. package/desktop/src/components/chat/StreamingIndicator.tsx +41 -0
  65. package/desktop/src/components/chat/TerminalChrome.tsx +35 -0
  66. package/desktop/src/components/chat/ThinkingBlock.tsx +87 -0
  67. package/desktop/src/components/chat/ToolCallBlock.tsx +247 -0
  68. package/desktop/src/components/chat/ToolCallGroup.tsx +617 -0
  69. package/desktop/src/components/chat/ToolResultBlock.tsx +107 -0
  70. package/desktop/src/components/chat/UserMessage.tsx +38 -0
  71. package/desktop/src/components/chat/chatBlocks.test.tsx +136 -0
  72. package/desktop/src/components/chat/clipboard.ts +25 -0
  73. package/desktop/src/components/chat/composerUtils.test.ts +55 -0
  74. package/desktop/src/components/chat/composerUtils.ts +149 -0
  75. package/desktop/src/components/controls/ModelSelector.tsx +156 -0
  76. package/desktop/src/components/controls/PermissionModeSelector.tsx +229 -0
  77. package/desktop/src/components/layout/AppShell.tsx +107 -0
  78. package/desktop/src/components/layout/ContentRouter.tsx +27 -0
  79. package/desktop/src/components/layout/ProjectFilter.tsx +126 -0
  80. package/desktop/src/components/layout/Sidebar.test.tsx +158 -0
  81. package/desktop/src/components/layout/Sidebar.tsx +384 -0
  82. package/desktop/src/components/layout/StatusBar.tsx +31 -0
  83. package/desktop/src/components/layout/TabBar.test.tsx +136 -0
  84. package/desktop/src/components/layout/TabBar.tsx +318 -0
  85. package/desktop/src/components/layout/TitleBar.tsx +96 -0
  86. package/desktop/src/components/layout/WindowControls.test.tsx +69 -0
  87. package/desktop/src/components/layout/WindowControls.tsx +89 -0
  88. package/desktop/src/components/markdown/MarkdownRenderer.test.tsx +100 -0
  89. package/desktop/src/components/markdown/MarkdownRenderer.tsx +229 -0
  90. package/desktop/src/components/settings/ClaudeOfficialLogin.tsx +107 -0
  91. package/desktop/src/components/shared/Button.tsx +63 -0
  92. package/desktop/src/components/shared/CopyButton.tsx +58 -0
  93. package/desktop/src/components/shared/DirectoryPicker.tsx +316 -0
  94. package/desktop/src/components/shared/Dropdown.tsx +91 -0
  95. package/desktop/src/components/shared/Input.tsx +38 -0
  96. package/desktop/src/components/shared/Modal.tsx +65 -0
  97. package/desktop/src/components/shared/ProjectContextChip.tsx +30 -0
  98. package/desktop/src/components/shared/Spinner.tsx +30 -0
  99. package/desktop/src/components/shared/Textarea.tsx +38 -0
  100. package/desktop/src/components/shared/Toast.tsx +47 -0
  101. package/desktop/src/components/shared/UpdateChecker.tsx +90 -0
  102. package/desktop/src/components/skills/SkillDetail.test.tsx +89 -0
  103. package/desktop/src/components/skills/SkillDetail.tsx +403 -0
  104. package/desktop/src/components/skills/SkillList.tsx +254 -0
  105. package/desktop/src/components/tasks/DayOfWeekPicker.tsx +57 -0
  106. package/desktop/src/components/tasks/NewTaskModal.tsx +407 -0
  107. package/desktop/src/components/tasks/PromptEditor.tsx +74 -0
  108. package/desktop/src/components/tasks/TaskEmptyState.tsx +30 -0
  109. package/desktop/src/components/tasks/TaskList.tsx +46 -0
  110. package/desktop/src/components/tasks/TaskRow.tsx +253 -0
  111. package/desktop/src/components/tasks/TaskRunsPanel.tsx +195 -0
  112. package/desktop/src/components/teams/TeamStatusBar.tsx +147 -0
  113. package/desktop/src/config/providerPresets.ts +78 -0
  114. package/desktop/src/config/spinnerVerbs.ts +193 -0
  115. package/desktop/src/hooks/useKeyboardShortcuts.ts +60 -0
  116. package/desktop/src/i18n/index.ts +54 -0
  117. package/desktop/src/i18n/locales/en.ts +670 -0
  118. package/desktop/src/i18n/locales/zh.ts +670 -0
  119. package/desktop/src/lib/__tests__/cronDescribe.test.ts +93 -0
  120. package/desktop/src/lib/cronDescribe.ts +188 -0
  121. package/desktop/src/lib/desktopRuntime.ts +54 -0
  122. package/desktop/src/lib/parseRunOutput.ts +79 -0
  123. package/desktop/src/main.tsx +13 -0
  124. package/desktop/src/mocks/data.ts +202 -0
  125. package/desktop/src/pages/ActiveSession.test.tsx +181 -0
  126. package/desktop/src/pages/ActiveSession.tsx +219 -0
  127. package/desktop/src/pages/AdapterSettings.tsx +375 -0
  128. package/desktop/src/pages/AgentTeams.tsx +200 -0
  129. package/desktop/src/pages/ComputerUseSettings.tsx +420 -0
  130. package/desktop/src/pages/EmptySession.tsx +518 -0
  131. package/desktop/src/pages/NewTaskModal.tsx +346 -0
  132. package/desktop/src/pages/ScheduledTasks.tsx +66 -0
  133. package/desktop/src/pages/ScheduledTasksEmpty.tsx +152 -0
  134. package/desktop/src/pages/ScheduledTasksList.tsx +416 -0
  135. package/desktop/src/pages/SessionControls.tsx +460 -0
  136. package/desktop/src/pages/Settings.tsx +1448 -0
  137. package/desktop/src/pages/ToolInspection.tsx +235 -0
  138. package/desktop/src/stores/adapterStore.ts +106 -0
  139. package/desktop/src/stores/agentStore.ts +34 -0
  140. package/desktop/src/stores/chatStore.test.ts +505 -0
  141. package/desktop/src/stores/chatStore.ts +850 -0
  142. package/desktop/src/stores/cliTaskStore.ts +152 -0
  143. package/desktop/src/stores/hahaOAuthStore.test.ts +77 -0
  144. package/desktop/src/stores/hahaOAuthStore.ts +97 -0
  145. package/desktop/src/stores/providerStore.ts +101 -0
  146. package/desktop/src/stores/sessionStore.test.ts +63 -0
  147. package/desktop/src/stores/sessionStore.ts +102 -0
  148. package/desktop/src/stores/settingsStore.ts +120 -0
  149. package/desktop/src/stores/skillStore.ts +51 -0
  150. package/desktop/src/stores/tabStore.ts +169 -0
  151. package/desktop/src/stores/taskStore.ts +68 -0
  152. package/desktop/src/stores/teamStore.ts +344 -0
  153. package/desktop/src/stores/uiStore.ts +100 -0
  154. package/desktop/src/stores/updateStore.test.ts +71 -0
  155. package/desktop/src/stores/updateStore.ts +221 -0
  156. package/desktop/src/theme/globals.css +465 -0
  157. package/desktop/src/types/adapter.ts +33 -0
  158. package/desktop/src/types/chat.ts +152 -0
  159. package/desktop/src/types/cliTask.ts +24 -0
  160. package/desktop/src/types/provider.ts +62 -0
  161. package/desktop/src/types/session.ts +27 -0
  162. package/desktop/src/types/settings.ts +22 -0
  163. package/desktop/src/types/skill.ts +38 -0
  164. package/desktop/src/types/task.ts +56 -0
  165. package/desktop/src/types/team.ts +38 -0
  166. package/desktop/src-tauri/Cargo.lock +5549 -0
  167. package/desktop/src-tauri/Cargo.toml +20 -0
  168. package/desktop/src-tauri/app-icon.svg +13 -0
  169. package/desktop/src-tauri/build.rs +3 -0
  170. package/desktop/src-tauri/capabilities/default.json +106 -0
  171. package/desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  172. package/desktop/src-tauri/icons/android/values/ic_launcher_background.xml +4 -0
  173. package/desktop/src-tauri/icons/icon.icns +0 -0
  174. package/desktop/src-tauri/icons/icon.ico +0 -0
  175. package/desktop/src-tauri/src/lib.rs +408 -0
  176. package/desktop/src-tauri/src/main.rs +6 -0
  177. package/desktop/src-tauri/tauri.conf.json +78 -0
  178. package/desktop/src-tauri/tauri.macos.conf.json +18 -0
  179. package/desktop/src-tauri/tauri.release-ci.json +5 -0
  180. package/desktop/src-tauri/tauri.windows.conf.json +16 -0
  181. package/desktop/src-tauri/windows-installer-hooks.nsh +17 -0
  182. package/desktop/tsconfig.json +25 -0
  183. package/desktop/vite.config.ts +26 -0
  184. package/desktop/vitest.config.ts +18 -0
  185. package/package.json +1 -1
  186. package/src/commands/desktop/desktop.tsx +9 -0
  187. package/src/commands/desktop/index.ts +26 -0
@@ -0,0 +1,850 @@
1
+ import { create } from 'zustand'
2
+ import { wsManager } from '../api/websocket'
3
+ import { sessionsApi } from '../api/sessions'
4
+ import { useTeamStore } from './teamStore'
5
+ import { useSessionStore } from './sessionStore'
6
+ import { useCLITaskStore } from './cliTaskStore'
7
+ import { useTabStore } from './tabStore'
8
+ import { randomSpinnerVerb } from '../config/spinnerVerbs'
9
+ import { AGENT_LIFECYCLE_TYPES } from '../types/team'
10
+ import type { MessageEntry } from '../types/session'
11
+ import type { PermissionMode } from '../types/settings'
12
+ import type {
13
+ AgentTaskNotification,
14
+ AttachmentRef,
15
+ ChatState,
16
+ ComputerUsePermissionRequest,
17
+ ComputerUsePermissionResponse,
18
+ UIAttachment,
19
+ UIMessage,
20
+ ServerMessage,
21
+ TokenUsage,
22
+ } from '../types/chat'
23
+
24
+ type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
25
+
26
+ export type PerSessionState = {
27
+ messages: UIMessage[]
28
+ chatState: ChatState
29
+ connectionState: ConnectionState
30
+ streamingText: string
31
+ streamingToolInput: string
32
+ activeToolUseId: string | null
33
+ activeToolName: string | null
34
+ activeThinkingId: string | null
35
+ pendingPermission: {
36
+ requestId: string
37
+ toolName: string
38
+ input: unknown
39
+ description?: string
40
+ } | null
41
+ pendingComputerUsePermission: {
42
+ requestId: string
43
+ request: ComputerUsePermissionRequest
44
+ } | null
45
+ tokenUsage: TokenUsage
46
+ elapsedSeconds: number
47
+ statusVerb: string
48
+ slashCommands: Array<{ name: string; description: string }>
49
+ agentTaskNotifications: Record<string, AgentTaskNotification>
50
+ elapsedTimer: ReturnType<typeof setInterval> | null
51
+ }
52
+
53
+ const DEFAULT_SESSION_STATE: PerSessionState = {
54
+ messages: [],
55
+ chatState: 'idle',
56
+ connectionState: 'disconnected',
57
+ streamingText: '',
58
+ streamingToolInput: '',
59
+ activeToolUseId: null,
60
+ activeToolName: null,
61
+ activeThinkingId: null,
62
+ pendingPermission: null,
63
+ pendingComputerUsePermission: null,
64
+ tokenUsage: { input_tokens: 0, output_tokens: 0 },
65
+ elapsedSeconds: 0,
66
+ statusVerb: '',
67
+ slashCommands: [],
68
+ agentTaskNotifications: {},
69
+ elapsedTimer: null,
70
+ }
71
+
72
+ function createDefaultSessionState(): PerSessionState {
73
+ return { ...DEFAULT_SESSION_STATE, messages: [], tokenUsage: { input_tokens: 0, output_tokens: 0 } }
74
+ }
75
+
76
+ type ChatStore = {
77
+ sessions: Record<string, PerSessionState>
78
+
79
+ getSession: (sessionId: string) => PerSessionState
80
+ connectToSession: (sessionId: string) => void
81
+ disconnectSession: (sessionId: string) => void
82
+ sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void
83
+ respondToPermission: (sessionId: string, requestId: string, allowed: boolean, rule?: string) => void
84
+ respondToComputerUsePermission: (
85
+ sessionId: string,
86
+ requestId: string,
87
+ response: ComputerUsePermissionResponse,
88
+ ) => void
89
+ setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void
90
+ stopGeneration: (sessionId: string) => void
91
+ loadHistory: (sessionId: string) => Promise<void>
92
+ clearMessages: (sessionId: string) => void
93
+ handleServerMessage: (sessionId: string, msg: ServerMessage) => void
94
+ }
95
+
96
+ const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
97
+ const pendingTaskToolUseIds = new Set<string>()
98
+
99
+ let msgCounter = 0
100
+ const nextId = () => `msg-${++msgCounter}-${Date.now()}`
101
+
102
+ // Streaming throttle for content_delta
103
+ let pendingDelta = ''
104
+ let flushTimer: ReturnType<typeof setTimeout> | null = null
105
+
106
+ /** Helper: immutably update a specific session within the sessions record */
107
+ function updateSessionIn(
108
+ sessions: Record<string, PerSessionState>,
109
+ sessionId: string,
110
+ updater: (s: PerSessionState) => Partial<PerSessionState>,
111
+ ): Record<string, PerSessionState> {
112
+ const session = sessions[sessionId]
113
+ if (!session) return sessions
114
+ return { ...sessions, [sessionId]: { ...session, ...updater(session) } }
115
+ }
116
+
117
+ export const useChatStore = create<ChatStore>((set, get) => ({
118
+ sessions: {},
119
+
120
+ getSession: (sessionId) => get().sessions[sessionId] ?? createDefaultSessionState(),
121
+
122
+ connectToSession: (sessionId) => {
123
+ const existing = get().sessions[sessionId]
124
+ if (existing && existing.connectionState !== 'disconnected') return
125
+
126
+ set((s) => ({
127
+ sessions: {
128
+ ...s.sessions,
129
+ [sessionId]: {
130
+ ...createDefaultSessionState(),
131
+ connectionState: 'connecting',
132
+ messages: existing?.messages ?? [],
133
+ },
134
+ },
135
+ }))
136
+
137
+ wsManager.clearHandlers(sessionId)
138
+ wsManager.connect(sessionId)
139
+ wsManager.onMessage(sessionId, (msg) => {
140
+ if (msg.type === 'connected') {
141
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ connectionState: 'connected' })) }))
142
+ }
143
+ get().handleServerMessage(sessionId, msg)
144
+ })
145
+
146
+ get().loadHistory(sessionId)
147
+ useCLITaskStore.getState().fetchSessionTasks(sessionId)
148
+ sessionsApi.getSlashCommands(sessionId)
149
+ .then(({ commands }) => {
150
+ if (get().sessions[sessionId]) {
151
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: commands })) }))
152
+ }
153
+ })
154
+ .catch(() => {
155
+ if (get().sessions[sessionId]) {
156
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: [] })) }))
157
+ }
158
+ })
159
+ },
160
+
161
+ disconnectSession: (sessionId) => {
162
+ const session = get().sessions[sessionId]
163
+ if (session?.elapsedTimer) clearInterval(session.elapsedTimer)
164
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
165
+ if (pendingDelta) {
166
+ const text = pendingDelta
167
+ pendingDelta = ''
168
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
169
+ }
170
+ wsManager.disconnect(sessionId)
171
+ set((s) => {
172
+ const { [sessionId]: _, ...rest } = s.sessions
173
+ return { sessions: rest }
174
+ })
175
+ },
176
+
177
+ sendMessage: (sessionId, content, attachments?) => {
178
+ const userFacingContent = content.trim()
179
+ const isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId)
180
+ const uiAttachments: UIAttachment[] | undefined =
181
+ attachments && attachments.length > 0
182
+ ? attachments.map((a) => ({
183
+ type: a.type,
184
+ name: a.name || a.path || a.mimeType || a.type,
185
+ data: a.data,
186
+ mimeType: a.mimeType,
187
+ }))
188
+ : undefined
189
+
190
+ const taskStore = useCLITaskStore.getState()
191
+ const allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed')
192
+
193
+ set((s) => {
194
+ const session = s.sessions[sessionId] ?? createDefaultSessionState()
195
+ if (flushTimer) {
196
+ clearTimeout(flushTimer)
197
+ flushTimer = null
198
+ }
199
+ const bufferedDelta = pendingDelta
200
+ pendingDelta = ''
201
+ const pendingAssistantText = `${session.streamingText}${bufferedDelta}`.trim()
202
+
203
+ const newMessages = pendingAssistantText
204
+ ? [
205
+ ...session.messages,
206
+ {
207
+ id: nextId(),
208
+ type: 'assistant_text' as const,
209
+ content: pendingAssistantText,
210
+ timestamp: Date.now(),
211
+ },
212
+ ]
213
+ : [...session.messages]
214
+ if (!isMemberSession && allTasksDone) {
215
+ newMessages.push({
216
+ id: nextId(),
217
+ type: 'task_summary',
218
+ tasks: taskStore.tasks.map((t) => ({ id: t.id, subject: t.subject, status: t.status, activeForm: t.activeForm })),
219
+ timestamp: Date.now(),
220
+ })
221
+ taskStore.clearTasks()
222
+ }
223
+ newMessages.push({
224
+ id: nextId(),
225
+ type: 'user_text',
226
+ content: userFacingContent,
227
+ attachments: isMemberSession ? undefined : uiAttachments,
228
+ timestamp: Date.now(),
229
+ ...(isMemberSession ? { pending: true } : {}),
230
+ })
231
+
232
+ if (!isMemberSession && session.elapsedTimer) clearInterval(session.elapsedTimer)
233
+
234
+ const timer = !isMemberSession
235
+ ? setInterval(() => {
236
+ set((st) => ({ sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({ elapsedSeconds: sess.elapsedSeconds + 1 })) }))
237
+ }, 1000)
238
+ : null
239
+
240
+ return {
241
+ sessions: {
242
+ ...s.sessions,
243
+ [sessionId]: {
244
+ ...session,
245
+ messages: newMessages,
246
+ chatState: 'thinking',
247
+ elapsedSeconds: 0,
248
+ streamingText: '',
249
+ statusVerb: isMemberSession ? '' : randomSpinnerVerb(),
250
+ elapsedTimer: timer,
251
+ connectionState: isMemberSession ? 'connected' : session.connectionState,
252
+ },
253
+ },
254
+ }
255
+ })
256
+
257
+ if (isMemberSession) {
258
+ void useTeamStore.getState().sendMessageToMember(sessionId, userFacingContent)
259
+ .catch((err) => {
260
+ set((s) => ({
261
+ sessions: updateSessionIn(s.sessions, sessionId, (session) => ({
262
+ chatState: 'idle',
263
+ messages: [
264
+ ...session.messages,
265
+ {
266
+ id: nextId(),
267
+ type: 'error',
268
+ message: err instanceof Error ? err.message : String(err),
269
+ code: 'TEAM_MEMBER_MESSAGE_FAILED',
270
+ timestamp: Date.now(),
271
+ },
272
+ ],
273
+ })),
274
+ }))
275
+ })
276
+ return
277
+ }
278
+
279
+ wsManager.send(sessionId, { type: 'user_message', content, attachments })
280
+ },
281
+
282
+ respondToPermission: (sessionId, requestId, allowed, rule?) => {
283
+ wsManager.send(sessionId, { type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) })
284
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) }))
285
+ },
286
+
287
+ respondToComputerUsePermission: (sessionId, requestId, response) => {
288
+ wsManager.send(sessionId, {
289
+ type: 'computer_use_permission_response',
290
+ requestId,
291
+ response,
292
+ })
293
+ set((s) => ({
294
+ sessions: updateSessionIn(s.sessions, sessionId, () => ({
295
+ pendingComputerUsePermission: null,
296
+ chatState: response.userConsented === false ? 'idle' : 'tool_executing',
297
+ })),
298
+ }))
299
+ },
300
+
301
+ setSessionPermissionMode: (sessionId, mode) => {
302
+ if (!get().sessions[sessionId]) return
303
+ wsManager.send(sessionId, { type: 'set_permission_mode', mode })
304
+ },
305
+
306
+ stopGeneration: (sessionId) => {
307
+ wsManager.send(sessionId, { type: 'stop_generation' })
308
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
309
+ if (pendingDelta) {
310
+ const text = pendingDelta
311
+ pendingDelta = ''
312
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
313
+ }
314
+ set((s) => {
315
+ const session = s.sessions[sessionId]
316
+ if (!session) return s
317
+ if (session.elapsedTimer) clearInterval(session.elapsedTimer)
318
+ return {
319
+ sessions: {
320
+ ...s.sessions,
321
+ [sessionId]: {
322
+ ...session,
323
+ chatState: 'idle',
324
+ pendingPermission: null,
325
+ pendingComputerUsePermission: null,
326
+ elapsedTimer: null,
327
+ },
328
+ },
329
+ }
330
+ })
331
+ },
332
+
333
+ loadHistory: async (sessionId) => {
334
+ try {
335
+ const { messages } = await sessionsApi.getMessages(sessionId)
336
+ const uiMessages = mapHistoryMessagesToUiMessages(messages)
337
+ const restoredNotifications = reconstructAgentNotifications(messages)
338
+ set((state) => {
339
+ const session = state.sessions[sessionId]
340
+ if (!session || session.messages.length > 0) return state
341
+ return { sessions: updateSessionIn(state.sessions, sessionId, (s) => ({
342
+ messages: uiMessages,
343
+ agentTaskNotifications: { ...s.agentTaskNotifications, ...restoredNotifications },
344
+ })) }
345
+ })
346
+ const lastTodos = extractLastTodoWriteFromHistory(messages)
347
+ if (lastTodos && lastTodos.length > 0) {
348
+ const taskStore = useCLITaskStore.getState()
349
+ if (taskStore.tasks.length === 0) taskStore.setTasksFromTodos(lastTodos)
350
+ }
351
+ if (hasUserMessagesAfterTaskCompletion(messages)) {
352
+ useCLITaskStore.getState().markCompletedAndDismissed()
353
+ }
354
+ } catch {
355
+ // Session may not have messages yet
356
+ }
357
+ },
358
+
359
+ clearMessages: (sessionId) => {
360
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], streamingText: '', chatState: 'idle' })) }))
361
+ },
362
+
363
+ handleServerMessage: (sessionId, msg) => {
364
+ const update = (updater: (session: PerSessionState) => Partial<PerSessionState>) => {
365
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, updater) }))
366
+ }
367
+
368
+ switch (msg.type) {
369
+ case 'connected':
370
+ break
371
+
372
+ case 'status':
373
+ update((session) => {
374
+ const pendingText = session.streamingText.trim()
375
+ const shouldFlush = pendingText && session.chatState === 'streaming' && msg.state !== 'streaming'
376
+ return {
377
+ chatState: msg.state,
378
+ ...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}),
379
+ ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}),
380
+ ...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),
381
+ ...(shouldFlush ? {
382
+ messages: [...session.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }],
383
+ streamingText: '',
384
+ } : {}),
385
+ }
386
+ })
387
+ if (msg.state === 'idle') {
388
+ const session = get().sessions[sessionId]
389
+ if (session?.elapsedTimer) {
390
+ clearInterval(session.elapsedTimer)
391
+ update(() => ({ elapsedTimer: null }))
392
+ }
393
+ }
394
+ // Sync tab status
395
+ useTabStore.getState().updateTabStatus(sessionId, msg.state === 'idle' ? 'idle' : 'running')
396
+ break
397
+
398
+ case 'content_start': {
399
+ const session = get().sessions[sessionId]
400
+ if (!session) break
401
+ const pendingText = session.streamingText.trim()
402
+ if (pendingText) {
403
+ update((s) => ({
404
+ messages: [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }],
405
+ streamingText: '',
406
+ }))
407
+ }
408
+ if (msg.blockType === 'text') {
409
+ update(() => ({ streamingText: '', chatState: 'streaming', activeThinkingId: null }))
410
+ } else if (msg.blockType === 'tool_use') {
411
+ update(() => ({
412
+ activeToolUseId: msg.toolUseId ?? null,
413
+ activeToolName: msg.toolName ?? null,
414
+ streamingToolInput: '',
415
+ chatState: 'tool_executing',
416
+ activeThinkingId: null,
417
+ }))
418
+ }
419
+ break
420
+ }
421
+
422
+ case 'content_delta':
423
+ if (msg.text !== undefined) {
424
+ pendingDelta += msg.text
425
+ if (!flushTimer) {
426
+ flushTimer = setTimeout(() => {
427
+ const text = pendingDelta
428
+ pendingDelta = ''
429
+ flushTimer = null
430
+ update((s) => ({ streamingText: s.streamingText + text }))
431
+ }, 50)
432
+ }
433
+ }
434
+ if (msg.toolInput !== undefined) update((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput }))
435
+ break
436
+
437
+ case 'thinking':
438
+ update((s) => {
439
+ const pendingText = s.streamingText.trim()
440
+ const base = pendingText
441
+ ? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }]
442
+ : s.messages
443
+ const last = base[base.length - 1]
444
+ if (last && last.type === 'thinking') {
445
+ const updated = [...base]
446
+ updated[updated.length - 1] = { ...last, content: last.content + msg.text }
447
+ return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' }
448
+ }
449
+ const id = nextId()
450
+ return {
451
+ messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }],
452
+ chatState: 'thinking',
453
+ activeThinkingId: id,
454
+ streamingText: '',
455
+ }
456
+ })
457
+ break
458
+
459
+ case 'tool_use_complete': {
460
+ const session = get().sessions[sessionId]
461
+ const toolName = msg.toolName || session?.activeToolName || 'unknown'
462
+ update((s) => ({
463
+ messages: [...s.messages, {
464
+ id: nextId(), type: 'tool_use', toolName,
465
+ toolUseId: msg.toolUseId || s.activeToolUseId || '',
466
+ input: msg.input, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
467
+ }],
468
+ activeToolUseId: null, activeToolName: null, activeThinkingId: null, streamingToolInput: '',
469
+ }))
470
+ if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) {
471
+ useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos)
472
+ } else if (TASK_TOOL_NAMES.has(toolName)) {
473
+ const useId = msg.toolUseId || session?.activeToolUseId
474
+ if (useId) pendingTaskToolUseIds.add(useId)
475
+ }
476
+ break
477
+ }
478
+
479
+ case 'tool_result':
480
+ update((s) => ({
481
+ messages: [...s.messages, {
482
+ id: nextId(), type: 'tool_result', toolUseId: msg.toolUseId,
483
+ content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
484
+ }],
485
+ chatState: 'thinking', activeThinkingId: null,
486
+ }))
487
+ if (pendingTaskToolUseIds.has(msg.toolUseId)) {
488
+ pendingTaskToolUseIds.delete(msg.toolUseId)
489
+ useCLITaskStore.getState().refreshTasks()
490
+ }
491
+ break
492
+
493
+ case 'permission_request':
494
+ update((s) => ({
495
+ pendingPermission: { requestId: msg.requestId, toolName: msg.toolName, input: msg.input, description: msg.description },
496
+ pendingComputerUsePermission: null,
497
+ chatState: 'permission_pending',
498
+ activeThinkingId: null,
499
+ messages: [...s.messages, {
500
+ id: nextId(), type: 'permission_request', requestId: msg.requestId,
501
+ toolName: msg.toolName, input: msg.input, description: msg.description, timestamp: Date.now(),
502
+ }],
503
+ }))
504
+ break
505
+
506
+ case 'computer_use_permission_request':
507
+ update(() => ({
508
+ pendingComputerUsePermission: {
509
+ requestId: msg.requestId,
510
+ request: msg.request,
511
+ },
512
+ pendingPermission: null,
513
+ chatState: 'permission_pending',
514
+ activeThinkingId: null,
515
+ }))
516
+ break
517
+
518
+ case 'message_complete': {
519
+ const session = get().sessions[sessionId]
520
+ if (!session) break
521
+ const text = session.streamingText
522
+ if (text) {
523
+ update((s) => ({
524
+ messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }],
525
+ streamingText: '',
526
+ }))
527
+ }
528
+ if (session.elapsedTimer) clearInterval(session.elapsedTimer)
529
+ update(() => ({
530
+ tokenUsage: msg.usage,
531
+ chatState: 'idle',
532
+ activeThinkingId: null,
533
+ pendingPermission: null,
534
+ pendingComputerUsePermission: null,
535
+ elapsedTimer: null,
536
+ }))
537
+ break
538
+ }
539
+
540
+ case 'error':
541
+ update((s) => {
542
+ const pendingText = s.streamingText.trim()
543
+ const newMessages = [...s.messages]
544
+ if (pendingText) {
545
+ newMessages.push({ id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() })
546
+ }
547
+ newMessages.push({ id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() })
548
+ return {
549
+ messages: newMessages,
550
+ chatState: 'idle',
551
+ activeThinkingId: null,
552
+ streamingText: '',
553
+ pendingPermission: null,
554
+ pendingComputerUsePermission: null,
555
+ }
556
+ })
557
+ useTabStore.getState().updateTabStatus(sessionId, 'error')
558
+ {
559
+ const session = get().sessions[sessionId]
560
+ if (session?.elapsedTimer) {
561
+ clearInterval(session.elapsedTimer)
562
+ update(() => ({ elapsedTimer: null }))
563
+ }
564
+ }
565
+ break
566
+
567
+ case 'team_created':
568
+ useTeamStore.getState().handleTeamCreated(msg.teamName)
569
+ break
570
+ case 'team_update':
571
+ useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members)
572
+ break
573
+ case 'team_deleted':
574
+ useTeamStore.getState().handleTeamDeleted(msg.teamName)
575
+ break
576
+ case 'task_update':
577
+ break
578
+ case 'session_title_updated':
579
+ useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title)
580
+ useTabStore.getState().updateTabTitle(msg.sessionId, msg.title)
581
+ break
582
+ case 'system_notification':
583
+ if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) {
584
+ update(() => ({ slashCommands: msg.data as Array<{ name: string; description: string }> }))
585
+ }
586
+ if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') {
587
+ const data = msg.data as Record<string, unknown>
588
+ const toolUseId =
589
+ typeof data.tool_use_id === 'string' && data.tool_use_id.trim()
590
+ ? data.tool_use_id
591
+ : null
592
+ const taskStatus = data.status
593
+ if (
594
+ toolUseId &&
595
+ (taskStatus === 'completed' ||
596
+ taskStatus === 'failed' ||
597
+ taskStatus === 'stopped')
598
+ ) {
599
+ update((session) => ({
600
+ agentTaskNotifications: {
601
+ ...session.agentTaskNotifications,
602
+ [toolUseId]: {
603
+ taskId:
604
+ typeof data.task_id === 'string' && data.task_id.trim()
605
+ ? data.task_id
606
+ : toolUseId,
607
+ toolUseId,
608
+ status: taskStatus,
609
+ summary:
610
+ typeof data.summary === 'string' && data.summary.trim()
611
+ ? data.summary
612
+ : undefined,
613
+ outputFile:
614
+ typeof data.output_file === 'string' && data.output_file.trim()
615
+ ? data.output_file
616
+ : undefined,
617
+ },
618
+ },
619
+ }))
620
+ }
621
+ }
622
+ break
623
+ case 'pong':
624
+ break
625
+ }
626
+ },
627
+ }))
628
+
629
+ // ─── History mapping helpers (unchanged from original) ─────────
630
+
631
+ type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown }
632
+ type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string }
633
+
634
+ /**
635
+ * Check if text is a teammate-message (internal agent-to-agent communication).
636
+ * Uses full open+close tag match to avoid false positives on user text
637
+ * that merely mentions the tag name (e.g., pasting code or discussing the protocol).
638
+ */
639
+ function isTeammateMessage(text: string): boolean {
640
+ return text.includes('<teammate-message') && text.includes('</teammate-message>')
641
+ }
642
+
643
+ const TEAMMATE_CONTENT_REGEX = /<teammate-message\s+teammate_id="([^"]+)"[^>]*>\n?([\s\S]*?)\n?<\/teammate-message>/g
644
+
645
+ function extractVisibleTeammateMessageContents(text: string): string[] {
646
+ const contents: string[] = []
647
+
648
+ for (const match of text.matchAll(TEAMMATE_CONTENT_REGEX)) {
649
+ const content = match[2]?.trim()
650
+ if (!content) continue
651
+
652
+ if (content.startsWith('{') && content.endsWith('}')) {
653
+ try {
654
+ const parsed = JSON.parse(content) as Record<string, unknown>
655
+ if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) {
656
+ continue
657
+ }
658
+ } catch {
659
+ // Keep non-JSON payloads that happen to look like JSON.
660
+ }
661
+ }
662
+
663
+ contents.push(content)
664
+ }
665
+
666
+ return contents
667
+ }
668
+
669
+ type HistoryMappingOptions = {
670
+ includeTeammateMessages?: boolean
671
+ }
672
+
673
+ /**
674
+ * Reconstruct agentTaskNotifications from history.
675
+ *
676
+ * During a live session, background agents report completion via system_notification
677
+ * events (task_notification). These are NOT persisted in JSONL history. On reload,
678
+ * we reconstruct them by correlating Agent tool_use names with <teammate-message>
679
+ * teammate_ids found in subsequent user messages.
680
+ */
681
+ export function reconstructAgentNotifications(messages: MessageEntry[]): Record<string, AgentTaskNotification> {
682
+ // Step 1: Collect Agent tool_use blocks → map agent name to toolUseId
683
+ const agentNameToToolUseId = new Map<string, string>()
684
+
685
+ for (const msg of messages) {
686
+ if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
687
+ for (const block of msg.content as AssistantHistoryBlock[]) {
688
+ if (block.type === 'tool_use' && block.name === 'Agent' && block.id) {
689
+ const input = block.input as Record<string, unknown> | undefined
690
+ const name = input?.name as string | undefined
691
+ // Keep first toolUseId per name (consistent with first-wins for teammateContent)
692
+ if (name && !agentNameToToolUseId.has(name)) agentNameToToolUseId.set(name, block.id)
693
+ }
694
+ }
695
+ }
696
+ }
697
+
698
+ if (agentNameToToolUseId.size === 0) return {}
699
+
700
+ // Step 2: Extract <teammate-message> content by teammate_id
701
+ // Skip lifecycle messages (shutdown_approved, idle_notification, etc.)
702
+ // which overwrite actual review content if stored later in history
703
+ const teammateContent = new Map<string, string>()
704
+ for (const msg of messages) {
705
+ if (msg.type !== 'user') continue
706
+ const text = typeof msg.content === 'string'
707
+ ? msg.content
708
+ : Array.isArray(msg.content)
709
+ ? (msg.content as Array<{ type?: string; text?: string }>).filter((b) => b.type === 'text' && b.text).map((b) => b.text).join('\n')
710
+ : ''
711
+ if (!text.includes('<teammate-message')) continue
712
+ for (const match of text.matchAll(TEAMMATE_CONTENT_REGEX)) {
713
+ if (match[1] && match[2]) {
714
+ const content = match[2].trim()
715
+ // Skip lifecycle JSON messages (shutdown, idle, terminated notifications)
716
+ if (content.startsWith('{') && content.endsWith('}')) {
717
+ try {
718
+ const parsed = JSON.parse(content) as Record<string, unknown>
719
+ if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) continue
720
+ } catch { /* not JSON, keep it */ }
721
+ }
722
+ // Only store the first meaningful content per teammate (avoid overwrite by later lifecycle msgs)
723
+ if (!teammateContent.has(match[1])) {
724
+ teammateContent.set(match[1], content)
725
+ }
726
+ }
727
+ }
728
+ }
729
+
730
+ // Step 3: Correlate and build notifications
731
+ const notifications: Record<string, AgentTaskNotification> = {}
732
+ for (const [name, toolUseId] of agentNameToToolUseId) {
733
+ const content = teammateContent.get(name)
734
+ if (content) {
735
+ notifications[toolUseId] = {
736
+ taskId: toolUseId,
737
+ toolUseId,
738
+ status: 'completed',
739
+ summary: content,
740
+ }
741
+ }
742
+ }
743
+
744
+ return notifications
745
+ }
746
+
747
+ export function mapHistoryMessagesToUiMessages(
748
+ messages: MessageEntry[],
749
+ options?: HistoryMappingOptions,
750
+ ): UIMessage[] {
751
+ const includeTeammateMessages = options?.includeTeammateMessages === true
752
+ const uiMessages: UIMessage[] = []
753
+ for (const msg of messages) {
754
+ const timestamp = new Date(msg.timestamp).getTime()
755
+ if (msg.type === 'user' && typeof msg.content === 'string') {
756
+ if (isTeammateMessage(msg.content)) {
757
+ if (!includeTeammateMessages) continue
758
+ const teammateContents = extractVisibleTeammateMessageContents(msg.content)
759
+ if (teammateContents.length === 0) continue
760
+ uiMessages.push({
761
+ id: msg.id || nextId(),
762
+ type: 'user_text',
763
+ content: teammateContents.join('\n\n'),
764
+ timestamp,
765
+ })
766
+ continue
767
+ }
768
+ uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp })
769
+ continue
770
+ }
771
+ if (msg.type === 'assistant' && typeof msg.content === 'string') {
772
+ uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, timestamp, model: msg.model })
773
+ continue
774
+ }
775
+ if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
776
+ for (const block of msg.content as AssistantHistoryBlock[]) {
777
+ if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp })
778
+ else if (block.type === 'text' && block.text) uiMessages.push({ id: nextId(), type: 'assistant_text', content: block.text, timestamp, model: msg.model })
779
+ else if (block.type === 'tool_use') uiMessages.push({ id: nextId(), type: 'tool_use', toolName: block.name ?? 'unknown', toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId })
780
+ }
781
+ continue
782
+ }
783
+ if ((msg.type === 'user' || msg.type === 'tool_result') && Array.isArray(msg.content)) {
784
+ const textParts: string[] = []
785
+ const attachments: UIAttachment[] = []
786
+ for (const block of msg.content as UserHistoryBlock[]) {
787
+ if (block.type === 'text' && block.text && isTeammateMessage(block.text)) {
788
+ if (!includeTeammateMessages) continue
789
+ textParts.push(...extractVisibleTeammateMessageContents(block.text))
790
+ } else if (block.type === 'text' && block.text) {
791
+ textParts.push(block.text)
792
+ }
793
+ else if (block.type === 'image') attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type })
794
+ else if (block.type === 'file') attachments.push({ type: 'file', name: block.name || 'file' })
795
+ else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId })
796
+ }
797
+ if (textParts.length > 0 || attachments.length > 0) {
798
+ uiMessages.push({ id: nextId(), type: 'user_text', content: textParts.join('\n'), attachments: attachments.length > 0 ? attachments : undefined, timestamp })
799
+ }
800
+ }
801
+ }
802
+ return uiMessages
803
+ }
804
+
805
+ function extractLastTodoWriteFromHistory(messages: MessageEntry[]): Array<{ content: string; status: string; activeForm?: string }> | null {
806
+ let foundIndex = -1
807
+ let todos: Array<{ content: string; status: string; activeForm?: string }> | null = null
808
+ for (let i = messages.length - 1; i >= 0; i--) {
809
+ const msg = messages[i]!
810
+ if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
811
+ const blocks = msg.content as AssistantHistoryBlock[]
812
+ for (let j = blocks.length - 1; j >= 0; j--) {
813
+ const block = blocks[j]!
814
+ if (block.type === 'tool_use' && block.name === 'TodoWrite') {
815
+ const input = block.input as { todos?: unknown } | undefined
816
+ if (input && Array.isArray(input.todos)) {
817
+ todos = input.todos as Array<{ content: string; status: string; activeForm?: string }>
818
+ foundIndex = i
819
+ break
820
+ }
821
+ }
822
+ }
823
+ if (todos) break
824
+ }
825
+ }
826
+ if (!todos) return null
827
+ const allDone = todos.every((t) => t.status === 'completed')
828
+ if (allDone) {
829
+ for (let i = foundIndex + 1; i < messages.length; i++) {
830
+ if (messages[i]!.type === 'user' && messages[i]!.content) return null
831
+ }
832
+ }
833
+ return todos
834
+ }
835
+
836
+ const TASK_RELATED_TOOL_NAMES = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList'])
837
+
838
+ function hasUserMessagesAfterTaskCompletion(messages: MessageEntry[]): boolean {
839
+ let lastTaskIndex = -1
840
+ for (let i = messages.length - 1; i >= 0; i--) {
841
+ const msg = messages[i]!
842
+ if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
843
+ const blocks = msg.content as AssistantHistoryBlock[]
844
+ if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) { lastTaskIndex = i; break }
845
+ }
846
+ }
847
+ if (lastTaskIndex < 0) return false
848
+ for (let i = lastTaskIndex + 1; i < messages.length; i++) { if (messages[i]!.type === 'user') return true }
849
+ return false
850
+ }