@swarmclawai/swarmclaw 1.2.6 → 1.2.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 (112) hide show
  1. package/README.md +24 -17
  2. package/next.config.ts +1 -0
  3. package/package.json +3 -2
  4. package/scripts/easy-setup.mjs +1 -1
  5. package/scripts/postinstall.mjs +1 -1
  6. package/skills/swarmclaw.md +115 -0
  7. package/skills/tools/browser.md +131 -0
  8. package/skills/tools/execute.md +98 -0
  9. package/skills/tools/files.md +98 -0
  10. package/skills/tools/memory.md +104 -0
  11. package/skills/tools/platform.md +144 -0
  12. package/skills/tools/skills.md +83 -0
  13. package/src/app/api/chats/[id]/messages/route.ts +23 -19
  14. package/src/app/api/chats/messages-route.test.ts +105 -51
  15. package/src/app/api/mcp-servers/[id]/test/route.ts +3 -2
  16. package/src/app/api/openclaw/deploy/route.ts +2 -0
  17. package/src/app/api/setup/doctor/route.ts +4 -4
  18. package/src/components/agents/agent-chat-list.tsx +23 -1
  19. package/src/components/agents/inspector-panel.tsx +165 -48
  20. package/src/components/chat/chat-area.tsx +38 -9
  21. package/src/components/chat/message-list.tsx +33 -19
  22. package/src/components/gateways/gateway-sheet.tsx +5 -2
  23. package/src/lib/agent-execute-defaults.test.ts +24 -0
  24. package/src/lib/agent-execute-defaults.ts +62 -0
  25. package/src/lib/chat/queued-message-queue.test.ts +134 -1
  26. package/src/lib/chat/queued-message-queue.ts +77 -2
  27. package/src/lib/server/agents/agent-service.ts +5 -0
  28. package/src/lib/server/builtin-extensions.ts +1 -0
  29. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +1 -1
  30. package/src/lib/server/chat-execution/chat-execution-tool-events.test.ts +1 -0
  31. package/src/lib/server/chat-execution/chat-execution-utils.ts +2 -2
  32. package/src/lib/server/chat-execution/chat-turn-preparation.ts +79 -42
  33. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +4 -0
  34. package/src/lib/server/chat-execution/continuation-evaluator.ts +8 -0
  35. package/src/lib/server/chat-execution/memory-mutation-tools.ts +1 -1
  36. package/src/lib/server/chat-execution/message-classifier.ts +11 -1
  37. package/src/lib/server/chat-execution/prompt-builder.test.ts +28 -0
  38. package/src/lib/server/chat-execution/prompt-builder.ts +14 -1
  39. package/src/lib/server/chat-execution/prompt-mode.test.ts +24 -0
  40. package/src/lib/server/chat-execution/prompt-mode.ts +5 -1
  41. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +6 -4
  42. package/src/lib/server/chat-execution/stream-agent-chat.ts +45 -16
  43. package/src/lib/server/chatrooms/chatroom-routing.test.ts +4 -0
  44. package/src/lib/server/connectors/discord.ts +2 -2
  45. package/src/lib/server/connectors/matrix.ts +3 -2
  46. package/src/lib/server/connectors/signal.ts +5 -4
  47. package/src/lib/server/connectors/slack.ts +10 -9
  48. package/src/lib/server/connectors/teams.ts +3 -2
  49. package/src/lib/server/connectors/telegram.ts +4 -4
  50. package/src/lib/server/connectors/whatsapp.ts +2 -2
  51. package/src/lib/server/daemon/controller.ts +7 -0
  52. package/src/lib/server/gateways/gateway-profile-service.ts +19 -1
  53. package/src/lib/server/messages/message-repository.test.ts +70 -0
  54. package/src/lib/server/messages/message-repository.ts +11 -6
  55. package/src/lib/server/openclaw/deploy.ts +32 -2
  56. package/src/lib/server/plugins-advanced.test.ts +1 -2
  57. package/src/lib/server/provider-health.ts +1 -1
  58. package/src/lib/server/runtime/process-manager.ts +13 -9
  59. package/src/lib/server/runtime/session-run-manager/queries.ts +15 -0
  60. package/src/lib/server/runtime/session-run-manager.test.ts +58 -0
  61. package/src/lib/server/sandbox/session-runtime.test.ts +18 -1
  62. package/src/lib/server/sandbox/session-runtime.ts +40 -28
  63. package/src/lib/server/session-tools/autonomy-tools.test.ts +7 -9
  64. package/src/lib/server/session-tools/context.ts +1 -1
  65. package/src/lib/server/session-tools/credential-env.ts +109 -0
  66. package/src/lib/server/session-tools/crud.ts +3 -3
  67. package/src/lib/server/session-tools/edit_file.ts +3 -2
  68. package/src/lib/server/session-tools/execute.test.ts +58 -0
  69. package/src/lib/server/session-tools/execute.ts +334 -0
  70. package/src/lib/server/session-tools/files-tool.ts +635 -0
  71. package/src/lib/server/session-tools/index.ts +14 -4
  72. package/src/lib/server/session-tools/memory-tool.ts +242 -0
  73. package/src/lib/server/session-tools/memory.ts +1 -1
  74. package/src/lib/server/session-tools/openclaw-nodes.ts +3 -2
  75. package/src/lib/server/session-tools/openclaw-workspace.ts +3 -2
  76. package/src/lib/server/session-tools/platform-tool.ts +617 -0
  77. package/src/lib/server/session-tools/session-info.ts +3 -2
  78. package/src/lib/server/session-tools/session-tools-wiring.test.ts +3 -4
  79. package/src/lib/server/session-tools/shell.ts +7 -122
  80. package/src/lib/server/session-tools/skills-tool.ts +396 -0
  81. package/src/lib/server/session-tools/web.ts +2 -2
  82. package/src/lib/server/storage-normalization.ts +2 -0
  83. package/src/lib/server/tool-aliases.ts +2 -1
  84. package/src/lib/server/tool-capability-policy-advanced.test.ts +9 -2
  85. package/src/lib/server/tool-capability-policy.test.ts +2 -1
  86. package/src/lib/server/tool-capability-policy.ts +60 -33
  87. package/src/lib/server/tool-planning.ts +11 -0
  88. package/src/lib/setup-defaults.ts +5 -0
  89. package/src/lib/tool-definitions.ts +1 -0
  90. package/src/lib/validation/schemas.test.ts +16 -0
  91. package/src/lib/validation/schemas.ts +16 -0
  92. package/src/stores/use-chat-store.test.ts +231 -0
  93. package/src/stores/use-chat-store.ts +62 -13
  94. package/src/types/agent.ts +348 -0
  95. package/src/types/app-settings.ts +175 -0
  96. package/src/types/approval.ts +27 -0
  97. package/src/types/connector.ts +187 -0
  98. package/src/types/extension.ts +386 -0
  99. package/src/types/index.ts +16 -3555
  100. package/src/types/message.ts +57 -0
  101. package/src/types/misc.ts +739 -0
  102. package/src/types/mission.ts +185 -0
  103. package/src/types/protocol.ts +422 -0
  104. package/src/types/provider.ts +52 -0
  105. package/src/types/run.ts +183 -0
  106. package/src/types/schedule.ts +59 -0
  107. package/src/types/session.ts +265 -0
  108. package/src/types/skill.ts +157 -0
  109. package/src/types/task.ts +140 -0
  110. package/src/types/working-state.ts +211 -0
  111. package/src/views/settings/section-heartbeat.tsx +2 -2
  112. package/src/lib/server/session-tools/sandbox.ts +0 -281
@@ -16,6 +16,7 @@ import { SandboxEnvPanel } from './sandbox-env-panel'
16
16
  import { CronJobForm } from './cron-job-form'
17
17
  import { toast } from 'sonner'
18
18
  import { StatusDot } from '@/components/ui/status-dot'
19
+ import { normalizeAgentExecuteConfig } from '@/lib/agent-execute-defaults'
19
20
  import { normalizeAgentSandboxConfig } from '@/lib/agent-sandbox-defaults'
20
21
  import { getEnabledToolIds, getEnabledExtensionIds, getEnabledCapabilityIds } from '@/lib/capability-selection'
21
22
  import { searchMemory } from '@/lib/memory'
@@ -1139,7 +1140,8 @@ function ConfigTab({ agent }: { agent: Agent }) {
1139
1140
  const isOpenClaw = agent.provider === 'openclaw'
1140
1141
  const schedules = useAppStore((s) => s.schedules)
1141
1142
  const agentSchedules = Object.values(schedules).filter((s) => s.agentId === agent.id)
1142
- const [sandboxOpen, setSandboxOpen] = useState(false)
1143
+ const [executeOpen, setExecuteOpen] = useState(false)
1144
+ const [browserSandboxOpen, setBrowserSandboxOpen] = useState(false)
1143
1145
  const [openclawOpen, setOpenclawOpen] = useState(false)
1144
1146
  const [detailsOpen, setDetailsOpen] = useState(false)
1145
1147
 
@@ -1162,9 +1164,14 @@ function ConfigTab({ agent }: { agent: Agent }) {
1162
1164
  {/* Automations section */}
1163
1165
  <AutomationsSection schedules={agentSchedules} agent={agent} />
1164
1166
 
1165
- {/* Sandbox (collapsible) */}
1166
- <CollapsibleSection title="Sandbox" open={sandboxOpen} onToggle={() => setSandboxOpen((v) => !v)}>
1167
- <SandboxConfigSection agent={agent} />
1167
+ {/* Execute (collapsible) */}
1168
+ <CollapsibleSection title="Execute" open={executeOpen} onToggle={() => setExecuteOpen((v) => !v)}>
1169
+ <ExecuteToolConfigSection agent={agent} />
1170
+ </CollapsibleSection>
1171
+
1172
+ {/* Browser sandbox (collapsible) */}
1173
+ <CollapsibleSection title="Browser Sandbox" open={browserSandboxOpen} onToggle={() => setBrowserSandboxOpen((v) => !v)}>
1174
+ <BrowserSandboxSection agent={agent} />
1168
1175
  </CollapsibleSection>
1169
1176
 
1170
1177
  {/* OpenClaw settings (collapsible, OpenClaw only) */}
@@ -1327,13 +1334,85 @@ function AutomationsSection({ schedules, agent }: { schedules: Array<{ id: strin
1327
1334
  )
1328
1335
  }
1329
1336
 
1330
- // ─── Sandbox Config Section ──────────────────────────────────────
1337
+ // ─── Execute Config Section ──────────────────────────────────────
1338
+
1339
+ function ExecuteToolConfigSection({ agent }: { agent: Agent }) {
1340
+ const loadAgents = useAppStore((s) => s.loadAgents)
1341
+ const [saving, setSaving] = useState(false)
1342
+ const config = normalizeAgentExecuteConfig(agent.executeConfig)
1343
+
1344
+ const update = useCallback(async (patch: Partial<NonNullable<typeof agent.executeConfig>>) => {
1345
+ setSaving(true)
1346
+ try {
1347
+ const next = {
1348
+ ...config,
1349
+ ...patch,
1350
+ network: {
1351
+ ...(config.network || {}),
1352
+ ...((patch.network as Record<string, unknown> | undefined) || {}),
1353
+ },
1354
+ }
1355
+ await api('PUT', `/agents/${agent.id}`, { executeConfig: next })
1356
+ await loadAgents()
1357
+ } catch (err: unknown) {
1358
+ toast.error(err instanceof Error ? err.message : 'Failed to update execute config')
1359
+ } finally {
1360
+ setSaving(false)
1361
+ }
1362
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1363
+ }, [agent.id, config])
1364
+
1365
+ return (
1366
+ <div className="pt-3 flex flex-col gap-3">
1367
+ <div className="text-[11px] text-text-3/60">
1368
+ `execute` uses just-bash in sandbox mode by default. Host mode is explicit and required for persistent writes.
1369
+ </div>
1370
+ <div>
1371
+ <label className="text-[10px] text-text-3/50 block mb-1">Backend</label>
1372
+ <select
1373
+ value={config.backend || 'sandbox'}
1374
+ onChange={(e) => void update({ backend: e.target.value as 'sandbox' | 'host' })}
1375
+ disabled={saving}
1376
+ className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text outline-none cursor-pointer focus:border-accent-bright/30"
1377
+ >
1378
+ <option value="sandbox">sandbox (just-bash)</option>
1379
+ <option value="host">host (real bash)</option>
1380
+ </select>
1381
+ </div>
1382
+ <div className="flex items-center justify-between">
1383
+ <span className="text-[11px] text-text-3/60">Allow network in sandbox mode</span>
1384
+ <ToggleSwitch
1385
+ on={config.network?.enabled !== false}
1386
+ onChange={() => void update({ network: { ...(config.network || {}), enabled: config.network?.enabled === false } })}
1387
+ disabled={saving}
1388
+ />
1389
+ </div>
1390
+ <div>
1391
+ <label className="text-[10px] text-text-3/50 block mb-1">Timeout (seconds)</label>
1392
+ <input
1393
+ type="number"
1394
+ defaultValue={config.timeout || 30}
1395
+ min={1}
1396
+ max={300}
1397
+ onBlur={(e) => void update({ timeout: Math.max(1, Math.min(300, Number(e.target.value) || 30)) })}
1398
+ className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text font-mono outline-none focus:border-accent-bright/30"
1399
+ />
1400
+ </div>
1401
+ <div className="text-[11px] text-text-3/50">
1402
+ `shell` remains the host command/process tool. Use `execute` for sandboxed one-shot scripts.
1403
+ </div>
1404
+ </div>
1405
+ )
1406
+ }
1407
+
1408
+ // ─── Browser Sandbox Section ─────────────────────────────────────
1331
1409
 
1332
- function SandboxConfigSection({ agent }: { agent: Agent }) {
1410
+ function BrowserSandboxSection({ agent }: { agent: Agent }) {
1333
1411
  const loadAgents = useAppStore((s) => s.loadAgents)
1334
1412
  const [saving, setSaving] = useState(false)
1335
1413
  const [dockerAvailable, setDockerAvailable] = useState<boolean | null>(null)
1336
1414
  const config = normalizeAgentSandboxConfig(agent.sandboxConfig)
1415
+ const browserEnabled = config.enabled && config.browser?.enabled !== false
1337
1416
 
1338
1417
  useEffect(() => {
1339
1418
  api<{ docker?: { available: boolean; version?: string | null } }>('GET', '/setup/doctor')
@@ -1344,7 +1423,16 @@ function SandboxConfigSection({ agent }: { agent: Agent }) {
1344
1423
  const update = useCallback(async (patch: Partial<NonNullable<typeof agent.sandboxConfig>>) => {
1345
1424
  setSaving(true)
1346
1425
  try {
1347
- const next = { ...config, ...patch }
1426
+ const next = {
1427
+ ...config,
1428
+ ...patch,
1429
+ browser: patch.browser === null
1430
+ ? null
1431
+ : {
1432
+ ...(config.browser || {}),
1433
+ ...((patch.browser as Record<string, unknown> | undefined) || {}),
1434
+ },
1435
+ }
1348
1436
  await api('PUT', `/agents/${agent.id}`, { sandboxConfig: next })
1349
1437
  await loadAgents()
1350
1438
  } catch (err: unknown) {
@@ -1358,69 +1446,98 @@ function SandboxConfigSection({ agent }: { agent: Agent }) {
1358
1446
  return (
1359
1447
  <div className="pt-3">
1360
1448
  <div className="flex items-center justify-between mb-3">
1361
- <span className="text-[12px] text-text-2">Prefer Docker sandboxes</span>
1362
- <ToggleSwitch on={config.enabled} onChange={() => void update({ enabled: !config.enabled })} disabled={saving} />
1449
+ <span className="text-[12px] text-text-2">Use Docker browser sandbox</span>
1450
+ <ToggleSwitch
1451
+ on={browserEnabled}
1452
+ onChange={() => void update({
1453
+ enabled: !browserEnabled,
1454
+ browser: {
1455
+ ...(config.browser || {}),
1456
+ enabled: !browserEnabled,
1457
+ },
1458
+ })}
1459
+ disabled={saving}
1460
+ />
1363
1461
  </div>
1364
1462
  {dockerAvailable === false && (
1365
1463
  <div className="text-[11px] text-amber-400/80 bg-amber-400/[0.06] rounded-[8px] px-2.5 py-2 mb-3 border border-amber-400/10">
1366
- Docker is not detected. SwarmClaw will fall back to host execution.
1464
+ Docker is not detected. Browser automation will use the host Playwright runtime.
1367
1465
  </div>
1368
1466
  )}
1369
1467
  {dockerAvailable === true && (
1370
1468
  <div className="text-[11px] text-emerald-400/70 mb-3 flex items-center gap-1.5">
1371
- <StatusDot status="online" size="sm" /> Docker available
1469
+ <StatusDot status="online" size="sm" /> Docker available for browser sandboxing
1372
1470
  </div>
1373
1471
  )}
1374
- {config.enabled && (
1472
+ {browserEnabled && (
1375
1473
  <div className="flex flex-col gap-2.5 mt-1">
1376
1474
  <div>
1377
- <label className="text-[10px] text-text-3/50 block mb-1">Image</label>
1378
- <input
1379
- type="text"
1380
- defaultValue={config.image || 'node:22-slim'}
1381
- onBlur={(e) => void update({ image: e.target.value.trim() || 'node:22-slim' })}
1382
- className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text font-mono outline-none focus:border-accent-bright/30"
1383
- />
1475
+ <label className="text-[10px] text-text-3/50 block mb-1">Scope</label>
1476
+ <select
1477
+ defaultValue={config.scope || 'session'}
1478
+ onChange={(e) => void update({ scope: e.target.value as 'session' | 'agent' })}
1479
+ className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text outline-none cursor-pointer focus:border-accent-bright/30"
1480
+ >
1481
+ <option value="session">session</option>
1482
+ <option value="agent">agent</option>
1483
+ </select>
1384
1484
  </div>
1385
1485
  <div>
1386
- <label className="text-[10px] text-text-3/50 block mb-1">Network</label>
1486
+ <label className="text-[10px] text-text-3/50 block mb-1">Mode</label>
1387
1487
  <select
1388
- defaultValue={config.network || 'none'}
1389
- onChange={(e) => void update({ network: e.target.value as 'none' | 'bridge' })}
1488
+ defaultValue={config.mode === 'non-main' ? 'non-main' : 'all'}
1489
+ onChange={(e) => void update({ mode: e.target.value as 'all' | 'non-main' })}
1490
+ className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text outline-none cursor-pointer focus:border-accent-bright/30"
1491
+ >
1492
+ <option value="all">all sessions</option>
1493
+ <option value="non-main">non-main sessions only</option>
1494
+ </select>
1495
+ </div>
1496
+ <div>
1497
+ <label className="text-[10px] text-text-3/50 block mb-1">Workspace access</label>
1498
+ <select
1499
+ defaultValue={config.workspaceAccess || 'rw'}
1500
+ onChange={(e) => void update({ workspaceAccess: e.target.value as 'ro' | 'rw' })}
1501
+ className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text outline-none cursor-pointer focus:border-accent-bright/30"
1502
+ >
1503
+ <option value="rw">read/write</option>
1504
+ <option value="ro">read-only</option>
1505
+ </select>
1506
+ </div>
1507
+ <div>
1508
+ <label className="text-[10px] text-text-3/50 block mb-1">Browser network</label>
1509
+ <select
1510
+ defaultValue={config.browser?.network || 'bridge'}
1511
+ onChange={(e) => void update({ browser: { ...(config.browser || {}), network: e.target.value as 'none' | 'bridge' } })}
1390
1512
  className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text outline-none cursor-pointer focus:border-accent-bright/30"
1391
1513
  >
1392
1514
  <option value="none">none (isolated)</option>
1393
1515
  <option value="bridge">bridge (internet access)</option>
1394
1516
  </select>
1395
1517
  </div>
1396
- <div className="grid grid-cols-2 gap-2">
1397
- <div>
1398
- <label className="text-[10px] text-text-3/50 block mb-1">Memory (MB)</label>
1399
- <input
1400
- type="number"
1401
- defaultValue={config.memoryMb || 512}
1402
- min={64}
1403
- max={8192}
1404
- onBlur={(e) => void update({ memoryMb: Math.max(64, Math.min(8192, Number(e.target.value) || 512)) })}
1405
- className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text font-mono outline-none focus:border-accent-bright/30"
1406
- />
1407
- </div>
1408
- <div>
1409
- <label className="text-[10px] text-text-3/50 block mb-1">CPUs</label>
1410
- <input
1411
- type="number"
1412
- defaultValue={config.cpus || 1.0}
1413
- min={0.25}
1414
- max={8}
1415
- step={0.25}
1416
- onBlur={(e) => void update({ cpus: Math.max(0.25, Math.min(8, Number(e.target.value) || 1)) })}
1417
- className="w-full rounded-[8px] border border-white/[0.06] bg-black/[0.14] px-2.5 py-1.5 text-[12px] text-text font-mono outline-none focus:border-accent-bright/30"
1418
- />
1419
- </div>
1518
+ <div className="flex items-center justify-between">
1519
+ <span className="text-[11px] text-text-3/60">Headless browser</span>
1520
+ <ToggleSwitch
1521
+ on={config.browser?.headless !== false}
1522
+ onChange={() => void update({ browser: { ...(config.browser || {}), headless: config.browser?.headless === false } })}
1523
+ disabled={saving}
1524
+ />
1420
1525
  </div>
1421
1526
  <div className="flex items-center justify-between">
1422
- <span className="text-[11px] text-text-3/60">Read-only root filesystem</span>
1423
- <ToggleSwitch on={config.readonlyRoot ?? false} onChange={() => void update({ readonlyRoot: !config.readonlyRoot })} disabled={saving} />
1527
+ <span className="text-[11px] text-text-3/60">Enable noVNC observer</span>
1528
+ <ToggleSwitch
1529
+ on={config.browser?.enableNoVnc !== false}
1530
+ onChange={() => void update({ browser: { ...(config.browser || {}), enableNoVnc: config.browser?.enableNoVnc === false } })}
1531
+ disabled={saving}
1532
+ />
1533
+ </div>
1534
+ <div className="flex items-center justify-between">
1535
+ <span className="text-[11px] text-text-3/60">Mount uploads into sandbox browser</span>
1536
+ <ToggleSwitch
1537
+ on={config.browser?.mountUploads !== false}
1538
+ onChange={() => void update({ browser: { ...(config.browser || {}), mountUploads: config.browser?.mountUploads === false } })}
1539
+ disabled={saving}
1540
+ />
1424
1541
  </div>
1425
1542
  </div>
1426
1543
  )}
@@ -57,6 +57,7 @@ export function ChatArea() {
57
57
  const refreshSession = useAppStore((s) => s.refreshSession)
58
58
  const appSettings = useAppStore((s) => s.appSettings)
59
59
  const messages = useChatStore((s) => s.messages)
60
+ const messageStartIndex = useChatStore((s) => s.messageStartIndex)
60
61
  const setMessages = useChatStore((s) => s.setMessages)
61
62
  const streaming = useChatStore((s) => s.streaming)
62
63
  const streamingSessionId = useChatStore((s) => s.streamingSessionId)
@@ -179,15 +180,15 @@ export function ChatArea() {
179
180
  const preserveLocalStream = chatState.streaming && chatState.streamingSessionId === requestedSessionId
180
181
  // Clear stale messages immediately so the skeleton loader shows instead of
181
182
  // the previous chat's messages flashing briefly during the fetch.
182
- if (!preserveLocalStream) setMessages([])
183
+ if (!preserveLocalStream) setMessages([], { startIndex: 0, totalMessages: 0 })
183
184
  setMessagesLoading(true)
184
185
  if (!preserveLocalStream) {
185
186
  useChatStore.setState({ streaming: false, streamingSessionId: null, streamSource: null, streamText: '', assistantRenderId: null, toolEvents: [] })
186
187
  }
187
188
  fetchMessagesPaginated(requestedSessionId, 100).then((data) => {
188
189
  if (cancelled || selectActiveSessionId(useAppStore.getState()) !== requestedSessionId) return
189
- setMessages(data.messages)
190
- useChatStore.setState({ hasMoreMessages: data.hasMore, totalMessages: data.total })
190
+ setMessages(data.messages, { startIndex: data.startIndex, totalMessages: data.total })
191
+ useChatStore.setState({ hasMoreMessages: data.hasMore })
191
192
  }).catch((err) => {
192
193
  if (cancelled || selectActiveSessionId(useAppStore.getState()) !== requestedSessionId) return
193
194
  console.error('Failed to load messages:', err)
@@ -197,6 +198,12 @@ export function ChatArea() {
197
198
  fallbackSession?.messages?.length
198
199
  ? fallbackSession.messages
199
200
  : (fallbackLastMessage ? [fallbackLastMessage] : []),
201
+ {
202
+ startIndex: 0,
203
+ totalMessages: fallbackSession?.messages?.length
204
+ ? fallbackSession.messages.length
205
+ : (fallbackLastMessage ? 1 : 0),
206
+ },
200
207
  )
201
208
  }).finally(() => {
202
209
  if (cancelled || selectActiveSessionId(useAppStore.getState()) !== requestedSessionId) return
@@ -268,6 +275,8 @@ export function ChatArea() {
268
275
  const shouldPollMessages = !!sessionId && (isServerActive || isOngoingMonitored)
269
276
  const messagesRef = useRef(messages)
270
277
  messagesRef.current = messages
278
+ const messageStartIndexRef = useRef(messageStartIndex)
279
+ messageStartIndexRef.current = messageStartIndex
271
280
  const isServerActiveRef = useRef(isServerActive)
272
281
  isServerActiveRef.current = isServerActive
273
282
  const ttsEnabledRef = useRef(ttsEnabled)
@@ -287,8 +296,9 @@ export function ChatArea() {
287
296
  if (currentChatState.streaming && currentChatState.streamingSessionId === sessionId && currentChatState.streamSource === 'local') return
288
297
  const previous = messagesRef.current
289
298
  if (messagesDiffer(msgs, previous)) {
290
- const newMsgs = msgs.length > previous.length ? msgs.slice(previous.length) : []
291
- setMessages(msgs)
299
+ const previousEndIndex = messageStartIndexRef.current + previous.length
300
+ const newMsgs = msgs.length > previousEndIndex ? msgs.slice(previousEndIndex) : []
301
+ setMessages(msgs, { startIndex: 0, totalMessages: msgs.length })
292
302
  if (ttsEnabledRef.current && typeof document !== 'undefined' && document.visibilityState === 'visible') {
293
303
  const latestAssistant = [...newMsgs].reverse().find((m) => {
294
304
  if (m.role !== 'assistant') return false
@@ -305,15 +315,34 @@ export function ChatArea() {
305
315
  // eslint-disable-next-line react-hooks/exhaustive-deps
306
316
  }, [sessionId])
307
317
 
318
+ // Targeted message fetch that bypasses the streaming guard — used by
319
+ // refreshQueue to ensure persisted messages appear before sending queue
320
+ // items are cleaned up by timeout.
321
+ const syncMessagesForQueue = useCallback(async () => {
322
+ if (!sessionId) return
323
+ try {
324
+ const msgs = await fetchMessages(sessionId)
325
+ if (messagesDiffer(msgs, messagesRef.current)) {
326
+ setMessages(msgs, { startIndex: 0, totalMessages: msgs.length })
327
+ }
328
+ } catch (err) { console.error('Failed to sync messages for queue:', err) }
329
+ }, [sessionId, setMessages])
330
+
308
331
  const refreshQueue = useCallback(async () => {
309
332
  if (!sessionId) return
310
333
  try {
311
334
  await loadQueuedMessages(sessionId)
335
+ // If there are "sending" queue items, fetch messages so persisted
336
+ // versions appear before the queue item gets cleaned up.
337
+ const chatState = useChatStore.getState()
338
+ const hasSendingItems = chatState.queuedMessages.some(
339
+ (item) => item.sessionId === sessionId && item.sending,
340
+ )
341
+ if (hasSendingItems) void syncMessagesForQueue()
312
342
  // Bridge the gap between "queue item disappears" and "isServerActive propagates".
313
343
  // If the server picked up a queued run, immediately show the thinking indicator
314
344
  // so users don't see a blank gap waiting for loadSessions to propagate.
315
345
  const refreshedSession = useAppStore.getState().sessions[sessionId]
316
- const chatState = useChatStore.getState()
317
346
  if (
318
347
  refreshedSession?.currentRunId
319
348
  && !chatState.streaming
@@ -324,7 +353,7 @@ export function ChatArea() {
324
353
  } catch (err) {
325
354
  console.error('Failed to refresh queue:', err)
326
355
  }
327
- }, [loadQueuedMessages, sessionId, startServerStreamingPlaceholder])
356
+ }, [loadQueuedMessages, syncMessagesForQueue, sessionId, startServerStreamingPlaceholder])
328
357
 
329
358
  // Subscribe to WS messages for this session — always subscribe when session exists,
330
359
  // only enable fallback polling when actively needed
@@ -369,7 +398,7 @@ export function ChatArea() {
369
398
  && (state.streamingSessionId === sessionId || state.streamingSessionId == null)
370
399
  ) {
371
400
  // Server finished — clear all streaming state and fetch final messages
372
- fetchMessages(sessionId).then(setMessages).catch(() => {})
401
+ fetchMessages(sessionId).then((msgs) => setMessages(msgs, { startIndex: 0, totalMessages: msgs.length })).catch(() => {})
373
402
  markSessionLocallyIdle(sessionId)
374
403
  useChatStore.setState({ streaming: false, streamingSessionId: null, streamSource: null, streamText: '', displayText: '', assistantRenderId: null, streamPhase: 'thinking', streamToolName: '', thinkingText: '', thinkingStartTime: 0 })
375
404
  }
@@ -404,7 +433,7 @@ export function ChatArea() {
404
433
  setConfirmClear(false)
405
434
  if (!sessionId) return
406
435
  await clearMessages(sessionId)
407
- setMessages([])
436
+ setMessages([], { startIndex: 0, totalMessages: 0 })
408
437
  await refreshSession(sessionId)
409
438
  }, [refreshSession, sessionId, setMessages])
410
439
 
@@ -10,6 +10,7 @@ import { selectActiveSessionId } from '@/stores/slices/session-slice'
10
10
  import { api } from '@/lib/app/api-client'
11
11
  import { buildStreamingAwareMessageList } from '@/lib/chat/chat-streaming-state'
12
12
  import { dedupeMessagesForDisplay } from '@/lib/chat/chat-display'
13
+ import { mergeQueuedTranscriptMessages } from '@/lib/chat/queued-message-queue'
13
14
  import { shouldShowDateSeparator } from '@/lib/chat/message-list-utils'
14
15
  import { errorMessage } from '@/lib/shared-utils'
15
16
  import { AgentAvatar } from '@/components/agents/agent-avatar'
@@ -190,7 +191,9 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
190
191
  const assistantRenderId = useChatStore((s) => s.assistantRenderId)
191
192
  const thinkingStartTime = useChatStore((s) => s.thinkingStartTime)
192
193
  const hasLiveArtifacts = useChatStore(selectHasLiveArtifacts)
194
+ const messageStartIndex = useChatStore((s) => s.messageStartIndex)
193
195
  const setMessages = useChatStore((s) => s.setMessages)
196
+ const queuedMessages = useChatStore((s) => s.queuedMessages)
194
197
  const retryLastMessage = useChatStore((s) => s.retryLastMessage)
195
198
  const editAndResend = useChatStore((s) => s.editAndResend)
196
199
  const sendMessage = useChatStore((s) => s.sendMessage)
@@ -234,20 +237,23 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
234
237
  // Use refs for callbacks so transcriptNodes memo doesn't bust on every messages change
235
238
  const messagesCallbackRef = useRef(messages)
236
239
  messagesCallbackRef.current = messages
240
+ const messageStartIndexRef = useRef(messageStartIndex)
241
+ messageStartIndexRef.current = messageStartIndex
237
242
  const sessionIdRef = useRef(sessionId)
238
243
  sessionIdRef.current = sessionId
239
244
 
240
- const toggleBookmark = useCallback(async (index: number) => {
245
+ const toggleBookmark = useCallback(async (absoluteIndex: number) => {
241
246
  const sid = sessionIdRef.current
242
247
  const msgs = messagesCallbackRef.current
248
+ const localIndex = absoluteIndex - messageStartIndexRef.current
243
249
  if (!sid) return
244
- const msg = msgs[index]
250
+ const msg = msgs[localIndex]
245
251
  if (!msg) return
246
252
  const next = !msg.bookmarked
247
253
  try {
248
- await api('PUT', `/chats/${sid}/messages`, { messageIndex: index, bookmarked: next })
254
+ await api('PUT', `/chats/${sid}/messages`, { messageIndex: absoluteIndex, bookmarked: next })
249
255
  const updated = [...msgs]
250
- updated[index] = { ...updated[index], bookmarked: next }
256
+ updated[localIndex] = { ...updated[localIndex], bookmarked: next }
251
257
  setMessages(updated)
252
258
  } catch (err: unknown) {
253
259
  console.error('Failed to toggle bookmark:', errorMessage(err))
@@ -298,21 +304,26 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
298
304
  return dedupeMessagesForDisplay(displayedMessages)
299
305
  }, [messages, showAlerts, showOk])
300
306
 
307
+ const displayedMessages = useMemo(
308
+ () => mergeQueuedTranscriptMessages(baseDisplayedMessages, queuedMessages, sessionId),
309
+ [baseDisplayedMessages, queuedMessages, sessionId],
310
+ )
311
+
301
312
  const latestPersistedStreamingMessage = useMemo(() => {
302
- for (let i = baseDisplayedMessages.length - 1; i >= 0; i -= 1) {
303
- const candidate = baseDisplayedMessages[i]
313
+ for (let i = displayedMessages.length - 1; i >= 0; i -= 1) {
314
+ const candidate = displayedMessages[i]
304
315
  if (candidate.role === 'assistant' && candidate.streaming === true) {
305
316
  return candidate
306
317
  }
307
318
  }
308
319
  return null
309
- }, [baseDisplayedMessages])
320
+ }, [displayedMessages])
310
321
 
311
322
  const currentRunHasCompletedAssistant = useMemo(
312
323
  () => (
313
324
  streaming
314
325
  && thinkingStartTime > 0
315
- && baseDisplayedMessages.some((message) => (
326
+ && displayedMessages.some((message) => (
316
327
  message.role === 'assistant'
317
328
  && message.streaming !== true
318
329
  && message.kind !== 'system'
@@ -321,7 +332,7 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
321
332
  && message.time >= thinkingStartTime
322
333
  ))
323
334
  ),
324
- [baseDisplayedMessages, streaming, thinkingStartTime],
335
+ [displayedMessages, streaming, thinkingStartTime],
325
336
  )
326
337
 
327
338
  const showLiveStreamRow = streaming
@@ -330,14 +341,14 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
330
341
  && (hasLiveArtifacts || !!latestPersistedStreamingMessage)
331
342
 
332
343
  const streamingAwareMessages = useMemo(() => (
333
- buildStreamingAwareMessageList(baseDisplayedMessages, {
344
+ buildStreamingAwareMessageList(displayedMessages, {
334
345
  localStreaming: streaming,
335
346
  hasLiveArtifacts,
336
347
  assistantRenderId,
337
348
  showLiveRow: showLiveStreamRow,
338
349
  syntheticAssistant: latestPersistedStreamingMessage,
339
350
  })
340
- ), [assistantRenderId, baseDisplayedMessages, hasLiveArtifacts, latestPersistedStreamingMessage, showLiveStreamRow, streaming])
351
+ ), [assistantRenderId, displayedMessages, hasLiveArtifacts, latestPersistedStreamingMessage, showLiveStreamRow, streaming])
341
352
 
342
353
  const filteredMessages = useMemo(() => {
343
354
  let nextMessages = bookmarkFilter
@@ -367,23 +378,26 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
367
378
  const originalIndexMap = useMemo(() => {
368
379
  const indexMap = new Map<Message, number>()
369
380
  messages.forEach((msg, index) => {
370
- indexMap.set(msg, index)
381
+ indexMap.set(msg, messageStartIndex + index)
371
382
  })
372
383
  return indexMap
373
- }, [messages])
384
+ }, [messageStartIndex, messages])
374
385
 
375
- const handleDeleteMessage = useCallback(async (messageIndex: number) => {
386
+ const handleDeleteMessage = useCallback(async (absoluteIndex: number) => {
376
387
  const sid = sessionIdRef.current
377
388
  const msgs = messagesCallbackRef.current
378
- if (!sid || messageIndex < 0) return
389
+ const localIndex = absoluteIndex - messageStartIndexRef.current
390
+ if (!sid || absoluteIndex < 0 || localIndex < 0) return
379
391
  try {
380
- await api('DELETE', `/chats/${sid}/messages`, { messageIndex })
381
- setMessages(msgs.filter((_: Message, idx: number) => idx !== messageIndex))
392
+ await api('DELETE', `/chats/${sid}/messages`, { messageIndex: absoluteIndex })
393
+ setMessages(
394
+ msgs.filter((_: Message, idx: number) => idx !== localIndex),
395
+ { totalMessages: Math.max(0, totalMessages - 1) },
396
+ )
382
397
  } catch {
383
398
  // best-effort
384
399
  }
385
- // eslint-disable-next-line react-hooks/exhaustive-deps
386
- }, [])
400
+ }, [setMessages, totalMessages])
387
401
 
388
402
  // Snapshot the settled count at memo time so it's captured in the closure.
389
403
  // Messages up to this count appear instantly; only new ones get entrance animations.
@@ -118,11 +118,14 @@ export function GatewaySheet() {
118
118
  setInvokeParamsText('{}')
119
119
  }, [open, editing, gatewayProfiles.length])
120
120
 
121
+ const refreshRef = useRef(refreshGatewayTopologyMutation)
122
+ refreshRef.current = refreshGatewayTopologyMutation
123
+
121
124
  const loadNodesAndDevices = useCallback(async (profileId: string) => {
122
125
  setNodesLoading(true)
123
126
  setNodesError('')
124
127
  try {
125
- const result = await refreshGatewayTopologyMutation.mutateAsync(profileId)
128
+ const result = await refreshRef.current.mutateAsync(profileId)
126
129
  setNodes(result.nodes)
127
130
  setNodePairings(result.nodePairings)
128
131
  setDevicePairings(result.devicePairings)
@@ -136,7 +139,7 @@ export function GatewaySheet() {
136
139
  } finally {
137
140
  setNodesLoading(false)
138
141
  }
139
- }, [refreshGatewayTopologyMutation])
142
+ }, [])
140
143
 
141
144
  useEffect(() => {
142
145
  if (!open || !editing?.id) return
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import {
4
+ DEFAULT_AGENT_EXECUTE_CONFIG,
5
+ normalizeAgentExecuteConfig,
6
+ } from '@/lib/agent-execute-defaults'
7
+
8
+ test('normalizeAgentExecuteConfig defaults to sandbox with network enabled', () => {
9
+ const normalized = normalizeAgentExecuteConfig(undefined)
10
+
11
+ assert.equal(normalized.backend, 'sandbox')
12
+ assert.equal(normalized.network?.enabled, true)
13
+ assert.equal(normalized.timeout, DEFAULT_AGENT_EXECUTE_CONFIG.timeout)
14
+ })
15
+
16
+ test('normalizeAgentExecuteConfig preserves explicit host backend and clamps timeout', () => {
17
+ const normalized = normalizeAgentExecuteConfig({
18
+ backend: 'host',
19
+ timeout: 999,
20
+ })
21
+
22
+ assert.equal(normalized.backend, 'host')
23
+ assert.equal(normalized.timeout, 300)
24
+ })
@@ -0,0 +1,62 @@
1
+ import type { Agent } from '@/types'
2
+
3
+ export type AgentExecuteConfig = NonNullable<Agent['executeConfig']>
4
+
5
+ export const DEFAULT_AGENT_EXECUTE_CONFIG: AgentExecuteConfig = {
6
+ backend: 'sandbox',
7
+ network: {
8
+ enabled: true,
9
+ },
10
+ timeout: 30,
11
+ }
12
+
13
+ function asRecord(value: unknown): Record<string, unknown> | null {
14
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
15
+ return value as Record<string, unknown>
16
+ }
17
+
18
+ function normalizeStringList(value: unknown): string[] | undefined {
19
+ if (!Array.isArray(value)) return undefined
20
+ const values = value
21
+ .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
22
+ .map((entry) => entry.trim())
23
+ return values.length > 0 ? values : undefined
24
+ }
25
+
26
+ function normalizePositiveInt(value: unknown, fallback: number, min: number, max: number): number {
27
+ const parsed = typeof value === 'number'
28
+ ? value
29
+ : typeof value === 'string'
30
+ ? Number.parseInt(value, 10)
31
+ : Number.NaN
32
+ if (!Number.isFinite(parsed)) return fallback
33
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
34
+ }
35
+
36
+ export function normalizeAgentExecuteConfig(config: Agent['executeConfig'] | unknown): AgentExecuteConfig {
37
+ const input = asRecord(config)
38
+ const networkInput = asRecord(input?.network)
39
+ const runtimesInput = asRecord(input?.runtimes)
40
+ const defaultNetworkEnabled = DEFAULT_AGENT_EXECUTE_CONFIG.network?.enabled ?? true
41
+ const allowedUrls = normalizeStringList(networkInput?.allowedUrls)
42
+ const credentials = normalizeStringList(input?.credentials)
43
+
44
+ return {
45
+ backend: input?.backend === 'host' ? 'host' : DEFAULT_AGENT_EXECUTE_CONFIG.backend,
46
+ network: {
47
+ enabled: networkInput?.enabled === false ? false : defaultNetworkEnabled,
48
+ ...(allowedUrls ? { allowedUrls } : {}),
49
+ },
50
+ ...(runtimesInput
51
+ ? {
52
+ runtimes: {
53
+ ...(typeof runtimesInput.python === 'boolean' ? { python: runtimesInput.python } : {}),
54
+ ...(typeof runtimesInput.javascript === 'boolean' ? { javascript: runtimesInput.javascript } : {}),
55
+ ...(typeof runtimesInput.sqlite === 'boolean' ? { sqlite: runtimesInput.sqlite } : {}),
56
+ },
57
+ }
58
+ : {}),
59
+ timeout: normalizePositiveInt(input?.timeout, DEFAULT_AGENT_EXECUTE_CONFIG.timeout ?? 30, 1, 300),
60
+ ...(credentials ? { credentials } : {}),
61
+ }
62
+ }