@swarmclawai/swarmclaw 0.2.0
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.
- package/README.md +577 -0
- package/bin/server-cmd.js +359 -0
- package/bin/swarmclaw.js +29 -0
- package/bin/swarmclaw.mjs +1504 -0
- package/next.config.ts +33 -0
- package/package.json +112 -0
- package/postcss.config.mjs +7 -0
- package/public/branding/swarmclaw-org-avatar.png +0 -0
- package/public/branding/swarmclaw-org-avatar.svg +58 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/screenshots/agents.png +0 -0
- package/public/screenshots/connectors.png +0 -0
- package/public/screenshots/dashboard.png +0 -0
- package/public/screenshots/new-session-openclaw.png +0 -0
- package/public/screenshots/providers.png +0 -0
- package/public/screenshots/schedules.png +0 -0
- package/public/screenshots/tasks.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/agents/[id]/route.ts +30 -0
- package/src/app/api/agents/[id]/thread/route.ts +66 -0
- package/src/app/api/agents/generate/route.ts +42 -0
- package/src/app/api/agents/route.ts +33 -0
- package/src/app/api/auth/route.ts +25 -0
- package/src/app/api/claude-skills/route.ts +42 -0
- package/src/app/api/clawhub/install/route.ts +39 -0
- package/src/app/api/clawhub/search/route.ts +11 -0
- package/src/app/api/connectors/[id]/route.ts +79 -0
- package/src/app/api/connectors/route.ts +60 -0
- package/src/app/api/credentials/[id]/route.ts +14 -0
- package/src/app/api/credentials/route.ts +31 -0
- package/src/app/api/daemon/health-check/route.ts +11 -0
- package/src/app/api/daemon/route.ts +22 -0
- package/src/app/api/dirs/pick/route.ts +60 -0
- package/src/app/api/dirs/route.ts +29 -0
- package/src/app/api/documents/[id]/route.ts +47 -0
- package/src/app/api/documents/route.ts +93 -0
- package/src/app/api/files/serve/route.ts +69 -0
- package/src/app/api/generate/info/route.ts +12 -0
- package/src/app/api/generate/route.ts +106 -0
- package/src/app/api/ip/route.ts +6 -0
- package/src/app/api/knowledge/[id]/route.ts +61 -0
- package/src/app/api/knowledge/route.ts +48 -0
- package/src/app/api/knowledge/upload/route.ts +86 -0
- package/src/app/api/logs/route.ts +65 -0
- package/src/app/api/mcp-servers/[id]/route.ts +32 -0
- package/src/app/api/mcp-servers/[id]/test/route.ts +23 -0
- package/src/app/api/mcp-servers/[id]/tools/route.ts +32 -0
- package/src/app/api/mcp-servers/route.ts +27 -0
- package/src/app/api/memory/[id]/route.ts +126 -0
- package/src/app/api/memory/maintenance/route.ts +63 -0
- package/src/app/api/memory/route.ts +111 -0
- package/src/app/api/memory-images/[filename]/route.ts +36 -0
- package/src/app/api/orchestrator/run/route.ts +43 -0
- package/src/app/api/plugins/install/route.ts +58 -0
- package/src/app/api/plugins/marketplace/route.ts +33 -0
- package/src/app/api/plugins/route.ts +21 -0
- package/src/app/api/preview-server/route.ts +339 -0
- package/src/app/api/providers/[id]/models/route.ts +29 -0
- package/src/app/api/providers/[id]/route.ts +34 -0
- package/src/app/api/providers/configs/route.ts +7 -0
- package/src/app/api/providers/ollama/route.ts +30 -0
- package/src/app/api/providers/openclaw/health/route.ts +23 -0
- package/src/app/api/providers/route.ts +28 -0
- package/src/app/api/runs/[id]/route.ts +9 -0
- package/src/app/api/runs/route.ts +13 -0
- package/src/app/api/schedules/[id]/route.ts +28 -0
- package/src/app/api/schedules/[id]/run/route.ts +104 -0
- package/src/app/api/schedules/route.ts +78 -0
- package/src/app/api/secrets/[id]/route.ts +29 -0
- package/src/app/api/secrets/route.ts +42 -0
- package/src/app/api/sessions/[id]/browser/route.ts +13 -0
- package/src/app/api/sessions/[id]/chat/route.ts +96 -0
- package/src/app/api/sessions/[id]/clear/route.ts +19 -0
- package/src/app/api/sessions/[id]/deploy/route.ts +34 -0
- package/src/app/api/sessions/[id]/devserver/route.ts +69 -0
- package/src/app/api/sessions/[id]/mailbox/route.ts +70 -0
- package/src/app/api/sessions/[id]/main-loop/route.ts +94 -0
- package/src/app/api/sessions/[id]/messages/route.ts +9 -0
- package/src/app/api/sessions/[id]/retry/route.ts +28 -0
- package/src/app/api/sessions/[id]/route.ts +103 -0
- package/src/app/api/sessions/[id]/stop/route.ts +13 -0
- package/src/app/api/sessions/heartbeat/route.ts +26 -0
- package/src/app/api/sessions/route.ts +85 -0
- package/src/app/api/settings/route.ts +58 -0
- package/src/app/api/setup/check-provider/route.ts +326 -0
- package/src/app/api/setup/doctor/route.ts +250 -0
- package/src/app/api/skills/[id]/route.ts +40 -0
- package/src/app/api/skills/import/route.ts +69 -0
- package/src/app/api/skills/route.ts +28 -0
- package/src/app/api/tasks/[id]/route.ts +102 -0
- package/src/app/api/tasks/route.ts +115 -0
- package/src/app/api/tts/route.ts +40 -0
- package/src/app/api/upload/route.ts +18 -0
- package/src/app/api/uploads/[filename]/route.ts +59 -0
- package/src/app/api/usage/route.ts +35 -0
- package/src/app/api/version/route.ts +81 -0
- package/src/app/api/version/update/route.ts +95 -0
- package/src/app/api/webhooks/[id]/history/route.ts +13 -0
- package/src/app/api/webhooks/[id]/route.ts +204 -0
- package/src/app/api/webhooks/route.ts +37 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +370 -0
- package/src/app/layout.tsx +52 -0
- package/src/app/page.tsx +172 -0
- package/src/cli/index.js +1232 -0
- package/src/cli/index.test.js +281 -0
- package/src/cli/index.ts +1158 -0
- package/src/cli/spec.js +284 -0
- package/src/components/agents/agent-card.tsx +219 -0
- package/src/components/agents/agent-chat-list.tsx +165 -0
- package/src/components/agents/agent-list.tsx +110 -0
- package/src/components/agents/agent-sheet.tsx +1220 -0
- package/src/components/auth/access-key-gate.tsx +248 -0
- package/src/components/auth/setup-wizard.tsx +940 -0
- package/src/components/auth/user-picker.tsx +88 -0
- package/src/components/chat/chat-area.tsx +406 -0
- package/src/components/chat/chat-header.tsx +491 -0
- package/src/components/chat/chat-tool-toggles.tsx +161 -0
- package/src/components/chat/code-block.tsx +146 -0
- package/src/components/chat/dev-server-bar.tsx +39 -0
- package/src/components/chat/message-bubble.tsx +486 -0
- package/src/components/chat/message-list.tsx +299 -0
- package/src/components/chat/session-debug-panel.tsx +196 -0
- package/src/components/chat/streaming-bubble.tsx +85 -0
- package/src/components/chat/thinking-indicator.tsx +26 -0
- package/src/components/chat/tool-call-bubble.tsx +438 -0
- package/src/components/chat/tool-request-banner.tsx +103 -0
- package/src/components/connectors/connector-list.tsx +196 -0
- package/src/components/connectors/connector-sheet.tsx +804 -0
- package/src/components/input/chat-input.tsx +235 -0
- package/src/components/knowledge/knowledge-list.tsx +206 -0
- package/src/components/knowledge/knowledge-sheet.tsx +316 -0
- package/src/components/layout/app-layout.tsx +1016 -0
- package/src/components/layout/daemon-indicator.tsx +56 -0
- package/src/components/layout/mobile-header.tsx +31 -0
- package/src/components/layout/network-banner.tsx +17 -0
- package/src/components/layout/update-banner.tsx +130 -0
- package/src/components/logs/log-list.tsx +358 -0
- package/src/components/mcp-servers/mcp-server-list.tsx +122 -0
- package/src/components/mcp-servers/mcp-server-sheet.tsx +243 -0
- package/src/components/memory/memory-card.tsx +63 -0
- package/src/components/memory/memory-detail.tsx +339 -0
- package/src/components/memory/memory-list.tsx +198 -0
- package/src/components/memory/memory-sheet.tsx +70 -0
- package/src/components/plugins/plugin-list.tsx +60 -0
- package/src/components/plugins/plugin-sheet.tsx +311 -0
- package/src/components/providers/provider-list.tsx +96 -0
- package/src/components/providers/provider-sheet.tsx +542 -0
- package/src/components/runs/run-list.tsx +231 -0
- package/src/components/schedules/schedule-card.tsx +63 -0
- package/src/components/schedules/schedule-list.tsx +76 -0
- package/src/components/schedules/schedule-sheet.tsx +336 -0
- package/src/components/secrets/secret-sheet.tsx +180 -0
- package/src/components/secrets/secrets-list.tsx +91 -0
- package/src/components/sessions/new-session-sheet.tsx +478 -0
- package/src/components/sessions/session-card.tsx +144 -0
- package/src/components/sessions/session-list.tsx +202 -0
- package/src/components/shared/ai-gen-block.tsx +77 -0
- package/src/components/shared/avatar.tsx +48 -0
- package/src/components/shared/bottom-sheet.tsx +30 -0
- package/src/components/shared/confirm-dialog.tsx +47 -0
- package/src/components/shared/connector-platform-icon.tsx +113 -0
- package/src/components/shared/dir-browser.tsx +285 -0
- package/src/components/shared/dropdown.tsx +55 -0
- package/src/components/shared/icon-button.tsx +25 -0
- package/src/components/shared/settings/plugin-manager.tsx +207 -0
- package/src/components/shared/settings/section-capability-policy.tsx +93 -0
- package/src/components/shared/settings/section-embedding.tsx +99 -0
- package/src/components/shared/settings/section-heartbeat.tsx +168 -0
- package/src/components/shared/settings/section-memory.tsx +77 -0
- package/src/components/shared/settings/section-orchestrator.tsx +108 -0
- package/src/components/shared/settings/section-providers.tsx +181 -0
- package/src/components/shared/settings/section-runtime-loop.tsx +183 -0
- package/src/components/shared/settings/section-secrets.tsx +132 -0
- package/src/components/shared/settings/section-user-preferences.tsx +24 -0
- package/src/components/shared/settings/section-voice.tsx +53 -0
- package/src/components/shared/settings/settings-sheet.tsx +88 -0
- package/src/components/shared/settings/types.ts +7 -0
- package/src/components/shared/settings/utils.ts +13 -0
- package/src/components/shared/settings-sheet.tsx +1 -0
- package/src/components/shared/skeleton.tsx +19 -0
- package/src/components/shared/usage-badge.tsx +28 -0
- package/src/components/skills/clawhub-browser.tsx +225 -0
- package/src/components/skills/skill-list.tsx +70 -0
- package/src/components/skills/skill-sheet.tsx +254 -0
- package/src/components/tasks/task-board.tsx +96 -0
- package/src/components/tasks/task-card.tsx +179 -0
- package/src/components/tasks/task-column.tsx +73 -0
- package/src/components/tasks/task-list.tsx +118 -0
- package/src/components/tasks/task-sheet.tsx +415 -0
- package/src/components/ui/avatar.tsx +109 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/scroll-area.tsx +58 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/sonner.tsx +22 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +56 -0
- package/src/components/usage/usage-list.tsx +105 -0
- package/src/components/webhooks/webhook-list.tsx +166 -0
- package/src/components/webhooks/webhook-sheet.tsx +402 -0
- package/src/hooks/use-auto-resize.ts +20 -0
- package/src/hooks/use-media-query.ts +21 -0
- package/src/hooks/use-speech-recognition.ts +83 -0
- package/src/instrumentation.ts +8 -0
- package/src/lib/agents.ts +13 -0
- package/src/lib/api-client.ts +100 -0
- package/src/lib/chat.ts +60 -0
- package/src/lib/memory.ts +42 -0
- package/src/lib/openclaw-endpoint.test.ts +48 -0
- package/src/lib/openclaw-endpoint.ts +67 -0
- package/src/lib/provider-config.ts +13 -0
- package/src/lib/providers/anthropic.ts +135 -0
- package/src/lib/providers/claude-cli.ts +202 -0
- package/src/lib/providers/codex-cli.ts +260 -0
- package/src/lib/providers/index.ts +351 -0
- package/src/lib/providers/ollama.ts +131 -0
- package/src/lib/providers/openai.ts +164 -0
- package/src/lib/providers/openclaw.ts +330 -0
- package/src/lib/providers/opencode-cli.ts +164 -0
- package/src/lib/runtime-loop.ts +15 -0
- package/src/lib/schedule-dedupe.test.ts +84 -0
- package/src/lib/schedule-dedupe.ts +174 -0
- package/src/lib/schedule-name.ts +62 -0
- package/src/lib/schedules.ts +16 -0
- package/src/lib/server/agent-registry.ts +70 -0
- package/src/lib/server/api-routes.test.ts +362 -0
- package/src/lib/server/autonomy-contract.ts +200 -0
- package/src/lib/server/build-llm.ts +155 -0
- package/src/lib/server/capability-router.test.ts +21 -0
- package/src/lib/server/capability-router.ts +172 -0
- package/src/lib/server/chat-execution.ts +894 -0
- package/src/lib/server/clawhub-client.test.ts +161 -0
- package/src/lib/server/clawhub-client.ts +26 -0
- package/src/lib/server/connectors/connector-routing.test.ts +243 -0
- package/src/lib/server/connectors/discord.ts +116 -0
- package/src/lib/server/connectors/googlechat.ts +66 -0
- package/src/lib/server/connectors/manager.ts +559 -0
- package/src/lib/server/connectors/matrix.ts +78 -0
- package/src/lib/server/connectors/media.ts +149 -0
- package/src/lib/server/connectors/openclaw.test.ts +375 -0
- package/src/lib/server/connectors/openclaw.ts +1132 -0
- package/src/lib/server/connectors/signal.ts +183 -0
- package/src/lib/server/connectors/slack.ts +258 -0
- package/src/lib/server/connectors/teams.ts +94 -0
- package/src/lib/server/connectors/telegram.ts +221 -0
- package/src/lib/server/connectors/types.ts +62 -0
- package/src/lib/server/connectors/whatsapp.ts +349 -0
- package/src/lib/server/context-manager.ts +232 -0
- package/src/lib/server/cost.ts +31 -0
- package/src/lib/server/daemon-state.ts +354 -0
- package/src/lib/server/data-dir.ts +3 -0
- package/src/lib/server/embeddings.ts +111 -0
- package/src/lib/server/execution-log.ts +257 -0
- package/src/lib/server/gateway/protocol.test.ts +54 -0
- package/src/lib/server/gateway/protocol.ts +114 -0
- package/src/lib/server/heartbeat-service.ts +366 -0
- package/src/lib/server/knowledge-db.test.ts +441 -0
- package/src/lib/server/logger.ts +47 -0
- package/src/lib/server/main-agent-loop.ts +1017 -0
- package/src/lib/server/mcp-client.test.ts +342 -0
- package/src/lib/server/mcp-client.ts +130 -0
- package/src/lib/server/memory-db.ts +1078 -0
- package/src/lib/server/memory-graph.test.ts +153 -0
- package/src/lib/server/memory-graph.ts +138 -0
- package/src/lib/server/openclaw-health.ts +245 -0
- package/src/lib/server/orchestrator-lg.ts +431 -0
- package/src/lib/server/orchestrator.ts +364 -0
- package/src/lib/server/playwright-proxy.mjs +70 -0
- package/src/lib/server/plugins.ts +229 -0
- package/src/lib/server/process-manager.ts +327 -0
- package/src/lib/server/provider-health.ts +113 -0
- package/src/lib/server/queue.ts +859 -0
- package/src/lib/server/runtime-settings.ts +119 -0
- package/src/lib/server/scheduler.ts +196 -0
- package/src/lib/server/session-mailbox.ts +129 -0
- package/src/lib/server/session-run-manager.ts +512 -0
- package/src/lib/server/session-tools/connector.ts +124 -0
- package/src/lib/server/session-tools/context-mgmt.ts +103 -0
- package/src/lib/server/session-tools/context.ts +114 -0
- package/src/lib/server/session-tools/crud.ts +673 -0
- package/src/lib/server/session-tools/delegate.ts +708 -0
- package/src/lib/server/session-tools/file.ts +264 -0
- package/src/lib/server/session-tools/index.ts +164 -0
- package/src/lib/server/session-tools/memory.ts +230 -0
- package/src/lib/server/session-tools/session-info.ts +422 -0
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +166 -0
- package/src/lib/server/session-tools/shell.ts +171 -0
- package/src/lib/server/session-tools/web.ts +408 -0
- package/src/lib/server/session-tools.ts +9 -0
- package/src/lib/server/skills-normalize.ts +130 -0
- package/src/lib/server/storage-mcp.test.ts +161 -0
- package/src/lib/server/storage.ts +670 -0
- package/src/lib/server/stream-agent-chat.ts +571 -0
- package/src/lib/server/task-reports.ts +122 -0
- package/src/lib/server/task-result.ts +161 -0
- package/src/lib/server/task-validation.test.ts +27 -0
- package/src/lib/server/task-validation.ts +90 -0
- package/src/lib/server/tool-capability-policy.test.ts +58 -0
- package/src/lib/server/tool-capability-policy.ts +262 -0
- package/src/lib/sessions.ts +68 -0
- package/src/lib/tasks.ts +20 -0
- package/src/lib/tts.ts +42 -0
- package/src/lib/upload.ts +10 -0
- package/src/lib/utils.ts +6 -0
- package/src/proxy.ts +43 -0
- package/src/stores/use-app-store.ts +468 -0
- package/src/stores/use-chat-store.ts +323 -0
- package/src/types/index.ts +621 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { loadAgents, loadSessions, loadSettings } from './storage'
|
|
2
|
+
import { enqueueSessionRun, getSessionRunState } from './session-run-manager'
|
|
3
|
+
import { log } from './logger'
|
|
4
|
+
import { buildMainLoopHeartbeatPrompt, getMainLoopStateForSession, isMainSession } from './main-agent-loop'
|
|
5
|
+
|
|
6
|
+
const HEARTBEAT_TICK_MS = 5_000
|
|
7
|
+
|
|
8
|
+
interface HeartbeatState {
|
|
9
|
+
timer: ReturnType<typeof setInterval> | null
|
|
10
|
+
running: boolean
|
|
11
|
+
lastBySession: Map<string, number>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const globalKey = '__swarmclaw_heartbeat_service__' as const
|
|
15
|
+
const globalScope = globalThis as typeof globalThis & { [globalKey]?: HeartbeatState }
|
|
16
|
+
const state: HeartbeatState = globalScope[globalKey] ?? (globalScope[globalKey] = {
|
|
17
|
+
timer: null,
|
|
18
|
+
running: false,
|
|
19
|
+
lastBySession: new Map<string, number>(),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
function parseIntBounded(value: unknown, fallback: number, min: number, max: number): number {
|
|
23
|
+
const parsed = typeof value === 'number'
|
|
24
|
+
? value
|
|
25
|
+
: typeof value === 'string'
|
|
26
|
+
? Number.parseInt(value, 10)
|
|
27
|
+
: Number.NaN
|
|
28
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
29
|
+
return Math.max(min, Math.min(max, Math.trunc(parsed)))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse a duration value into seconds.
|
|
34
|
+
* Accepts: "30m", "1h", "2h30m", "45s", "1800", 1800, null/undefined.
|
|
35
|
+
* Returns integer seconds clamped to [0, 86400].
|
|
36
|
+
*/
|
|
37
|
+
function parseDuration(value: unknown, fallbackSec: number): number {
|
|
38
|
+
if (value === null || value === undefined) return fallbackSec
|
|
39
|
+
if (typeof value === 'number') {
|
|
40
|
+
if (!Number.isFinite(value)) return fallbackSec
|
|
41
|
+
return Math.max(0, Math.min(86400, Math.trunc(value)))
|
|
42
|
+
}
|
|
43
|
+
if (typeof value !== 'string') return fallbackSec
|
|
44
|
+
const trimmed = value.trim().toLowerCase()
|
|
45
|
+
if (!trimmed) return fallbackSec
|
|
46
|
+
// Plain numeric string — treat as seconds (backward compat)
|
|
47
|
+
const asNum = Number(trimmed)
|
|
48
|
+
if (Number.isFinite(asNum)) {
|
|
49
|
+
return Math.max(0, Math.min(86400, Math.trunc(asNum)))
|
|
50
|
+
}
|
|
51
|
+
const m = trimmed.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/)
|
|
52
|
+
if (!m || (!m[1] && !m[2] && !m[3])) return fallbackSec
|
|
53
|
+
const hours = m[1] ? Number.parseInt(m[1], 10) : 0
|
|
54
|
+
const minutes = m[2] ? Number.parseInt(m[2], 10) : 0
|
|
55
|
+
const seconds = m[3] ? Number.parseInt(m[3], 10) : 0
|
|
56
|
+
const total = hours * 3600 + minutes * 60 + seconds
|
|
57
|
+
return Math.max(0, Math.min(86400, total))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseTimeHHMM(raw: unknown): { h: number; m: number } | null {
|
|
61
|
+
if (typeof raw !== 'string') return null
|
|
62
|
+
const val = raw.trim()
|
|
63
|
+
if (!val) return null
|
|
64
|
+
const m = val.match(/^(\d{1,2}):(\d{2})$/)
|
|
65
|
+
if (!m) return null
|
|
66
|
+
const h = Number.parseInt(m[1], 10)
|
|
67
|
+
const mm = Number.parseInt(m[2], 10)
|
|
68
|
+
if (!Number.isFinite(h) || !Number.isFinite(mm)) return null
|
|
69
|
+
if (h < 0 || h > 24 || mm < 0 || mm > 59) return null
|
|
70
|
+
if (h === 24 && mm !== 0) return null
|
|
71
|
+
return { h, m: mm }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getMinutesInTimezone(date: Date, timezone?: string | null): number | null {
|
|
75
|
+
try {
|
|
76
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
77
|
+
hour: '2-digit',
|
|
78
|
+
minute: '2-digit',
|
|
79
|
+
hour12: false,
|
|
80
|
+
timeZone: timezone || undefined,
|
|
81
|
+
})
|
|
82
|
+
const parts = formatter.formatToParts(date)
|
|
83
|
+
const hh = Number.parseInt(parts.find((p) => p.type === 'hour')?.value || '', 10)
|
|
84
|
+
const mm = Number.parseInt(parts.find((p) => p.type === 'minute')?.value || '', 10)
|
|
85
|
+
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null
|
|
86
|
+
return hh * 60 + mm
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function inActiveWindow(nowDate: Date, startRaw: unknown, endRaw: unknown, tzRaw: unknown): boolean {
|
|
93
|
+
const start = parseTimeHHMM(startRaw)
|
|
94
|
+
const end = parseTimeHHMM(endRaw)
|
|
95
|
+
if (!start || !end) return true
|
|
96
|
+
|
|
97
|
+
const tz = typeof tzRaw === 'string' && tzRaw.trim() ? tzRaw.trim() : undefined
|
|
98
|
+
const current = getMinutesInTimezone(nowDate, tz)
|
|
99
|
+
if (current == null) return true
|
|
100
|
+
|
|
101
|
+
const startM = start.h * 60 + start.m
|
|
102
|
+
const endM = end.h * 60 + end.m
|
|
103
|
+
if (startM === endM) return true
|
|
104
|
+
if (startM < endM) return current >= startM && current < endM
|
|
105
|
+
return current >= startM || current < endM
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface HeartbeatConfig {
|
|
109
|
+
intervalSec: number
|
|
110
|
+
prompt: string
|
|
111
|
+
enabled: boolean
|
|
112
|
+
model: string | null
|
|
113
|
+
ackMaxChars: number
|
|
114
|
+
showOk: boolean
|
|
115
|
+
showAlerts: boolean
|
|
116
|
+
target: string | null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const DEFAULT_HEARTBEAT_PROMPT = 'Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.'
|
|
120
|
+
|
|
121
|
+
function resolveInterval(obj: Record<string, any>, currentSec: number): number {
|
|
122
|
+
// Prefer heartbeatInterval (duration string) over heartbeatIntervalSec (raw number)
|
|
123
|
+
if (obj.heartbeatInterval !== undefined && obj.heartbeatInterval !== null) {
|
|
124
|
+
return parseDuration(obj.heartbeatInterval, currentSec)
|
|
125
|
+
}
|
|
126
|
+
if (obj.heartbeatIntervalSec !== undefined && obj.heartbeatIntervalSec !== null) {
|
|
127
|
+
return parseIntBounded(obj.heartbeatIntervalSec, currentSec, 0, 86400)
|
|
128
|
+
}
|
|
129
|
+
return currentSec
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function resolveStr(obj: Record<string, any>, key: string, current: string | null): string | null {
|
|
133
|
+
const val = obj[key]
|
|
134
|
+
if (typeof val === 'string' && val.trim()) return val.trim()
|
|
135
|
+
return current
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveBool(obj: Record<string, any>, key: string, current: boolean): boolean {
|
|
139
|
+
if (obj[key] === true) return true
|
|
140
|
+
if (obj[key] === false) return false
|
|
141
|
+
return current
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveNum(obj: Record<string, any>, key: string, current: number): number {
|
|
145
|
+
const val = obj[key]
|
|
146
|
+
if (typeof val === 'number' && Number.isFinite(val)) return Math.trunc(val)
|
|
147
|
+
return current
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function heartbeatConfigForSession(session: any, settings: Record<string, any>, agents: Record<string, any>): HeartbeatConfig {
|
|
151
|
+
// Global defaults — 30 min interval (was 120s)
|
|
152
|
+
let intervalSec = resolveInterval(settings, 1800)
|
|
153
|
+
const globalPrompt = (typeof settings.heartbeatPrompt === 'string' && settings.heartbeatPrompt.trim())
|
|
154
|
+
? settings.heartbeatPrompt.trim()
|
|
155
|
+
: DEFAULT_HEARTBEAT_PROMPT
|
|
156
|
+
|
|
157
|
+
let enabled = intervalSec > 0
|
|
158
|
+
let prompt = globalPrompt
|
|
159
|
+
let model: string | null = resolveStr(settings, 'heartbeatModel', null)
|
|
160
|
+
let ackMaxChars = resolveNum(settings, 'heartbeatAckMaxChars', 300)
|
|
161
|
+
let showOk = resolveBool(settings, 'heartbeatShowOk', false)
|
|
162
|
+
let showAlerts = resolveBool(settings, 'heartbeatShowAlerts', true)
|
|
163
|
+
let target: string | null = resolveStr(settings, 'heartbeatTarget', null)
|
|
164
|
+
|
|
165
|
+
// Agent layer overrides
|
|
166
|
+
if (session.agentId) {
|
|
167
|
+
const agent = agents[session.agentId]
|
|
168
|
+
if (agent) {
|
|
169
|
+
if (agent.heartbeatEnabled === false) enabled = false
|
|
170
|
+
if (agent.heartbeatEnabled === true) enabled = true
|
|
171
|
+
intervalSec = resolveInterval(agent, intervalSec)
|
|
172
|
+
if (typeof agent.heartbeatPrompt === 'string' && agent.heartbeatPrompt.trim()) {
|
|
173
|
+
prompt = agent.heartbeatPrompt.trim()
|
|
174
|
+
}
|
|
175
|
+
model = resolveStr(agent, 'heartbeatModel', model)
|
|
176
|
+
ackMaxChars = resolveNum(agent, 'heartbeatAckMaxChars', ackMaxChars)
|
|
177
|
+
showOk = resolveBool(agent, 'heartbeatShowOk', showOk)
|
|
178
|
+
showAlerts = resolveBool(agent, 'heartbeatShowAlerts', showAlerts)
|
|
179
|
+
target = resolveStr(agent, 'heartbeatTarget', target)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Session layer overrides
|
|
184
|
+
if (session.heartbeatEnabled === false) enabled = false
|
|
185
|
+
if (session.heartbeatEnabled === true) enabled = true
|
|
186
|
+
intervalSec = resolveInterval(session, intervalSec)
|
|
187
|
+
if (typeof session.heartbeatPrompt === 'string' && session.heartbeatPrompt.trim()) {
|
|
188
|
+
prompt = session.heartbeatPrompt.trim()
|
|
189
|
+
}
|
|
190
|
+
target = resolveStr(session, 'heartbeatTarget', target)
|
|
191
|
+
|
|
192
|
+
return { enabled: enabled && intervalSec > 0, intervalSec, prompt, model, ackMaxChars, showOk, showAlerts, target }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function lastUserMessageAt(session: any): number {
|
|
196
|
+
if (!Array.isArray(session?.messages)) return 0
|
|
197
|
+
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
198
|
+
const msg = session.messages[i]
|
|
199
|
+
if (msg?.role === 'user' && typeof msg.time === 'number' && msg.time > 0) {
|
|
200
|
+
return msg.time
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return 0
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function resolveHeartbeatUserIdleSec(settings: Record<string, any>, fallbackSec: number): number {
|
|
207
|
+
const configured = settings.heartbeatUserIdleSec
|
|
208
|
+
if (configured === undefined || configured === null || configured === '') {
|
|
209
|
+
return fallbackSec
|
|
210
|
+
}
|
|
211
|
+
return parseIntBounded(configured, fallbackSec, 0, 86_400)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function shouldRunHeartbeats(settings: Record<string, any>): boolean {
|
|
215
|
+
const loopMode = settings.loopMode === 'ongoing' ? 'ongoing' : 'bounded'
|
|
216
|
+
return loopMode === 'ongoing'
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function tickHeartbeats() {
|
|
220
|
+
const settings = loadSettings()
|
|
221
|
+
const globalOngoing = shouldRunHeartbeats(settings)
|
|
222
|
+
|
|
223
|
+
const now = Date.now()
|
|
224
|
+
const nowDate = new Date(now)
|
|
225
|
+
if (!inActiveWindow(nowDate, settings.heartbeatActiveStart, settings.heartbeatActiveEnd, settings.heartbeatTimezone)) {
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const sessions = loadSessions()
|
|
230
|
+
const agents = loadAgents()
|
|
231
|
+
const hasScopedAgents = Object.values(agents).some((a: any) => a?.heartbeatEnabled === true)
|
|
232
|
+
|
|
233
|
+
// Prune tracked sessions that no longer exist or have heartbeat disabled
|
|
234
|
+
for (const trackedId of state.lastBySession.keys()) {
|
|
235
|
+
const s = sessions[trackedId] as any
|
|
236
|
+
if (!s) {
|
|
237
|
+
state.lastBySession.delete(trackedId)
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
const cfg = heartbeatConfigForSession(s, settings, agents)
|
|
241
|
+
if (!cfg.enabled) {
|
|
242
|
+
state.lastBySession.delete(trackedId)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const session of Object.values(sessions) as any[]) {
|
|
247
|
+
if (!session?.id) continue
|
|
248
|
+
if (!Array.isArray(session.tools) || session.tools.length === 0) continue
|
|
249
|
+
if (session.sessionType && session.sessionType !== 'human' && session.sessionType !== 'orchestrated') continue
|
|
250
|
+
|
|
251
|
+
// Check if this session or its agent has explicit heartbeat opt-in
|
|
252
|
+
const agent = session.agentId ? agents[session.agentId] : null
|
|
253
|
+
const explicitOptIn = session.heartbeatEnabled === true || (agent && agent.heartbeatEnabled === true)
|
|
254
|
+
|
|
255
|
+
// If global loopMode is bounded, only allow sessions with explicit opt-in
|
|
256
|
+
if (!globalOngoing && !explicitOptIn) continue
|
|
257
|
+
|
|
258
|
+
if (hasScopedAgents && !explicitOptIn) {
|
|
259
|
+
const sessionForcedOn = session.heartbeatEnabled === true
|
|
260
|
+
if (!sessionForcedOn && (!agent || agent.heartbeatEnabled !== true)) continue
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const cfg = heartbeatConfigForSession(session, settings, agents)
|
|
264
|
+
if (!cfg.enabled) continue
|
|
265
|
+
|
|
266
|
+
// For sessions with explicit opt-in, use a shorter idle threshold (just intervalSec * 2).
|
|
267
|
+
// For inherited/global heartbeats, keep the 180s minimum to avoid noisy auto-fire.
|
|
268
|
+
const defaultIdleSec = explicitOptIn
|
|
269
|
+
? cfg.intervalSec * 2
|
|
270
|
+
: Math.max(cfg.intervalSec * 2, 180)
|
|
271
|
+
const userIdleThresholdSec = resolveHeartbeatUserIdleSec(settings, defaultIdleSec)
|
|
272
|
+
const lastUserAt = lastUserMessageAt(session)
|
|
273
|
+
if (lastUserAt <= 0) continue
|
|
274
|
+
const idleMs = now - lastUserAt
|
|
275
|
+
if (idleMs < userIdleThresholdSec * 1000) continue
|
|
276
|
+
|
|
277
|
+
if (isMainSession(session)) {
|
|
278
|
+
const loopState = getMainLoopStateForSession(session.id)
|
|
279
|
+
if (loopState?.paused) continue
|
|
280
|
+
const loopStatus = loopState?.status || 'idle'
|
|
281
|
+
const pendingEvents = loopState?.pendingEvents?.length || 0
|
|
282
|
+
if ((loopStatus === 'ok' || loopStatus === 'idle') && pendingEvents === 0) continue
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const last = state.lastBySession.get(session.id) || 0
|
|
286
|
+
if (now - last < cfg.intervalSec * 1000) continue
|
|
287
|
+
|
|
288
|
+
const runState = getSessionRunState(session.id)
|
|
289
|
+
if (runState.runningRunId) continue
|
|
290
|
+
|
|
291
|
+
const heartbeatMessage = isMainSession(session)
|
|
292
|
+
? buildMainLoopHeartbeatPrompt(session, cfg.prompt)
|
|
293
|
+
: cfg.prompt
|
|
294
|
+
|
|
295
|
+
const enqueue = enqueueSessionRun({
|
|
296
|
+
sessionId: session.id,
|
|
297
|
+
message: heartbeatMessage,
|
|
298
|
+
internal: true,
|
|
299
|
+
source: 'heartbeat',
|
|
300
|
+
mode: 'collect',
|
|
301
|
+
dedupeKey: `heartbeat:${session.id}`,
|
|
302
|
+
modelOverride: cfg.model || undefined,
|
|
303
|
+
heartbeatConfig: {
|
|
304
|
+
ackMaxChars: cfg.ackMaxChars,
|
|
305
|
+
showOk: cfg.showOk,
|
|
306
|
+
showAlerts: cfg.showAlerts,
|
|
307
|
+
target: cfg.target,
|
|
308
|
+
},
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// Set timestamp AFTER successful enqueue so a busy session retries next tick
|
|
312
|
+
state.lastBySession.set(session.id, now)
|
|
313
|
+
|
|
314
|
+
enqueue.promise.catch((err) => {
|
|
315
|
+
log.warn('heartbeat', `Heartbeat run failed for session ${session.id}`, err?.message || String(err))
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Seed lastBySession from persisted lastActiveAt values so that a cold restart
|
|
322
|
+
* doesn't cause every session to fire a heartbeat immediately on the first tick.
|
|
323
|
+
*/
|
|
324
|
+
function seedLastActive() {
|
|
325
|
+
const sessions = loadSessions()
|
|
326
|
+
for (const session of Object.values(sessions) as any[]) {
|
|
327
|
+
if (!session?.id) continue
|
|
328
|
+
if (typeof session.lastActiveAt === 'number' && session.lastActiveAt > 0) {
|
|
329
|
+
// Only seed entries we don't already have (preserves HMR state)
|
|
330
|
+
if (!state.lastBySession.has(session.id)) {
|
|
331
|
+
state.lastBySession.set(session.id, session.lastActiveAt)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function startHeartbeatService() {
|
|
338
|
+
// Always replace the timer so HMR picks up the latest tickHeartbeats function.
|
|
339
|
+
// Without this, the old setInterval closure keeps running stale code.
|
|
340
|
+
if (state.timer) {
|
|
341
|
+
clearInterval(state.timer)
|
|
342
|
+
state.timer = null
|
|
343
|
+
}
|
|
344
|
+
state.running = true
|
|
345
|
+
seedLastActive()
|
|
346
|
+
state.timer = setInterval(() => {
|
|
347
|
+
tickHeartbeats().catch((err) => {
|
|
348
|
+
log.error('heartbeat', 'Heartbeat tick failed', err?.message || String(err))
|
|
349
|
+
})
|
|
350
|
+
}, HEARTBEAT_TICK_MS)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function stopHeartbeatService() {
|
|
354
|
+
state.running = false
|
|
355
|
+
if (state.timer) {
|
|
356
|
+
clearInterval(state.timer)
|
|
357
|
+
state.timer = null
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function getHeartbeatServiceStatus() {
|
|
362
|
+
return {
|
|
363
|
+
running: state.running,
|
|
364
|
+
trackedSessions: state.lastBySession.size,
|
|
365
|
+
}
|
|
366
|
+
}
|