@swarmclawai/swarmclaw 1.2.1 → 1.2.2

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 (144) hide show
  1. package/README.md +9 -0
  2. package/package.json +2 -2
  3. package/skills/coding-agent/SKILL.md +111 -0
  4. package/skills/github/SKILL.md +140 -0
  5. package/skills/nano-banana-pro/SKILL.md +62 -0
  6. package/skills/nano-banana-pro/scripts/generate_image.py +235 -0
  7. package/skills/nano-pdf/SKILL.md +53 -0
  8. package/skills/openai-image-gen/SKILL.md +78 -0
  9. package/skills/openai-image-gen/scripts/gen.py +328 -0
  10. package/skills/resourceful-problem-solving/SKILL.md +49 -0
  11. package/skills/skill-creator/SKILL.md +147 -0
  12. package/skills/skill-creator/scripts/init_skill.py +378 -0
  13. package/skills/skill-creator/scripts/quick_validate.py +159 -0
  14. package/skills/summarize/SKILL.md +77 -0
  15. package/src/app/api/auth/route.ts +20 -5
  16. package/src/app/api/chats/[id]/devserver/route.ts +13 -19
  17. package/src/app/api/chats/[id]/messages/route.ts +13 -15
  18. package/src/app/api/chats/[id]/route.ts +9 -10
  19. package/src/app/api/chats/[id]/stop/route.ts +5 -7
  20. package/src/app/api/chats/messages-route.test.ts +8 -6
  21. package/src/app/api/chats/route.ts +9 -10
  22. package/src/app/api/ip/route.ts +2 -2
  23. package/src/app/api/preview-server/route.ts +1 -1
  24. package/src/app/api/projects/[id]/route.ts +7 -46
  25. package/src/components/chat/chat-area.tsx +45 -23
  26. package/src/components/chat/message-bubble.test.ts +35 -0
  27. package/src/components/chat/message-bubble.tsx +19 -9
  28. package/src/components/chat/message-list.tsx +37 -3
  29. package/src/components/input/chat-input.tsx +34 -14
  30. package/src/instrumentation.ts +1 -1
  31. package/src/lib/chat/assistant-render-id.ts +3 -0
  32. package/src/lib/chat/chat-streaming-state.test.ts +42 -3
  33. package/src/lib/chat/chat-streaming-state.ts +20 -8
  34. package/src/lib/chat/queued-message-queue.test.ts +23 -1
  35. package/src/lib/chat/queued-message-queue.ts +11 -2
  36. package/src/lib/providers/cli-utils.test.ts +124 -0
  37. package/src/lib/server/activity/activity-log.ts +21 -0
  38. package/src/lib/server/agents/agent-availability.test.ts +10 -5
  39. package/src/lib/server/agents/agent-cascade.ts +79 -59
  40. package/src/lib/server/agents/agent-registry.ts +3 -1
  41. package/src/lib/server/agents/agent-repository.ts +90 -0
  42. package/src/lib/server/agents/delegation-job-repository.ts +53 -0
  43. package/src/lib/server/agents/delegation-jobs.ts +11 -4
  44. package/src/lib/server/agents/guardian-checkpoint-repository.ts +35 -0
  45. package/src/lib/server/agents/guardian.ts +2 -2
  46. package/src/lib/server/agents/main-agent-loop.ts +10 -3
  47. package/src/lib/server/agents/main-loop-state-repository.ts +38 -0
  48. package/src/lib/server/agents/subagent-runtime.ts +9 -6
  49. package/src/lib/server/agents/subagent-swarm.ts +3 -2
  50. package/src/lib/server/agents/task-session.ts +3 -4
  51. package/src/lib/server/approvals/approval-repository.ts +30 -0
  52. package/src/lib/server/autonomy/supervisor-incident-repository.ts +42 -0
  53. package/src/lib/server/chat-execution/chat-execution-types.ts +38 -0
  54. package/src/lib/server/chat-execution/chat-execution-utils.ts +1 -1
  55. package/src/lib/server/chat-execution/chat-execution.ts +84 -1926
  56. package/src/lib/server/chat-execution/chat-turn-finalization.ts +620 -0
  57. package/src/lib/server/chat-execution/chat-turn-partial-persistence.ts +221 -0
  58. package/src/lib/server/chat-execution/chat-turn-preflight.ts +133 -0
  59. package/src/lib/server/chat-execution/chat-turn-preparation.ts +817 -0
  60. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +296 -0
  61. package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +5 -5
  62. package/src/lib/server/chat-execution/message-classifier.test.ts +329 -0
  63. package/src/lib/server/chat-execution/post-stream-finalization.ts +1 -1
  64. package/src/lib/server/chat-execution/prompt-builder.ts +11 -0
  65. package/src/lib/server/chat-execution/prompt-sections.ts +5 -6
  66. package/src/lib/server/chat-execution/situational-awareness.ts +12 -7
  67. package/src/lib/server/chat-execution/stream-agent-chat.ts +16 -13
  68. package/src/lib/server/chatrooms/chatroom-repository.ts +32 -0
  69. package/src/lib/server/connectors/connector-repository.ts +58 -0
  70. package/src/lib/server/connectors/runtime-state.test.ts +117 -0
  71. package/src/lib/server/credentials/credential-repository.ts +7 -0
  72. package/src/lib/server/gateways/gateway-profile-repository.ts +4 -0
  73. package/src/lib/server/memory/memory-abstract.test.ts +59 -0
  74. package/src/lib/server/missions/mission-repository.ts +74 -0
  75. package/src/lib/server/missions/mission-service/actions.ts +6 -0
  76. package/src/lib/server/missions/mission-service/bindings.ts +9 -0
  77. package/src/lib/server/missions/mission-service/context.ts +4 -0
  78. package/src/lib/server/missions/mission-service/core.ts +2269 -0
  79. package/src/lib/server/missions/mission-service/queries.ts +12 -0
  80. package/src/lib/server/missions/mission-service/recovery.ts +5 -0
  81. package/src/lib/server/missions/mission-service/ticks.ts +9 -0
  82. package/src/lib/server/missions/mission-service.test.ts +9 -2
  83. package/src/lib/server/missions/mission-service.ts +6 -2266
  84. package/src/lib/server/persistence/repository-utils.ts +154 -0
  85. package/src/lib/server/persistence/storage-context.ts +51 -0
  86. package/src/lib/server/persistence/transaction.ts +1 -0
  87. package/src/lib/server/projects/project-repository.ts +36 -0
  88. package/src/lib/server/projects/project-service.ts +79 -0
  89. package/src/lib/server/protocols/protocol-normalization.test.ts +6 -4
  90. package/src/lib/server/runtime/alert-dispatch.ts +1 -1
  91. package/src/lib/server/runtime/daemon-policy.ts +1 -1
  92. package/src/lib/server/runtime/daemon-state/core.ts +1570 -0
  93. package/src/lib/server/runtime/daemon-state/health.ts +6 -0
  94. package/src/lib/server/runtime/daemon-state/policy.ts +7 -0
  95. package/src/lib/server/runtime/daemon-state/supervisor.ts +6 -0
  96. package/src/lib/server/runtime/daemon-state.test.ts +48 -0
  97. package/src/lib/server/runtime/daemon-state.ts +3 -1470
  98. package/src/lib/server/runtime/estop-repository.ts +4 -0
  99. package/src/lib/server/runtime/estop.ts +3 -1
  100. package/src/lib/server/runtime/heartbeat-service.test.ts +2 -2
  101. package/src/lib/server/runtime/heartbeat-service.ts +55 -34
  102. package/src/lib/server/runtime/heartbeat-wake.ts +6 -4
  103. package/src/lib/server/runtime/idle-window.ts +2 -2
  104. package/src/lib/server/runtime/network.ts +11 -0
  105. package/src/lib/server/runtime/orchestrator-events.ts +2 -2
  106. package/src/lib/server/runtime/queue/claims.ts +4 -0
  107. package/src/lib/server/runtime/queue/core.ts +2079 -0
  108. package/src/lib/server/runtime/queue/execution.ts +7 -0
  109. package/src/lib/server/runtime/queue/followups.ts +4 -0
  110. package/src/lib/server/runtime/queue/queries.ts +12 -0
  111. package/src/lib/server/runtime/queue/recovery.ts +7 -0
  112. package/src/lib/server/runtime/queue-recovery.test.ts +48 -13
  113. package/src/lib/server/runtime/queue-repository.ts +17 -0
  114. package/src/lib/server/runtime/queue.ts +5 -2061
  115. package/src/lib/server/runtime/run-ledger.ts +6 -5
  116. package/src/lib/server/runtime/run-repository.ts +73 -0
  117. package/src/lib/server/runtime/runtime-lock-repository.ts +8 -0
  118. package/src/lib/server/runtime/runtime-settings.ts +1 -1
  119. package/src/lib/server/runtime/runtime-state.ts +99 -0
  120. package/src/lib/server/runtime/scheduler.ts +4 -2
  121. package/src/lib/server/runtime/session-run-manager/cancellation.ts +157 -0
  122. package/src/lib/server/runtime/session-run-manager/drain.ts +246 -0
  123. package/src/lib/server/runtime/session-run-manager/enqueue.ts +287 -0
  124. package/src/lib/server/runtime/session-run-manager/queries.ts +117 -0
  125. package/src/lib/server/runtime/session-run-manager/recovery.ts +238 -0
  126. package/src/lib/server/runtime/session-run-manager/state.ts +441 -0
  127. package/src/lib/server/runtime/session-run-manager/types.ts +74 -0
  128. package/src/lib/server/runtime/session-run-manager.ts +72 -1377
  129. package/src/lib/server/runtime/watch-job-repository.ts +35 -0
  130. package/src/lib/server/runtime/watch-jobs.ts +3 -1
  131. package/src/lib/server/schedules/schedule-repository.ts +42 -0
  132. package/src/lib/server/sessions/session-repository.ts +85 -0
  133. package/src/lib/server/settings/settings-repository.ts +25 -0
  134. package/src/lib/server/skills/skill-discovery.test.ts +2 -2
  135. package/src/lib/server/skills/skill-discovery.ts +2 -2
  136. package/src/lib/server/skills/skill-repository.ts +14 -0
  137. package/src/lib/server/storage.ts +13 -24
  138. package/src/lib/server/tasks/task-repository.ts +54 -0
  139. package/src/lib/server/usage/usage-repository.ts +30 -0
  140. package/src/lib/server/webhooks/webhook-repository.ts +10 -0
  141. package/src/lib/strip-internal-metadata.test.ts +42 -41
  142. package/src/stores/use-chat-store.test.ts +54 -0
  143. package/src/stores/use-chat-store.ts +21 -5
  144. /package/{bundled-skills → skills}/google-workspace/SKILL.md +0 -0
@@ -0,0 +1,620 @@
1
+ import type { Message, MessageToolEvent, SSEEvent, Session, UsageRecord } from '@/types'
2
+ import { applyExactOutputContract, classifyExactOutputContract, type ExactOutputContract } from '@/lib/server/chat-execution/exact-output-contract'
3
+ import { stripMainLoopMetaForPersistence } from '@/lib/server/agents/main-agent-loop'
4
+ import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from '@/lib/server/agents/assistant-control'
5
+ import { pruneStreamingAssistantArtifacts } from '@/lib/chat/chat-streaming-state'
6
+ import { pruneIncompleteToolEvents } from '@/lib/server/chat-execution/chat-streaming-utils'
7
+ import { reconcileConnectorDeliveryText } from '@/lib/server/chat-execution/chat-execution-connector-delivery'
8
+ import {
9
+ classifyHeartbeatResponse,
10
+ estimateConversationTone,
11
+ extractHeartbeatStatus,
12
+ getPersistedAssistantText,
13
+ hasPersistableAssistantPayload,
14
+ normalizeAssistantArtifactLinks,
15
+ pruneOldHeartbeatMessages,
16
+ shouldAutoRouteHeartbeatAlerts,
17
+ shouldReplaceRecentAssistantMessage,
18
+ shouldReplaceRecentConnectorFollowupMessage,
19
+ shouldSuppressRedundantConnectorDeliveryFollowup,
20
+ } from '@/lib/server/chat-execution/chat-execution-utils'
21
+ import {
22
+ dedupeConsecutiveToolEvents,
23
+ deriveTerminalRunError,
24
+ } from '@/lib/server/chat-execution/chat-execution-tool-events'
25
+ import { estimateCost } from '@/lib/server/cost'
26
+ import { refreshSessionIdentityState } from '@/lib/server/identity-continuity'
27
+ import { log } from '@/lib/server/logger'
28
+ import { syncSessionArchiveMemory } from '@/lib/server/memory/session-archive-memory'
29
+ import {
30
+ applyMissionOutcomeForTurn,
31
+ } from '@/lib/server/missions/mission-service'
32
+ import { runCapabilityHook, transformCapabilityText } from '@/lib/server/native-capabilities'
33
+ import { isHeartbeatSource } from '@/lib/server/runtime/heartbeat-source'
34
+ import { perf } from '@/lib/server/runtime/perf'
35
+ import { getAgent } from '@/lib/server/agents/agent-repository'
36
+ import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
37
+ import { getSession, getSessionMessages, saveSession } from '@/lib/server/sessions/session-repository'
38
+ import { appendUsage } from '@/lib/server/usage/usage-repository'
39
+ import { notify } from '@/lib/server/ws-hub'
40
+
41
+ import type { ExecuteChatTurnInput, ExecuteChatTurnResult } from './chat-execution-types'
42
+ import type { PartialAssistantPersistence } from '@/lib/server/chat-execution/chat-turn-partial-persistence'
43
+ import {
44
+ applyMessageLifecycleHooks,
45
+ type PreparedExecutableChatTurn,
46
+ } from '@/lib/server/chat-execution/chat-turn-preparation'
47
+ import {
48
+ runPostLlmToolRouting,
49
+ type ToolRoutingResult,
50
+ } from '@/lib/server/chat-execution/chat-turn-tool-routing'
51
+
52
+ const EXACT_OUTPUT_CONTRACT_TIMEOUT_MS = 5_000
53
+
54
+ function resolveHeartbeatLastConnectorTarget(session: Session | null | undefined): {
55
+ connectorId?: string
56
+ channelId: string
57
+ } | null {
58
+ if (!isDirectConnectorSession(session)) return null
59
+ const connectorId = typeof session?.connectorContext?.connectorId === 'string'
60
+ ? session.connectorContext.connectorId.trim()
61
+ : ''
62
+ const channelId = typeof session?.connectorContext?.channelId === 'string'
63
+ ? session.connectorContext.channelId.trim()
64
+ : ''
65
+ if (!channelId) return null
66
+ return {
67
+ connectorId: connectorId || undefined,
68
+ channelId,
69
+ }
70
+ }
71
+
72
+ function shouldAutoDraftSkillSuggestion(params: {
73
+ assistantPersisted: boolean
74
+ internal: boolean
75
+ isHeartbeatRun: boolean
76
+ agentAutoDraftSetting: boolean
77
+ toolEventCount: number
78
+ messageCount: number
79
+ }): boolean {
80
+ if (!params.assistantPersisted) return false
81
+ if (params.internal || params.isHeartbeatRun) return false
82
+ if (!params.agentAutoDraftSetting) return false
83
+ if (params.toolEventCount === 0) return false
84
+ return params.messageCount >= 4
85
+ }
86
+
87
+ async function resolveExactOutputContractWithTimeout(params: {
88
+ sessionId: string
89
+ agentId?: string | null
90
+ userMessage: string
91
+ currentResponse: string
92
+ toolEvents: MessageToolEvent[]
93
+ internal: boolean
94
+ source: string
95
+ }): Promise<ExactOutputContract | null> {
96
+ if (params.internal || params.source !== 'chat') return null
97
+ if (params.toolEvents.length === 0) return null
98
+ const { extractExplicitExactLiteral } = await import('@/lib/server/chat-execution/exact-output-contract')
99
+ if (!extractExplicitExactLiteral(params.userMessage)) return null
100
+
101
+ let timer: NodeJS.Timeout | null = null
102
+ try {
103
+ return await Promise.race<ExactOutputContract | null>([
104
+ classifyExactOutputContract({
105
+ sessionId: params.sessionId,
106
+ agentId: params.agentId || null,
107
+ userMessage: params.userMessage,
108
+ currentResponse: params.currentResponse,
109
+ toolEvents: params.toolEvents,
110
+ }).catch(() => null),
111
+ new Promise<null>((resolve) => {
112
+ timer = setTimeout(() => resolve(null), EXACT_OUTPUT_CONTRACT_TIMEOUT_MS)
113
+ }),
114
+ ])
115
+ } finally {
116
+ if (timer) clearTimeout(timer)
117
+ }
118
+ }
119
+
120
+ export function shouldAppendMissedRequestedToolNotice(params: {
121
+ missedRequestedTools: string[]
122
+ fullResponse: string
123
+ errorMessage?: string
124
+ calledToolCount?: number
125
+ }): boolean {
126
+ if (!Array.isArray(params.missedRequestedTools) || params.missedRequestedTools.length === 0) return false
127
+ if (params.errorMessage) return false
128
+ if (params.fullResponse.includes('Tool execution notice:')) return false
129
+ if (!params.fullResponse.trim() && (params.calledToolCount || 0) === 0) return false
130
+ return true
131
+ }
132
+
133
+ export function pruneSuppressedHeartbeatStreamMessage(messages: Message[]): boolean {
134
+ return pruneStreamingAssistantArtifacts(messages)
135
+ }
136
+
137
+ export async function finalizeChatTurn(params: {
138
+ input: ExecuteChatTurnInput
139
+ prepared: PreparedExecutableChatTurn
140
+ partialPersistence: PartialAssistantPersistence
141
+ fullResponse: string
142
+ errorMessage?: string
143
+ initialToolRoutingResult?: ToolRoutingResult | null
144
+ responseCacheHit: boolean
145
+ directUsage: {
146
+ inputTokens: number
147
+ outputTokens: number
148
+ received: boolean
149
+ }
150
+ durationMs: number
151
+ emit: (event: SSEEvent) => void
152
+ }): Promise<ExecuteChatTurnResult> {
153
+ const {
154
+ input,
155
+ prepared,
156
+ partialPersistence,
157
+ initialToolRoutingResult = null,
158
+ responseCacheHit,
159
+ directUsage,
160
+ durationMs,
161
+ emit,
162
+ } = params
163
+ let { fullResponse, errorMessage } = params
164
+ const { message } = input
165
+ const {
166
+ sessionId,
167
+ internal = false,
168
+ runId,
169
+ source = 'chat',
170
+ } = input
171
+ const {
172
+ session,
173
+ sessionForRun,
174
+ appSettings,
175
+ lifecycleRunId,
176
+ mission,
177
+ extensionsForRun,
178
+ effectiveMessage,
179
+ providerType,
180
+ hideAssistantTranscript,
181
+ isHeartbeatRun,
182
+ hasExtensions,
183
+ runStartedAt,
184
+ runMessageStartIndex,
185
+ toolPolicy,
186
+ } = prepared
187
+
188
+ const endPostProcessPerf = perf.start('chat-execution', 'post-process', { sessionId })
189
+
190
+ if (!hasExtensions && fullResponse && !errorMessage && !responseCacheHit) {
191
+ const inputTokens = directUsage.received ? directUsage.inputTokens : Math.ceil(message.length / 4)
192
+ const outputTokens = directUsage.received ? directUsage.outputTokens : Math.ceil(fullResponse.length / 4)
193
+ const totalTokens = inputTokens + outputTokens
194
+ if (totalTokens > 0) {
195
+ const cost = estimateCost(sessionForRun.model, inputTokens, outputTokens)
196
+ const history = getSessionMessages(sessionId)
197
+ const usageRecord: UsageRecord = {
198
+ sessionId,
199
+ messageIndex: history.length,
200
+ model: sessionForRun.model,
201
+ provider: providerType,
202
+ inputTokens,
203
+ outputTokens,
204
+ totalTokens,
205
+ estimatedCost: cost,
206
+ timestamp: Date.now(),
207
+ durationMs,
208
+ agentId: sessionForRun.agentId || null,
209
+ projectId: sessionForRun.projectId || null,
210
+ }
211
+ appendUsage(sessionId, usageRecord)
212
+ emit({
213
+ t: 'md',
214
+ text: JSON.stringify({ usage: { inputTokens, outputTokens, totalTokens, estimatedCost: cost } }),
215
+ })
216
+ }
217
+ }
218
+
219
+ const toolEvents = partialPersistence.getToolEvents()
220
+ const toolRoutingResult = initialToolRoutingResult || await runPostLlmToolRouting({
221
+ session: sessionForRun,
222
+ sessionId,
223
+ message,
224
+ effectiveMessage,
225
+ enabledExtensions: extensionsForRun,
226
+ toolPolicy,
227
+ appSettings,
228
+ internal,
229
+ source,
230
+ toolEvents,
231
+ emit,
232
+ }, fullResponse, errorMessage)
233
+
234
+ fullResponse = toolRoutingResult.fullResponse
235
+ errorMessage = toolRoutingResult.errorMessage
236
+
237
+ const {
238
+ thinkingText,
239
+ streamErrors,
240
+ accumulatedUsage,
241
+ } = partialPersistence.getSnapshot()
242
+
243
+ if (shouldAppendMissedRequestedToolNotice({
244
+ missedRequestedTools: toolRoutingResult.missedRequestedTools,
245
+ fullResponse,
246
+ errorMessage,
247
+ calledToolCount: toolRoutingResult.calledNames.size,
248
+ })) {
249
+ const notice = `Tool execution notice: requested tool(s) ${toolRoutingResult.missedRequestedTools.join(', ')} were not actually invoked in this run.`
250
+ emit({ t: 'err', text: notice })
251
+ const trimmedResponse = (fullResponse || '').trim()
252
+ fullResponse = trimmedResponse
253
+ ? `${trimmedResponse}\n\n${notice}`
254
+ : notice
255
+ }
256
+
257
+ const terminalError = deriveTerminalRunError({
258
+ errorMessage,
259
+ fullResponse: fullResponse || '',
260
+ streamErrors,
261
+ toolEvents,
262
+ internal,
263
+ })
264
+ if (terminalError && terminalError !== errorMessage) {
265
+ if (!errorMessage) {
266
+ log.warn('chat-run', `Run ended without a visible response for session ${sessionId}`, {
267
+ runId,
268
+ source,
269
+ internal,
270
+ provider: providerType,
271
+ messagePreview: effectiveMessage.slice(0, 200),
272
+ inferredError: terminalError,
273
+ })
274
+ }
275
+ errorMessage = terminalError
276
+ }
277
+
278
+ const persistedToolEvents = dedupeConsecutiveToolEvents(pruneIncompleteToolEvents(toolEvents))
279
+ let finalText = (fullResponse || '').trim() || (!internal && errorMessage ? `Error: ${errorMessage}` : '')
280
+ if (extensionsForRun.length > 0 && finalText && !isHeartbeatRun) {
281
+ try {
282
+ finalText = await transformCapabilityText(
283
+ 'transformOutboundMessage',
284
+ { session: sessionForRun, text: finalText },
285
+ { enabledIds: extensionsForRun },
286
+ )
287
+ } catch {
288
+ // Outbound transforms are non-critical.
289
+ }
290
+ }
291
+ finalText = reconcileConnectorDeliveryText(finalText, persistedToolEvents)
292
+ finalText = normalizeAssistantArtifactLinks(finalText, session.cwd)
293
+ finalText = applyExactOutputContract({
294
+ contract: await resolveExactOutputContractWithTimeout({
295
+ sessionId,
296
+ agentId: sessionForRun.agentId || null,
297
+ userMessage: message,
298
+ currentResponse: finalText,
299
+ toolEvents: persistedToolEvents,
300
+ internal,
301
+ source,
302
+ }),
303
+ text: finalText,
304
+ errorMessage,
305
+ toolEvents: persistedToolEvents,
306
+ })
307
+ const rawTextForPersistence = stripMainLoopMetaForPersistence(finalText)
308
+ const hiddenControlOnly = shouldSuppressHiddenControlText(rawTextForPersistence)
309
+ const textForPersistence = stripHiddenControlTokens(rawTextForPersistence)
310
+ const persistedText = getPersistedAssistantText(textForPersistence, persistedToolEvents)
311
+ let persistedResponseForHooks = textForPersistence
312
+
313
+ if (isHeartbeatRun && rawTextForPersistence) {
314
+ const heartbeatStatus = extractHeartbeatStatus(rawTextForPersistence)
315
+ if (heartbeatStatus) emit({ t: 'status', text: JSON.stringify(heartbeatStatus) })
316
+ }
317
+
318
+ const heartbeatConfig = input.heartbeatConfig
319
+ let heartbeatClassification: 'suppress' | 'strip' | 'keep' | null = null
320
+ if (isHeartbeatRun && rawTextForPersistence.length > 0) {
321
+ heartbeatClassification = classifyHeartbeatResponse(
322
+ rawTextForPersistence,
323
+ heartbeatConfig?.ackMaxChars ?? 300,
324
+ toolEvents.length > 0,
325
+ )
326
+ if (heartbeatClassification !== 'suppress' && !toolEvents.length) {
327
+ const prevText = session.lastHeartbeatText || ''
328
+ const prevSentAt = session.lastHeartbeatSentAt || 0
329
+ const isDuplicate = prevText.trim() === persistedText.trim()
330
+ && (Date.now() - prevSentAt) < 24 * 60 * 60 * 1000
331
+ if (isDuplicate) {
332
+ heartbeatClassification = 'suppress'
333
+ log.info('heartbeat', `Duplicate heartbeat suppressed for session ${sessionId} (same text within 24h)`)
334
+ }
335
+ }
336
+ }
337
+
338
+ if (isHeartbeatRun && session.agentId) {
339
+ notify(`heartbeat:agent:${session.agentId}`)
340
+ }
341
+
342
+ const shouldPersistAssistant = !hiddenControlOnly
343
+ && !hideAssistantTranscript
344
+ && hasPersistableAssistantPayload(persistedText, thinkingText, persistedToolEvents)
345
+ && heartbeatClassification !== 'suppress'
346
+ && !(isHeartbeatRun && (
347
+ heartbeatConfig?.deliveryMode === 'silent'
348
+ || (heartbeatConfig?.deliveryMode === 'tool_only' && !isDirectConnectorSession(session))
349
+ ))
350
+
351
+ const normalizeResumeId = (value: unknown): string | null =>
352
+ typeof value === 'string' && value.trim() ? value.trim() : null
353
+
354
+ const current = getSession(sessionId)
355
+ let assistantPersisted = false
356
+ if (current) {
357
+ current.messages = Array.isArray(current.messages) ? current.messages : []
358
+ if (!isDirectConnectorSession(current) && current.connectorContext) {
359
+ current.connectorContext = undefined
360
+ }
361
+ const currentAgent = current.agentId ? getAgent(current.agentId) : null
362
+ pruneStreamingAssistantArtifacts(current.messages, {
363
+ minIndex: runMessageStartIndex,
364
+ minTime: runStartedAt,
365
+ })
366
+ const persistField = (key: string, value: unknown) => {
367
+ const normalized = normalizeResumeId(value)
368
+ if ((current as unknown as Record<string, unknown>)[key] !== normalized) {
369
+ ;(current as unknown as Record<string, unknown>)[key] = normalized
370
+ }
371
+ }
372
+
373
+ persistField('claudeSessionId', session.claudeSessionId)
374
+ persistField('codexThreadId', session.codexThreadId)
375
+ persistField('opencodeSessionId', session.opencodeSessionId)
376
+
377
+ const sourceResume = session.delegateResumeIds
378
+ if (sourceResume && typeof sourceResume === 'object') {
379
+ const currentResume = (current.delegateResumeIds && typeof current.delegateResumeIds === 'object')
380
+ ? current.delegateResumeIds
381
+ : {}
382
+ const sr = sourceResume as Record<string, unknown>
383
+ const cr = currentResume as Record<string, unknown>
384
+ const nextResume = {
385
+ claudeCode: normalizeResumeId(sr.claudeCode ?? cr.claudeCode),
386
+ codex: normalizeResumeId(sr.codex ?? cr.codex),
387
+ opencode: normalizeResumeId(sr.opencode ?? cr.opencode),
388
+ gemini: normalizeResumeId(sr.gemini ?? cr.gemini),
389
+ }
390
+ if (JSON.stringify(currentResume) !== JSON.stringify(nextResume)) {
391
+ current.delegateResumeIds = nextResume
392
+ }
393
+ }
394
+
395
+ if (shouldPersistAssistant) {
396
+ const persistedKind = isHeartbeatRun ? 'heartbeat' : 'chat'
397
+ const nowTs = Date.now()
398
+ const nextAssistantMessage = await applyMessageLifecycleHooks({
399
+ session: current,
400
+ message: {
401
+ role: 'assistant',
402
+ text: persistedText,
403
+ time: nowTs,
404
+ thinking: thinkingText || undefined,
405
+ toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
406
+ kind: persistedKind,
407
+ },
408
+ enabledIds: extensionsForRun,
409
+ phase: isHeartbeatRun ? 'heartbeat' : 'assistant_final',
410
+ runId: lifecycleRunId,
411
+ })
412
+ if (nextAssistantMessage) {
413
+ const previous = current.messages.at(-1)
414
+ const nextToolEvents = nextAssistantMessage.toolEvents || []
415
+ const nextKind = nextAssistantMessage.kind || persistedKind
416
+ if (shouldSuppressRedundantConnectorDeliveryFollowup({
417
+ previous,
418
+ nextText: nextAssistantMessage.text,
419
+ nextToolEvents,
420
+ nextKind,
421
+ now: nowTs,
422
+ })) {
423
+ persistedResponseForHooks = nextAssistantMessage.text
424
+ } else if (previous?.runId === lifecycleRunId || shouldReplaceRecentAssistantMessage({
425
+ previous,
426
+ nextToolEvents,
427
+ nextKind,
428
+ now: nowTs,
429
+ }) || shouldReplaceRecentConnectorFollowupMessage({
430
+ previous,
431
+ nextText: nextAssistantMessage.text,
432
+ nextToolEvents,
433
+ nextKind,
434
+ now: nowTs,
435
+ })) {
436
+ current.messages[current.messages.length - 1] = nextAssistantMessage
437
+ assistantPersisted = true
438
+ } else {
439
+ current.messages.push(nextAssistantMessage)
440
+ assistantPersisted = true
441
+ }
442
+ persistedResponseForHooks = nextAssistantMessage.text
443
+ if (assistantPersisted) {
444
+ if (isHeartbeatRun) {
445
+ current.lastHeartbeatText = nextAssistantMessage.text
446
+ current.lastHeartbeatSentAt = nowTs
447
+ }
448
+ try {
449
+ await runCapabilityHook('onMessage', { session: current, message: nextAssistantMessage }, { enabledIds: extensionsForRun })
450
+ } catch {
451
+ // onMessage hooks are non-critical.
452
+ }
453
+
454
+ if (!internal) {
455
+ const tone = estimateConversationTone(nextAssistantMessage.text)
456
+ if (tone !== current.conversationTone) {
457
+ current.conversationTone = tone
458
+ }
459
+ }
460
+ }
461
+
462
+ if (
463
+ assistantPersisted
464
+ && isHeartbeatRun
465
+ && shouldAutoRouteHeartbeatAlerts(heartbeatConfig)
466
+ && heartbeatConfig?.target
467
+ && heartbeatConfig.target !== 'none'
468
+ ) {
469
+ try {
470
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
471
+ const { sendConnectorMessage } = require('../connectors/manager')
472
+ let connectorId: string | undefined
473
+ let channelId: string | undefined
474
+ if (heartbeatConfig.target === 'last') {
475
+ const lastTarget = resolveHeartbeatLastConnectorTarget(current)
476
+ if (lastTarget) {
477
+ connectorId = lastTarget.connectorId
478
+ channelId = lastTarget.channelId
479
+ }
480
+ } else if (heartbeatConfig.target.includes(':')) {
481
+ const [cId, chId] = heartbeatConfig.target.split(':', 2)
482
+ connectorId = cId
483
+ channelId = chId
484
+ } else {
485
+ channelId = heartbeatConfig.target
486
+ }
487
+ if (channelId) {
488
+ sendConnectorMessage({ connectorId, channelId, text: nextAssistantMessage.text }).catch((err: unknown) => {
489
+ log.warn('connector', 'Heartbeat connector delivery failed', {
490
+ connectorId,
491
+ channelId,
492
+ sessionId,
493
+ error: typeof err === 'object' && err !== null && 'message' in err ? (err as Error).message : String(err),
494
+ })
495
+ })
496
+ }
497
+ } catch {
498
+ // Best effort — connector manager may not be loaded.
499
+ }
500
+ }
501
+
502
+ if (
503
+ assistantPersisted
504
+ && isHeartbeatRun
505
+ && shouldAutoRouteHeartbeatAlerts(heartbeatConfig)
506
+ && !heartbeatConfig?.target
507
+ && isDirectConnectorSession(current)
508
+ ) {
509
+ const recentInbound = current.connectorContext?.lastInboundAt
510
+ && (Date.now() - current.connectorContext.lastInboundAt) < 60_000
511
+ const connectorId = typeof current.connectorContext?.connectorId === 'string'
512
+ ? current.connectorContext.connectorId.trim()
513
+ : ''
514
+ const channelId = typeof current.connectorContext?.channelId === 'string'
515
+ ? current.connectorContext.channelId.trim()
516
+ : ''
517
+ if (!recentInbound && channelId) {
518
+ try {
519
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
520
+ const { sendConnectorMessage: sendMsg } = require('../connectors/manager')
521
+ sendMsg({ connectorId: connectorId || undefined, channelId, text: nextAssistantMessage.text }).catch((err: unknown) => {
522
+ log.warn('connector', 'Auto-route connector delivery failed', {
523
+ connectorId,
524
+ channelId,
525
+ sessionId,
526
+ error: typeof err === 'object' && err !== null && 'message' in err ? (err as Error).message : String(err),
527
+ })
528
+ })
529
+ } catch {
530
+ // Best effort — connector manager may not be loaded.
531
+ }
532
+ }
533
+ }
534
+ }
535
+ }
536
+ if (isHeartbeatRun && heartbeatClassification === 'suppress') {
537
+ pruneSuppressedHeartbeatStreamMessage(current.messages)
538
+ }
539
+
540
+ if (isHeartbeatRun) {
541
+ const pruned = pruneOldHeartbeatMessages(current.messages)
542
+ if (pruned > 0) {
543
+ log.info('heartbeat', `Pruned ${pruned} old heartbeat message(s) from session ${sessionId}`)
544
+ }
545
+ }
546
+
547
+ try {
548
+ await runCapabilityHook('afterChatTurn', {
549
+ session: current,
550
+ message,
551
+ response: persistedResponseForHooks,
552
+ source,
553
+ internal,
554
+ toolEvents: persistedToolEvents,
555
+ }, { enabledIds: extensionsForRun })
556
+ } catch {
557
+ // afterChatTurn hooks are non-critical.
558
+ }
559
+
560
+ if (!isHeartbeatSource(source)) {
561
+ current.lastActiveAt = Date.now()
562
+ }
563
+
564
+ refreshSessionIdentityState(current, currentAgent)
565
+ let resolvedMissionId = mission?.id || current.missionId || null
566
+ if (resolvedMissionId) {
567
+ const updatedMission = await applyMissionOutcomeForTurn({
568
+ session: current,
569
+ missionId: resolvedMissionId,
570
+ source,
571
+ runId: lifecycleRunId,
572
+ message,
573
+ assistantText: hiddenControlOnly ? '' : textForPersistence,
574
+ error: errorMessage || null,
575
+ toolEvents: persistedToolEvents,
576
+ })
577
+ if (updatedMission?.id) {
578
+ resolvedMissionId = updatedMission.id
579
+ current.missionId = updatedMission.id
580
+ }
581
+ }
582
+ try {
583
+ syncSessionArchiveMemory(current, { agent: currentAgent })
584
+ } catch {
585
+ // Archive sync is best-effort.
586
+ }
587
+ saveSession(sessionId, current)
588
+ if (current.agentId && shouldAutoDraftSkillSuggestion({
589
+ assistantPersisted,
590
+ internal,
591
+ isHeartbeatRun,
592
+ agentAutoDraftSetting: currentAgent?.autoDraftSkillSuggestions === true,
593
+ toolEventCount: persistedToolEvents.length,
594
+ messageCount: current.messages.length,
595
+ })) {
596
+ try {
597
+ const { createSkillSuggestionFromSession } = await import('@/lib/server/skills/skill-suggestions')
598
+ await createSkillSuggestionFromSession(sessionId)
599
+ } catch {
600
+ // Reviewed skill drafting is best-effort.
601
+ }
602
+ }
603
+ notify(`messages:${sessionId}`)
604
+ }
605
+
606
+ endPostProcessPerf({ toolEventCount: persistedToolEvents.length })
607
+
608
+ return {
609
+ runId,
610
+ sessionId,
611
+ missionId: mission?.id || null,
612
+ text: hiddenControlOnly ? '' : textForPersistence,
613
+ persisted: assistantPersisted,
614
+ toolEvents: persistedToolEvents,
615
+ error: errorMessage,
616
+ inputTokens: accumulatedUsage.inputTokens || undefined,
617
+ outputTokens: accumulatedUsage.outputTokens || undefined,
618
+ estimatedCost: accumulatedUsage.estimatedCost || undefined,
619
+ }
620
+ }