@swarmclawai/swarmclaw 0.7.4 → 0.7.5
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 +5 -5
- package/package.json +1 -1
- package/src/app/api/agents/[id]/thread/route.ts +4 -89
- package/src/lib/server/agent-thread-session.test.ts +85 -0
- package/src/lib/server/agent-thread-session.ts +123 -0
- package/src/lib/server/data-dir.test.ts +56 -0
- package/src/lib/server/data-dir.ts +15 -9
- package/src/lib/server/heartbeat-service.ts +13 -4
- package/src/lib/server/heartbeat-wake.ts +6 -2
package/README.md
CHANGED
|
@@ -117,7 +117,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
|
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
The installer resolves the latest stable release tag and installs that version by default.
|
|
120
|
-
To pin a version: `SWARMCLAW_VERSION=v0.7.
|
|
120
|
+
To pin a version: `SWARMCLAW_VERSION=v0.7.5 curl ... | bash`
|
|
121
121
|
|
|
122
122
|
Or run locally from the repo (friendly for non-technical users):
|
|
123
123
|
|
|
@@ -670,7 +670,7 @@ npm run update:easy # safe update helper for local installs
|
|
|
670
670
|
SwarmClaw uses tag-based releases (`vX.Y.Z`) as the stable channel.
|
|
671
671
|
|
|
672
672
|
```bash
|
|
673
|
-
# example patch release (v0.7.
|
|
673
|
+
# example patch release (v0.7.5 style)
|
|
674
674
|
npm version patch
|
|
675
675
|
git push origin main --follow-tags
|
|
676
676
|
```
|
|
@@ -680,13 +680,13 @@ On `v*` tags, GitHub Actions will:
|
|
|
680
680
|
2. Create a GitHub Release
|
|
681
681
|
3. Build and publish Docker images to `ghcr.io/swarmclawai/swarmclaw` (`:vX.Y.Z`, `:latest`, `:sha-*`)
|
|
682
682
|
|
|
683
|
-
#### v0.7.
|
|
683
|
+
#### v0.7.5 Release Readiness Notes
|
|
684
684
|
|
|
685
|
-
Before shipping `v0.7.
|
|
685
|
+
Before shipping `v0.7.5`, confirm the following user-facing changes are reflected in docs:
|
|
686
686
|
|
|
687
687
|
1. Sandbox docs are updated everywhere to reflect the current Deno-only `sandbox_exec` behavior and the guidance to prefer `http_request` for simple API calls.
|
|
688
688
|
2. OpenClaw docs cover the current gateway/runtime behavior, including per-agent gateway routing, control-plane actions, and inspector-side advanced controls.
|
|
689
|
-
3. Site and README install/version strings are updated to `v0.7.
|
|
689
|
+
3. Site and README install/version strings are updated to `v0.7.5`, including install snippets, release notes index text, and sidebar/footer labels.
|
|
690
690
|
4. Release notes summarize the user-visible setup/auth/runtime changes from the current worktree, especially gateway/external-agent/setup flow improvements.
|
|
691
691
|
5. CLI and tool docs do not reference removed or non-functional surfaces such as the old `openclaw_sandbox` bridge.
|
|
692
692
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -1,98 +1,13 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import {
|
|
3
|
-
import { loadAgents, saveAgents, loadSessions, saveSessions } from '@/lib/server/storage'
|
|
4
|
-
import { WORKSPACE_DIR } from '@/lib/server/data-dir'
|
|
5
|
-
import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agent-runtime-config'
|
|
2
|
+
import { ensureAgentThreadSession } from '@/lib/server/agent-thread-session'
|
|
6
3
|
|
|
7
4
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
8
5
|
const { id: agentId } = await params
|
|
9
|
-
const agents = loadAgents()
|
|
10
|
-
const agent = agents[agentId]
|
|
11
|
-
if (!agent) {
|
|
12
|
-
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
|
13
|
-
}
|
|
14
|
-
|
|
15
6
|
const body = await req.json().catch(() => ({}))
|
|
16
7
|
const user = body.user || 'default'
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (agent.threadSessionId && sessions[agent.threadSessionId]) {
|
|
21
|
-
const existing = sessions[agent.threadSessionId] as Record<string, unknown>
|
|
22
|
-
let changed = false
|
|
23
|
-
if (existing.shortcutForAgentId !== agentId) {
|
|
24
|
-
existing.shortcutForAgentId = agentId
|
|
25
|
-
changed = true
|
|
26
|
-
}
|
|
27
|
-
if (existing.name !== agent.name) {
|
|
28
|
-
existing.name = agent.name
|
|
29
|
-
changed = true
|
|
30
|
-
}
|
|
31
|
-
if (changed) saveSessions(sessions)
|
|
32
|
-
return NextResponse.json(existing)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Legacy fallback for older shortcut sessions that were named using the
|
|
36
|
-
// old agent-thread convention before the explicit link was persisted.
|
|
37
|
-
const existing = Object.values(sessions).find(
|
|
38
|
-
(s: Record<string, unknown>) =>
|
|
39
|
-
(
|
|
40
|
-
s.shortcutForAgentId === agentId
|
|
41
|
-
|| s.name === `agent-thread:${agentId}`
|
|
42
|
-
)
|
|
43
|
-
&& s.user === user
|
|
44
|
-
)
|
|
45
|
-
if (existing) {
|
|
46
|
-
agent.threadSessionId = (existing as Record<string, unknown>).id as string
|
|
47
|
-
agent.updatedAt = Date.now()
|
|
48
|
-
saveAgents(agents)
|
|
49
|
-
let changed = false
|
|
50
|
-
const existingRecord = existing as Record<string, unknown>
|
|
51
|
-
if (existingRecord.shortcutForAgentId !== agentId) {
|
|
52
|
-
existingRecord.shortcutForAgentId = agentId
|
|
53
|
-
changed = true
|
|
54
|
-
}
|
|
55
|
-
if (existingRecord.name !== agent.name) {
|
|
56
|
-
existingRecord.name = agent.name
|
|
57
|
-
changed = true
|
|
58
|
-
}
|
|
59
|
-
if (changed) saveSessions(sessions)
|
|
60
|
-
return NextResponse.json(existing)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Create a new shortcut chat session for this agent.
|
|
64
|
-
const sessionId = `agent-chat-${agentId}-${genId()}`
|
|
65
|
-
const now = Date.now()
|
|
66
|
-
const baseSession = {
|
|
67
|
-
id: sessionId,
|
|
68
|
-
name: agent.name,
|
|
69
|
-
shortcutForAgentId: agentId,
|
|
70
|
-
cwd: WORKSPACE_DIR,
|
|
71
|
-
user: user,
|
|
72
|
-
provider: agent.provider,
|
|
73
|
-
model: agent.model,
|
|
74
|
-
credentialId: agent.credentialId || null,
|
|
75
|
-
fallbackCredentialIds: agent.fallbackCredentialIds || [],
|
|
76
|
-
apiEndpoint: agent.apiEndpoint || null,
|
|
77
|
-
claudeSessionId: null,
|
|
78
|
-
messages: [],
|
|
79
|
-
createdAt: now,
|
|
80
|
-
lastActiveAt: now,
|
|
81
|
-
active: false,
|
|
82
|
-
sessionType: 'human' as const,
|
|
83
|
-
agentId,
|
|
84
|
-
plugins: agent.plugins || agent.tools || [],
|
|
85
|
-
heartbeatEnabled: agent.heartbeatEnabled || false,
|
|
86
|
-
heartbeatIntervalSec: agent.heartbeatIntervalSec || null,
|
|
8
|
+
const session = ensureAgentThreadSession(agentId, user)
|
|
9
|
+
if (!session) {
|
|
10
|
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
|
87
11
|
}
|
|
88
|
-
const session = applyResolvedRoute(baseSession, resolvePrimaryAgentRoute(agent))
|
|
89
|
-
|
|
90
|
-
sessions[sessionId] = session as Record<string, unknown>
|
|
91
|
-
saveSessions(sessions)
|
|
92
|
-
|
|
93
|
-
agent.threadSessionId = sessionId
|
|
94
|
-
agent.updatedAt = Date.now()
|
|
95
|
-
saveAgents(agents)
|
|
96
|
-
|
|
97
12
|
return NextResponse.json(session)
|
|
98
13
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { describe, it } from 'node:test'
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
|
|
9
|
+
|
|
10
|
+
function runWithTempDataDir(script: string) {
|
|
11
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-agent-thread-'))
|
|
12
|
+
try {
|
|
13
|
+
const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
|
|
14
|
+
cwd: repoRoot,
|
|
15
|
+
env: {
|
|
16
|
+
...process.env,
|
|
17
|
+
DATA_DIR: path.join(tempDir, 'data'),
|
|
18
|
+
WORKSPACE_DIR: path.join(tempDir, 'workspace'),
|
|
19
|
+
BROWSER_PROFILES_DIR: path.join(tempDir, 'browser-profiles'),
|
|
20
|
+
},
|
|
21
|
+
encoding: 'utf-8',
|
|
22
|
+
})
|
|
23
|
+
assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
|
|
24
|
+
const lines = (result.stdout || '')
|
|
25
|
+
.trim()
|
|
26
|
+
.split('\n')
|
|
27
|
+
.map((line) => line.trim())
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
|
|
30
|
+
return JSON.parse(jsonLine || '{}')
|
|
31
|
+
} finally {
|
|
32
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('ensureAgentThreadSession', () => {
|
|
37
|
+
it('creates and reuses an agent shortcut chat for heartbeat-enabled agents', () => {
|
|
38
|
+
const output = runWithTempDataDir(`
|
|
39
|
+
const storageMod = await import('./src/lib/server/storage.ts')
|
|
40
|
+
const storage = storageMod.default || storageMod['module.exports'] || storageMod
|
|
41
|
+
const helperMod = await import('./src/lib/server/agent-thread-session.ts')
|
|
42
|
+
const ensureAgentThreadSession = helperMod.ensureAgentThreadSession
|
|
43
|
+
|| helperMod.default?.ensureAgentThreadSession
|
|
44
|
+
|| helperMod['module.exports']?.ensureAgentThreadSession
|
|
45
|
+
|
|
46
|
+
const now = Date.now()
|
|
47
|
+
storage.saveAgents({
|
|
48
|
+
molly: {
|
|
49
|
+
id: 'molly',
|
|
50
|
+
name: 'Molly',
|
|
51
|
+
description: 'Autonomous helper',
|
|
52
|
+
provider: 'openai',
|
|
53
|
+
model: 'gpt-test',
|
|
54
|
+
credentialId: null,
|
|
55
|
+
apiEndpoint: null,
|
|
56
|
+
fallbackCredentialIds: [],
|
|
57
|
+
heartbeatEnabled: true,
|
|
58
|
+
heartbeatIntervalSec: 600,
|
|
59
|
+
createdAt: now,
|
|
60
|
+
updatedAt: now,
|
|
61
|
+
plugins: ['memory', 'web_search'],
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const first = ensureAgentThreadSession('molly')
|
|
66
|
+
const second = ensureAgentThreadSession('molly')
|
|
67
|
+
const agents = storage.loadAgents()
|
|
68
|
+
const sessions = storage.loadSessions()
|
|
69
|
+
|
|
70
|
+
console.log(JSON.stringify({
|
|
71
|
+
firstId: first?.id,
|
|
72
|
+
secondId: second?.id,
|
|
73
|
+
threadSessionId: agents.molly?.threadSessionId || null,
|
|
74
|
+
session: first ? sessions[first.id] : null,
|
|
75
|
+
}))
|
|
76
|
+
`)
|
|
77
|
+
|
|
78
|
+
assert.equal(output.firstId, output.secondId)
|
|
79
|
+
assert.equal(output.threadSessionId, output.firstId)
|
|
80
|
+
assert.equal(output.session.shortcutForAgentId, 'molly')
|
|
81
|
+
assert.equal(output.session.agentId, 'molly')
|
|
82
|
+
assert.equal(output.session.heartbeatEnabled, true)
|
|
83
|
+
assert.deepEqual(output.session.plugins, ['memory', 'web_search'])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { genId } from '@/lib/id'
|
|
2
|
+
import type { Agent, Session } from '@/types'
|
|
3
|
+
import { applyResolvedRoute, resolvePrimaryAgentRoute } from './agent-runtime-config'
|
|
4
|
+
import { WORKSPACE_DIR } from './data-dir'
|
|
5
|
+
import { loadAgents, loadSessions, saveAgents, saveSessions } from './storage'
|
|
6
|
+
|
|
7
|
+
function buildEmptyDelegateResumeIds(): NonNullable<Session['delegateResumeIds']> {
|
|
8
|
+
return {
|
|
9
|
+
claudeCode: null,
|
|
10
|
+
codex: null,
|
|
11
|
+
opencode: null,
|
|
12
|
+
gemini: null,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function buildThreadSession(agent: Agent, sessionId: string, user: string, createdAt: number, existing?: Session): Session {
|
|
17
|
+
const baseSession: Session = {
|
|
18
|
+
id: sessionId,
|
|
19
|
+
name: agent.name,
|
|
20
|
+
shortcutForAgentId: agent.id,
|
|
21
|
+
cwd: existing?.cwd || WORKSPACE_DIR,
|
|
22
|
+
user: existing?.user || user,
|
|
23
|
+
provider: agent.provider,
|
|
24
|
+
model: agent.model,
|
|
25
|
+
credentialId: agent.credentialId || null,
|
|
26
|
+
fallbackCredentialIds: agent.fallbackCredentialIds || [],
|
|
27
|
+
apiEndpoint: agent.apiEndpoint || null,
|
|
28
|
+
gatewayProfileId: agent.gatewayProfileId || null,
|
|
29
|
+
claudeSessionId: existing?.claudeSessionId || null,
|
|
30
|
+
codexThreadId: existing?.codexThreadId || null,
|
|
31
|
+
opencodeSessionId: existing?.opencodeSessionId || null,
|
|
32
|
+
delegateResumeIds: existing?.delegateResumeIds || buildEmptyDelegateResumeIds(),
|
|
33
|
+
messages: Array.isArray(existing?.messages) ? existing.messages : [],
|
|
34
|
+
createdAt: existing?.createdAt || createdAt,
|
|
35
|
+
lastActiveAt: createdAt,
|
|
36
|
+
active: existing?.active || false,
|
|
37
|
+
sessionType: existing?.sessionType || 'human',
|
|
38
|
+
agentId: agent.id,
|
|
39
|
+
parentSessionId: existing?.parentSessionId || null,
|
|
40
|
+
plugins: agent.plugins || agent.tools || [],
|
|
41
|
+
tools: agent.plugins || agent.tools || [],
|
|
42
|
+
heartbeatEnabled: agent.heartbeatEnabled || false,
|
|
43
|
+
heartbeatIntervalSec: agent.heartbeatIntervalSec || null,
|
|
44
|
+
heartbeatTarget: existing?.heartbeatTarget || null,
|
|
45
|
+
sessionResetMode: existing?.sessionResetMode || null,
|
|
46
|
+
sessionIdleTimeoutSec: existing?.sessionIdleTimeoutSec || null,
|
|
47
|
+
sessionMaxAgeSec: existing?.sessionMaxAgeSec || null,
|
|
48
|
+
sessionDailyResetAt: existing?.sessionDailyResetAt || null,
|
|
49
|
+
sessionResetTimezone: existing?.sessionResetTimezone || null,
|
|
50
|
+
thinkingLevel: existing?.thinkingLevel || null,
|
|
51
|
+
browserProfileId: existing?.browserProfileId || null,
|
|
52
|
+
connectorThinkLevel: existing?.connectorThinkLevel || null,
|
|
53
|
+
connectorSessionScope: existing?.connectorSessionScope || null,
|
|
54
|
+
connectorReplyMode: existing?.connectorReplyMode || null,
|
|
55
|
+
connectorThreadBinding: existing?.connectorThreadBinding || null,
|
|
56
|
+
connectorGroupPolicy: existing?.connectorGroupPolicy || null,
|
|
57
|
+
connectorIdleTimeoutSec: existing?.connectorIdleTimeoutSec || null,
|
|
58
|
+
connectorMaxAgeSec: existing?.connectorMaxAgeSec || null,
|
|
59
|
+
mailbox: existing?.mailbox || null,
|
|
60
|
+
connectorContext: existing?.connectorContext || undefined,
|
|
61
|
+
lastAutoMemoryAt: existing?.lastAutoMemoryAt || null,
|
|
62
|
+
lastHeartbeatText: existing?.lastHeartbeatText || null,
|
|
63
|
+
lastHeartbeatSentAt: existing?.lastHeartbeatSentAt || null,
|
|
64
|
+
lastSessionResetAt: existing?.lastSessionResetAt || null,
|
|
65
|
+
lastSessionResetReason: existing?.lastSessionResetReason || null,
|
|
66
|
+
identityState: existing?.identityState || null,
|
|
67
|
+
sessionArchiveState: existing?.sessionArchiveState || null,
|
|
68
|
+
pinned: existing?.pinned || false,
|
|
69
|
+
file: existing?.file || null,
|
|
70
|
+
queuedCount: existing?.queuedCount,
|
|
71
|
+
currentRunId: existing?.currentRunId || null,
|
|
72
|
+
conversationTone: existing?.conversationTone,
|
|
73
|
+
emoji: existing?.emoji,
|
|
74
|
+
creature: existing?.creature,
|
|
75
|
+
vibe: existing?.vibe,
|
|
76
|
+
theme: existing?.theme,
|
|
77
|
+
avatar: existing?.avatar,
|
|
78
|
+
canvasContent: existing?.canvasContent || null,
|
|
79
|
+
}
|
|
80
|
+
return applyResolvedRoute(baseSession, resolvePrimaryAgentRoute(agent))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function ensureAgentThreadSession(agentId: string, user = 'default'): Session | null {
|
|
84
|
+
const agents = loadAgents()
|
|
85
|
+
const agent = agents[agentId] as Agent | undefined
|
|
86
|
+
if (!agent) return null
|
|
87
|
+
|
|
88
|
+
const sessions = loadSessions()
|
|
89
|
+
const now = Date.now()
|
|
90
|
+
|
|
91
|
+
const existingId = typeof agent.threadSessionId === 'string' ? agent.threadSessionId : ''
|
|
92
|
+
if (existingId && sessions[existingId]) {
|
|
93
|
+
const session = buildThreadSession(agent, existingId, user, now, sessions[existingId] as Session)
|
|
94
|
+
sessions[existingId] = session
|
|
95
|
+
saveSessions(sessions)
|
|
96
|
+
return session
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const legacySession = Object.values(sessions).find((session) => (
|
|
100
|
+
(session.shortcutForAgentId === agentId || session.name === `agent-thread:${agentId}`)
|
|
101
|
+
&& session.user === user
|
|
102
|
+
)) as Session | undefined
|
|
103
|
+
|
|
104
|
+
if (legacySession) {
|
|
105
|
+
agent.threadSessionId = legacySession.id
|
|
106
|
+
agent.updatedAt = now
|
|
107
|
+
saveAgents(agents)
|
|
108
|
+
const session = buildThreadSession(agent, legacySession.id, user, now, legacySession)
|
|
109
|
+
sessions[legacySession.id] = session
|
|
110
|
+
saveSessions(sessions)
|
|
111
|
+
return session
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sessionId = `agent-chat-${agentId}-${genId()}`
|
|
115
|
+
const session = buildThreadSession(agent, sessionId, user, now)
|
|
116
|
+
sessions[sessionId] = session
|
|
117
|
+
saveSessions(sessions)
|
|
118
|
+
|
|
119
|
+
agent.threadSessionId = sessionId
|
|
120
|
+
agent.updatedAt = now
|
|
121
|
+
saveAgents(agents)
|
|
122
|
+
return session
|
|
123
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { describe, it } from 'node:test'
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
|
|
9
|
+
|
|
10
|
+
function extractLastJson(stdout: string): Record<string, unknown> {
|
|
11
|
+
const lines = stdout
|
|
12
|
+
.trim()
|
|
13
|
+
.split('\n')
|
|
14
|
+
.map((line) => line.trim())
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
|
|
17
|
+
return JSON.parse(jsonLine || '{}')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('data-dir resolution', () => {
|
|
21
|
+
it('falls back to in-project workspace when the external workspace root exists but child writes fail', () => {
|
|
22
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-data-dir-'))
|
|
23
|
+
const fakeHome = path.join(tempDir, 'home')
|
|
24
|
+
const dataDir = path.join(tempDir, 'data')
|
|
25
|
+
const externalWorkspace = path.join(fakeHome, '.swarmclaw', 'workspace')
|
|
26
|
+
fs.mkdirSync(externalWorkspace, { recursive: true })
|
|
27
|
+
fs.chmodSync(externalWorkspace, 0o555)
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', `
|
|
31
|
+
const modNs = await import('./src/lib/server/data-dir.ts')
|
|
32
|
+
const mod = modNs.default || modNs['module.exports'] || modNs
|
|
33
|
+
console.log(JSON.stringify({
|
|
34
|
+
dataDir: mod.DATA_DIR,
|
|
35
|
+
workspaceDir: mod.WORKSPACE_DIR,
|
|
36
|
+
}))
|
|
37
|
+
`], {
|
|
38
|
+
cwd: repoRoot,
|
|
39
|
+
env: {
|
|
40
|
+
...process.env,
|
|
41
|
+
HOME: fakeHome,
|
|
42
|
+
DATA_DIR: dataDir,
|
|
43
|
+
},
|
|
44
|
+
encoding: 'utf-8',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
|
|
48
|
+
const payload = extractLastJson(result.stdout || '')
|
|
49
|
+
assert.equal(payload.dataDir, dataDir)
|
|
50
|
+
assert.equal(payload.workspaceDir, path.join(dataDir, 'workspace'))
|
|
51
|
+
} finally {
|
|
52
|
+
fs.chmodSync(externalWorkspace, 0o755)
|
|
53
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -4,18 +4,26 @@ import fs from 'fs'
|
|
|
4
4
|
|
|
5
5
|
export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
|
|
6
6
|
|
|
7
|
+
function supportsChildWrites(dir: string): boolean {
|
|
8
|
+
try {
|
|
9
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
10
|
+
const probeDir = fs.mkdtempSync(path.join(dir, '.swarmclaw-probe-'))
|
|
11
|
+
fs.rmSync(probeDir, { recursive: true, force: true })
|
|
12
|
+
return true
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
// Workspace lives outside the project directory to avoid triggering Next.js HMR
|
|
8
19
|
// when agents create/modify files. Falls back to data/workspace for Docker/CI.
|
|
9
20
|
function resolveWorkspaceDir(): string {
|
|
10
21
|
if (process.env.WORKSPACE_DIR) return process.env.WORKSPACE_DIR
|
|
11
22
|
const external = path.join(os.homedir(), '.swarmclaw', 'workspace')
|
|
12
|
-
|
|
13
|
-
fs.mkdirSync(external, { recursive: true })
|
|
23
|
+
if (supportsChildWrites(external)) {
|
|
14
24
|
return external
|
|
15
|
-
} catch {
|
|
16
|
-
// If we can't create the external dir (permissions, etc.), fall back to in-project
|
|
17
|
-
return path.join(DATA_DIR, 'workspace')
|
|
18
25
|
}
|
|
26
|
+
return path.join(DATA_DIR, 'workspace')
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export const WORKSPACE_DIR = resolveWorkspaceDir()
|
|
@@ -23,12 +31,10 @@ export const WORKSPACE_DIR = resolveWorkspaceDir()
|
|
|
23
31
|
function resolveBrowserProfilesDir(): string {
|
|
24
32
|
if (process.env.BROWSER_PROFILES_DIR) return process.env.BROWSER_PROFILES_DIR
|
|
25
33
|
const external = path.join(os.homedir(), '.swarmclaw', 'browser-profiles')
|
|
26
|
-
|
|
27
|
-
fs.mkdirSync(external, { recursive: true })
|
|
34
|
+
if (supportsChildWrites(external)) {
|
|
28
35
|
return external
|
|
29
|
-
} catch {
|
|
30
|
-
return path.join(DATA_DIR, 'browser-profiles')
|
|
31
36
|
}
|
|
37
|
+
return path.join(DATA_DIR, 'browser-profiles')
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export const BROWSER_PROFILES_DIR = resolveBrowserProfilesDir()
|
|
@@ -12,6 +12,7 @@ import { log } from './logger'
|
|
|
12
12
|
import { WORKSPACE_DIR } from './data-dir'
|
|
13
13
|
import { drainSystemEvents } from './system-events'
|
|
14
14
|
import { buildIdentityContinuityContext } from './identity-continuity'
|
|
15
|
+
import { ensureAgentThreadSession } from './agent-thread-session'
|
|
15
16
|
|
|
16
17
|
const HEARTBEAT_TICK_MS = 5_000
|
|
17
18
|
|
|
@@ -358,8 +359,12 @@ async function tickHeartbeats() {
|
|
|
358
359
|
return
|
|
359
360
|
}
|
|
360
361
|
|
|
361
|
-
const sessions = loadSessions()
|
|
362
362
|
const agents = loadAgents()
|
|
363
|
+
for (const agent of Object.values(agents) as any[]) {
|
|
364
|
+
if (!agent?.id || agent.heartbeatEnabled !== true) continue
|
|
365
|
+
ensureAgentThreadSession(String(agent.id))
|
|
366
|
+
}
|
|
367
|
+
const sessions = loadSessions()
|
|
363
368
|
const hasScopedAgents = Object.values(agents).some((a: any) => a?.heartbeatEnabled === true)
|
|
364
369
|
|
|
365
370
|
// Prune tracked sessions that no longer exist or have heartbeat disabled
|
|
@@ -377,7 +382,6 @@ async function tickHeartbeats() {
|
|
|
377
382
|
|
|
378
383
|
for (const session of Object.values(sessions) as any[]) {
|
|
379
384
|
if (!session?.id) continue
|
|
380
|
-
if (!Array.isArray(session.plugins) || session.plugins.length === 0) continue
|
|
381
385
|
if (session.sessionType && session.sessionType !== 'human') continue
|
|
382
386
|
|
|
383
387
|
// Check if this session or its agent has explicit heartbeat opt-in
|
|
@@ -402,8 +406,13 @@ async function tickHeartbeats() {
|
|
|
402
406
|
: Math.max(cfg.intervalSec * 2, 180)
|
|
403
407
|
const userIdleThresholdSec = resolveHeartbeatUserIdleSec(settings, defaultIdleSec)
|
|
404
408
|
const lastUserAt = lastUserMessageAt(session)
|
|
405
|
-
|
|
406
|
-
|
|
409
|
+
const baselineAt = lastUserAt > 0
|
|
410
|
+
? lastUserAt
|
|
411
|
+
: explicitOptIn
|
|
412
|
+
? (typeof session.lastActiveAt === 'number' ? session.lastActiveAt : (typeof session.createdAt === 'number' ? session.createdAt : 0))
|
|
413
|
+
: 0
|
|
414
|
+
if (baselineAt <= 0) continue
|
|
415
|
+
const idleMs = now - baselineAt
|
|
407
416
|
if (idleMs < userIdleThresholdSec * 1000) continue
|
|
408
417
|
|
|
409
418
|
const last = state.lastBySession.get(session.id) || 0
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Requests are debounced with a 250ms coalesce window to batch rapid-fire events.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { ensureAgentThreadSession } from './agent-thread-session'
|
|
6
7
|
import { loadSessions, loadAgents, loadSettings } from './storage'
|
|
7
8
|
import { enqueueSessionRun } from './session-run-manager'
|
|
8
9
|
import { log } from './logger'
|
|
@@ -29,7 +30,6 @@ function flushWakes(): void {
|
|
|
29
30
|
const wakes = new Map(state.pending)
|
|
30
31
|
state.pending.clear()
|
|
31
32
|
|
|
32
|
-
const sessions = loadSessions()
|
|
33
33
|
const agents = loadAgents()
|
|
34
34
|
const settings = loadSettings()
|
|
35
35
|
|
|
@@ -39,6 +39,7 @@ function flushWakes(): void {
|
|
|
39
39
|
|
|
40
40
|
// If only agentId provided, find the agent's most recently active session
|
|
41
41
|
if (!sessionId && wake.agentId) {
|
|
42
|
+
const sessions = loadSessions()
|
|
42
43
|
let bestSession: { id: string; lastActiveAt: number } | null = null
|
|
43
44
|
for (const s of Object.values(sessions) as Array<Record<string, unknown>>) {
|
|
44
45
|
if (s.agentId !== wake.agentId) continue
|
|
@@ -48,11 +49,14 @@ function flushWakes(): void {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
sessionId = bestSession?.id
|
|
52
|
+
if (!sessionId) {
|
|
53
|
+
sessionId = ensureAgentThreadSession(wake.agentId)?.id
|
|
54
|
+
}
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
if (!sessionId) continue
|
|
54
58
|
|
|
55
|
-
const session =
|
|
59
|
+
const session = loadSessions()[sessionId] as Record<string, unknown> | undefined
|
|
56
60
|
if (!session) continue
|
|
57
61
|
|
|
58
62
|
const agentId = (session.agentId || wake.agentId) as string | undefined
|