@swarmclawai/swarmclaw 0.7.1 → 0.7.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 (237) hide show
  1. package/README.md +155 -150
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +26 -0
  4. package/src/app/api/agents/[id]/thread/route.ts +37 -9
  5. package/src/app/api/agents/route.ts +13 -2
  6. package/src/app/api/auth/route.ts +76 -7
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
  8. package/src/app/api/{sessions → chats}/[id]/browser/route.ts +5 -1
  9. package/src/app/api/{sessions → chats}/[id]/chat/route.ts +7 -3
  10. package/src/app/api/{sessions → chats}/[id]/checkpoints/route.ts +1 -1
  11. package/src/app/api/chats/[id]/main-loop/route.ts +13 -0
  12. package/src/app/api/{sessions → chats}/[id]/messages/route.ts +19 -13
  13. package/src/app/api/{sessions → chats}/[id]/restore/route.ts +1 -1
  14. package/src/app/api/{sessions → chats}/[id]/route.ts +22 -52
  15. package/src/app/api/{sessions → chats}/[id]/stop/route.ts +6 -1
  16. package/src/app/api/{sessions → chats}/route.ts +21 -7
  17. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  18. package/src/app/api/connectors/doctor/route.ts +13 -0
  19. package/src/app/api/files/open/route.ts +16 -14
  20. package/src/app/api/memory/maintenance/route.ts +11 -1
  21. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  22. package/src/app/api/openclaw/skills/route.ts +11 -3
  23. package/src/app/api/plugins/dependencies/route.ts +24 -0
  24. package/src/app/api/plugins/install/route.ts +15 -92
  25. package/src/app/api/plugins/route.ts +6 -26
  26. package/src/app/api/plugins/settings/route.ts +40 -0
  27. package/src/app/api/plugins/ui/route.ts +1 -0
  28. package/src/app/api/settings/route.ts +49 -7
  29. package/src/app/api/tasks/[id]/route.ts +15 -6
  30. package/src/app/api/tasks/bulk/route.ts +2 -2
  31. package/src/app/api/tasks/route.ts +9 -4
  32. package/src/app/api/usage/route.ts +30 -0
  33. package/src/app/api/webhooks/[id]/route.ts +8 -1
  34. package/src/app/page.tsx +9 -2
  35. package/src/cli/index.js +39 -33
  36. package/src/cli/index.ts +43 -49
  37. package/src/cli/spec.js +29 -27
  38. package/src/components/agents/agent-card.tsx +16 -13
  39. package/src/components/agents/agent-chat-list.tsx +104 -4
  40. package/src/components/agents/agent-list.tsx +54 -22
  41. package/src/components/agents/agent-sheet.tsx +209 -18
  42. package/src/components/agents/cron-job-form.tsx +3 -3
  43. package/src/components/agents/inspector-panel.tsx +110 -50
  44. package/src/components/auth/access-key-gate.tsx +36 -97
  45. package/src/components/auth/setup-wizard.tsx +5 -38
  46. package/src/components/chat/chat-area.tsx +39 -27
  47. package/src/components/{sessions/session-card.tsx → chat/chat-card.tsx} +7 -23
  48. package/src/components/chat/chat-header.tsx +299 -314
  49. package/src/components/{sessions/session-list.tsx → chat/chat-list.tsx} +11 -14
  50. package/src/components/chat/chat-tool-toggles.tsx +26 -17
  51. package/src/components/chat/checkpoint-timeline.tsx +4 -4
  52. package/src/components/chat/message-bubble.tsx +4 -1
  53. package/src/components/chat/message-list.tsx +5 -3
  54. package/src/components/chat/session-debug-panel.tsx +1 -1
  55. package/src/components/chat/tool-request-banner.tsx +3 -3
  56. package/src/components/chatrooms/agent-hover-card.tsx +3 -3
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +2 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  59. package/src/components/connectors/connector-list.tsx +265 -127
  60. package/src/components/connectors/connector-sheet.tsx +218 -1
  61. package/src/components/home/home-view.tsx +129 -5
  62. package/src/components/layout/app-layout.tsx +392 -182
  63. package/src/components/layout/mobile-header.tsx +26 -8
  64. package/src/components/plugins/plugin-list.tsx +487 -254
  65. package/src/components/plugins/plugin-sheet.tsx +236 -13
  66. package/src/components/projects/project-detail.tsx +183 -0
  67. package/src/components/settings/gateway-connection-panel.tsx +1 -1
  68. package/src/components/shared/agent-picker-list.tsx +2 -2
  69. package/src/components/shared/command-palette.tsx +111 -25
  70. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  71. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  72. package/src/components/shared/settings/section-heartbeat.tsx +78 -1
  73. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  74. package/src/components/shared/settings/section-providers.tsx +1 -1
  75. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  76. package/src/components/shared/settings/section-secrets.tsx +6 -6
  77. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  78. package/src/components/shared/settings/section-voice.tsx +5 -1
  79. package/src/components/shared/settings/section-web-search.tsx +10 -2
  80. package/src/components/shared/settings/settings-page.tsx +244 -56
  81. package/src/components/tasks/approvals-panel.tsx +205 -18
  82. package/src/components/tasks/task-board.tsx +242 -46
  83. package/src/components/usage/metrics-dashboard.tsx +147 -1
  84. package/src/components/wallets/wallet-panel.tsx +17 -5
  85. package/src/components/webhooks/webhook-sheet.tsx +8 -8
  86. package/src/lib/auth.ts +17 -0
  87. package/src/lib/chat-streaming-state.test.ts +108 -0
  88. package/src/lib/chat-streaming-state.ts +108 -0
  89. package/src/lib/chat.ts +1 -1
  90. package/src/lib/{sessions.ts → chats.ts} +28 -18
  91. package/src/lib/openclaw-agent-id.test.ts +14 -0
  92. package/src/lib/openclaw-agent-id.ts +31 -0
  93. package/src/lib/providers/claude-cli.ts +1 -1
  94. package/src/lib/server/agent-assignment.test.ts +112 -0
  95. package/src/lib/server/agent-assignment.ts +169 -0
  96. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  97. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  98. package/src/lib/server/approvals.ts +483 -75
  99. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  100. package/src/lib/server/browser-state.test.ts +118 -0
  101. package/src/lib/server/browser-state.ts +123 -0
  102. package/src/lib/server/build-llm.test.ts +36 -0
  103. package/src/lib/server/build-llm.ts +11 -4
  104. package/src/lib/server/builtin-plugins.ts +34 -0
  105. package/src/lib/server/capability-router.ts +10 -8
  106. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  107. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  108. package/src/lib/server/chat-execution.ts +285 -165
  109. package/src/lib/server/chatroom-health.test.ts +26 -0
  110. package/src/lib/server/chatroom-health.ts +2 -3
  111. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  112. package/src/lib/server/chatroom-helpers.ts +48 -8
  113. package/src/lib/server/connectors/discord.ts +175 -11
  114. package/src/lib/server/connectors/doctor.test.ts +80 -0
  115. package/src/lib/server/connectors/doctor.ts +116 -0
  116. package/src/lib/server/connectors/manager.ts +948 -112
  117. package/src/lib/server/connectors/policy.test.ts +222 -0
  118. package/src/lib/server/connectors/policy.ts +452 -0
  119. package/src/lib/server/connectors/slack.ts +188 -9
  120. package/src/lib/server/connectors/telegram.ts +65 -15
  121. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  122. package/src/lib/server/connectors/thread-context.ts +72 -0
  123. package/src/lib/server/connectors/types.ts +41 -11
  124. package/src/lib/server/cost.ts +34 -1
  125. package/src/lib/server/daemon-state.ts +61 -3
  126. package/src/lib/server/data-dir.ts +13 -0
  127. package/src/lib/server/delegation-jobs.test.ts +140 -0
  128. package/src/lib/server/delegation-jobs.ts +248 -0
  129. package/src/lib/server/document-utils.test.ts +47 -0
  130. package/src/lib/server/document-utils.ts +397 -0
  131. package/src/lib/server/heartbeat-service.ts +14 -40
  132. package/src/lib/server/heartbeat-source.test.ts +22 -0
  133. package/src/lib/server/heartbeat-source.ts +7 -0
  134. package/src/lib/server/identity-continuity.test.ts +77 -0
  135. package/src/lib/server/identity-continuity.ts +127 -0
  136. package/src/lib/server/mailbox-utils.ts +347 -0
  137. package/src/lib/server/main-agent-loop.ts +28 -1103
  138. package/src/lib/server/memory-db.ts +4 -6
  139. package/src/lib/server/memory-tiers.ts +40 -0
  140. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  141. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  142. package/src/lib/server/openclaw-exec-config.ts +5 -6
  143. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  144. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  145. package/src/lib/server/openclaw-sync.ts +3 -2
  146. package/src/lib/server/orchestrator-lg.ts +20 -9
  147. package/src/lib/server/orchestrator.ts +7 -7
  148. package/src/lib/server/playwright-proxy.mjs +27 -3
  149. package/src/lib/server/plugins.test.ts +207 -0
  150. package/src/lib/server/plugins.ts +927 -66
  151. package/src/lib/server/provider-health.ts +38 -6
  152. package/src/lib/server/queue.ts +13 -28
  153. package/src/lib/server/scheduler.ts +2 -0
  154. package/src/lib/server/session-archive-memory.test.ts +85 -0
  155. package/src/lib/server/session-archive-memory.ts +230 -0
  156. package/src/lib/server/session-mailbox.ts +8 -18
  157. package/src/lib/server/session-reset-policy.test.ts +99 -0
  158. package/src/lib/server/session-reset-policy.ts +311 -0
  159. package/src/lib/server/session-run-manager.ts +33 -82
  160. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  161. package/src/lib/server/session-tools/calendar.ts +366 -0
  162. package/src/lib/server/session-tools/canvas.ts +1 -1
  163. package/src/lib/server/session-tools/chatroom.ts +4 -2
  164. package/src/lib/server/session-tools/connector.ts +114 -10
  165. package/src/lib/server/session-tools/context.ts +21 -5
  166. package/src/lib/server/session-tools/crawl.ts +447 -0
  167. package/src/lib/server/session-tools/crud.ts +74 -28
  168. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  169. package/src/lib/server/session-tools/delegate.ts +497 -24
  170. package/src/lib/server/session-tools/discovery.ts +24 -6
  171. package/src/lib/server/session-tools/document.ts +283 -0
  172. package/src/lib/server/session-tools/edit_file.ts +4 -2
  173. package/src/lib/server/session-tools/email.ts +320 -0
  174. package/src/lib/server/session-tools/extract.ts +137 -0
  175. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  176. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  177. package/src/lib/server/session-tools/file.ts +241 -25
  178. package/src/lib/server/session-tools/git.ts +1 -1
  179. package/src/lib/server/session-tools/http.ts +1 -1
  180. package/src/lib/server/session-tools/human-loop.ts +227 -0
  181. package/src/lib/server/session-tools/image-gen.ts +380 -0
  182. package/src/lib/server/session-tools/index.ts +130 -50
  183. package/src/lib/server/session-tools/mailbox.ts +276 -0
  184. package/src/lib/server/session-tools/memory.ts +172 -3
  185. package/src/lib/server/session-tools/monitor.ts +151 -8
  186. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  187. package/src/lib/server/session-tools/openclaw-nodes.ts +1 -1
  188. package/src/lib/server/session-tools/openclaw-workspace.ts +1 -1
  189. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  190. package/src/lib/server/session-tools/platform.ts +148 -7
  191. package/src/lib/server/session-tools/plugin-creator.ts +89 -26
  192. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  193. package/src/lib/server/session-tools/replicate.ts +301 -0
  194. package/src/lib/server/session-tools/sample-ui.ts +1 -1
  195. package/src/lib/server/session-tools/sandbox.ts +4 -2
  196. package/src/lib/server/session-tools/schedule.ts +24 -12
  197. package/src/lib/server/session-tools/session-info.ts +43 -7
  198. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  199. package/src/lib/server/session-tools/shell.ts +5 -2
  200. package/src/lib/server/session-tools/subagent.ts +194 -28
  201. package/src/lib/server/session-tools/table.ts +587 -0
  202. package/src/lib/server/session-tools/wallet.ts +42 -12
  203. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  204. package/src/lib/server/session-tools/web.ts +926 -91
  205. package/src/lib/server/storage.ts +255 -16
  206. package/src/lib/server/stream-agent-chat.ts +116 -268
  207. package/src/lib/server/structured-extract.test.ts +72 -0
  208. package/src/lib/server/structured-extract.ts +373 -0
  209. package/src/lib/server/task-mention.test.ts +16 -2
  210. package/src/lib/server/task-mention.ts +61 -10
  211. package/src/lib/server/tool-aliases.ts +66 -18
  212. package/src/lib/server/tool-capability-policy.test.ts +9 -9
  213. package/src/lib/server/tool-capability-policy.ts +38 -27
  214. package/src/lib/server/tool-retry.ts +2 -0
  215. package/src/lib/server/watch-jobs.test.ts +173 -0
  216. package/src/lib/server/watch-jobs.ts +532 -0
  217. package/src/lib/server/ws-hub.ts +5 -3
  218. package/src/lib/tool-definitions.ts +4 -0
  219. package/src/lib/validation/schemas.test.ts +26 -0
  220. package/src/lib/validation/schemas.ts +10 -1
  221. package/src/lib/ws-client.ts +14 -12
  222. package/src/proxy.ts +5 -5
  223. package/src/stores/use-app-store.ts +5 -11
  224. package/src/stores/use-chat-store.ts +38 -9
  225. package/src/types/index.ts +352 -47
  226. package/src/app/api/sessions/[id]/main-loop/route.ts +0 -94
  227. package/src/components/sessions/new-session-sheet.tsx +0 -253
  228. package/src/lib/server/main-session.ts +0 -24
  229. package/src/lib/server/session-run-manager.test.ts +0 -23
  230. /package/src/app/api/{sessions → chats}/[id]/clear/route.ts +0 -0
  231. /package/src/app/api/{sessions → chats}/[id]/deploy/route.ts +0 -0
  232. /package/src/app/api/{sessions → chats}/[id]/devserver/route.ts +0 -0
  233. /package/src/app/api/{sessions → chats}/[id]/edit-resend/route.ts +0 -0
  234. /package/src/app/api/{sessions → chats}/[id]/fork/route.ts +0 -0
  235. /package/src/app/api/{sessions → chats}/[id]/mailbox/route.ts +0 -0
  236. /package/src/app/api/{sessions → chats}/[id]/retry/route.ts +0 -0
  237. /package/src/app/api/{sessions → chats}/heartbeat/route.ts +0 -0
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs'
2
2
  import os from 'os'
3
+ import path from 'path'
3
4
  import {
4
5
  loadSessions,
5
6
  saveSessions,
@@ -23,12 +24,12 @@ import { buildSessionTools } from './session-tools'
23
24
  import type { StructuredToolInterface } from '@langchain/core/tools'
24
25
  import type { Session } from '@/types'
25
26
  import { stripMainLoopMetaForPersistence } from './main-agent-loop'
27
+ import { getPluginManager } from './plugins'
26
28
  import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
27
- import { getMemoryDb } from './memory-db'
28
29
  import { routeTaskIntent } from './capability-router'
29
30
  import { notify } from './ws-hub'
30
31
  import { resolveConcreteToolPolicyBlock, resolveSessionToolPolicy } from './tool-capability-policy'
31
- import { toolIdMatches } from './tool-aliases'
32
+ import { pluginIdMatches } from './tool-aliases'
32
33
  import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
33
34
  import {
34
35
  getCachedLlmResponse,
@@ -38,8 +39,13 @@ import {
38
39
  } from './llm-response-cache'
39
40
  import type { Message, MessageToolEvent, SSEEvent, UsageRecord } from '@/types'
40
41
  import { markProviderFailure, markProviderSuccess, rankDelegatesByHealth } from './provider-health'
42
+ import { isHeartbeatSource, isInternalHeartbeatRun } from './heartbeat-source'
41
43
  import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
42
- type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli'
44
+ import { buildIdentityContinuityContext, refreshSessionIdentityState } from './identity-continuity'
45
+ import { syncSessionArchiveMemory } from './session-archive-memory'
46
+ import { evaluateSessionFreshness, resetSessionRuntime, resolveSessionResetPolicy } from './session-reset-policy'
47
+ import { pruneStreamingAssistantArtifacts, upsertStreamingAssistantArtifact } from '@/lib/chat-streaming-state'
48
+ type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli' | 'delegate_to_gemini_cli'
43
49
 
44
50
  /** Slice history from the most recent context-clear marker forward */
45
51
  function applyContextClearBoundary(messages: Message[]): Message[] {
@@ -50,6 +56,8 @@ function applyContextClearBoundary(messages: Message[]): Message[] {
50
56
  }
51
57
 
52
58
  interface SessionWithTools {
59
+ plugins?: string[] | null
60
+ /** @deprecated Use plugins */
53
61
  tools?: string[] | null
54
62
  }
55
63
 
@@ -90,6 +98,10 @@ export interface ExecuteChatTurnResult {
90
98
  estimatedCost?: number
91
99
  }
92
100
 
101
+ export function shouldApplySessionFreshnessReset(source: string): boolean {
102
+ return source !== 'eval'
103
+ }
104
+
93
105
  function extractEventJson(line: string): SSEEvent | null {
94
106
  if (!line.startsWith('data: ')) return null
95
107
  try {
@@ -99,8 +111,17 @@ function extractEventJson(line: string): SSEEvent | null {
99
111
  }
100
112
  }
101
113
 
102
- function collectToolEvent(ev: SSEEvent, bag: MessageToolEvent[]) {
114
+ export function collectToolEvent(ev: SSEEvent, bag: MessageToolEvent[]) {
103
115
  if (ev.t === 'tool_call') {
116
+ const previous = bag[bag.length - 1]
117
+ if (
118
+ previous
119
+ && previous.name === (ev.toolName || 'unknown')
120
+ && previous.input === (ev.toolInput || '')
121
+ && !previous.output
122
+ ) {
123
+ return
124
+ }
104
125
  bag.push({
105
126
  name: ev.toolName || 'unknown',
106
127
  input: ev.toolInput || '',
@@ -123,6 +144,96 @@ function collectToolEvent(ev: SSEEvent, bag: MessageToolEvent[]) {
123
144
  }
124
145
  }
125
146
 
147
+ export function dedupeConsecutiveToolEvents(events: MessageToolEvent[]): MessageToolEvent[] {
148
+ const sameEvent = (left: MessageToolEvent, right: MessageToolEvent): boolean => (
149
+ left.name === right.name
150
+ && left.input === right.input
151
+ && (left.output || '') === (right.output || '')
152
+ && (left.error === true) === (right.error === true)
153
+ )
154
+ const sameBlock = (startA: number, startB: number, size: number): boolean => {
155
+ for (let offset = 0; offset < size; offset += 1) {
156
+ if (!sameEvent(events[startA + offset], events[startB + offset])) return false
157
+ }
158
+ return true
159
+ }
160
+
161
+ const deduped: MessageToolEvent[] = []
162
+ for (let index = 0; index < events.length;) {
163
+ const remaining = events.length - index
164
+ let collapsed = false
165
+ for (let blockSize = Math.floor(remaining / 2); blockSize >= 1; blockSize -= 1) {
166
+ if (!sameBlock(index, index + blockSize, blockSize)) continue
167
+ for (let offset = 0; offset < blockSize; offset += 1) deduped.push(events[index + offset])
168
+ const blockStart = index
169
+ index += blockSize
170
+ while (index + blockSize <= events.length && sameBlock(blockStart, index, blockSize)) {
171
+ index += blockSize
172
+ }
173
+ collapsed = true
174
+ break
175
+ }
176
+ if (collapsed) continue
177
+ deduped.push(events[index])
178
+ index += 1
179
+ }
180
+ return deduped
181
+ }
182
+
183
+ function extractDelegateResponse(outputText: string): string | null {
184
+ try {
185
+ const parsed = JSON.parse(outputText) as Record<string, unknown>
186
+ if (typeof parsed.response === 'string' && parsed.response.trim()) return parsed.response.trim()
187
+ if (typeof parsed.result === 'string' && parsed.result.trim()) return parsed.result.trim()
188
+ return null
189
+ } catch {
190
+ return null
191
+ }
192
+ }
193
+
194
+ function normalizeWorkspaceSandboxLinks(text: string, cwd: string): string {
195
+ return text.replace(/sandbox:\/workspace\/([^\s)"'\]`]+)/g, (raw, relativePath: string) => {
196
+ const normalized = String(relativePath || '').replace(/^\/+/, '')
197
+ if (!normalized) return raw
198
+ const resolvedCwd = path.resolve(cwd)
199
+ const resolved = path.resolve(resolvedCwd, normalized)
200
+ if (!resolved.startsWith(resolvedCwd)) return raw
201
+ if (!fs.existsSync(resolved)) return raw
202
+ return `/api/files/serve?path=${encodeURIComponent(resolved)}`
203
+ })
204
+ }
205
+
206
+ function normalizeAbsoluteFileMarkdownLinks(text: string): string {
207
+ return text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (raw, label: string, target: string) => {
208
+ if (!path.isAbsolute(target)) return raw
209
+ const resolved = path.resolve(target)
210
+ if (!fs.existsSync(resolved)) return raw
211
+ return `[${label}](/api/files/serve?path=${encodeURIComponent(resolved)})`
212
+ })
213
+ }
214
+
215
+ export function normalizeAssistantArtifactLinks(text: string, cwd: string): string {
216
+ const uploadsNormalized = text.replace(/sandbox:\/api\/uploads\//g, '/api/uploads/')
217
+ const workspaceNormalized = normalizeWorkspaceSandboxLinks(uploadsNormalized, cwd)
218
+ return normalizeAbsoluteFileMarkdownLinks(workspaceNormalized)
219
+ }
220
+
221
+ function extractHeartbeatStatus(text: string): { goal?: string; status?: string; summary?: string; nextAction?: string } | null {
222
+ const match = text.match(/\[AGENT_HEARTBEAT_META\]\s*(\{[^\n]*\})/i)
223
+ if (!match) return null
224
+ try {
225
+ const meta = JSON.parse(match[1]) as Record<string, unknown>
226
+ const payload: { goal?: string; status?: string; summary?: string; nextAction?: string } = {}
227
+ if (typeof meta.goal === 'string' && meta.goal.trim()) payload.goal = meta.goal.trim()
228
+ if (typeof meta.status === 'string' && meta.status.trim()) payload.status = meta.status.trim()
229
+ if (typeof meta.summary === 'string' && meta.summary.trim()) payload.summary = meta.summary.trim()
230
+ if (typeof meta.next_action === 'string' && meta.next_action.trim()) payload.nextAction = meta.next_action.trim()
231
+ return Object.keys(payload).length > 0 ? payload : null
232
+ } catch {
233
+ return null
234
+ }
235
+ }
236
+
126
237
  function shouldReplaceRecentAssistantMessage(params: {
127
238
  previous: Message | null | undefined
128
239
  nextToolEvents: MessageToolEvent[]
@@ -138,12 +249,17 @@ function shouldReplaceRecentAssistantMessage(params: {
138
249
  return prevTools === 0
139
250
  }
140
251
 
141
- function requestedToolNamesFromMessage(message: string): string[] {
252
+ export function pruneSuppressedHeartbeatStreamMessage(messages: Message[]): boolean {
253
+ return pruneStreamingAssistantArtifacts(messages)
254
+ }
255
+
256
+ export function requestedToolNamesFromMessage(message: string): string[] {
142
257
  const lower = message.toLowerCase()
143
258
  const candidates = [
144
259
  'delegate_to_claude_code',
145
260
  'delegate_to_codex_cli',
146
261
  'delegate_to_opencode_cli',
262
+ 'delegate_to_gemini_cli',
147
263
  'connector_message_tool',
148
264
  'sessions_tool',
149
265
  'whoami_tool',
@@ -176,15 +292,24 @@ function requestedToolNamesFromMessage(message: string): string[] {
176
292
  'sandbox_list_runtimes',
177
293
  'git',
178
294
  'canvas',
179
- 'delegate',
180
295
  'schedule_wake',
181
296
  'spawn_subagent',
297
+ 'mailbox',
298
+ 'ask_human',
299
+ 'document',
300
+ 'extract',
301
+ 'table',
302
+ 'crawl',
182
303
  'context_status',
183
304
  'context_summarize',
184
305
  'openclaw_nodes',
185
306
  'openclaw_workspace',
186
307
  ]
187
- return candidates.filter((name) => lower.includes(name.toLowerCase()))
308
+ const requested = candidates.filter((name) => lower.includes(name.toLowerCase()))
309
+ if (/(^|[\s(])`delegate`([\s).,!?]|$)|\bdelegate tool\b|\buse delegate\b/.test(lower)) {
310
+ requested.push('delegate')
311
+ }
312
+ return Array.from(new Set(requested))
188
313
  }
189
314
 
190
315
  function parseKeyValueArgs(raw: string): Record<string, string> {
@@ -326,7 +451,7 @@ function extractDelegationTask(message: string, toolName: string): string | null
326
451
  }
327
452
 
328
453
  function hasToolEnabled(session: SessionWithTools, toolName: string): boolean {
329
- return toolIdMatches(session?.tools || [], toolName)
454
+ return pluginIdMatches(session?.plugins || session?.tools || [], toolName)
330
455
  }
331
456
 
332
457
  function enabledDelegationTools(session: SessionWithTools): DelegateTool[] {
@@ -334,6 +459,7 @@ function enabledDelegationTools(session: SessionWithTools): DelegateTool[] {
334
459
  if (hasToolEnabled(session, 'claude_code') || hasToolEnabled(session, 'delegate')) tools.push('delegate_to_claude_code')
335
460
  if (hasToolEnabled(session, 'codex_cli')) tools.push('delegate_to_codex_cli')
336
461
  if (hasToolEnabled(session, 'opencode_cli')) tools.push('delegate_to_opencode_cli')
462
+ if (hasToolEnabled(session, 'gemini_cli')) tools.push('delegate_to_gemini_cli')
337
463
  return tools
338
464
  }
339
465
 
@@ -394,17 +520,28 @@ function syncSessionFromAgent(sessionId: string): void {
394
520
  if (!agent) return
395
521
 
396
522
  let changed = false
397
- if (agent.provider && agent.provider !== session.provider) { session.provider = agent.provider; changed = true }
398
- if (agent.model !== undefined && agent.model !== session.model) { session.model = agent.model; changed = true }
399
- if (agent.credentialId !== undefined && agent.credentialId !== session.credentialId) { session.credentialId = agent.credentialId ?? null; changed = true }
400
- if (agent.apiEndpoint !== undefined) {
523
+ if (!session.provider && agent.provider) { session.provider = agent.provider; changed = true }
524
+ if ((session.model === undefined || session.model === null || session.model === '') && agent.model !== undefined) {
525
+ session.model = agent.model
526
+ changed = true
527
+ }
528
+ if (session.credentialId === undefined && agent.credentialId !== undefined) {
529
+ session.credentialId = agent.credentialId ?? null
530
+ changed = true
531
+ }
532
+ if ((session.apiEndpoint === undefined || session.apiEndpoint === null) && agent.apiEndpoint !== undefined) {
401
533
  const normalized = normalizeProviderEndpoint(agent.provider, agent.apiEndpoint ?? null)
402
534
  if (normalized !== session.apiEndpoint) { session.apiEndpoint = normalized; changed = true }
403
535
  }
404
- if (!Array.isArray(session.tools)) {
405
- session.tools = Array.isArray(agent.tools) ? [...agent.tools] : []
536
+ if (!Array.isArray(session.plugins)) {
537
+ session.plugins = Array.isArray(agent.plugins) ? [...agent.plugins] : []
406
538
  changed = true
407
539
  }
540
+ const isShortcutChat = session.shortcutForAgentId === agent.id || agent.threadSessionId === sessionId
541
+ if (isShortcutChat) {
542
+ if (session.shortcutForAgentId !== agent.id) { session.shortcutForAgentId = agent.id; changed = true }
543
+ if (session.name !== agent.name) { session.name = agent.name; changed = true }
544
+ }
408
545
 
409
546
  if (changed) {
410
547
  sessions[sessionId] = session
@@ -431,6 +568,8 @@ function buildAgentSystemPrompt(session: Session): string | undefined {
431
568
  if (agent.description) identityLines.push(`Description: ${agent.description}`)
432
569
  identityLines.push('I should always embody this identity. I am not just an "Assistant" — I am a persistent companion with my own soul and presence.')
433
570
  parts.push(identityLines.join('\n'))
571
+ const continuityBlock = buildIdentityContinuityContext(session, agent)
572
+ if (continuityBlock) parts.push(continuityBlock)
434
573
 
435
574
  // 2. Runtime & Capabilities (OpenClaw Style)
436
575
  const runtimeLines = [
@@ -529,75 +668,6 @@ function estimateConversationTone(text: string): string {
529
668
  return 'neutral'
530
669
  }
531
670
 
532
- const AUTO_MEMORY_MIN_INTERVAL_MS = 45 * 60 * 1000
533
-
534
- function normalizeMemoryText(value: string): string {
535
- return (value || '').replace(/\s+/g, ' ').trim()
536
- }
537
-
538
- function shouldStoreAutoMemoryNote(opts: {
539
- session: Session
540
- source: string
541
- internal: boolean
542
- message: string
543
- response: string
544
- now: number
545
- }): boolean {
546
- const { session, source, internal, message, response, now } = opts
547
- if (internal) return false
548
- if (source !== 'chat' && source !== 'connector') return false
549
- if (!session?.agentId) return false
550
- if (!Array.isArray(session.tools) || !session.tools.includes('memory')) return false
551
- const msg = (message || '').trim()
552
- const resp = (response || '').trim()
553
- if (msg.length < 20 || resp.length < 40) return false
554
- if (/^(ok|okay|cool|thanks|thx|got it|nice)[.! ]*$/i.test(msg)) return false
555
- if (resp === 'HEARTBEAT_OK') return false
556
- const last = typeof session.lastAutoMemoryAt === 'number' ? session.lastAutoMemoryAt : 0
557
- if (last > 0 && now - last < AUTO_MEMORY_MIN_INTERVAL_MS) return false
558
- return true
559
- }
560
-
561
- function storeAutoMemoryNote(opts: {
562
- session: Session
563
- message: string
564
- response: string
565
- source: string
566
- now: number
567
- }): string | null {
568
- const { session, message, response, source, now } = opts
569
- try {
570
- const db = getMemoryDb()
571
- const compactMessage = message.replace(/\s+/g, ' ').trim().slice(0, 220)
572
- const compactResponse = response.replace(/\s+/g, ' ').trim().slice(0, 700)
573
- const title = `[auto] ${compactMessage.slice(0, 90)}`
574
- const content = [
575
- `source: ${source}`,
576
- `user_request: ${compactMessage}`,
577
- `assistant_outcome: ${compactResponse}`,
578
- ].join('\n')
579
- const latest = db.getLatestBySessionCategory?.(session.id, 'execution')
580
- if (latest) {
581
- const sameTitle = normalizeMemoryText(latest.title) === normalizeMemoryText(title)
582
- const sameContent = normalizeMemoryText(latest.content) === normalizeMemoryText(content)
583
- if (sameTitle && sameContent) {
584
- session.lastAutoMemoryAt = now
585
- return latest.id
586
- }
587
- }
588
- const created = db.add({
589
- agentId: session.agentId as string,
590
- sessionId: session.id as string,
591
- category: 'execution',
592
- title,
593
- content,
594
- })
595
- session.lastAutoMemoryAt = now
596
- return created?.id || null
597
- } catch {
598
- return null
599
- }
600
- }
601
671
 
602
672
  export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promise<ExecuteChatTurnResult> {
603
673
  const { message } = input
@@ -618,31 +688,61 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
618
688
  const sessions = loadSessions()
619
689
  const session = sessions[sessionId]
620
690
  if (!session) throw new Error(`Session not found: ${sessionId}`)
691
+ session.messages = Array.isArray(session.messages) ? session.messages : []
692
+ const runStartedAt = Date.now()
693
+ const runMessageStartIndex = session.messages.length
621
694
 
622
695
  const appSettings = loadSettings()
623
- const toolPolicy = resolveSessionToolPolicy(session.tools, appSettings)
624
- const isHeartbeatRun = internal && source === 'heartbeat'
625
- const isAutoRunNoHistory = isHeartbeatRun || (internal && source === 'main-loop-followup')
626
- const heartbeatStatus = session.mainLoopState?.status || 'idle'
627
- const mainLoopIdle = session.name === '__main__'
628
- && (heartbeatStatus === 'ok' || heartbeatStatus === 'idle')
629
- && !(session.mainLoopState?.pendingEvents?.length > 0)
630
- const heartbeatStatusOnly = isHeartbeatRun && mainLoopIdle
631
- const toolsForRun = heartbeatStatusOnly ? [] : toolPolicy.enabledTools
632
- let sessionForRun = toolsForRun === session.tools
696
+ const agentForSession = session.agentId ? loadAgents()[session.agentId] : null
697
+ const toolPolicy = resolveSessionToolPolicy(session.plugins, appSettings)
698
+ const isHeartbeatRun = isInternalHeartbeatRun(internal, source)
699
+ const isAutoRunNoHistory = isHeartbeatRun
700
+ const heartbeatStatusOnly = false
701
+ if (shouldApplySessionFreshnessReset(source)) {
702
+ const freshness = evaluateSessionFreshness({
703
+ session,
704
+ policy: resolveSessionResetPolicy({
705
+ session,
706
+ agent: agentForSession,
707
+ settings: appSettings,
708
+ }),
709
+ })
710
+ if (!freshness.fresh) {
711
+ try { syncSessionArchiveMemory(session, { agent: agentForSession }) } catch { /* archive sync is best-effort */ }
712
+ resetSessionRuntime(session, freshness.reason || 'session_reset')
713
+ onEvent?.({ t: 'status', text: JSON.stringify({ sessionReset: freshness.reason || 'session_reset' }) })
714
+ sessions[sessionId] = session
715
+ saveSessions(sessions)
716
+ }
717
+ }
718
+ const pluginsForRun = heartbeatStatusOnly ? [] : toolPolicy.enabledPlugins
719
+ let sessionForRun = pluginsForRun === session.plugins
633
720
  ? session
634
- : { ...session, tools: toolsForRun }
721
+ : { ...session, plugins: pluginsForRun }
722
+ let effectiveMessage = message
723
+
724
+ if (pluginsForRun.length > 0) {
725
+ try {
726
+ effectiveMessage = await getPluginManager().transformText(
727
+ 'transformInboundMessage',
728
+ { session: sessionForRun, text: message },
729
+ { enabledIds: pluginsForRun },
730
+ )
731
+ } catch {
732
+ effectiveMessage = message
733
+ }
734
+ }
635
735
 
636
736
  // Apply model override for heartbeat runs (cheaper model)
637
737
  if (isHeartbeatRun && input.modelOverride) {
638
738
  sessionForRun = { ...sessionForRun, model: input.modelOverride }
639
739
  }
640
740
 
641
- if (!heartbeatStatusOnly && toolPolicy.blockedTools.length > 0) {
642
- const blockedSummary = toolPolicy.blockedTools
741
+ if (!heartbeatStatusOnly && toolPolicy.blockedPlugins.length > 0) {
742
+ const blockedSummary = toolPolicy.blockedPlugins
643
743
  .map((entry) => `${entry.tool} (${entry.reason})`)
644
744
  .join(', ')
645
- onEvent?.({ t: 'err', text: `Capability policy blocked tools for this run: ${blockedSummary}` })
745
+ onEvent?.({ t: 'err', text: `Capability policy blocked plugins for this run: ${blockedSummary}` })
646
746
  }
647
747
 
648
748
  // --- Agent spend-limit enforcement (hourly/daily/monthly) ---
@@ -727,7 +827,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
727
827
  internal,
728
828
  provider: session.provider,
729
829
  model: session.model,
730
- messagePreview: message.slice(0, 200),
830
+ messagePreview: effectiveMessage.slice(0, 200),
731
831
  hasImage: !!(imagePath || imageUrl),
732
832
  },
733
833
  })
@@ -744,7 +844,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
744
844
 
745
845
  if (!internal) {
746
846
  const linkAnalysis = await runLinkUnderstanding(message)
747
- session.messages.push({
847
+ const nextUserMessage: Message = {
748
848
  role: 'user',
749
849
  text: message,
750
850
  time: Date.now(),
@@ -752,7 +852,8 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
752
852
  imageUrl: imageUrl || undefined,
753
853
  attachedFiles: attachedFiles?.length ? attachedFiles : undefined,
754
854
  replyToId: input.replyToId || undefined,
755
- })
855
+ }
856
+ session.messages.push(nextUserMessage)
756
857
  if (linkAnalysis.length > 0) {
757
858
  session.messages.push({
758
859
  role: 'assistant',
@@ -763,6 +864,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
763
864
  }
764
865
  session.lastActiveAt = Date.now()
765
866
  saveSessions(sessions)
867
+ try {
868
+ await getPluginManager().runHook('onMessage', { session, message: nextUserMessage }, { enabledIds: pluginsForRun })
869
+ } catch { /* onMessage hooks are non-critical */ }
766
870
  }
767
871
 
768
872
  const systemPrompt = buildAgentSystemPrompt(session)
@@ -811,19 +915,18 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
811
915
  const fresh = loadSessions()
812
916
  const current = fresh[sessionId]
813
917
  if (!current) return
918
+ current.messages = Array.isArray(current.messages) ? current.messages : []
814
919
  const partialMsg: Message = {
815
920
  role: 'assistant',
816
921
  text: streamingPartialText,
817
922
  time: Date.now(),
818
923
  streaming: true,
819
- toolEvents: toolEvents.length ? [...toolEvents] : undefined,
820
- }
821
- const lastMsg = current.messages.at(-1)
822
- if (lastMsg?.streaming) {
823
- current.messages[current.messages.length - 1] = partialMsg
824
- } else {
825
- current.messages.push(partialMsg)
924
+ toolEvents: toolEvents.length ? dedupeConsecutiveToolEvents([...toolEvents]) : undefined,
826
925
  }
926
+ upsertStreamingAssistantArtifact(current.messages, partialMsg, {
927
+ minIndex: runMessageStartIndex,
928
+ minTime: runStartedAt,
929
+ })
827
930
  fresh[sessionId] = current
828
931
  saveSessions(fresh)
829
932
  notify(`messages:${sessionId}`)
@@ -861,7 +964,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
861
964
  const responseCacheConfig = resolveLlmResponseCacheConfig(appSettings)
862
965
  let responseCacheHit = false
863
966
  let responseCacheInput: LlmResponseCacheKeyInput | null = null
864
- const hasTools = !!sessionForRun.tools?.length && !NON_LANGGRAPH_PROVIDER_IDS.has(providerType)
967
+ const hasPlugins = !!(sessionForRun.plugins?.length || sessionForRun.tools?.length) && !NON_LANGGRAPH_PROVIDER_IDS.has(providerType)
865
968
 
866
969
  let durationMs = 0
867
970
  const startTs = Date.now()
@@ -873,11 +976,11 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
873
976
  ? getSessionMessages(sessionId).slice(-6)
874
977
  : undefined
875
978
 
876
- console.log(`[chat-execution] provider=${providerType}, hasTools=${hasTools}, imagePath=${imagePath || 'none'}, attachedFiles=${attachedFiles?.length || 0}, tools=${(sessionForRun.tools || []).length}`)
877
- if (hasTools) {
979
+ console.log(`[chat-execution] provider=${providerType}, hasPlugins=${hasPlugins}, imagePath=${imagePath || 'none'}, attachedFiles=${attachedFiles?.length || 0}, plugins=${(sessionForRun.plugins || sessionForRun.tools || []).length}`)
980
+ if (hasPlugins) {
878
981
  fullResponse = (await streamAgentChat({
879
982
  session: sessionForRun,
880
- message: message,
983
+ message: effectiveMessage,
881
984
  imagePath,
882
985
  attachedFiles,
883
986
  apiKey,
@@ -895,7 +998,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
895
998
  model: sessionForRun.model,
896
999
  apiEndpoint: sessionForRun.apiEndpoint || '',
897
1000
  systemPrompt,
898
- message: message,
1001
+ message: effectiveMessage,
899
1002
  imagePath,
900
1003
  imageUrl,
901
1004
  attachedFiles,
@@ -923,7 +1026,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
923
1026
  } else {
924
1027
  fullResponse = await provider.handler.streamChat({
925
1028
  session: sessionForRun,
926
- message: message,
1029
+ message: effectiveMessage,
927
1030
  imagePath,
928
1031
  apiKey,
929
1032
  systemPrompt,
@@ -967,7 +1070,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
967
1070
 
968
1071
  // Record usage for the direct (non-tools) streamChat path.
969
1072
  // streamAgentChat already calls appendUsage internally for the tools path.
970
- if (!hasTools && fullResponse && !errorMessage && !responseCacheHit) {
1073
+ if (!hasPlugins && fullResponse && !errorMessage && !responseCacheHit) {
971
1074
  const inputTokens = directUsage.received ? directUsage.inputTokens : Math.ceil(message.length / 4)
972
1075
  const outputTokens = directUsage.received ? directUsage.outputTokens : Math.ceil(fullResponse.length / 4)
973
1076
  const totalTokens = inputTokens + outputTokens
@@ -998,7 +1101,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
998
1101
  ? requestedToolNamesFromMessage(message)
999
1102
  : []
1000
1103
  const routingDecision = (!internal && source === 'chat')
1001
- ? routeTaskIntent(message, toolsForRun, appSettings)
1104
+ ? routeTaskIntent(message, pluginsForRun, appSettings)
1002
1105
  : null
1003
1106
  const calledNames = new Set((toolEvents || []).map((t) => t.name))
1004
1107
 
@@ -1034,6 +1137,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1034
1137
  if (requestedName === 'delegate_to_opencode_cli') {
1035
1138
  return { toolName: 'delegate', args: { ...rawArgs, backend: 'opencode' } }
1036
1139
  }
1140
+ if (requestedName === 'delegate_to_gemini_cli') {
1141
+ return { toolName: 'delegate', args: { ...rawArgs, backend: 'gemini' } }
1142
+ }
1037
1143
 
1038
1144
  const managePrefix = 'manage_'
1039
1145
  if (requestedName.startsWith(managePrefix) && requestedName !== 'manage_platform') {
@@ -1065,7 +1171,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1065
1171
  return false
1066
1172
  }
1067
1173
  const agent = session.agentId ? loadAgents()[session.agentId] : null
1068
- const { tools, cleanup } = await buildSessionTools(session.cwd, sessionForRun.tools || [], {
1174
+ const { tools, cleanup } = await buildSessionTools(session.cwd, sessionForRun.plugins || sessionForRun.tools || [], {
1069
1175
  agentId: session.agentId || null,
1070
1176
  sessionId,
1071
1177
  platformAssignScope: agent?.platformAssignScope || 'self',
@@ -1081,10 +1187,16 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1081
1187
  const toolOutput = await selectedTool.invoke(translated.args)
1082
1188
  const outputText = typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput)
1083
1189
  emit({ t: 'tool_result', toolName, toolOutput: outputText })
1084
- // Don't overwrite fullResponse with raw tool output — it's already captured
1085
- // in toolEvents. Only set a brief notice when the LLM produced no text,
1086
- // so the message bubble isn't empty.
1087
- if (!fullResponse.trim() && outputText?.trim()) {
1190
+ const delegateResponse = (
1191
+ toolName === 'delegate'
1192
+ || toolName.startsWith('delegate_to_')
1193
+ ) ? extractDelegateResponse(outputText) : null
1194
+ if (delegateResponse) {
1195
+ fullResponse = delegateResponse
1196
+ } else if (!fullResponse.trim() && outputText?.trim()) {
1197
+ // Don't overwrite fullResponse with raw tool output — it's already captured
1198
+ // in toolEvents. Only set a brief notice when the LLM produced no text,
1199
+ // so the message bubble isn't empty.
1088
1200
  const label = toolName.replace(/_/g, ' ')
1089
1201
  fullResponse = `Used **${label}** — see tool output above for details.`
1090
1202
  }
@@ -1109,10 +1221,11 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1109
1221
  }
1110
1222
  }
1111
1223
 
1112
- const forcedDelegationTools: Array<'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli'> = [
1224
+ const forcedDelegationTools: DelegateTool[] = [
1113
1225
  'delegate_to_claude_code',
1114
1226
  'delegate_to_codex_cli',
1115
1227
  'delegate_to_opencode_cli',
1228
+ 'delegate_to_gemini_cli',
1116
1229
  ]
1117
1230
  for (const toolName of forcedDelegationTools) {
1118
1231
  if (!requestedToolNames.includes(toolName)) continue
@@ -1136,7 +1249,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1136
1249
  const delegationOrder = rankDelegatesByHealth(baseDelegationOrder as DelegateTool[])
1137
1250
  .filter((tool) => enabledDelegateTools.includes(tool))
1138
1251
  for (const delegateTool of delegationOrder) {
1139
- const invoked = await invokeSessionTool(delegateTool, { task: message.trim() }, 'Auto-delegation failed')
1252
+ const invoked = await invokeSessionTool(delegateTool, { task: effectiveMessage.trim() }, 'Auto-delegation failed')
1140
1253
  if (invoked) break
1141
1254
  }
1142
1255
  }
@@ -1156,7 +1269,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1156
1269
  for (const delegateTool of fallbackOrder) {
1157
1270
  const invoked = await invokeSessionTool(
1158
1271
  delegateTool,
1159
- { task: message.trim() },
1272
+ { task: effectiveMessage.trim() },
1160
1273
  `Provider failover via ${delegateTool} failed`,
1161
1274
  )
1162
1275
  if (invoked) {
@@ -1174,7 +1287,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1174
1287
  if (canAutoRouteWithTools && routingDecision?.intent === 'browsing' && routingDecision.primaryUrl && hasToolEnabled(sessionForRun, 'browser')) {
1175
1288
  await invokeSessionTool(
1176
1289
  'browser',
1177
- { action: 'navigate', url: routingDecision.primaryUrl },
1290
+ { action: 'read_page', url: routingDecision.primaryUrl },
1178
1291
  'Auto browser routing failed',
1179
1292
  )
1180
1293
  }
@@ -1184,7 +1297,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1184
1297
  if (routeUrl && hasToolEnabled(sessionForRun, 'web_fetch')) {
1185
1298
  await invokeSessionTool('web_fetch', { url: routeUrl }, 'Auto web_fetch routing failed')
1186
1299
  } else if (hasToolEnabled(sessionForRun, 'web_search')) {
1187
- await invokeSessionTool('web_search', { query: message.trim(), maxResults: 5 }, 'Auto web_search routing failed')
1300
+ await invokeSessionTool('web_search', { query: effectiveMessage.trim(), maxResults: 5 }, 'Auto web_search routing failed')
1188
1301
  }
1189
1302
  }
1190
1303
 
@@ -1219,27 +1332,23 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1219
1332
  errorMessage = streamErrors[streamErrors.length - 1]
1220
1333
  }
1221
1334
 
1222
- const finalText = (fullResponse || '').trim() || (!internal && errorMessage ? `Error: ${errorMessage}` : '')
1223
- const textForPersistence = stripMainLoopMetaForPersistence(finalText, internal)
1335
+ let finalText = (fullResponse || '').trim() || (!internal && errorMessage ? `Error: ${errorMessage}` : '')
1336
+ if (pluginsForRun.length > 0 && finalText && !isHeartbeatRun) {
1337
+ try {
1338
+ finalText = await getPluginManager().transformText(
1339
+ 'transformOutboundMessage',
1340
+ { session: sessionForRun, text: finalText },
1341
+ { enabledIds: pluginsForRun },
1342
+ )
1343
+ } catch { /* outbound transforms are non-critical */ }
1344
+ }
1345
+ finalText = normalizeAssistantArtifactLinks(finalText, session.cwd)
1346
+ const textForPersistence = stripMainLoopMetaForPersistence(finalText)
1347
+ const persistedToolEvents = dedupeConsecutiveToolEvents(toolEvents)
1224
1348
 
1225
- // Emit status SSE event from [MAIN_LOOP_META] if present
1226
- if (internal && finalText) {
1227
- const metaMatch = finalText.match(/\[MAIN_LOOP_META\]\s*(\{[^\n]*\})/i)
1228
- if (metaMatch) {
1229
- try {
1230
- const meta = JSON.parse(metaMatch[1])
1231
- const statusPayload: Record<string, string | undefined> = {}
1232
- if (meta.goal) statusPayload.goal = String(meta.goal)
1233
- if (meta.status) statusPayload.status = String(meta.status)
1234
- if (meta.summary) statusPayload.summary = String(meta.summary)
1235
- if (meta.next_action) statusPayload.nextAction = String(meta.next_action)
1236
- if (Object.keys(statusPayload).length > 0) {
1237
- emit({ t: 'status', text: JSON.stringify(statusPayload) })
1238
- }
1239
- } catch {
1240
- // ignore malformed meta JSON
1241
- }
1242
- }
1349
+ if (isHeartbeatRun && finalText) {
1350
+ const heartbeatStatus = extractHeartbeatStatus(finalText)
1351
+ if (heartbeatStatus) emit({ t: 'status', text: JSON.stringify(heartbeatStatus) })
1243
1352
  }
1244
1353
 
1245
1354
  // HEARTBEAT_OK suppression
@@ -1275,7 +1384,13 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1275
1384
  const fresh = loadSessions()
1276
1385
  const current = fresh[sessionId]
1277
1386
  if (current) {
1387
+ current.messages = Array.isArray(current.messages) ? current.messages : []
1388
+ const currentAgent = current.agentId ? loadAgents()[current.agentId] : null
1278
1389
  let changed = false
1390
+ changed = pruneStreamingAssistantArtifacts(current.messages, {
1391
+ minIndex: runMessageStartIndex,
1392
+ minTime: runStartedAt,
1393
+ }) || changed
1279
1394
  const persistField = (key: string, value: unknown) => {
1280
1395
  const normalized = normalizeResumeId(value)
1281
1396
  if ((current as Record<string, unknown>)[key] !== normalized) {
@@ -1307,7 +1422,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1307
1422
  }
1308
1423
 
1309
1424
  if (shouldPersistAssistant) {
1310
- const persistedKind = internal && source === 'heartbeat' ? 'heartbeat' : 'chat'
1425
+ const persistedKind = isHeartbeatRun ? 'heartbeat' : 'chat'
1311
1426
  const persistedText = heartbeatClassification === 'strip'
1312
1427
  ? textForPersistence.replace(/HEARTBEAT_OK/gi, '').trim()
1313
1428
  : textForPersistence
@@ -1317,13 +1432,13 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1317
1432
  text: persistedText,
1318
1433
  time: nowTs,
1319
1434
  thinking: thinkingText || undefined,
1320
- toolEvents: toolEvents.length ? toolEvents : undefined,
1435
+ toolEvents: persistedToolEvents.length ? persistedToolEvents : undefined,
1321
1436
  kind: persistedKind,
1322
1437
  }
1323
1438
  const previous = current.messages.at(-1)
1324
1439
  if (previous?.streaming || shouldReplaceRecentAssistantMessage({
1325
1440
  previous,
1326
- nextToolEvents: toolEvents,
1441
+ nextToolEvents: persistedToolEvents,
1327
1442
  nextKind: persistedKind,
1328
1443
  now: nowTs,
1329
1444
  })) {
@@ -1336,6 +1451,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1336
1451
  current.lastHeartbeatSentAt = nowTs
1337
1452
  }
1338
1453
  changed = true
1454
+ try {
1455
+ await getPluginManager().runHook('onMessage', { session: current, message: nextAssistantMessage }, { enabledIds: pluginsForRun })
1456
+ } catch { /* onMessage hooks are non-critical */ }
1339
1457
 
1340
1458
  // Conversation tone detection
1341
1459
  if (!internal) {
@@ -1390,30 +1508,32 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1390
1508
  }
1391
1509
  }
1392
1510
  }
1511
+ if (isHeartbeatRun && heartbeatClassification === 'suppress') {
1512
+ changed = pruneSuppressedHeartbeatStreamMessage(current.messages) || changed
1513
+ }
1393
1514
 
1394
- const autoMemoryEligible = shouldStoreAutoMemoryNote({
1395
- session: current,
1396
- source,
1397
- internal,
1398
- message: message,
1399
- response: textForPersistence,
1400
- now: Date.now(),
1401
- })
1402
- if (autoMemoryEligible) {
1403
- const storedId = storeAutoMemoryNote({
1515
+ // Fire afterChatTurn hook for all enabled plugins (memory auto-save, logging, etc.)
1516
+ try {
1517
+ await getPluginManager().runHook('afterChatTurn', {
1404
1518
  session: current,
1405
- message: message,
1519
+ message,
1406
1520
  response: textForPersistence,
1407
1521
  source,
1408
- now: Date.now(),
1409
- })
1410
- if (storedId) changed = true
1411
- }
1522
+ internal,
1523
+ }, { enabledIds: pluginsForRun })
1524
+ } catch { /* afterChatTurn hooks are non-critical */ }
1412
1525
 
1413
1526
  // Don't extend idle timeout for heartbeat runs — only user-initiated activity counts
1414
- if (source !== 'heartbeat' && source !== 'heartbeat-wake' && source !== 'main-loop-followup') {
1527
+ if (!isHeartbeatSource(source)) {
1415
1528
  current.lastActiveAt = Date.now()
1416
1529
  }
1530
+
1531
+ refreshSessionIdentityState(current, currentAgent)
1532
+ changed = true
1533
+ try {
1534
+ const archiveSync = syncSessionArchiveMemory(current, { agent: currentAgent })
1535
+ if (archiveSync.stored) changed = true
1536
+ } catch { /* archive sync is best-effort */ }
1417
1537
  fresh[sessionId] = current
1418
1538
  saveSessions(fresh)
1419
1539
  notify(`messages:${sessionId}`)
@@ -1424,7 +1544,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1424
1544
  sessionId,
1425
1545
  text: finalText,
1426
1546
  persisted: shouldPersistAssistant,
1427
- toolEvents,
1547
+ toolEvents: persistedToolEvents,
1428
1548
  error: errorMessage,
1429
1549
  inputTokens: accumulatedUsage.inputTokens || undefined,
1430
1550
  outputTokens: accumulatedUsage.outputTokens || undefined,