@swarmclawai/swarmclaw 1.5.50 → 1.5.52

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 CHANGED
@@ -81,8 +81,11 @@ Extension tutorial: https://swarmclaw.ai/docs/extension-tutorial
81
81
  Download the one-click installer from [swarmclaw.ai/downloads](https://swarmclaw.ai/downloads).
82
82
  Available for macOS (Apple Silicon & Intel), Windows, and Linux (AppImage + .deb).
83
83
 
84
- Current builds are unsigned, so on first launch:
85
- - **macOS:** right-click the app in Finder → **Open** → **Open** to bypass Gatekeeper.
84
+ Current builds are ad-hoc signed but not notarized, so on first launch:
85
+ - **macOS:** right-click the app in Finder → **Open** → **Open** to bypass Gatekeeper. If macOS instead reports *"SwarmClaw is damaged and can't be opened"* (common on Apple Silicon when the dmg was quarantined by Safari), strip the quarantine attribute and relaunch:
86
+ ```bash
87
+ xattr -dr com.apple.quarantine /Applications/SwarmClaw.app
88
+ ```
86
89
  - **Windows:** if SmartScreen appears, click **More info** → **Run anyway**.
87
90
  - **Linux (AppImage):** `chmod +x` the downloaded file, then run it.
88
91
 
@@ -396,6 +399,24 @@ Operational docs: https://swarmclaw.ai/docs/observability
396
399
 
397
400
  ## Releases
398
401
 
402
+ ### v1.5.52 Highlights
403
+
404
+ - **Session X-Ray now surfaces the backend execution log** ([#48](https://github.com/swarmclawai/swarmclaw/pull/48), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). The debug panel fetches entries from the SQLite execution log on open and merges them with in-memory message events, sorted by time. Expandable entries show provider, model, stream errors, duration, and token counts — the info that was previously invisible when Ollama or other local-model runs failed silently. A new **Tools** filter tab, an `exec` badge for log-sourced entries, an entry count in the stats bar, and a Refresh button round it out. New API route `GET /api/chats/:id/execution-log` with `limit`, `since`, and `category` query params, registered in the CLI manifest as `swarmclaw chats execution-log`.
405
+ - **Execution errors now captured in the log** ([#48](https://github.com/swarmclawai/swarmclaw/pull/48)). `finalizeChatTurn()` writes a structured `error` entry to the execution log on terminal failure, recording provider, model, stream errors, duration, token counts, and whether a response was produced — so the Session X-Ray above actually has something to show.
406
+ - **Fix: blank task-sheet no longer shows `"null"` under *Blocked By*** ([#47](https://github.com/swarmclawai/swarmclaw/pull/47), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). A successful task create/update returns `error: null`, and the old `'error' in res` guard treated that as a truthy error and rendered `String(null)` as a red "null" string under the Blocked By field. Now only non-empty string errors trigger the UI, and `depError` is cleared on dialog close so stale state cannot leak across re-opens.
407
+
408
+ ### v1.5.51 Highlights
409
+
410
+ - **Desktop app now actually opens and renders on macOS**: packaged builds were broken in v1.5.50 by a stack of independent issues that each masked the next. This release unblocks the cold-boot path end to end. Measured cold-boot time on a populated install: ~1 second to first `/api/healthz` response, down from a hard 60-second timeout.
411
+ - Ad-hoc code signing (`identity: '-'`) via a new `scripts/electron-after-pack.cjs` hook that runs `codesign --sign - --force --deep` after electron-builder packages the bundle. The bundle identifier is now sealed as `ai.swarmclaw.desktop` with all 74k resources sealed, so quarantined dmgs surface as "unidentified developer" (right-click → Open) instead of the more confusing "damaged" error.
412
+ - Per-architecture native module sync: the afterPack hook copies `better-sqlite3`, `@mongodb-js/zstd`, `node-liblzma`, and `utf-8-validate` `.node` binaries from the electron-builder-rebuilt root `node_modules` into the packaged `.next/standalone/node_modules`. Without this, the standalone server hit `ERR_DLOPEN_FAILED: NODE_MODULE_VERSION 137` on launch because Next.js's output-tracing copied the Node-ABI build of better-sqlite3 into standalone while electron-builder only rebuilt the root tree for Electron's ABI.
413
+ - `scripts/run-next-build.mjs` now copies `mdn-data` (used by `css-tree` via `jsdom`) into standalone alongside the existing `css-tree/data` patch, so pages that depend on it don't 500 with `Cannot find module 'mdn-data/css/at-rules.json'`.
414
+ - `isomorphic-dompurify` replaced by the browser-only `dompurify` in `agent-avatar.tsx`. The isomorphic wrapper was pulling `jsdom`'s ESM-only `@exodus/bytes` dep into every server bundle the avatar was referenced from, which blew up SSR under Electron 33 (Node 20.18) with `ERR_REQUIRE_ESM` on every page.
415
+ - Session-consolidation migrations, `initWsServer`, and `ensureDaemonStarted` moved into a `setImmediate` deferred block in `src/instrumentation.ts` so Next.js can bind the HTTP listener before per-install work runs.
416
+ - **App icon fixed**: the Dock no longer shows Electron's default `exec` placeholder. `scripts/gen-icons.mjs` generates `resources/icon.icns`, `resources/icon.ico`, and `resources/icon.png` from `public/branding/swarmclaw-org-avatar.png`; the main process sets the Dock icon at launch and passes it to every `BrowserWindow`.
417
+ - **Embedded server log file + improved failure dialog**: the Electron wrapper now tees the child Next.js server's stdout/stderr into `<userData>/logs/server.log` (`~/Library/Application Support/@swarmclawai/swarmclaw/logs/server.log` on macOS, 1 MB rotation). If startup fails or the server exits, the error dialog shows the tail of the log inline and exposes an **Open Logs Folder** button that jumps Finder straight to the file. This is what made root-cause debugging possible in the first place — if you hit any kind of regression here, grab that log and open an issue.
418
+ - **Embedded server timeout raised from 60s to 5 minutes**: a safety net. On a healthy install the server is up in about a second; 300 seconds is there for pathological cold boots (very large data dirs, contested Apple Silicon Gatekeeper verification on unsigned binaries, etc.) and should never be hit in normal use.
419
+
399
420
  ### v1.5.50 Highlights
400
421
 
401
422
  - **Fix: opencode-web remote instances no longer fail with `EACCES`**: SwarmClaw used to send the local workspace path (e.g. `/root/.swarmclaw/workspace`) as a `directory=` query parameter on every opencode-web request. Remote opencode-web instances tried to `lstat` that path and rejected the call. The provider now auto-detects local vs. remote from the endpoint hostname (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`) and only sends `directory=` when the endpoint is local. Thanks to [@SteamedFish](https://github.com/SteamedFish) for the detailed root-cause writeup in [#45](https://github.com/swarmclawai/swarmclaw/issues/45).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.50",
3
+ "version": "1.5.52",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -101,10 +101,10 @@
101
101
  "@langchain/langgraph": "^1.2.2",
102
102
  "@langchain/openai": "^1.2.8",
103
103
  "@modelcontextprotocol/sdk": "^1.27.1",
104
+ "@multiavatar/multiavatar": "^1.0.7",
104
105
  "@opentelemetry/api": "^1.9.1",
105
106
  "@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
106
107
  "@opentelemetry/sdk-node": "^0.214.0",
107
- "@multiavatar/multiavatar": "^1.0.7",
108
108
  "@playwright/mcp": "^0.0.68",
109
109
  "@slack/bolt": "^4.6.0",
110
110
  "@swarmdock/sdk": "^0.5.3",
@@ -127,17 +127,17 @@
127
127
  "class-variance-authority": "^0.7.1",
128
128
  "clsx": "^2.1.1",
129
129
  "commander": "^13.1.0",
130
- "electron-updater": "^6.3.9",
131
130
  "cron-parser": "^5.5.0",
132
131
  "cronstrue": "^3.12.0",
133
132
  "dagre": "^0.8.5",
134
133
  "discord.js": "^14.25.1",
134
+ "electron-updater": "^6.3.9",
135
135
  "ethers": "^6.16.0",
136
136
  "exceljs": "^4.4.0",
137
137
  "grammy": "^1.40.0",
138
138
  "highlight.js": "^11.11.1",
139
+ "dompurify": "^3.3.3",
139
140
  "imapflow": "^1.2.11",
140
- "isomorphic-dompurify": "^3.7.1",
141
141
  "just-bash": "^2.14.0",
142
142
  "langchain": "^1.2.30",
143
143
  "lucide-react": "^0.574.0",
@@ -175,7 +175,8 @@
175
175
  "electron": "^33.3.0",
176
176
  "electron-builder": "^25.1.8",
177
177
  "eslint": "^9",
178
- "eslint-config-next": "16.1.7"
178
+ "eslint-config-next": "16.1.7",
179
+ "png-to-ico": "^3.0.1"
179
180
  },
180
181
  "optionalDependencies": {
181
182
  "botbuilder": "^4.23.3",
@@ -164,14 +164,27 @@ export function repairStandaloneCssTreeData(cwd = process.cwd()) {
164
164
  const standaloneDir = path.join(cwd, '.next', 'standalone')
165
165
  if (!fs.existsSync(standaloneDir)) return false
166
166
 
167
- const dataDst = path.join(standaloneDir, 'node_modules', 'css-tree', 'data')
168
- if (fs.existsSync(dataDst)) return false
167
+ let repaired = false
169
168
 
170
- const dataSrc = path.join(cwd, 'node_modules', 'css-tree', 'data')
171
- if (!fs.existsSync(dataSrc)) return false
169
+ const cssTreeDst = path.join(standaloneDir, 'node_modules', 'css-tree', 'data')
170
+ const cssTreeSrc = path.join(cwd, 'node_modules', 'css-tree', 'data')
171
+ if (!fs.existsSync(cssTreeDst) && fs.existsSync(cssTreeSrc)) {
172
+ fs.cpSync(cssTreeSrc, cssTreeDst, { recursive: true, force: true })
173
+ repaired = true
174
+ }
172
175
 
173
- fs.cpSync(dataSrc, dataDst, { recursive: true, force: true })
174
- return true
176
+ // css-tree's CJS entry calls require('mdn-data/css/*.json') at load time,
177
+ // and Next's output-tracing does not pull the raw JSON data files into the
178
+ // standalone tree. Copy them in so jsdom (via css-tree) loads correctly
179
+ // under the packaged app.
180
+ const mdnDataDst = path.join(standaloneDir, 'node_modules', 'mdn-data')
181
+ const mdnDataSrc = path.join(cwd, 'node_modules', 'mdn-data')
182
+ if (!fs.existsSync(mdnDataDst) && fs.existsSync(mdnDataSrc)) {
183
+ fs.cpSync(mdnDataSrc, mdnDataDst, { recursive: true, force: true })
184
+ repaired = true
185
+ }
186
+
187
+ return repaired
175
188
  }
176
189
 
177
190
  export function repairStandaloneNextMetadata(cwd = process.cwd()) {
@@ -0,0 +1,20 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { notFound } from '@/lib/server/collection-helpers'
3
+ import { getSession } from '@/lib/server/sessions/session-repository'
4
+ import { queryLogs } from '@/lib/server/execution-log'
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
9
+ const { id } = await params
10
+ const session = getSession(id)
11
+ if (!session) return notFound()
12
+
13
+ const { searchParams } = new URL(req.url)
14
+ const limit = Math.min(200, Math.max(1, Number(searchParams.get('limit')) || 100))
15
+ const since = searchParams.get('since') ? Number(searchParams.get('since')) : undefined
16
+ const category = searchParams.get('category') as Parameters<typeof queryLogs>[0]['category'] | undefined
17
+
18
+ const entries = queryLogs({ sessionId: id, limit, since, category })
19
+ return NextResponse.json(entries)
20
+ }
package/src/cli/index.js CHANGED
@@ -607,6 +607,7 @@ const COMMAND_GROUPS = [
607
607
  defaultBody: { action: 'status' },
608
608
  }),
609
609
  cmd('checkpoints', 'GET', '/chats/:id/checkpoints', 'List checkpoint history for a chat'),
610
+ cmd('execution-log', 'GET', '/chats/:id/execution-log', 'Get execution log entries for a chat'),
610
611
  cmd('migrate-messages', 'POST', '/chats/migrate-messages', 'Migrate messages from session blobs to relational table'),
611
612
  ],
612
613
  },
@@ -2,9 +2,15 @@
2
2
 
3
3
  import { useMemo } from 'react'
4
4
  import multiavatar from '@multiavatar/multiavatar'
5
- import DOMPurify from 'isomorphic-dompurify'
5
+ import DOMPurify from 'dompurify'
6
6
 
7
+ // The browser DOMPurify package runs client-side only; this component is a
8
+ // 'use client' boundary so sanitizeSvg only executes after hydration where
9
+ // `window` is available. We previously used isomorphic-dompurify, but that
10
+ // pulls jsdom (and its @exodus/bytes ESM deps) into every server bundle the
11
+ // component is referenced from, which breaks SSR under Electron 33's Node 20.
7
12
  function sanitizeSvg(svg: string): string {
13
+ if (typeof window === 'undefined') return svg
8
14
  return DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } })
9
15
  }
10
16
 
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useState, useEffect, useCallback } from 'react'
4
4
  import type { Message } from '@/types'
5
5
  import { IconButton } from '@/components/shared/icon-button'
6
6
  import { CheckpointTimeline } from './checkpoint-timeline'
@@ -19,7 +19,20 @@ interface DebugEvent {
19
19
  type: EventType
20
20
  label: string
21
21
  detail: string
22
+ extraDetail?: Record<string, unknown> | null
22
23
  time: number
24
+ source: 'message' | 'execlog'
25
+ }
26
+
27
+ interface ExecLogEntry {
28
+ id: string
29
+ sessionId: string
30
+ runId: string | null
31
+ agentId: string | null
32
+ category: string
33
+ summary: string
34
+ detail: Record<string, unknown> | null
35
+ ts: number
23
36
  }
24
37
 
25
38
  function classifyMessage(msg: Message): DebugEvent {
@@ -27,30 +40,66 @@ function classifyMessage(msg: Message): DebugEvent {
27
40
 
28
41
  if (msg.role === 'user') {
29
42
  if (text.startsWith('[System]')) {
30
- return { type: 'system', label: 'System', detail: text.replace('[System] ', ''), time: msg.time }
43
+ return { type: 'system', label: 'System', detail: text.replace('[System] ', ''), time: msg.time, source: 'message' }
31
44
  }
32
45
  if (text.startsWith('[Agent ')) {
33
46
  const match = text.match(/\[Agent (.+?) result\]/)
34
- return { type: 'agent_result', label: `Agent: ${match?.[1] || 'Unknown'}`, detail: text.replace(/\[Agent .+? result\]:?\n?/, ''), time: msg.time }
47
+ return { type: 'agent_result', label: `Agent: ${match?.[1] || 'Unknown'}`, detail: text.replace(/\[Agent .+? result\]:?\n?/, ''), time: msg.time, source: 'message' }
35
48
  }
36
49
  if (text.startsWith('[Memory search')) {
37
- return { type: 'system', label: 'Memory Search', detail: text.replace('[Memory search results]:\n', ''), time: msg.time }
50
+ return { type: 'system', label: 'Memory Search', detail: text.replace('[Memory search results]:\n', ''), time: msg.time, source: 'message' }
38
51
  }
39
- return { type: 'user', label: 'User', detail: text, time: msg.time }
52
+ return { type: 'user', label: 'User', detail: text, time: msg.time, source: 'message' }
40
53
  }
41
54
 
42
55
  // assistant
43
56
  if (text.startsWith('[Delegating to ')) {
44
57
  const match = text.match(/\[Delegating to (.+?)\]/)
45
- return { type: 'delegation', label: `Delegate: ${match?.[1] || 'Unknown'}`, detail: text.replace(/\[Delegating to .+?\]:?\s?/, ''), time: msg.time }
58
+ return { type: 'delegation', label: `Delegate: ${match?.[1] || 'Unknown'}`, detail: text.replace(/\[Delegating to .+?\]:?\s?/, ''), time: msg.time, source: 'message' }
46
59
  }
47
60
  if (text.startsWith('[Error]')) {
48
- return { type: 'error', label: 'Error', detail: text.replace('[Error] ', ''), time: msg.time }
61
+ return { type: 'error', label: 'Error', detail: text.replace('[Error] ', ''), time: msg.time, source: 'message' }
49
62
  }
50
63
  if (text.startsWith('Starting task:')) {
51
- return { type: 'system', label: 'Task Start', detail: text, time: msg.time }
64
+ return { type: 'system', label: 'Task Start', detail: text, time: msg.time, source: 'message' }
65
+ }
66
+ return { type: 'assistant', label: 'Assistant', detail: text, time: msg.time, source: 'message' }
67
+ }
68
+
69
+ function classifyExecLogEntry(entry: ExecLogEntry): DebugEvent {
70
+ const catMap: Record<string, EventType> = {
71
+ error: 'error',
72
+ tool_call: 'tool_call',
73
+ tool_result: 'tool_call',
74
+ decision: 'system',
75
+ trigger: 'system',
76
+ loop_detection: 'system',
77
+ delegation_start: 'delegation',
78
+ delegation_complete: 'agent_result',
79
+ delegation_fail: 'error',
80
+ }
81
+ const type: EventType = catMap[entry.category] ?? 'system'
82
+ const labelMap: Record<string, string> = {
83
+ error: 'Error',
84
+ tool_call: 'Tool Call',
85
+ tool_result: 'Tool Result',
86
+ decision: 'Decision',
87
+ trigger: 'Trigger',
88
+ loop_detection: 'Loop Detect',
89
+ delegation_start: 'Delegation',
90
+ delegation_complete: 'Delegation Result',
91
+ delegation_fail: 'Delegation Error',
92
+ heartbeat_failure: 'Heartbeat Fail',
93
+ }
94
+ const label = labelMap[entry.category] ?? entry.category.replace(/_/g, ' ')
95
+ return {
96
+ type,
97
+ label,
98
+ detail: entry.summary,
99
+ extraDetail: entry.detail,
100
+ time: entry.ts,
101
+ source: 'execlog',
52
102
  }
53
- return { type: 'assistant', label: 'Assistant', detail: text, time: msg.time }
54
103
  }
55
104
 
56
105
  const TYPE_COLORS: Record<EventType, string> = {
@@ -67,14 +116,66 @@ function fmtTime(ts: number) {
67
116
  return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
68
117
  }
69
118
 
119
+ function ExtraDetail({ data }: { data: Record<string, unknown> }) {
120
+ const entries = Object.entries(data).filter(([, v]) => v !== null && v !== undefined)
121
+ if (entries.length === 0) return null
122
+ return (
123
+ <div className="mt-2 rounded-[8px] bg-black/30 border border-white/[0.06] p-3 text-[11px] font-mono space-y-1">
124
+ {entries.map(([k, v]) => (
125
+ <div key={k} className="flex gap-2 flex-wrap">
126
+ <span className="text-text-3/70 shrink-0">{k}:</span>
127
+ <span className="text-text-2 break-all">
128
+ {Array.isArray(v)
129
+ ? v.map(String).join(', ') || '(empty)'
130
+ : typeof v === 'object'
131
+ ? JSON.stringify(v)
132
+ : String(v)}
133
+ </span>
134
+ </div>
135
+ ))}
136
+ </div>
137
+ )
138
+ }
139
+
70
140
  export function SessionDebugPanel({ messages, open, onClose }: Props) {
71
141
  const [tab, setTab] = useState<'log' | 'checkpoints'>('log')
72
142
  const [filter, setFilter] = useState<EventType | 'all'>('all')
73
143
  const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
74
-
144
+ const [execLogs, setExecLogs] = useState<ExecLogEntry[]>([])
145
+ const [loadingExec, setLoadingExec] = useState(false)
146
+
75
147
  const currentSessionId = useAppStore(selectActiveSessionId)
76
148
 
77
- const events = messages.map(classifyMessage)
149
+ const fetchExecLogs = useCallback(async (sessionId: string) => {
150
+ setLoadingExec(true)
151
+ try {
152
+ const res = await fetch(`/api/chats/${sessionId}/execution-log?limit=200`)
153
+ if (res.ok) {
154
+ const data = await res.json() as ExecLogEntry[]
155
+ setExecLogs(Array.isArray(data) ? data : [])
156
+ }
157
+ } catch {
158
+ // non-critical
159
+ } finally {
160
+ setLoadingExec(false)
161
+ }
162
+ }, [])
163
+
164
+ useEffect(() => {
165
+ if (open && currentSessionId) {
166
+ void fetchExecLogs(currentSessionId)
167
+ } else if (!open) {
168
+ setExecLogs([])
169
+ setExpandedIdx(null)
170
+ }
171
+ }, [open, currentSessionId, fetchExecLogs])
172
+
173
+ const msgEvents = messages.map(classifyMessage)
174
+ const execEvents = execLogs.map(classifyExecLogEntry)
175
+
176
+ // Merge and sort by time
177
+ const allEvents = [...msgEvents, ...execEvents].sort((a, b) => a.time - b.time)
178
+ const events = allEvents
78
179
  const filtered = filter === 'all' ? events : events.filter((e) => e.type === filter)
79
180
 
80
181
  if (!open) return null
@@ -85,6 +186,7 @@ export function SessionDebugPanel({ messages, open, onClose }: Props) {
85
186
  { id: 'agent_result', label: 'Results' },
86
187
  { id: 'error', label: 'Errors' },
87
188
  { id: 'system', label: 'System' },
189
+ { id: 'tool_call', label: 'Tools' },
88
190
  ]
89
191
 
90
192
  return (
@@ -97,20 +199,20 @@ export function SessionDebugPanel({ messages, open, onClose }: Props) {
97
199
  <path d="M6 20v-4" />
98
200
  </svg>
99
201
  <span className="font-display text-[16px] font-600 tracking-[-0.02em] flex-1">Session X-Ray</span>
100
-
202
+
101
203
  <div className="flex bg-white/[0.04] p-0.5 rounded-[8px] mr-2">
102
- <button
103
- onClick={() => setTab('log')}
104
- className={`px-3 py-1 rounded-[6px] text-[11px] font-600 transition-all ${tab === 'log' ? 'bg-white/[0.08] text-text shadow-sm' : 'text-text-3 hover:text-text-2'}`}
105
- >
106
- Event Log
107
- </button>
108
- <button
109
- onClick={() => setTab('checkpoints')}
110
- className={`px-3 py-1 rounded-[6px] text-[11px] font-600 transition-all ${tab === 'checkpoints' ? 'bg-accent-soft text-accent-bright' : 'text-text-3 hover:text-text-2'}`}
111
- >
112
- Checkpoints
113
- </button>
204
+ <button
205
+ onClick={() => setTab('log')}
206
+ className={`px-3 py-1 rounded-[6px] text-[11px] font-600 transition-all ${tab === 'log' ? 'bg-white/[0.08] text-text shadow-sm' : 'text-text-3 hover:text-text-2'}`}
207
+ >
208
+ Event Log
209
+ </button>
210
+ <button
211
+ onClick={() => setTab('checkpoints')}
212
+ className={`px-3 py-1 rounded-[6px] text-[11px] font-600 transition-all ${tab === 'checkpoints' ? 'bg-accent-soft text-accent-bright' : 'text-text-3 hover:text-text-2'}`}
213
+ >
214
+ Checkpoints
215
+ </button>
114
216
  </div>
115
217
 
116
218
  <IconButton onClick={onClose} aria-label="Close debug panel">
@@ -138,6 +240,16 @@ export function SessionDebugPanel({ messages, open, onClose }: Props) {
138
240
  {f.label}
139
241
  </button>
140
242
  ))}
243
+ {currentSessionId && (
244
+ <button
245
+ onClick={() => void fetchExecLogs(currentSessionId)}
246
+ disabled={loadingExec}
247
+ className="ml-auto px-3 py-1.5 rounded-[8px] text-[11px] font-600 cursor-pointer transition-all border bg-surface border-white/[0.06] text-text-3 hover:text-text-2 disabled:opacity-40 whitespace-nowrap"
248
+ style={{ fontFamily: 'inherit' }}
249
+ >
250
+ {loadingExec ? 'Refreshing…' : '↺ Refresh'}
251
+ </button>
252
+ )}
141
253
  </div>
142
254
 
143
255
  {/* Event timeline */}
@@ -162,27 +274,40 @@ export function SessionDebugPanel({ messages, open, onClose }: Props) {
162
274
  />
163
275
 
164
276
  {/* Content */}
165
- <div className="flex items-center gap-2 mb-0.5">
277
+ <div className="flex items-center gap-2 mb-0.5 flex-wrap">
166
278
  <span className="text-[11px] font-700 uppercase tracking-wider" style={{ color }}>
167
279
  {event.label}
168
280
  </span>
169
281
  <span className="text-[10px] text-text-3/70 font-mono">{fmtTime(event.time)}</span>
282
+ {event.source === 'execlog' && (
283
+ <span className="text-[9px] text-text-3/40 font-mono uppercase tracking-wider">exec</span>
284
+ )}
170
285
  </div>
171
286
 
172
287
  <p className={`text-[12px] text-text-3 leading-[1.5] ${expanded ? 'whitespace-pre-wrap' : 'line-clamp-2'}`}>
173
288
  {event.detail}
174
289
  </p>
175
290
 
291
+ {expanded && event.extraDetail && (
292
+ <ExtraDetail data={event.extraDetail} />
293
+ )}
294
+
176
295
  {!expanded && event.detail.length > 150 && (
177
296
  <span className="text-[11px] text-accent-bright/60 mt-1 inline-block">click to expand</span>
178
297
  )}
298
+ {!expanded && event.extraDetail && Object.keys(event.extraDetail).length > 0 && (
299
+ <span className="text-[11px] text-accent-bright/60 mt-1 inline-block ml-2">+ details</span>
300
+ )}
179
301
  </button>
180
302
  )
181
303
  })}
182
304
 
183
- {filtered.length === 0 && (
305
+ {filtered.length === 0 && !loadingExec && (
184
306
  <p className="text-center text-[13px] text-text-3 py-12">No events matching filter</p>
185
307
  )}
308
+ {filtered.length === 0 && loadingExec && (
309
+ <p className="text-center text-[13px] text-text-3 py-12">Loading…</p>
310
+ )}
186
311
  </div>
187
312
  </div>
188
313
 
@@ -198,17 +323,19 @@ export function SessionDebugPanel({ messages, open, onClose }: Props) {
198
323
  </span>
199
324
  )
200
325
  })}
326
+ <span className="ml-auto text-[10px] text-text-3/40 font-mono">{execLogs.length} exec log entries</span>
201
327
  </div>
202
328
  </>
203
329
  ) : (
204
330
  <div className="flex-1 overflow-y-auto">
205
- {currentSessionId ? (
206
- <CheckpointTimeline sessionId={currentSessionId} />
207
- ) : (
208
- <div className="p-12 text-center text-text-3">No active chat</div>
209
- )}
331
+ {currentSessionId ? (
332
+ <CheckpointTimeline sessionId={currentSessionId} />
333
+ ) : (
334
+ <div className="p-12 text-center text-text-3">No active chat</div>
335
+ )}
210
336
  </div>
211
337
  )}
212
338
  </div>
213
339
  )
214
340
  }
341
+
@@ -186,6 +186,7 @@ export function TaskSheet() {
186
186
 
187
187
  const onClose = () => {
188
188
  formInitRef.current = null
189
+ setDepError(null)
189
190
  setOpen(false)
190
191
  setEditingId(null)
191
192
  }
@@ -215,14 +216,16 @@ export function TaskSheet() {
215
216
  try {
216
217
  if (editing) {
217
218
  const res = await updateTaskMutation.mutateAsync({ id: editing.id, patch: payload })
218
- if (res && typeof res === 'object' && 'error' in res) {
219
- setDepError(String((res as unknown as Record<string, unknown>).error))
219
+ const errMsg = res && typeof res === 'object' ? (res as unknown as Record<string, unknown>).error : undefined
220
+ if (typeof errMsg === 'string' && errMsg.trim()) {
221
+ setDepError(errMsg)
220
222
  return
221
223
  }
222
224
  } else {
223
225
  const res = await createTaskMutation.mutateAsync(payload)
224
- if (res && typeof res === 'object' && 'error' in res) {
225
- setDepError(String((res as unknown as Record<string, unknown>).error))
226
+ const errMsg = res && typeof res === 'object' ? (res as unknown as Record<string, unknown>).error : undefined
227
+ if (typeof errMsg === 'string' && errMsg.trim()) {
228
+ setDepError(errMsg)
226
229
  return
227
230
  }
228
231
  }
@@ -10,25 +10,29 @@ export async function register() {
10
10
  const { initWsServer, closeWsServer } = await import('./lib/server/ws-hub')
11
11
  const { ensureDaemonStarted } = await import('@/lib/server/runtime/daemon-state')
12
12
  await ensureOpenTelemetryStarted()
13
-
14
- // One-time migration: backfill allKnownPeerIds on existing connector sessions
15
- try {
16
- const { backfillAllKnownPeerIds, pruneThreadConnectorMirrors } = await import('@/lib/server/connectors/session-consolidation')
17
- backfillAllKnownPeerIds()
18
- pruneThreadConnectorMirrors()
19
- } catch (err) {
20
- log.error(TAG, 'connector session consolidation failed:', err)
21
- }
22
13
 
23
- // In worker-only mode, we FORCE the daemon to start, but skip the WebSocket listener
24
- if (isWorkerOnly) {
25
- log.info(TAG, 'Booting in WORKER ONLY mode')
26
- ensureDaemonStarted('worker-boot')
27
- } else {
28
- // In normal mode, we start the WS server, and conditionally start the daemon if autostart allows
29
- initWsServer()
30
- ensureDaemonStarted('instrumentation')
31
- }
14
+ // Defer migrations, WS init, and daemon startup so the HTTP listener can bind
15
+ // and /api/healthz can respond immediately. Heavy per-install work (session
16
+ // migrations on large data dirs, daemon recovery) no longer gates first boot.
17
+ setImmediate(() => {
18
+ void (async () => {
19
+ try {
20
+ const { backfillAllKnownPeerIds, pruneThreadConnectorMirrors } = await import('@/lib/server/connectors/session-consolidation')
21
+ backfillAllKnownPeerIds()
22
+ pruneThreadConnectorMirrors()
23
+ } catch (err) {
24
+ log.error(TAG, 'connector session consolidation failed:', err)
25
+ }
26
+
27
+ if (isWorkerOnly) {
28
+ log.info(TAG, 'Booting in WORKER ONLY mode')
29
+ ensureDaemonStarted('worker-boot')
30
+ } else {
31
+ initWsServer()
32
+ ensureDaemonStarted('instrumentation')
33
+ }
34
+ })()
35
+ })
32
36
 
33
37
  // Graceful shutdown: stop background services and close WS connections
34
38
  const shutdownState = hmrSingleton('__swarmclaw_shutdown_state__', () => ({
@@ -33,6 +33,7 @@ import {
33
33
  import { estimateCost } from '@/lib/server/cost'
34
34
  import { refreshSessionIdentityState } from '@/lib/server/identity-continuity'
35
35
  import { log } from '@/lib/server/logger'
36
+ import { logExecution } from '@/lib/server/execution-log'
36
37
  import { syncSessionArchiveMemory } from '@/lib/server/memory/session-archive-memory'
37
38
  import { runCapabilityHook, transformCapabilityText } from '@/lib/server/native-capabilities'
38
39
  import { isHeartbeatSource } from '@/lib/server/runtime/heartbeat-source'
@@ -287,6 +288,22 @@ export async function finalizeChatTurn(params: {
287
288
  inferredError: terminalError,
288
289
  })
289
290
  }
291
+ logExecution(sessionId, 'error', terminalError, {
292
+ runId,
293
+ agentId: sessionForRun.agentId || null,
294
+ detail: {
295
+ provider: providerType,
296
+ model: sessionForRun.model,
297
+ streamErrors: streamErrors.length > 0 ? streamErrors : undefined,
298
+ source,
299
+ durationMs,
300
+ inputTokens: directUsage.received ? directUsage.inputTokens : null,
301
+ outputTokens: directUsage.received ? directUsage.outputTokens : null,
302
+ tokenUsageReceived: directUsage.received,
303
+ hadResponse: !!(fullResponse || '').trim(),
304
+ toolEventCount: toolEvents.length,
305
+ },
306
+ })
290
307
  errorMessage = terminalError
291
308
  }
292
309