@swarmclawai/swarmclaw 0.9.4 → 0.9.6

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 (32) hide show
  1. package/README.md +8 -8
  2. package/package.json +1 -1
  3. package/src/app/api/settings/route.ts +1 -0
  4. package/src/app/api/wallets/[id]/send/route.ts +10 -4
  5. package/src/app/api/wallets/route.ts +5 -2
  6. package/src/app/settings/page.tsx +9 -0
  7. package/src/components/wallets/wallet-panel.tsx +12 -1
  8. package/src/components/wallets/wallet-section.tsx +4 -1
  9. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +35 -0
  10. package/src/lib/server/agents/main-agent-loop.ts +12 -1
  11. package/src/lib/server/agents/subagent-runtime.test.ts +99 -16
  12. package/src/lib/server/agents/subagent-runtime.ts +115 -19
  13. package/src/lib/server/agents/subagent-swarm.ts +3 -3
  14. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +112 -0
  15. package/src/lib/server/chat-execution/chat-execution.ts +357 -152
  16. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +51 -0
  17. package/src/lib/server/chat-execution/stream-agent-chat.ts +201 -38
  18. package/src/lib/server/chat-execution/stream-continuation.ts +46 -0
  19. package/src/lib/server/connectors/contact-boundaries.ts +70 -8
  20. package/src/lib/server/connectors/manager.test.ts +129 -7
  21. package/src/lib/server/plugins.test.ts +263 -0
  22. package/src/lib/server/plugins.ts +406 -10
  23. package/src/lib/server/session-tools/context.ts +15 -1
  24. package/src/lib/server/session-tools/index.ts +42 -6
  25. package/src/lib/server/session-tools/session-tools-wiring.test.ts +50 -0
  26. package/src/lib/server/session-tools/subagent.ts +3 -3
  27. package/src/lib/server/tool-loop-detection.test.ts +21 -0
  28. package/src/lib/server/tool-loop-detection.ts +79 -0
  29. package/src/lib/server/wallet/wallet-service.test.ts +25 -1
  30. package/src/lib/server/wallet/wallet-service.ts +13 -0
  31. package/src/types/index.ts +134 -1
  32. package/src/views/settings/section-wallets.tsx +35 -0
@@ -129,6 +129,49 @@ function resolveHeartbeatLastConnectorTarget(session: Session | null | undefined
129
129
  }
130
130
  }
131
131
 
132
+ type PersistPhase = 'user' | 'system' | 'assistant_partial' | 'assistant_final' | 'heartbeat'
133
+
134
+ async function applyMessageLifecycleHooks(params: {
135
+ session: Session
136
+ message: Message
137
+ enabledIds: string[]
138
+ phase: PersistPhase
139
+ runId?: string
140
+ isSynthetic?: boolean
141
+ }): Promise<Message | null> {
142
+ const pluginManager = getPluginManager()
143
+ let currentMessage = params.message
144
+ const toolEvents = Array.isArray(currentMessage.toolEvents)
145
+ ? currentMessage.toolEvents.filter((event) => typeof event.output === 'string' || event.error === true)
146
+ : []
147
+
148
+ for (const event of toolEvents) {
149
+ currentMessage = await pluginManager.runToolResultPersist(
150
+ {
151
+ session: params.session,
152
+ message: currentMessage,
153
+ toolName: event.name,
154
+ toolCallId: event.toolCallId,
155
+ isSynthetic: params.isSynthetic,
156
+ },
157
+ { enabledIds: params.enabledIds },
158
+ )
159
+ }
160
+
161
+ const writeResult = await pluginManager.runBeforeMessageWrite(
162
+ {
163
+ session: params.session,
164
+ message: currentMessage,
165
+ phase: params.phase,
166
+ runId: params.runId,
167
+ },
168
+ { enabledIds: params.enabledIds },
169
+ )
170
+
171
+ if (writeResult.block) return null
172
+ return writeResult.message
173
+ }
174
+
132
175
  interface SessionWithCredentials {
133
176
  credentialId?: string | null
134
177
  }
@@ -512,7 +555,7 @@ function buildAgentSystemPrompt(session: Session): string | undefined {
512
555
  // 2. Runtime & Capabilities
513
556
  const runtimeLines = [
514
557
  '## Runtime',
515
- `os=${process.platform} | host=${os.hostname()} | agent=${agent.id} | provider=${agent.provider} | model=${agent.model}`,
558
+ `os=${process.platform} | host=${os.hostname()} | agent=${agent.id} | provider=${session.provider} | model=${session.model}`,
516
559
  `capabilities=${buildAgentRuntimeCapabilities(enabledPlugins).join(',')}`,
517
560
  'tool_access=universal',
518
561
  ]
@@ -627,6 +670,8 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
627
670
  const runMessageStartIndex = session.messages.length
628
671
 
629
672
  const appSettings = loadSettings()
673
+ const pluginManager = getPluginManager()
674
+ const lifecycleRunId = runId || `${sessionId}:${runStartedAt}`
630
675
  const agentForSession = session.agentId ? loadAgents()[session.agentId] : null
631
676
  if (isAgentDisabled(agentForSession)) {
632
677
  const disabledError = buildAgentDisabledMessage(agentForSession, 'run chats')
@@ -634,14 +679,24 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
634
679
 
635
680
  let persisted = false
636
681
  if (!internal) {
637
- session.messages.push({
638
- role: 'assistant',
639
- text: disabledError,
640
- time: Date.now(),
682
+ const disabledMessage = await applyMessageLifecycleHooks({
683
+ session,
684
+ message: {
685
+ role: 'assistant',
686
+ text: disabledError,
687
+ time: Date.now(),
688
+ },
689
+ enabledIds: Array.isArray(session.plugins) ? session.plugins : [],
690
+ phase: 'assistant_final',
691
+ runId: lifecycleRunId,
692
+ isSynthetic: true,
641
693
  })
642
- session.lastActiveAt = Date.now()
643
- saveSessions(sessions)
644
- persisted = true
694
+ if (disabledMessage) {
695
+ session.messages.push(disabledMessage)
696
+ session.lastActiveAt = Date.now()
697
+ saveSessions(sessions)
698
+ persisted = true
699
+ }
645
700
  }
646
701
 
647
702
  return {
@@ -673,6 +728,19 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
673
728
  })
674
729
  if (!freshness.fresh) {
675
730
  try { syncSessionArchiveMemory(session, { agent: agentForSession }) } catch { /* archive sync is best-effort */ }
731
+ await pluginManager.runHook(
732
+ 'sessionEnd',
733
+ {
734
+ sessionId: session.id,
735
+ session,
736
+ messageCount: Array.isArray(session.messages) ? session.messages.length : 0,
737
+ durationMs: Date.now() - (session.createdAt || runStartedAt),
738
+ reason: freshness.reason || 'session_reset',
739
+ },
740
+ {
741
+ enabledIds: Array.isArray(session.plugins) ? session.plugins : [],
742
+ },
743
+ )
676
744
  resetSessionRuntime(session, freshness.reason || 'session_reset')
677
745
  onEvent?.({ t: 'status', text: JSON.stringify({ sessionReset: freshness.reason || 'session_reset' }) })
678
746
  sessions[sessionId] = session
@@ -683,6 +751,16 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
683
751
  try { syncSessionArchiveMemory(session, { agent: agentForSession }) } catch { /* archive sync is best-effort */ }
684
752
  }
685
753
  const pluginsForRun = heartbeatStatusOnly ? [] : toolPolicy.enabledPlugins
754
+ if (runMessageStartIndex === 0) {
755
+ await pluginManager.runHook(
756
+ 'sessionStart',
757
+ {
758
+ session,
759
+ resumedFrom: session.parentSessionId || null,
760
+ },
761
+ { enabledIds: pluginsForRun },
762
+ )
763
+ }
686
764
  let sessionForRun = pluginsForRun === session.plugins
687
765
  ? session
688
766
  : { ...session, plugins: pluginsForRun }
@@ -714,6 +792,31 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
714
792
  sessionForRun = { ...sessionForRun, model: input.modelOverride }
715
793
  }
716
794
 
795
+ if (pluginsForRun.length > 0) {
796
+ const modelResolvePrompt = heartbeatLightContext
797
+ ? (buildLightHeartbeatSystemPrompt(sessionForRun) || '')
798
+ : (buildAgentSystemPrompt(sessionForRun) || '')
799
+ const modelResolve = await pluginManager.runBeforeModelResolve(
800
+ {
801
+ session: sessionForRun,
802
+ prompt: modelResolvePrompt,
803
+ message: effectiveMessage,
804
+ provider: sessionForRun.provider,
805
+ model: sessionForRun.model,
806
+ apiEndpoint: sessionForRun.apiEndpoint || null,
807
+ },
808
+ { enabledIds: pluginsForRun },
809
+ )
810
+ if (modelResolve) {
811
+ sessionForRun = {
812
+ ...sessionForRun,
813
+ provider: modelResolve.providerOverride ?? sessionForRun.provider,
814
+ model: modelResolve.modelOverride ?? sessionForRun.model,
815
+ ...(modelResolve.apiEndpointOverride !== undefined ? { apiEndpoint: modelResolve.apiEndpointOverride } : {}),
816
+ }
817
+ }
818
+ }
819
+
717
820
  if (!heartbeatStatusOnly && toolPolicy.blockedPlugins.length > 0) {
718
821
  const blockedSummary = toolPolicy.blockedPlugins
719
822
  .map((entry) => `${entry.tool} (${entry.reason})`)
@@ -736,14 +839,24 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
736
839
 
737
840
  let persisted = false
738
841
  if (!internal) {
739
- session.messages.push({
740
- role: 'assistant',
741
- text: budgetError,
742
- time: Date.now(),
842
+ const budgetMessage = await applyMessageLifecycleHooks({
843
+ session,
844
+ message: {
845
+ role: 'assistant',
846
+ text: budgetError,
847
+ time: Date.now(),
848
+ },
849
+ enabledIds: Array.isArray(session.plugins) ? session.plugins : [],
850
+ phase: 'assistant_final',
851
+ runId: lifecycleRunId,
852
+ isSynthetic: true,
743
853
  })
744
- session.lastActiveAt = Date.now()
745
- saveSessions(sessions)
746
- persisted = true
854
+ if (budgetMessage) {
855
+ session.messages.push(budgetMessage)
856
+ session.lastActiveAt = Date.now()
857
+ saveSessions(sessions)
858
+ persisted = true
859
+ }
747
860
  }
748
861
 
749
862
  return {
@@ -773,14 +886,24 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
773
886
 
774
887
  let persisted = false
775
888
  if (!internal) {
776
- session.messages.push({
777
- role: 'assistant',
778
- text: spendError,
779
- time: Date.now(),
889
+ const spendMessage = await applyMessageLifecycleHooks({
890
+ session,
891
+ message: {
892
+ role: 'assistant',
893
+ text: spendError,
894
+ time: Date.now(),
895
+ },
896
+ enabledIds: Array.isArray(session.plugins) ? session.plugins : [],
897
+ phase: 'assistant_final',
898
+ runId: lifecycleRunId,
899
+ isSynthetic: true,
780
900
  })
781
- session.lastActiveAt = Date.now()
782
- saveSessions(sessions)
783
- persisted = true
901
+ if (spendMessage) {
902
+ session.messages.push(spendMessage)
903
+ session.lastActiveAt = Date.now()
904
+ saveSessions(sessions)
905
+ persisted = true
906
+ }
784
907
  }
785
908
 
786
909
  return {
@@ -821,30 +944,48 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
821
944
  const shouldPersistUserMessage = shouldPersistInboundUserMessage(internal, source)
822
945
  if (shouldPersistUserMessage) {
823
946
  const linkAnalysis = !internal ? await runLinkUnderstanding(message) : []
824
- const nextUserMessage: Message = {
825
- role: 'user',
826
- text: message,
827
- time: Date.now(),
828
- imagePath: imagePath || undefined,
829
- imageUrl: imageUrl || undefined,
830
- attachedFiles: attachedFiles?.length ? attachedFiles : undefined,
831
- replyToId: input.replyToId || undefined,
832
- }
833
- session.messages.push(nextUserMessage)
834
- if (linkAnalysis.length > 0) {
835
- session.messages.push({
836
- role: 'assistant',
837
- kind: 'system',
838
- text: `[Automated Link Analysis]\n${linkAnalysis.join('\n\n')}`,
947
+ const nextUserMessage = await applyMessageLifecycleHooks({
948
+ session,
949
+ message: {
950
+ role: 'user',
951
+ text: message,
839
952
  time: Date.now(),
840
- })
841
- }
842
- session.lastActiveAt = Date.now()
843
- saveSessions(sessions)
844
- if (!internal) {
845
- try {
846
- await getPluginManager().runHook('onMessage', { session, message: nextUserMessage }, { enabledIds: pluginsForRun })
847
- } catch { /* onMessage hooks are non-critical */ }
953
+ imagePath: imagePath || undefined,
954
+ imageUrl: imageUrl || undefined,
955
+ attachedFiles: attachedFiles?.length ? attachedFiles : undefined,
956
+ replyToId: input.replyToId || undefined,
957
+ },
958
+ enabledIds: pluginsForRun,
959
+ phase: 'user',
960
+ runId: lifecycleRunId,
961
+ })
962
+ if (nextUserMessage) {
963
+ session.messages.push(nextUserMessage)
964
+ if (linkAnalysis.length > 0) {
965
+ const linkAnalysisMessage = await applyMessageLifecycleHooks({
966
+ session,
967
+ message: {
968
+ role: 'assistant',
969
+ kind: 'system',
970
+ text: `[Automated Link Analysis]\n${linkAnalysis.join('\n\n')}`,
971
+ time: Date.now(),
972
+ },
973
+ enabledIds: pluginsForRun,
974
+ phase: 'system',
975
+ runId: lifecycleRunId,
976
+ isSynthetic: true,
977
+ })
978
+ if (linkAnalysisMessage) {
979
+ session.messages.push(linkAnalysisMessage)
980
+ }
981
+ }
982
+ session.lastActiveAt = Date.now()
983
+ saveSessions(sessions)
984
+ if (!internal) {
985
+ try {
986
+ await pluginManager.runHook('onMessage', { session, message: nextUserMessage }, { enabledIds: pluginsForRun })
987
+ } catch { /* onMessage hooks are non-critical */ }
988
+ }
848
989
  }
849
990
  }
850
991
 
@@ -864,8 +1005,8 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
864
1005
  // to avoid duplicating tool discipline, operating guidance, and capability sections.
865
1006
  // lightContext mode uses a minimal prompt for both paths to reduce token cost.
866
1007
  const systemPrompt = heartbeatLightContext
867
- ? buildLightHeartbeatSystemPrompt(session)
868
- : (hasPlugins ? undefined : buildAgentSystemPrompt(session))
1008
+ ? buildLightHeartbeatSystemPrompt(sessionForRun)
1009
+ : (hasPlugins ? undefined : buildAgentSystemPrompt(sessionForRun))
869
1010
  const toolEvents: MessageToolEvent[] = []
870
1011
  const streamErrors: string[] = []
871
1012
  const accumulatedUsage = { inputTokens: 0, outputTokens: 0, estimatedCost: 0 }
@@ -876,6 +1017,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
876
1017
  let lastPartialSnapshotKey = ''
877
1018
  let partialSaveTimeout: ReturnType<typeof setTimeout> | null = null
878
1019
  let partialPersistenceClosed = false
1020
+ let partialPersistChain: Promise<void> = Promise.resolve()
879
1021
 
880
1022
  const stopPartialAssistantPersistence = () => {
881
1023
  partialPersistenceClosed = true
@@ -885,35 +1027,41 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
885
1027
  }
886
1028
  }
887
1029
 
888
- const persistStreamingAssistantArtifact = () => {
1030
+ const persistStreamingAssistantArtifact = async () => {
889
1031
  partialSaveTimeout = null
890
1032
  if (partialPersistenceClosed) return
891
1033
  const persistedToolEvents = toolEvents.length ? dedupeConsecutiveToolEvents([...toolEvents]) : []
892
1034
  if (!hasPersistableAssistantPayload(streamingPartialText, thinkingText, persistedToolEvents)) return
893
1035
 
894
- const snapshotKey = JSON.stringify([
895
- streamingPartialText,
896
- thinkingText,
897
- getToolEventsSnapshotKey(persistedToolEvents),
898
- ])
899
- if (snapshotKey === lastPartialSnapshotKey) return
900
-
901
- lastPartialSnapshotKey = snapshotKey
902
- lastPartialSaveAt = Date.now()
903
-
904
1036
  try {
905
1037
  const fresh = loadSessions()
906
1038
  const current = fresh[sessionId]
907
1039
  if (!current) return
908
1040
  current.messages = Array.isArray(current.messages) ? current.messages : []
909
- const partialMsg: Message = {
910
- role: 'assistant',
911
- text: streamingPartialText,
912
- time: Date.now(),
913
- streaming: true,
914
- thinking: thinkingText || undefined,
915
- toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
916
- }
1041
+ const partialMsg = await applyMessageLifecycleHooks({
1042
+ session: current,
1043
+ message: {
1044
+ role: 'assistant',
1045
+ text: streamingPartialText,
1046
+ time: Date.now(),
1047
+ streaming: true,
1048
+ thinking: thinkingText || undefined,
1049
+ toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
1050
+ },
1051
+ enabledIds: pluginsForRun,
1052
+ phase: 'assistant_partial',
1053
+ runId: lifecycleRunId,
1054
+ isSynthetic: true,
1055
+ })
1056
+ if (!partialMsg) return
1057
+ const snapshotKey = JSON.stringify([
1058
+ partialMsg.text,
1059
+ partialMsg.thinking || '',
1060
+ getToolEventsSnapshotKey(partialMsg.toolEvents || []),
1061
+ ])
1062
+ if (snapshotKey === lastPartialSnapshotKey) return
1063
+ lastPartialSnapshotKey = snapshotKey
1064
+ lastPartialSaveAt = Date.now()
917
1065
  upsertStreamingAssistantArtifact(current.messages, partialMsg, {
918
1066
  minIndex: runMessageStartIndex,
919
1067
  minTime: runStartedAt,
@@ -924,6 +1072,14 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
924
1072
  } catch { /* partial save is best-effort */ }
925
1073
  }
926
1074
 
1075
+ const triggerPartialAssistantPersist = () => {
1076
+ partialPersistChain = partialPersistChain
1077
+ .catch(() => {})
1078
+ .then(async () => {
1079
+ await persistStreamingAssistantArtifact()
1080
+ })
1081
+ }
1082
+
927
1083
  const queuePartialAssistantPersist = (immediate = false) => {
928
1084
  if (partialPersistenceClosed) return
929
1085
  const now = Date.now()
@@ -933,12 +1089,12 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
933
1089
  clearTimeout(partialSaveTimeout)
934
1090
  partialSaveTimeout = null
935
1091
  }
936
- persistStreamingAssistantArtifact()
1092
+ triggerPartialAssistantPersist()
937
1093
  return
938
1094
  }
939
1095
  if (partialSaveTimeout) return
940
1096
  partialSaveTimeout = setTimeout(() => {
941
- persistStreamingAssistantArtifact()
1097
+ triggerPartialAssistantPersist()
942
1098
  }, minIntervalMs - (now - lastPartialSaveAt))
943
1099
  }
944
1100
 
@@ -1084,6 +1240,20 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1084
1240
  })
1085
1241
  emit({ t: 'd', text: cached.text })
1086
1242
  } else {
1243
+ await pluginManager.runHook(
1244
+ 'llmInput',
1245
+ {
1246
+ session: sessionForRun,
1247
+ runId: lifecycleRunId,
1248
+ provider: providerType,
1249
+ model: sessionForRun.model,
1250
+ systemPrompt,
1251
+ prompt: effectiveMessage,
1252
+ historyMessages: directHistorySnapshot,
1253
+ imagesCount: resolvedImagePath ? 1 : 0,
1254
+ },
1255
+ { enabledIds: pluginsForRun },
1256
+ )
1087
1257
  fullResponse = await provider.handler.streamChat({
1088
1258
  session: sessionForRun,
1089
1259
  message: effectiveMessage,
@@ -1101,6 +1271,26 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1101
1271
  onUsage: (u) => { directUsage.inputTokens = u.inputTokens; directUsage.outputTokens = u.outputTokens; directUsage.received = true },
1102
1272
  signal: abortController.signal,
1103
1273
  })
1274
+ await pluginManager.runHook(
1275
+ 'llmOutput',
1276
+ {
1277
+ session: sessionForRun,
1278
+ runId: lifecycleRunId,
1279
+ provider: providerType,
1280
+ model: sessionForRun.model,
1281
+ assistantTexts: fullResponse ? [fullResponse] : [],
1282
+ response: fullResponse,
1283
+ usage: directUsage.received
1284
+ ? {
1285
+ input: directUsage.inputTokens,
1286
+ output: directUsage.outputTokens,
1287
+ total: directUsage.inputTokens + directUsage.outputTokens,
1288
+ estimatedCost: estimateCost(sessionForRun.model, directUsage.inputTokens, directUsage.outputTokens),
1289
+ }
1290
+ : undefined,
1291
+ },
1292
+ { enabledIds: pluginsForRun },
1293
+ )
1104
1294
  if (canUseResponseCache && responseCacheInput && fullResponse) {
1105
1295
  setCachedLlmResponse(responseCacheInput, fullResponse, responseCacheConfig)
1106
1296
  }
@@ -1127,6 +1317,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1127
1317
  notify('sessions')
1128
1318
  if (signal) signal.removeEventListener('abort', abortFromOutside)
1129
1319
  }
1320
+ await partialPersistChain.catch(() => {})
1130
1321
 
1131
1322
  if (!errorMessage) {
1132
1323
  markProviderSuccess(providerType)
@@ -1228,6 +1419,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1228
1419
  const hiddenControlOnly = shouldSuppressHiddenControlText(rawTextForPersistence)
1229
1420
  const textForPersistence = stripHiddenControlTokens(rawTextForPersistence)
1230
1421
  const persistedText = getPersistedAssistantText(textForPersistence, persistedToolEvents)
1422
+ let persistedResponseForHooks = textForPersistence
1231
1423
 
1232
1424
  if (isHeartbeatRun && rawTextForPersistence) {
1233
1425
  const heartbeatStatus = extractHeartbeatStatus(rawTextForPersistence)
@@ -1269,6 +1461,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1269
1461
 
1270
1462
  const fresh = loadSessions()
1271
1463
  const current = fresh[sessionId]
1464
+ let assistantPersisted = false
1272
1465
  if (current) {
1273
1466
  current.messages = Array.isArray(current.messages) ? current.messages : []
1274
1467
  if (!isDirectConnectorSession(current) && current.connectorContext) {
@@ -1311,99 +1504,111 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1311
1504
  if (shouldPersistAssistant) {
1312
1505
  const persistedKind = isHeartbeatRun ? 'heartbeat' : 'chat'
1313
1506
  const nowTs = Date.now()
1314
- const nextAssistantMessage: Message = {
1315
- role: 'assistant',
1316
- text: persistedText,
1317
- time: nowTs,
1318
- thinking: thinkingText || undefined,
1319
- toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
1320
- kind: persistedKind,
1321
- }
1322
- const previous = current.messages.at(-1)
1323
- if (previous?.streaming || shouldReplaceRecentAssistantMessage({
1324
- previous,
1325
- nextToolEvents: persistedToolEvents,
1326
- nextKind: persistedKind,
1327
- now: nowTs,
1328
- })) {
1329
- current.messages[current.messages.length - 1] = nextAssistantMessage
1330
- } else {
1331
- current.messages.push(nextAssistantMessage)
1332
- }
1333
- if (isHeartbeatRun) {
1334
- current.lastHeartbeatText = persistedText
1335
- current.lastHeartbeatSentAt = nowTs
1336
- }
1337
- try {
1338
- await getPluginManager().runHook('onMessage', { session: current, message: nextAssistantMessage }, { enabledIds: pluginsForRun })
1339
- } catch { /* onMessage hooks are non-critical */ }
1340
-
1341
- // Conversation tone detection
1342
- if (!internal) {
1343
- const tone = estimateConversationTone(persistedText)
1344
- if (tone !== current.conversationTone) {
1345
- current.conversationTone = tone
1507
+ const nextAssistantMessage = await applyMessageLifecycleHooks({
1508
+ session: current,
1509
+ message: {
1510
+ role: 'assistant',
1511
+ text: persistedText,
1512
+ time: nowTs,
1513
+ thinking: thinkingText || undefined,
1514
+ toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
1515
+ kind: persistedKind,
1516
+ },
1517
+ enabledIds: pluginsForRun,
1518
+ phase: isHeartbeatRun ? 'heartbeat' : 'assistant_final',
1519
+ runId: lifecycleRunId,
1520
+ })
1521
+ if (nextAssistantMessage) {
1522
+ const previous = current.messages.at(-1)
1523
+ const nextToolEvents = nextAssistantMessage.toolEvents || []
1524
+ const nextKind = nextAssistantMessage.kind || persistedKind
1525
+ if (previous?.streaming || shouldReplaceRecentAssistantMessage({
1526
+ previous,
1527
+ nextToolEvents,
1528
+ nextKind,
1529
+ now: nowTs,
1530
+ })) {
1531
+ current.messages[current.messages.length - 1] = nextAssistantMessage
1532
+ } else {
1533
+ current.messages.push(nextAssistantMessage)
1534
+ }
1535
+ assistantPersisted = true
1536
+ persistedResponseForHooks = nextAssistantMessage.text
1537
+ if (isHeartbeatRun) {
1538
+ current.lastHeartbeatText = nextAssistantMessage.text
1539
+ current.lastHeartbeatSentAt = nowTs
1346
1540
  }
1347
- }
1348
-
1349
- // Target routing for non-suppressed heartbeat alerts
1350
- if (
1351
- isHeartbeatRun
1352
- && shouldAutoRouteHeartbeatAlerts(heartbeatConfig)
1353
- && heartbeatConfig?.target
1354
- && heartbeatConfig.target !== 'none'
1355
- ) {
1356
1541
  try {
1357
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1358
- const { sendConnectorMessage } = require('../connectors/manager')
1359
- let connectorId: string | undefined
1360
- let channelId: string | undefined
1361
- if (heartbeatConfig.target === 'last') {
1362
- const lastTarget = resolveHeartbeatLastConnectorTarget(current)
1363
- if (lastTarget) {
1364
- connectorId = lastTarget.connectorId
1365
- channelId = lastTarget.channelId
1366
- }
1367
- } else if (heartbeatConfig.target.includes(':')) {
1368
- const [cId, chId] = heartbeatConfig.target.split(':', 2)
1369
- connectorId = cId
1370
- channelId = chId
1371
- } else {
1372
- channelId = heartbeatConfig.target
1542
+ await pluginManager.runHook('onMessage', { session: current, message: nextAssistantMessage }, { enabledIds: pluginsForRun })
1543
+ } catch { /* onMessage hooks are non-critical */ }
1544
+
1545
+ // Conversation tone detection
1546
+ if (!internal) {
1547
+ const tone = estimateConversationTone(nextAssistantMessage.text)
1548
+ if (tone !== current.conversationTone) {
1549
+ current.conversationTone = tone
1373
1550
  }
1374
- if (channelId) {
1375
- sendConnectorMessage({ connectorId, channelId, text: persistedText }).catch(() => {})
1376
- }
1377
- } catch {
1378
- // Best effort — connector manager may not be loaded
1379
1551
  }
1380
- }
1381
1552
 
1382
- // Auto-discover connectors linked to this agent when no explicit target is set
1383
- // Skip if a real inbound message was handled recently — the agent just responded to it
1384
- if (
1385
- isHeartbeatRun
1386
- && shouldAutoRouteHeartbeatAlerts(heartbeatConfig)
1387
- && !heartbeatConfig?.target
1388
- && isDirectConnectorSession(current)
1389
- ) {
1390
- const recentInbound = current.connectorContext?.lastInboundAt
1391
- && (Date.now() - current.connectorContext.lastInboundAt) < 60_000
1392
- const connectorId = typeof current.connectorContext?.connectorId === 'string'
1393
- ? current.connectorContext.connectorId.trim()
1394
- : ''
1395
- const channelId = typeof current.connectorContext?.channelId === 'string'
1396
- ? current.connectorContext.channelId.trim()
1397
- : ''
1398
- if (!recentInbound && channelId) {
1553
+ // Target routing for non-suppressed heartbeat alerts
1554
+ if (
1555
+ isHeartbeatRun
1556
+ && shouldAutoRouteHeartbeatAlerts(heartbeatConfig)
1557
+ && heartbeatConfig?.target
1558
+ && heartbeatConfig.target !== 'none'
1559
+ ) {
1399
1560
  try {
1400
1561
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1401
- const { sendConnectorMessage: sendMsg } = require('../connectors/manager')
1402
- sendMsg({ connectorId: connectorId || undefined, channelId, text: persistedText }).catch(() => {})
1562
+ const { sendConnectorMessage } = require('../connectors/manager')
1563
+ let connectorId: string | undefined
1564
+ let channelId: string | undefined
1565
+ if (heartbeatConfig.target === 'last') {
1566
+ const lastTarget = resolveHeartbeatLastConnectorTarget(current)
1567
+ if (lastTarget) {
1568
+ connectorId = lastTarget.connectorId
1569
+ channelId = lastTarget.channelId
1570
+ }
1571
+ } else if (heartbeatConfig.target.includes(':')) {
1572
+ const [cId, chId] = heartbeatConfig.target.split(':', 2)
1573
+ connectorId = cId
1574
+ channelId = chId
1575
+ } else {
1576
+ channelId = heartbeatConfig.target
1577
+ }
1578
+ if (channelId) {
1579
+ sendConnectorMessage({ connectorId, channelId, text: nextAssistantMessage.text }).catch(() => {})
1580
+ }
1403
1581
  } catch {
1404
1582
  // Best effort — connector manager may not be loaded
1405
1583
  }
1406
1584
  }
1585
+
1586
+ // Auto-discover connectors linked to this agent when no explicit target is set
1587
+ // Skip if a real inbound message was handled recently — the agent just responded to it
1588
+ if (
1589
+ isHeartbeatRun
1590
+ && shouldAutoRouteHeartbeatAlerts(heartbeatConfig)
1591
+ && !heartbeatConfig?.target
1592
+ && isDirectConnectorSession(current)
1593
+ ) {
1594
+ const recentInbound = current.connectorContext?.lastInboundAt
1595
+ && (Date.now() - current.connectorContext.lastInboundAt) < 60_000
1596
+ const connectorId = typeof current.connectorContext?.connectorId === 'string'
1597
+ ? current.connectorContext.connectorId.trim()
1598
+ : ''
1599
+ const channelId = typeof current.connectorContext?.channelId === 'string'
1600
+ ? current.connectorContext.channelId.trim()
1601
+ : ''
1602
+ if (!recentInbound && channelId) {
1603
+ try {
1604
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1605
+ const { sendConnectorMessage: sendMsg } = require('../connectors/manager')
1606
+ sendMsg({ connectorId: connectorId || undefined, channelId, text: nextAssistantMessage.text }).catch(() => {})
1607
+ } catch {
1608
+ // Best effort — connector manager may not be loaded
1609
+ }
1610
+ }
1611
+ }
1407
1612
  }
1408
1613
  }
1409
1614
  if (isHeartbeatRun && heartbeatClassification === 'suppress') {
@@ -1424,7 +1629,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1424
1629
  await getPluginManager().runHook('afterChatTurn', {
1425
1630
  session: current,
1426
1631
  message,
1427
- response: textForPersistence,
1632
+ response: persistedResponseForHooks,
1428
1633
  source,
1429
1634
  internal,
1430
1635
  toolEvents: persistedToolEvents,
@@ -1458,7 +1663,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1458
1663
  runId,
1459
1664
  sessionId,
1460
1665
  text: hiddenControlOnly ? '' : textForPersistence,
1461
- persisted: shouldPersistAssistant,
1666
+ persisted: assistantPersisted,
1462
1667
  toolEvents: persistedToolEvents,
1463
1668
  error: errorMessage,
1464
1669
  inputTokens: accumulatedUsage.inputTokens || undefined,