@swarmclawai/swarmclaw 0.7.6 → 0.7.8

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 (86) hide show
  1. package/README.md +19 -10
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +16 -0
  4. package/src/app/api/agents/route.ts +2 -0
  5. package/src/app/api/chats/[id]/route.ts +21 -1
  6. package/src/app/api/chats/route.ts +13 -1
  7. package/src/app/api/connectors/[id]/route.ts +20 -2
  8. package/src/app/api/connectors/route.ts +12 -8
  9. package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
  10. package/src/app/api/external-agents/[id]/route.ts +38 -6
  11. package/src/app/api/external-agents/route.ts +17 -1
  12. package/src/app/api/gateways/[id]/health/route.ts +8 -0
  13. package/src/app/api/gateways/[id]/route.ts +53 -1
  14. package/src/app/api/gateways/route.ts +53 -0
  15. package/src/app/api/openclaw/deploy/route.ts +139 -0
  16. package/src/app/api/projects/[id]/route.ts +6 -2
  17. package/src/app/api/projects/route.ts +4 -3
  18. package/src/app/api/secrets/[id]/route.ts +1 -0
  19. package/src/app/api/secrets/route.ts +2 -1
  20. package/src/app/api/settings/route.ts +2 -0
  21. package/src/cli/index.js +40 -0
  22. package/src/cli/index.test.js +68 -0
  23. package/src/cli/spec.js +60 -0
  24. package/src/components/agents/agent-sheet.tsx +281 -33
  25. package/src/components/auth/setup-wizard.tsx +75 -2
  26. package/src/components/chat/chat-area.tsx +36 -19
  27. package/src/components/chat/chat-header.tsx +4 -0
  28. package/src/components/chat/delegation-banner.test.ts +14 -1
  29. package/src/components/chat/delegation-banner.tsx +1 -1
  30. package/src/components/gateways/gateway-sheet.tsx +140 -8
  31. package/src/components/layout/app-layout.tsx +40 -23
  32. package/src/components/openclaw/openclaw-deploy-panel.tsx +591 -9
  33. package/src/components/projects/project-detail.tsx +217 -0
  34. package/src/components/projects/project-sheet.tsx +176 -4
  35. package/src/components/providers/provider-list.tsx +221 -17
  36. package/src/components/shared/settings/section-capability-policy.tsx +38 -0
  37. package/src/components/shared/settings/section-voice.tsx +11 -3
  38. package/src/components/tasks/approvals-panel.tsx +177 -18
  39. package/src/components/tasks/task-board.tsx +137 -23
  40. package/src/components/tasks/task-card.tsx +29 -0
  41. package/src/components/tasks/task-sheet.tsx +16 -4
  42. package/src/lib/server/agent-runtime-config.ts +142 -7
  43. package/src/lib/server/agent-thread-session.ts +9 -1
  44. package/src/lib/server/capability-router.test.ts +22 -0
  45. package/src/lib/server/capability-router.ts +54 -18
  46. package/src/lib/server/chat-execution.ts +33 -3
  47. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  48. package/src/lib/server/connectors/manager.ts +99 -74
  49. package/src/lib/server/daemon-state.ts +83 -46
  50. package/src/lib/server/elevenlabs.test.ts +59 -1
  51. package/src/lib/server/heartbeat-service.ts +5 -1
  52. package/src/lib/server/main-agent-loop.test.ts +260 -0
  53. package/src/lib/server/main-agent-loop.ts +559 -14
  54. package/src/lib/server/openclaw-deploy.test.ts +8 -0
  55. package/src/lib/server/openclaw-deploy.ts +679 -19
  56. package/src/lib/server/orchestrator-lg.ts +1 -0
  57. package/src/lib/server/orchestrator.ts +11 -0
  58. package/src/lib/server/plugins.ts +6 -1
  59. package/src/lib/server/project-context.ts +162 -0
  60. package/src/lib/server/project-utils.ts +150 -0
  61. package/src/lib/server/queue-followups.test.ts +147 -2
  62. package/src/lib/server/queue.ts +278 -8
  63. package/src/lib/server/session-run-manager.ts +31 -0
  64. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  65. package/src/lib/server/session-tools/connector.ts +26 -1
  66. package/src/lib/server/session-tools/context.ts +5 -0
  67. package/src/lib/server/session-tools/crud.ts +265 -76
  68. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  69. package/src/lib/server/session-tools/delegate.ts +38 -2
  70. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  71. package/src/lib/server/session-tools/memory.ts +14 -2
  72. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  73. package/src/lib/server/session-tools/platform.ts +60 -19
  74. package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
  75. package/src/lib/server/session-tools/web.ts +153 -6
  76. package/src/lib/server/stream-agent-chat.test.ts +27 -2
  77. package/src/lib/server/stream-agent-chat.ts +104 -30
  78. package/src/lib/server/tool-aliases.ts +2 -0
  79. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  80. package/src/lib/server/tool-capability-policy.ts +29 -1
  81. package/src/lib/server/tool-planning.test.ts +44 -0
  82. package/src/lib/server/tool-planning.ts +269 -0
  83. package/src/lib/setup-defaults.ts +2 -2
  84. package/src/lib/tool-definitions.ts +2 -1
  85. package/src/lib/validation/schemas.ts +9 -0
  86. package/src/types/index.ts +104 -0
@@ -1,6 +1,15 @@
1
- import type { GoalContract, MessageToolEvent } from '@/types'
1
+ import type { GoalContract, Message, MessageToolEvent, Session } from '@/types'
2
+ import { mergeGoalContracts, parseGoalContractFromText, parseMainLoopPlan, parseMainLoopReview } from './autonomy-contract'
3
+ import { enqueueSystemEvent } from './system-events'
4
+ import { loadSessions, loadSettings } from './storage'
2
5
 
3
6
  const LEGACY_META_LINE_RE = /\[(?:MAIN_LOOP_META|MAIN_LOOP_PLAN|MAIN_LOOP_REVIEW|AGENT_HEARTBEAT_META)\]\s*(\{[^\n]*\})?/i
7
+ const HEARTBEAT_META_RE = /\[AGENT_HEARTBEAT_META\]\s*(\{[^\n]*\})/i
8
+ const MAX_PENDING_EVENTS = 16
9
+ const MAX_TIMELINE_ITEMS = 40
10
+ const MAX_WORKING_MEMORY_NOTES = 12
11
+ const DEFAULT_FOLLOWUP_DELAY_MS = 1500
12
+ const DEFAULT_MAX_FOLLOWUP_CHAIN = 3
4
13
 
5
14
  export interface MainLoopState {
6
15
  goal: string | null
@@ -66,14 +75,422 @@ export interface HandleMainLoopRunResultInput {
66
75
  estimatedCost?: number
67
76
  }
68
77
 
78
+ type MainSessionLike = Partial<Session> & Record<string, unknown>
79
+
80
+ const globalKey = '__swarmclaw_main_loop_state__' as const
81
+ const globalScope = globalThis as typeof globalThis & { [globalKey]?: Map<string, MainLoopState> }
82
+ const stateMap = globalScope[globalKey] ?? (globalScope[globalKey] = new Map<string, MainLoopState>())
83
+
84
+ function now(): number {
85
+ return Date.now()
86
+ }
87
+
88
+ function asSession(session: unknown): MainSessionLike | null {
89
+ if (!session || typeof session !== 'object' || Array.isArray(session)) return null
90
+ return session as MainSessionLike
91
+ }
92
+
93
+ function cleanText(value: unknown, maxChars = 320): string | null {
94
+ if (typeof value !== 'string') return null
95
+ const normalized = value.replace(/\s+/g, ' ').trim()
96
+ return normalized ? normalized.slice(0, maxChars) : null
97
+ }
98
+
99
+ function cleanMultiline(value: unknown, maxChars = 1400): string | null {
100
+ if (typeof value !== 'string') return null
101
+ const normalized = value
102
+ .split('\n')
103
+ .map((line) => line.trim())
104
+ .filter(Boolean)
105
+ .join('\n')
106
+ .slice(0, maxChars)
107
+ .trim()
108
+ return normalized || null
109
+ }
110
+
111
+ function normalizeConfidence(value: unknown): number | null {
112
+ const raw = typeof value === 'number'
113
+ ? value
114
+ : typeof value === 'string'
115
+ ? Number.parseFloat(value)
116
+ : Number.NaN
117
+ if (!Number.isFinite(raw)) return null
118
+ return Math.max(0, Math.min(1, raw))
119
+ }
120
+
121
+ function defaultState(): MainLoopState {
122
+ return {
123
+ goal: null,
124
+ goalContract: null,
125
+ summary: null,
126
+ nextAction: null,
127
+ planSteps: [],
128
+ currentPlanStep: null,
129
+ reviewNote: null,
130
+ reviewConfidence: null,
131
+ missionTaskId: null,
132
+ momentumScore: 0,
133
+ paused: false,
134
+ status: 'idle',
135
+ autonomyMode: 'assist',
136
+ pendingEvents: [],
137
+ timeline: [],
138
+ missionTokens: 0,
139
+ missionCostUsd: 0,
140
+ followupChainCount: 0,
141
+ metaMissCount: 0,
142
+ workingMemoryNotes: [],
143
+ lastMemoryNoteAt: null,
144
+ lastPlannedAt: null,
145
+ lastReviewedAt: null,
146
+ lastTickAt: null,
147
+ updatedAt: now(),
148
+ }
149
+ }
150
+
151
+ function normalizeStatus(value: unknown, fallback: MainLoopState['status'] = 'idle'): MainLoopState['status'] {
152
+ return value === 'progress' || value === 'blocked' || value === 'ok' || value === 'idle'
153
+ ? value
154
+ : fallback
155
+ }
156
+
157
+ function normalizeAutonomyMode(value: unknown, fallback: MainLoopState['autonomyMode'] = 'assist'): MainLoopState['autonomyMode'] {
158
+ return value === 'autonomous' || value === 'assist' ? value : fallback
159
+ }
160
+
161
+ function uniqueStrings(values: string[], maxItems: number): string[] {
162
+ const seen = new Set<string>()
163
+ const out: string[] = []
164
+ for (const value of values) {
165
+ const normalized = cleanText(value, 280)
166
+ if (!normalized) continue
167
+ const key = normalized.toLowerCase()
168
+ if (seen.has(key)) continue
169
+ seen.add(key)
170
+ out.push(normalized)
171
+ if (out.length >= maxItems) break
172
+ }
173
+ return out
174
+ }
175
+
176
+ function normalizePendingEvents(value: unknown): MainLoopState['pendingEvents'] {
177
+ if (!Array.isArray(value)) return []
178
+ const out: MainLoopState['pendingEvents'] = []
179
+ for (const entry of value) {
180
+ if (!entry || typeof entry !== 'object') continue
181
+ const record = entry as Record<string, unknown>
182
+ const text = cleanText(record.text, 320)
183
+ if (!text) continue
184
+ out.push({
185
+ id: typeof record.id === 'string' && record.id.trim() ? record.id.trim() : `evt-${out.length + 1}`,
186
+ type: typeof record.type === 'string' && record.type.trim() ? record.type.trim() : 'event',
187
+ text,
188
+ createdAt: typeof record.createdAt === 'number' && Number.isFinite(record.createdAt)
189
+ ? Math.trunc(record.createdAt)
190
+ : now(),
191
+ })
192
+ if (out.length >= MAX_PENDING_EVENTS) break
193
+ }
194
+ return out
195
+ }
196
+
197
+ function normalizeTimeline(value: unknown): MainLoopState['timeline'] {
198
+ if (!Array.isArray(value)) return []
199
+ const out: MainLoopState['timeline'] = []
200
+ for (const entry of value) {
201
+ if (!entry || typeof entry !== 'object') continue
202
+ const record = entry as Record<string, unknown>
203
+ const note = cleanText(record.note, 320)
204
+ if (!note) continue
205
+ const status = record.status === 'idle'
206
+ || record.status === 'progress'
207
+ || record.status === 'blocked'
208
+ || record.status === 'ok'
209
+ || record.status === 'reflection'
210
+ ? record.status
211
+ : undefined
212
+ out.push({
213
+ id: typeof record.id === 'string' && record.id.trim() ? record.id.trim() : `tl-${out.length + 1}`,
214
+ at: typeof record.at === 'number' && Number.isFinite(record.at) ? Math.trunc(record.at) : now(),
215
+ source: typeof record.source === 'string' && record.source.trim() ? record.source.trim() : 'state',
216
+ note,
217
+ status,
218
+ })
219
+ if (out.length >= MAX_TIMELINE_ITEMS) break
220
+ }
221
+ return out
222
+ }
223
+
224
+ function parseHeartbeatMeta(text: string): { goal?: string; status?: MainLoopState['status']; summary?: string; nextAction?: string } | null {
225
+ const match = (text || '').match(HEARTBEAT_META_RE)
226
+ if (!match) return null
227
+ try {
228
+ const parsed = JSON.parse(match[1]) as Record<string, unknown>
229
+ const payload: { goal?: string; status?: MainLoopState['status']; summary?: string; nextAction?: string } = {}
230
+ const goal = cleanText(parsed.goal, 400)
231
+ const summary = cleanText(parsed.summary, 500)
232
+ const nextAction = cleanText(parsed.next_action, 240)
233
+ if (goal) payload.goal = goal
234
+ if (summary) payload.summary = summary
235
+ if (nextAction) payload.nextAction = nextAction
236
+ if (parsed.status === 'idle' || parsed.status === 'progress' || parsed.status === 'blocked' || parsed.status === 'ok') {
237
+ payload.status = normalizeStatus(parsed.status, 'idle')
238
+ }
239
+ return Object.keys(payload).length > 0 ? payload : null
240
+ } catch {
241
+ return null
242
+ }
243
+ }
244
+
245
+ function clampState(state: MainLoopState): MainLoopState {
246
+ state.planSteps = uniqueStrings(state.planSteps || [], 8)
247
+ state.workingMemoryNotes = uniqueStrings(state.workingMemoryNotes || [], MAX_WORKING_MEMORY_NOTES)
248
+ state.pendingEvents = normalizePendingEvents(state.pendingEvents).slice(-MAX_PENDING_EVENTS)
249
+ state.timeline = normalizeTimeline(state.timeline).slice(-MAX_TIMELINE_ITEMS)
250
+ state.goal = cleanText(state.goal, 500)
251
+ state.summary = cleanText(state.summary, 1000)
252
+ state.nextAction = cleanText(state.nextAction, 240)
253
+ state.currentPlanStep = cleanText(state.currentPlanStep, 240)
254
+ state.reviewNote = cleanText(state.reviewNote, 320)
255
+ state.reviewConfidence = normalizeConfidence(state.reviewConfidence)
256
+ state.momentumScore = Math.max(-10, Math.min(10, Math.trunc(state.momentumScore || 0)))
257
+ state.followupChainCount = Math.max(0, Math.min(10, Math.trunc(state.followupChainCount || 0)))
258
+ state.metaMissCount = Math.max(0, Math.min(100, Math.trunc(state.metaMissCount || 0)))
259
+ state.missionTokens = Math.max(0, Math.trunc(state.missionTokens || 0))
260
+ state.missionCostUsd = Math.max(0, Number.isFinite(state.missionCostUsd) ? Number(state.missionCostUsd) : 0)
261
+ state.updatedAt = typeof state.updatedAt === 'number' && Number.isFinite(state.updatedAt) ? Math.trunc(state.updatedAt) : now()
262
+ return state
263
+ }
264
+
265
+ function normalizeState(input?: Partial<MainLoopState> | null): MainLoopState {
266
+ const next = defaultState()
267
+ if (input) {
268
+ if (input.goalContract) next.goalContract = input.goalContract
269
+ if (typeof input.goal === 'string' || input.goal === null) next.goal = input.goal
270
+ if (typeof input.summary === 'string' || input.summary === null) next.summary = input.summary
271
+ if (typeof input.nextAction === 'string' || input.nextAction === null) next.nextAction = input.nextAction
272
+ if (Array.isArray(input.planSteps)) next.planSteps = [...input.planSteps]
273
+ if (typeof input.currentPlanStep === 'string' || input.currentPlanStep === null) next.currentPlanStep = input.currentPlanStep
274
+ if (typeof input.reviewNote === 'string' || input.reviewNote === null) next.reviewNote = input.reviewNote
275
+ if (typeof input.reviewConfidence === 'number' || typeof input.reviewConfidence === 'string' || input.reviewConfidence === null) {
276
+ next.reviewConfidence = normalizeConfidence(input.reviewConfidence)
277
+ }
278
+ if (typeof input.missionTaskId === 'string' || input.missionTaskId === null) next.missionTaskId = input.missionTaskId
279
+ if (typeof input.momentumScore === 'number') next.momentumScore = input.momentumScore
280
+ if (typeof input.paused === 'boolean') next.paused = input.paused
281
+ if (input.status) next.status = normalizeStatus(input.status, next.status)
282
+ if (input.autonomyMode) next.autonomyMode = normalizeAutonomyMode(input.autonomyMode, next.autonomyMode)
283
+ if (Array.isArray(input.pendingEvents)) next.pendingEvents = [...input.pendingEvents]
284
+ if (Array.isArray(input.timeline)) next.timeline = [...input.timeline]
285
+ if (typeof input.missionTokens === 'number') next.missionTokens = input.missionTokens
286
+ if (typeof input.missionCostUsd === 'number') next.missionCostUsd = input.missionCostUsd
287
+ if (typeof input.followupChainCount === 'number') next.followupChainCount = input.followupChainCount
288
+ if (typeof input.metaMissCount === 'number') next.metaMissCount = input.metaMissCount
289
+ if (Array.isArray(input.workingMemoryNotes)) next.workingMemoryNotes = [...input.workingMemoryNotes]
290
+ if (typeof input.lastMemoryNoteAt === 'number' || input.lastMemoryNoteAt === null) next.lastMemoryNoteAt = input.lastMemoryNoteAt ?? null
291
+ if (typeof input.lastPlannedAt === 'number' || input.lastPlannedAt === null) next.lastPlannedAt = input.lastPlannedAt ?? null
292
+ if (typeof input.lastReviewedAt === 'number' || input.lastReviewedAt === null) next.lastReviewedAt = input.lastReviewedAt ?? null
293
+ if (typeof input.lastTickAt === 'number' || input.lastTickAt === null) next.lastTickAt = input.lastTickAt ?? null
294
+ if (typeof input.updatedAt === 'number') next.updatedAt = input.updatedAt
295
+ }
296
+ return clampState(next)
297
+ }
298
+
299
+ function appendTimeline(state: MainLoopState, source: string, note: string, status?: MainLoopState['timeline'][number]['status']): void {
300
+ const cleaned = cleanText(note, 320)
301
+ if (!cleaned) return
302
+ const previous = state.timeline.at(-1)
303
+ if (previous && previous.source === source && previous.note === cleaned) return
304
+ state.timeline.push({
305
+ id: `tl-${now()}-${state.timeline.length + 1}`,
306
+ at: now(),
307
+ source,
308
+ note: cleaned,
309
+ status,
310
+ })
311
+ state.timeline = state.timeline.slice(-MAX_TIMELINE_ITEMS)
312
+ }
313
+
314
+ function appendWorkingMemory(state: MainLoopState, note: string): void {
315
+ const cleaned = cleanText(note, 240)
316
+ if (!cleaned) return
317
+ state.workingMemoryNotes = uniqueStrings([...(state.workingMemoryNotes || []), cleaned], MAX_WORKING_MEMORY_NOTES)
318
+ state.lastMemoryNoteAt = now()
319
+ }
320
+
321
+ function extractLatestGoal(messages: Message[]): { goal: string | null; goalContract: GoalContract | null } {
322
+ let goal: string | null = null
323
+ let goalContract: GoalContract | null = null
324
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
325
+ const message = messages[index]
326
+ if (message.role !== 'user') continue
327
+ const text = cleanMultiline(message.text, 900)
328
+ if (!text) continue
329
+ goal = text
330
+ goalContract = mergeGoalContracts(goalContract, parseGoalContractFromText(text))
331
+ break
332
+ }
333
+ return { goal, goalContract }
334
+ }
335
+
336
+ function hydrateStateFromSession(sessionId: string): MainLoopState | null {
337
+ const sessions = loadSessions()
338
+ const session = sessions[sessionId]
339
+ if (!session || !isMainSession(session)) return null
340
+
341
+ const messages = Array.isArray(session.messages) ? session.messages : []
342
+ const hydrated = defaultState()
343
+ hydrated.autonomyMode = session.heartbeatEnabled === true ? 'autonomous' : 'assist'
344
+ hydrated.updatedAt = typeof session.lastActiveAt === 'number' ? session.lastActiveAt : now()
345
+
346
+ const initial = extractLatestGoal(messages)
347
+ hydrated.goal = initial.goal
348
+ hydrated.goalContract = initial.goalContract
349
+
350
+ for (const message of messages) {
351
+ if (message.role !== 'assistant' || typeof message.text !== 'string') continue
352
+ const heartbeat = parseHeartbeatMeta(message.text)
353
+ if (heartbeat?.goal) hydrated.goal = heartbeat.goal
354
+ if (heartbeat?.summary) hydrated.summary = heartbeat.summary
355
+ if (heartbeat?.nextAction) hydrated.nextAction = heartbeat.nextAction
356
+ if (heartbeat?.status) hydrated.status = heartbeat.status
357
+
358
+ const plan = parseMainLoopPlan(message.text)
359
+ if (plan?.steps?.length) hydrated.planSteps = plan.steps
360
+ if (plan?.current_step) hydrated.currentPlanStep = plan.current_step
361
+ if (plan) hydrated.lastPlannedAt = typeof message.time === 'number' ? message.time : hydrated.lastPlannedAt
362
+
363
+ const review = parseMainLoopReview(message.text)
364
+ if (review?.note) hydrated.reviewNote = review.note
365
+ if (typeof review?.confidence === 'number') hydrated.reviewConfidence = review.confidence
366
+ if (review) hydrated.lastReviewedAt = typeof message.time === 'number' ? message.time : hydrated.lastReviewedAt
367
+
368
+ const stripped = stripMainLoopMetaForPersistence(message.text)
369
+ if (stripped && !/^HEARTBEAT_OK$/i.test(stripped) && !/^NO_MESSAGE$/i.test(stripped)) {
370
+ hydrated.summary = cleanText(stripped, 1000) || hydrated.summary
371
+ }
372
+ if (Array.isArray(message.toolEvents) && message.toolEvents.length > 0) {
373
+ const toolNames = uniqueStrings(message.toolEvents.map((event: MessageToolEvent) => event.name || '').filter(Boolean), 4)
374
+ if (toolNames.length > 0) appendWorkingMemory(hydrated, `Recent tools: ${toolNames.join(', ')}`)
375
+ }
376
+ }
377
+
378
+ return normalizeState(hydrated)
379
+ }
380
+
381
+ function getOrCreateState(sessionId: string): MainLoopState | null {
382
+ const existing = stateMap.get(sessionId)
383
+ if (existing) return existing
384
+ const hydrated = hydrateStateFromSession(sessionId)
385
+ if (!hydrated) return null
386
+ stateMap.set(sessionId, hydrated)
387
+ return hydrated
388
+ }
389
+
390
+ function summarizePendingEvents(events: MainLoopState['pendingEvents']): string {
391
+ if (!events.length) return ''
392
+ return events
393
+ .slice(-6)
394
+ .map((event) => `- [${new Date(event.createdAt).toISOString()}] (${event.type}) ${event.text}`)
395
+ .join('\n')
396
+ }
397
+
398
+ function summarizeTimeline(items: MainLoopState['timeline']): string {
399
+ if (!items.length) return ''
400
+ return items
401
+ .slice(-6)
402
+ .map((entry) => `- [${new Date(entry.at).toISOString()}] ${entry.source}: ${entry.note}`)
403
+ .join('\n')
404
+ }
405
+
406
+ function formatGoalContract(goalContract: GoalContract | null): string {
407
+ if (!goalContract) return ''
408
+ const lines = [`Objective: ${goalContract.objective}`]
409
+ if (goalContract.constraints?.length) lines.push(`Constraints: ${goalContract.constraints.join(' | ')}`)
410
+ if (typeof goalContract.budgetUsd === 'number') lines.push(`Budget: $${goalContract.budgetUsd}`)
411
+ if (typeof goalContract.deadlineAt === 'number') lines.push(`Deadline: ${new Date(goalContract.deadlineAt).toISOString()}`)
412
+ if (goalContract.successMetric) lines.push(`Success metric: ${goalContract.successMetric}`)
413
+ return lines.join('\n')
414
+ }
415
+
416
+ function extractWaitSignal(text: string, toolEvents: MessageToolEvent[]): boolean {
417
+ const haystack = `${text}\n${toolEvents.map((event) => `${event.name} ${event.input || ''} ${event.output || ''}`).join('\n')}`
418
+ return /\b(wait for|waiting for|approval|human reply|mailbox|watch job|pending approval)\b/i.test(haystack)
419
+ }
420
+
421
+ function followupLimit(): number {
422
+ const settings = loadSettings()
423
+ const raw = settings.maxFollowupChain
424
+ const parsed = typeof raw === 'number'
425
+ ? raw
426
+ : typeof raw === 'string'
427
+ ? Number.parseInt(raw, 10)
428
+ : Number.NaN
429
+ if (!Number.isFinite(parsed)) return DEFAULT_MAX_FOLLOWUP_CHAIN
430
+ return Math.max(0, Math.min(12, Math.trunc(parsed)))
431
+ }
432
+
433
+ function eventStatusForType(type: string): MainLoopState['status'] {
434
+ if (/fail|error|approval/i.test(type)) return 'blocked'
435
+ if (/complete|done|ok|success/i.test(type)) return 'ok'
436
+ return 'progress'
437
+ }
438
+
69
439
  export function isMainSession(session: unknown): boolean {
70
- void session
71
- return false
440
+ const candidate = asSession(session)
441
+ if (!candidate) return false
442
+ if (typeof candidate.parentSessionId === 'string' && candidate.parentSessionId.trim()) return false
443
+ const sessionType = typeof (candidate as Record<string, unknown>).sessionType === 'string'
444
+ ? (candidate as Record<string, unknown>).sessionType
445
+ : null
446
+ if (sessionType === 'orchestrated') return false
447
+ const hasAgent = typeof candidate.agentId === 'string' && candidate.agentId.trim().length > 0
448
+ if (!hasAgent) return false
449
+ const shortcutThread = typeof candidate.shortcutForAgentId === 'string' && candidate.shortcutForAgentId.trim().length > 0
450
+ const connectorScope = typeof candidate.connectorSessionScope === 'string' && candidate.connectorSessionScope === 'main'
451
+ const contextScope = candidate.connectorContext && typeof candidate.connectorContext === 'object'
452
+ ? (candidate.connectorContext as Record<string, unknown>).scope === 'main'
453
+ : false
454
+ const heartbeatOptIn = candidate.heartbeatEnabled === true
455
+ return shortcutThread || connectorScope || contextScope || heartbeatOptIn
72
456
  }
73
457
 
74
458
  export function buildMainLoopHeartbeatPrompt(session: unknown, fallbackPrompt: string): string {
75
- void session
76
- return fallbackPrompt
459
+ const candidate = asSession(session)
460
+ if (!candidate?.id) return fallbackPrompt
461
+ const state = getOrCreateState(String(candidate.id))
462
+ if (!state) return fallbackPrompt
463
+
464
+ const planLines = state.planSteps.length > 0
465
+ ? state.planSteps.map((step, index) => `${index + 1}. ${step}`).join('\n')
466
+ : ''
467
+
468
+ return [
469
+ 'MAIN_AGENT_HEARTBEAT_TICK',
470
+ `Time: ${new Date().toISOString()}`,
471
+ state.goal ? `Current goal:\n${state.goal}` : '',
472
+ formatGoalContract(state.goalContract),
473
+ `Autonomy mode: ${state.autonomyMode}`,
474
+ `Current status: ${state.status}`,
475
+ state.summary ? `Latest summary:\n${state.summary}` : '',
476
+ state.nextAction ? `Planned next action: ${state.nextAction}` : '',
477
+ state.currentPlanStep ? `Current plan step: ${state.currentPlanStep}` : '',
478
+ planLines ? `Plan:\n${planLines}` : '',
479
+ state.pendingEvents.length > 0 ? `Pending external events:\n${summarizePendingEvents(state.pendingEvents)}` : '',
480
+ state.timeline.length > 0 ? `Recent timeline:\n${summarizeTimeline(state.timeline)}` : '',
481
+ state.workingMemoryNotes.length > 0 ? `Working memory:\n- ${state.workingMemoryNotes.join('\n- ')}` : '',
482
+ fallbackPrompt ? `Base heartbeat instructions:\n${fallbackPrompt}` : '',
483
+ '',
484
+ 'You are the durable main mission thread for this agent.',
485
+ 'Use the goal, plan, and pending events above to decide the highest-value next step.',
486
+ 'Prefer acting with tools over restating the plan. Do not repeat completed work.',
487
+ 'If you revise the plan, emit exactly one line like:',
488
+ '[MAIN_LOOP_PLAN]{"steps":["step 1","step 2"],"current_step":"step 1"}',
489
+ 'After acting, emit exactly one review line like:',
490
+ '[MAIN_LOOP_REVIEW]{"note":"what changed","confidence":0.72,"needs_replan":false}',
491
+ 'If you are actively progressing, also emit [AGENT_HEARTBEAT_META] with goal/status/next_action.',
492
+ 'Reply HEARTBEAT_OK only when nothing needs action right now.',
493
+ ].filter(Boolean).join('\n')
77
494
  }
78
495
 
79
496
  export function stripMainLoopMetaForPersistence(text: string): string {
@@ -85,22 +502,150 @@ export function stripMainLoopMetaForPersistence(text: string): string {
85
502
  }
86
503
 
87
504
  export function getMainLoopStateForSession(sessionId: string): MainLoopState | null {
88
- void sessionId
89
- return null
505
+ const state = getOrCreateState(sessionId)
506
+ return state ? normalizeState(state) : null
90
507
  }
91
508
 
92
509
  export function setMainLoopStateForSession(sessionId: string, patch: Partial<MainLoopState>): MainLoopState | null {
93
- void sessionId
94
- void patch
95
- return null
510
+ const current = getOrCreateState(sessionId)
511
+ if (!current) return null
512
+ const next = normalizeState({
513
+ ...current,
514
+ ...patch,
515
+ planSteps: patch.planSteps ?? current.planSteps,
516
+ pendingEvents: patch.pendingEvents ?? current.pendingEvents,
517
+ timeline: patch.timeline ?? current.timeline,
518
+ workingMemoryNotes: patch.workingMemoryNotes ?? current.workingMemoryNotes,
519
+ updatedAt: now(),
520
+ })
521
+ stateMap.set(sessionId, next)
522
+ return normalizeState(next)
96
523
  }
97
524
 
98
525
  export function pushMainLoopEventToMainSessions(input: PushMainLoopEventInput): number {
99
- void input
100
- return 0
526
+ const text = cleanText(input.text, 320)
527
+ if (!text) return 0
528
+ const sessions = loadSessions()
529
+ const nowTs = now()
530
+ let count = 0
531
+
532
+ for (const session of Object.values(sessions)) {
533
+ if (!isMainSession(session)) continue
534
+ const state = getOrCreateState(session.id)
535
+ if (!state) continue
536
+
537
+ const eventText = input.user ? `${input.user}: ${text}` : text
538
+ const previous = state.pendingEvents.at(-1)
539
+ if (!previous || previous.type !== input.type || previous.text !== eventText) {
540
+ state.pendingEvents.push({
541
+ id: `evt-${nowTs}-${state.pendingEvents.length + 1}`,
542
+ type: input.type || 'event',
543
+ text: eventText,
544
+ createdAt: nowTs,
545
+ })
546
+ state.pendingEvents = state.pendingEvents.slice(-MAX_PENDING_EVENTS)
547
+ }
548
+ state.status = eventStatusForType(input.type || 'event')
549
+ appendTimeline(state, input.type || 'event', eventText, state.status)
550
+ state.updatedAt = nowTs
551
+ stateMap.set(session.id, clampState(state))
552
+ enqueueSystemEvent(session.id, `[Main loop] ${eventText}`, `main-loop:${input.type || 'event'}`)
553
+ count += 1
554
+ }
555
+
556
+ return count
101
557
  }
102
558
 
103
559
  export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): MainLoopFollowupRequest | null {
104
- void input
105
- return null
560
+ const state = getOrCreateState(input.sessionId)
561
+ if (!state) return null
562
+
563
+ const resultText = input.resultText || ''
564
+ const persistedText = stripMainLoopMetaForPersistence(resultText)
565
+ const toolEvents = Array.isArray(input.toolEvents) ? input.toolEvents : []
566
+ const toolNames = uniqueStrings(toolEvents.map((event) => event.name || '').filter(Boolean), 8)
567
+ const heartbeat = parseHeartbeatMeta(resultText)
568
+ const plan = parseMainLoopPlan(resultText)
569
+ const review = parseMainLoopReview(resultText)
570
+ const messageGoal = parseGoalContractFromText(input.message || '')
571
+ const nowTs = now()
572
+
573
+ state.goalContract = mergeGoalContracts(state.goalContract, messageGoal)
574
+ if (!state.goal) state.goal = cleanMultiline(input.message, 900)
575
+ if (heartbeat?.goal) state.goal = heartbeat.goal
576
+ if (heartbeat?.summary) state.summary = heartbeat.summary
577
+ if (heartbeat?.nextAction) state.nextAction = heartbeat.nextAction
578
+ if (heartbeat?.status) state.status = heartbeat.status
579
+
580
+ if (plan?.steps?.length) state.planSteps = plan.steps
581
+ if (plan?.current_step) state.currentPlanStep = plan.current_step
582
+ if (plan) state.lastPlannedAt = nowTs
583
+
584
+ if (review?.note) state.reviewNote = review.note
585
+ if (typeof review?.confidence === 'number') state.reviewConfidence = review.confidence
586
+ if (review) state.lastReviewedAt = nowTs
587
+
588
+ if (toolNames.length > 0) {
589
+ appendWorkingMemory(state, `Used tools: ${toolNames.join(', ')}`)
590
+ state.momentumScore = Math.min(10, state.momentumScore + 1)
591
+ } else if (persistedText && !/^HEARTBEAT_OK$/i.test(persistedText) && !/^NO_MESSAGE$/i.test(persistedText)) {
592
+ state.momentumScore = Math.min(10, state.momentumScore + 1)
593
+ } else {
594
+ state.momentumScore = Math.max(-10, state.momentumScore - 1)
595
+ }
596
+
597
+ if (persistedText && !/^HEARTBEAT_OK$/i.test(persistedText) && !/^NO_MESSAGE$/i.test(persistedText)) {
598
+ state.summary = cleanText(persistedText, 1000) || state.summary
599
+ appendTimeline(state, input.source || 'run', persistedText, input.error ? 'blocked' : state.status)
600
+ }
601
+
602
+ if (input.error) {
603
+ state.status = 'blocked'
604
+ appendTimeline(state, input.source || 'run', `Error: ${input.error}`, 'blocked')
605
+ }
606
+
607
+ state.lastTickAt = nowTs
608
+ state.updatedAt = nowTs
609
+ state.missionTokens += Math.max(0, Math.trunc((input.inputTokens || 0) + (input.outputTokens || 0)))
610
+ state.missionCostUsd += Math.max(0, Number(input.estimatedCost || 0))
611
+ state.metaMissCount = heartbeat || plan || review ? 0 : state.metaMissCount + 1
612
+
613
+ if (input.internal) {
614
+ state.pendingEvents = []
615
+ }
616
+
617
+ const cleanedResult = persistedText.trim()
618
+ const waitingForExternal = extractWaitSignal(resultText, toolEvents)
619
+ const gotTerminalAck = /^HEARTBEAT_OK$/i.test(cleanedResult) || /^NO_MESSAGE$/i.test(cleanedResult)
620
+ const needsReplan = review?.needs_replan === true || ((review?.confidence ?? 1) < 0.45)
621
+ const limit = followupLimit()
622
+
623
+ let followup: MainLoopFollowupRequest | null = null
624
+ if (!input.internal || input.source === 'chat') {
625
+ state.followupChainCount = 0
626
+ } else if (input.error || waitingForExternal || gotTerminalAck) {
627
+ state.followupChainCount = 0
628
+ if (gotTerminalAck && state.status !== 'blocked') state.status = 'ok'
629
+ } else {
630
+ const shouldContinue = needsReplan || state.status === 'progress' || (!!state.nextAction && toolNames.length > 0)
631
+ if (shouldContinue && state.followupChainCount < limit) {
632
+ state.followupChainCount += 1
633
+ const message = needsReplan
634
+ ? 'Replan from the latest outcome, then execute only the highest-value remaining step. Do not repeat completed work.'
635
+ : state.nextAction
636
+ ? `Continue the objective. Resume from this next action: ${state.nextAction}`
637
+ : 'Continue the objective and finish the next highest-value remaining step.'
638
+ followup = {
639
+ message,
640
+ delayMs: DEFAULT_FOLLOWUP_DELAY_MS,
641
+ dedupeKey: `main-loop:${input.sessionId}:${state.followupChainCount}:${state.currentPlanStep || state.nextAction || 'continue'}`,
642
+ }
643
+ appendTimeline(state, 'followup', message, 'progress')
644
+ } else {
645
+ state.followupChainCount = 0
646
+ }
647
+ }
648
+
649
+ stateMap.set(input.sessionId, clampState(state))
650
+ return followup
106
651
  }
@@ -18,12 +18,16 @@ test('docker smart deploy bundle uses official image and provider-specific metad
18
18
  assert.equal(bundle.providerLabel, 'DigitalOcean')
19
19
  assert.equal(bundle.endpoint, 'https://gateway.example.com/v1')
20
20
  assert.equal(bundle.wsUrl, 'wss://gateway.example.com')
21
+ assert.equal(bundle.useCase, 'single-vps')
22
+ assert.equal(bundle.exposure, 'caddy')
21
23
  assert.match(bundle.summary, /official OpenClaw Docker image/i)
22
24
  assert.deepEqual(bundle.files.map((file) => file.name), [
23
25
  'cloud-init.yaml',
24
26
  '.env',
25
27
  'docker-compose.yml',
26
28
  'bootstrap.sh',
29
+ 'docker-compose.proxy.yml',
30
+ 'Caddyfile',
27
31
  ])
28
32
 
29
33
  const envFile = bundle.files.find((file) => file.name === '.env')
@@ -36,6 +40,10 @@ test('docker smart deploy bundle uses official image and provider-specific metad
36
40
  assert.match(cloudInit.content, /docker\.io/)
37
41
  assert.match(cloudInit.content, /docker pull "\$\{OPENCLAW_IMAGE:-openclaw:latest\}"/)
38
42
  assert.match(cloudInit.content, /\/opt\/openclaw\/docker-compose\.yml/)
43
+
44
+ const caddyfile = bundle.files.find((file) => file.name === 'Caddyfile')
45
+ assert.ok(caddyfile)
46
+ assert.match(caddyfile.content, /gateway\.example\.com/)
39
47
  })
40
48
 
41
49
  test('render bundle stays aligned with the official repo flow', () => {