@swarmclawai/swarmclaw 1.0.2 → 1.0.3

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
@@ -136,6 +136,17 @@ Running `swarmclaw` with no arguments starts the server on `http://localhost:345
136
136
  Global install runs `postinstall`, which rebuilds `better-sqlite3` and prepares the sandbox browser image when Docker is available.
137
137
  If Docker is not installed yet, SwarmClaw keeps running and falls back to host execution for shell, browser, and `sandbox_exec`.
138
138
  No Deno install is required for the local `sandbox_exec` path.
139
+ Runtime state defaults to `~/.swarmclaw` unless you set `SWARMCLAW_HOME`.
140
+ If the standalone server bundle is missing, the first launch builds it under `<swarmclaw-home>/builds/package-<version>` before starting.
141
+
142
+ ### Local project install
143
+
144
+ ```bash
145
+ npm i @swarmclawai/swarmclaw
146
+ npx swarmclaw
147
+ ```
148
+
149
+ Local installs keep runtime state in `<project>/.swarmclaw` by default. The same project-local install works with `pnpm`, `yarn`, or `bun`; use that package manager's exec command to launch the local binary, or run `./node_modules/.bin/swarmclaw` directly.
139
150
 
140
151
  ### One-off run
141
152
 
@@ -146,6 +157,8 @@ yarn dlx @swarmclawai/swarmclaw
146
157
  bunx @swarmclawai/swarmclaw
147
158
  ```
148
159
 
160
+ One-off runs use the published package without keeping a project-local install. Runtime state defaults to `~/.swarmclaw` unless you set `SWARMCLAW_HOME`.
161
+
149
162
  ### Install script
150
163
 
151
164
  ```bash
@@ -155,7 +168,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
155
168
  The installer resolves the latest stable release tag and installs that version by default.
156
169
  It also builds the production bundle so `npm run start` is ready immediately after install.
157
170
  No Deno install is required; local sandbox execution is Docker-first with automatic host Node fallback.
158
- To pin a version: `SWARMCLAW_VERSION=v0.9.6 curl ... | bash`
171
+ To pin a version: `SWARMCLAW_VERSION=v1.0.3 curl ... | bash`
159
172
 
160
173
  Or run locally from the repo (friendly for non-technical users):
161
174
 
@@ -603,7 +616,7 @@ A fuller step-by-step walkthrough lives at https://swarmclaw.ai/docs/plugin-tuto
603
616
 
604
617
  ### Browser, Watch, and Delegation Upgrades
605
618
 
606
- - **Persistent Browser Profiles**: The built-in `browser` plugin now keeps a reusable profile per chat/session, and subagents inherit the parent profile by default. Profiles live under `~/.swarmclaw/browser-profiles` unless you override `BROWSER_PROFILES_DIR`, so cookies, storage, and authenticated state survive longer-running work without polluting the project tree. Browser state is exposed at `GET /api/chats/[id]/browser`.
619
+ - **Persistent Browser Profiles**: The built-in `browser` plugin now keeps a reusable profile per chat/session, and subagents inherit the parent profile by default. Profiles live under `<swarmclaw-home>/browser-profiles` by default (`~/.swarmclaw/browser-profiles` for global installs, `<project>/.swarmclaw/browser-profiles` for local installs) unless you override `BROWSER_PROFILES_DIR`, so cookies, storage, and authenticated state survive longer-running work without polluting the project tree. Browser state is exposed at `GET /api/chats/[id]/browser`.
607
620
  - **Higher-Level Browser Actions**: In addition to raw Playwright-style actions, `browser` supports workflow-oriented actions such as `read_page`, `extract_links`, `extract_form_fields`, `extract_table`, `fill_form`, `submit_form`, `scroll_until`, `download_file`, `complete_web_task`, `verify_text`, `verify_element`, `verify_list`, `verify_value`, `profile`, and `reset_profile`.
608
621
  - **Structured Browser State**: Browser sessions persist recent observations, tabs, artifacts (screenshots / PDFs / downloads), current URL, and last errors in `browser_sessions`, which makes autonomous browser tasks easier to resume, inspect, and hand off across turns.
609
622
  - **Durable Watches**: `schedule_wake` now uses persisted watch jobs instead of an in-memory timer, and `monitor_tool` supports `create_watch`, `list_watches`, `get_watch`, and `cancel_watch` across `time`, `http`, `file`, `task`, `webhook`, and `page` conditions. The same watch system also powers the new `mailbox`, session-mailbox, and approval waits used by human-loop flows. Watches support common checks like status/status sets, regex or text matches, content changes, existence checks, inbound mailbox correlation IDs, and webhook event filters.
package/bin/doctor-cmd.js CHANGED
@@ -11,6 +11,8 @@ const {
11
11
  SWARMCLAW_HOME,
12
12
  findStandaloneServer,
13
13
  isGitCheckout,
14
+ resolvePackageBuildRoot,
15
+ resolveInstalledNext,
14
16
  } = require('./server-cmd.js')
15
17
 
16
18
  function readPid(pidFile) {
@@ -38,8 +40,10 @@ function buildDoctorReport(opts = {}) {
38
40
  const dataDir = path.join(homeDir, 'data')
39
41
  const workspaceDir = path.join(homeDir, 'workspace')
40
42
  const browserProfilesDir = path.join(homeDir, 'browser-profiles')
41
- const nextCliPath = path.join(pkgRoot, 'node_modules', 'next', 'dist', 'bin', 'next')
43
+ const nextInstall = resolveInstalledNext(pkgRoot)
44
+ const nextCliPath = nextInstall?.nextCli || path.join(pkgRoot, 'node_modules', 'next', 'dist', 'bin', 'next')
42
45
  const standaloneServer = findStandaloneServer({ pkgRoot })
46
+ const buildRoot = resolvePackageBuildRoot(pkgRoot)
43
47
  const pid = readPid(pidFile)
44
48
  const running = pid ? isProcessRunning(pid) : false
45
49
 
@@ -64,6 +68,7 @@ function buildDoctorReport(opts = {}) {
64
68
  return {
65
69
  packageVersion: readPackageVersion(pkgRoot) || 'unknown',
66
70
  packageRoot: pkgRoot,
71
+ buildRoot,
67
72
  installKind: isGitCheckout(pkgRoot) ? 'git' : 'package',
68
73
  homeDir,
69
74
  dataDir,
@@ -96,6 +101,7 @@ function printHumanReport(report) {
96
101
  `Package version: ${report.packageVersion}`,
97
102
  `Install kind: ${report.installKind}`,
98
103
  `Package root: ${report.packageRoot}`,
104
+ `Build root: ${report.buildRoot}`,
99
105
  `Home: ${report.homeDir}`,
100
106
  `Data: ${report.dataDir}`,
101
107
  `Workspace: ${report.workspaceDir}`,
package/bin/server-cmd.js CHANGED
@@ -36,6 +36,7 @@ const LOG_FILE = path.join(SWARMCLAW_HOME, 'server.log')
36
36
  const DATA_DIR = path.join(SWARMCLAW_HOME, 'data')
37
37
  const WORKSPACE_DIR = path.join(SWARMCLAW_HOME, 'workspace')
38
38
  const BROWSER_PROFILES_DIR = path.join(SWARMCLAW_HOME, 'browser-profiles')
39
+ const BUILD_WORKSPACES_DIR = path.join(SWARMCLAW_HOME, 'builds')
39
40
 
40
41
  // ---------------------------------------------------------------------------
41
42
  // Helpers
@@ -83,15 +84,112 @@ function getVersion() {
83
84
  return readPackageVersion(PKG_ROOT) || 'unknown'
84
85
  }
85
86
 
87
+ function resolveInstalledNext(pkgRoot = PKG_ROOT) {
88
+ try {
89
+ const nextPackageJson = require.resolve('next/package.json', { paths: [pkgRoot] })
90
+ const nextPackageDir = path.dirname(nextPackageJson)
91
+ return {
92
+ nextCli: path.join(nextPackageDir, 'dist', 'bin', 'next'),
93
+ nodeModulesDir: path.dirname(nextPackageDir),
94
+ }
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
86
100
  function ensurePackageDependencies(pkgRoot = PKG_ROOT) {
87
- const nextCli = path.join(pkgRoot, 'node_modules', 'next', 'dist', 'bin', 'next')
88
- if (fs.existsSync(nextCli)) return nextCli
101
+ const resolved = resolveInstalledNext(pkgRoot)
102
+ if (resolved && fs.existsSync(resolved.nextCli)) return resolved
89
103
 
90
104
  const packageManager = detectPackageManager(pkgRoot, process.env)
91
105
  const install = getInstallCommand(packageManager)
92
106
  log(`Installing dependencies with ${packageManager}...`)
93
107
  execFileSync(install.command, install.args, { cwd: pkgRoot, stdio: 'inherit' })
94
- return nextCli
108
+
109
+ const installed = resolveInstalledNext(pkgRoot)
110
+ if (installed && fs.existsSync(installed.nextCli)) return installed
111
+
112
+ throw new Error('Next.js CLI was not found after installing dependencies.')
113
+ }
114
+
115
+ function resolvePackageBuildRoot(pkgRoot = PKG_ROOT) {
116
+ if (isGitCheckout(pkgRoot)) return pkgRoot
117
+ const version = readPackageVersion(pkgRoot) || 'unknown'
118
+ return path.join(BUILD_WORKSPACES_DIR, `package-${version}`)
119
+ }
120
+
121
+ function copyBuildWorkspaceContents(sourceRoot, targetRoot) {
122
+ const excluded = new Set([
123
+ '.git',
124
+ '.next',
125
+ 'data',
126
+ 'node_modules',
127
+ ])
128
+
129
+ ensureDir(targetRoot)
130
+
131
+ for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {
132
+ if (excluded.has(entry.name)) continue
133
+
134
+ const sourcePath = path.join(sourceRoot, entry.name)
135
+ const targetPath = path.join(targetRoot, entry.name)
136
+ fs.rmSync(targetPath, { recursive: true, force: true })
137
+ fs.cpSync(sourcePath, targetPath, {
138
+ recursive: true,
139
+ force: true,
140
+ dereference: true,
141
+ })
142
+ }
143
+ }
144
+
145
+ function symlinkDir(targetPath, linkPath) {
146
+ fs.rmSync(linkPath, { recursive: true, force: true })
147
+ fs.symlinkSync(targetPath, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
148
+ }
149
+
150
+ function prepareBuildWorkspace({ pkgRoot = PKG_ROOT, buildRoot = resolvePackageBuildRoot(pkgRoot), nodeModulesDir } = {}) {
151
+ copyBuildWorkspaceContents(pkgRoot, buildRoot)
152
+ symlinkDir(nodeModulesDir, path.join(buildRoot, 'node_modules'))
153
+ return buildRoot
154
+ }
155
+
156
+ function resolveStandaloneCandidateRoots(pkgRoot = PKG_ROOT) {
157
+ const roots = [pkgRoot]
158
+ const buildRoot = resolvePackageBuildRoot(pkgRoot)
159
+ if (buildRoot !== pkgRoot) roots.push(buildRoot)
160
+ return roots
161
+ }
162
+
163
+ function locateStandaloneServer({ pkgRoot = PKG_ROOT } = {}) {
164
+ for (const root of resolveStandaloneCandidateRoots(pkgRoot)) {
165
+ const standaloneBase = resolveStandaloneBase(root)
166
+ if (!fs.existsSync(standaloneBase)) continue
167
+
168
+ const direct = path.join(standaloneBase, 'server.js')
169
+ if (fs.existsSync(direct)) {
170
+ return { root, serverJs: direct }
171
+ }
172
+
173
+ function search(dir) {
174
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
175
+ for (const entry of entries) {
176
+ const full = path.join(dir, entry.name)
177
+ if (entry.isFile() && entry.name === 'server.js') return full
178
+ if (entry.isDirectory() && entry.name !== 'node_modules') {
179
+ const found = search(full)
180
+ if (found) return found
181
+ }
182
+ }
183
+ return null
184
+ }
185
+
186
+ const nested = search(standaloneBase)
187
+ if (nested) {
188
+ return { root, serverJs: nested }
189
+ }
190
+ }
191
+
192
+ return null
95
193
  }
96
194
 
97
195
  // ---------------------------------------------------------------------------
@@ -108,11 +206,17 @@ function runBuild({ pkgRoot = PKG_ROOT } = {}) {
108
206
  ensureDir(SWARMCLAW_HOME)
109
207
  ensureDir(DATA_DIR)
110
208
 
111
- const nextCli = ensurePackageDependencies(pkgRoot)
209
+ const { nextCli, nodeModulesDir } = ensurePackageDependencies(pkgRoot)
210
+ const buildRoot = resolvePackageBuildRoot(pkgRoot)
211
+
212
+ if (buildRoot !== pkgRoot) {
213
+ prepareBuildWorkspace({ pkgRoot, buildRoot, nodeModulesDir })
214
+ log(`Using build workspace: ${buildRoot}`)
215
+ }
112
216
 
113
217
  log('Building Next.js application (this may take a minute)...')
114
218
  execFileSync(process.execPath, [nextCli, 'build', '--webpack'], {
115
- cwd: pkgRoot,
219
+ cwd: buildRoot,
116
220
  stdio: 'inherit',
117
221
  env: {
118
222
  ...process.env,
@@ -130,29 +234,7 @@ function runBuild({ pkgRoot = PKG_ROOT } = {}) {
130
234
  // ---------------------------------------------------------------------------
131
235
 
132
236
  function findStandaloneServer({ pkgRoot = PKG_ROOT } = {}) {
133
- const standaloneBase = resolveStandaloneBase(pkgRoot)
134
-
135
- if (!fs.existsSync(standaloneBase)) {
136
- return null
137
- }
138
-
139
- const direct = path.join(standaloneBase, 'server.js')
140
- if (fs.existsSync(direct)) return direct
141
-
142
- function search(dir) {
143
- const entries = fs.readdirSync(dir, { withFileTypes: true })
144
- for (const entry of entries) {
145
- const full = path.join(dir, entry.name)
146
- if (entry.isFile() && entry.name === 'server.js') return full
147
- if (entry.isDirectory() && entry.name !== 'node_modules') {
148
- const found = search(full)
149
- if (found) return found
150
- }
151
- }
152
- return null
153
- }
154
-
155
- return search(standaloneBase)
237
+ return locateStandaloneServer({ pkgRoot })?.serverJs || null
156
238
  }
157
239
 
158
240
  // ---------------------------------------------------------------------------
@@ -160,11 +242,12 @@ function findStandaloneServer({ pkgRoot = PKG_ROOT } = {}) {
160
242
  // ---------------------------------------------------------------------------
161
243
 
162
244
  function startServer(opts, { pkgRoot = PKG_ROOT } = {}) {
163
- const serverJs = findStandaloneServer({ pkgRoot })
164
- if (!serverJs) {
245
+ const standalone = locateStandaloneServer({ pkgRoot })
246
+ if (!standalone) {
165
247
  logError('Standalone server.js not found in the installed package. Try running: swarmclaw server --build')
166
248
  process.exit(1)
167
249
  }
250
+ const { root: runtimeRoot, serverJs } = standalone
168
251
 
169
252
  ensureDir(SWARMCLAW_HOME)
170
253
  ensureDir(DATA_DIR)
@@ -186,13 +269,14 @@ function startServer(opts, { pkgRoot = PKG_ROOT } = {}) {
186
269
 
187
270
  log(`Starting server on ${host}:${port} (WebSocket: ${wsPort})...`)
188
271
  log(`Package root: ${pkgRoot}`)
272
+ log(`Runtime root: ${runtimeRoot}`)
189
273
  log(`Home: ${SWARMCLAW_HOME}`)
190
274
  log(`Data directory: ${DATA_DIR}`)
191
275
 
192
276
  if (opts.detach) {
193
277
  const logStream = fs.openSync(LOG_FILE, 'a')
194
278
  const child = spawn(process.execPath, [serverJs], {
195
- cwd: pkgRoot,
279
+ cwd: runtimeRoot,
196
280
  detached: true,
197
281
  env,
198
282
  stdio: ['ignore', logStream, logStream],
@@ -205,7 +289,7 @@ function startServer(opts, { pkgRoot = PKG_ROOT } = {}) {
205
289
  process.exit(0)
206
290
  } else {
207
291
  const child = spawn(process.execPath, [serverJs], {
208
- cwd: pkgRoot,
292
+ cwd: runtimeRoot,
209
293
  env,
210
294
  stdio: 'inherit',
211
295
  })
@@ -263,6 +347,7 @@ function showStatus() {
263
347
  }
264
348
 
265
349
  log(`Package: ${PKG_ROOT}`)
350
+ log(`Build workspace: ${resolvePackageBuildRoot()}`)
266
351
  log(`Home: ${SWARMCLAW_HOME}`)
267
352
  log(`Data: ${DATA_DIR}`)
268
353
  log(`Workspace: ${WORKSPACE_DIR}`)
@@ -370,6 +455,7 @@ if (require.main === module) {
370
455
 
371
456
  module.exports = {
372
457
  DATA_DIR,
458
+ BUILD_WORKSPACES_DIR,
373
459
  BROWSER_PROFILES_DIR,
374
460
  PKG_ROOT,
375
461
  SWARMCLAW_HOME,
@@ -377,8 +463,13 @@ module.exports = {
377
463
  findStandaloneServer,
378
464
  getVersion,
379
465
  isGitCheckout,
466
+ locateStandaloneServer,
380
467
  main,
381
468
  needsBuild,
469
+ prepareBuildWorkspace,
470
+ resolveInstalledNext,
471
+ resolvePackageBuildRoot,
472
+ resolveStandaloneCandidateRoots,
382
473
  resolveStandaloneBase,
383
474
  runBuild,
384
475
  }
package/bin/worker-cmd.js CHANGED
@@ -9,7 +9,7 @@ const {
9
9
  PKG_ROOT,
10
10
  SWARMCLAW_HOME,
11
11
  WORKSPACE_DIR,
12
- findStandaloneServer,
12
+ locateStandaloneServer,
13
13
  } = require('./server-cmd.js')
14
14
 
15
15
  function printHelp() {
@@ -51,14 +51,15 @@ function main(args = process.argv.slice(3)) {
51
51
  console.log(`[swarmclaw] Workspace directory: ${WORKSPACE_DIR}`)
52
52
  console.log(`[swarmclaw] Browser profiles: ${BROWSER_PROFILES_DIR}`)
53
53
 
54
- const serverJs = findStandaloneServer()
55
- if (!serverJs) {
54
+ const standalone = locateStandaloneServer()
55
+ if (!standalone) {
56
56
  console.error('[swarmclaw] Standalone server.js not found in the installed package. Try running: swarmclaw server --build')
57
57
  process.exit(1)
58
58
  }
59
+ const { root: runtimeRoot, serverJs } = standalone
59
60
 
60
61
  const child = spawn(process.execPath, [serverJs], {
61
- cwd: PKG_ROOT,
62
+ cwd: runtimeRoot,
62
63
  env: process.env,
63
64
  stdio: 'inherit',
64
65
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Self-hosted AI agent orchestration dashboard with OpenClaw integration, multi-provider support, LangGraph workflows, and chat platform connectors.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -65,7 +65,7 @@
65
65
  "lint:baseline": "node ./scripts/lint-baseline.mjs check",
66
66
  "lint:baseline:update": "node ./scripts/lint-baseline.mjs update",
67
67
  "cli": "node ./bin/swarmclaw.js",
68
- "test:cli": "node --test src/cli/*.test.js bin/*.test.js",
68
+ "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/postinstall.test.mjs",
69
69
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts",
70
70
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
71
71
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -122,12 +122,11 @@
122
122
  "sonner": "^2.0.7",
123
123
  "tailwindcss": "^4",
124
124
  "tailwind-merge": "^3.4.1",
125
+ "typescript": "^5",
125
126
  "tw-animate-css": "^1.4.0",
126
127
  "ws": "^8.19.0",
127
128
  "zod": "^4.3.6",
128
- "zustand": "^5.0.11"
129
- },
130
- "devDependencies": {
129
+ "zustand": "^5.0.11",
131
130
  "@types/better-sqlite3": "^7.6.13",
132
131
  "@types/mailparser": "^3.4.6",
133
132
  "@types/node": "^20",
@@ -135,11 +134,12 @@
135
134
  "@types/qrcode": "^1.5.6",
136
135
  "@types/react": "^19",
137
136
  "@types/react-dom": "^19",
138
- "@types/ws": "^8.18.1",
137
+ "@types/ws": "^8.18.1"
138
+ },
139
+ "devDependencies": {
139
140
  "eslint": "^9",
140
141
  "eslint-config-next": "16.1.6",
141
- "tsx": "^4.20.6",
142
- "typescript": "^5"
142
+ "tsx": "^4.20.6"
143
143
  },
144
144
  "optionalDependencies": {
145
145
  "botbuilder": "^4.23.3",
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import fs from 'node:fs'
3
4
  import path from 'node:path'
4
5
  import { fileURLToPath } from 'node:url'
5
- import { writeFileSync } from 'node:fs'
6
6
  import { spawnSync } from 'node:child_process'
7
7
  const INSTALL_METADATA_FILE = '.swarmclaw-install.json'
8
8
  const scriptDir = path.dirname(fileURLToPath(import.meta.url))
@@ -48,7 +48,7 @@ function formatFailure(result) {
48
48
  }
49
49
 
50
50
  try {
51
- writeFileSync(
51
+ fs.writeFileSync(
52
52
  new URL(`../${INSTALL_METADATA_FILE}`, import.meta.url),
53
53
  JSON.stringify({
54
54
  packageManager: installedWith,
@@ -72,18 +72,22 @@ if (result.error || (result.status ?? 0) !== 0) {
72
72
  }
73
73
 
74
74
  if (!process.env.CI) {
75
- const sandboxImage = spawnSync(process.execPath, [ensureSandboxBrowserScript, '--quiet'], {
76
- cwd: packageRoot,
77
- encoding: 'utf8',
78
- stdio: 'pipe',
79
- })
80
- if (sandboxImage.error || (sandboxImage.status ?? 0) !== 0) {
81
- logWarn(`sandbox browser image setup failed: ${formatFailure(sandboxImage)}`)
82
- logWarn('Retry manually with: node ./scripts/ensure-sandbox-browser-image.mjs')
83
- }
75
+ if (!fs.existsSync(ensureSandboxBrowserScript)) {
76
+ logNote('Sandbox browser image helper is not present in this install context. Skipping setup.')
77
+ } else {
78
+ const sandboxImage = spawnSync(process.execPath, [ensureSandboxBrowserScript, '--quiet'], {
79
+ cwd: packageRoot,
80
+ encoding: 'utf8',
81
+ stdio: 'pipe',
82
+ })
83
+ if (sandboxImage.error || (sandboxImage.status ?? 0) !== 0) {
84
+ logWarn(`sandbox browser image setup failed: ${formatFailure(sandboxImage)}`)
85
+ logWarn('Retry manually with: node ./scripts/ensure-sandbox-browser-image.mjs')
86
+ }
84
87
 
85
- if (!commandExists('docker')) {
86
- logNote('Docker was not found. Container sandboxes will fall back to host execution until Docker is installed.')
88
+ if (!commandExists('docker')) {
89
+ logNote('Docker was not found. Container sandboxes will fall back to host execution until Docker is installed.')
90
+ }
87
91
  }
88
92
  }
89
93
 
@@ -61,6 +61,16 @@ const CHART_COLORS = [
61
61
  '#60A5FA', '#4ADE80',
62
62
  ]
63
63
 
64
+ function numericChartValue(value: unknown): number {
65
+ if (Array.isArray(value)) return numericChartValue(value[0])
66
+ if (typeof value === 'number') return Number.isFinite(value) ? value : 0
67
+ if (typeof value === 'string') {
68
+ const parsed = Number(value)
69
+ return Number.isFinite(parsed) ? parsed : 0
70
+ }
71
+ return 0
72
+ }
73
+
64
74
 
65
75
  function formatBucketLabel(bucket: string, range: Range): string {
66
76
  if (range === '24h') {
@@ -306,7 +316,7 @@ export default function UsagePage() {
306
316
  <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
307
317
  <XAxis dataKey="label" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
308
318
  <YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={formatTokens} />
309
- <Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [formatTokens(value ?? 0), 'Tokens']} />
319
+ <Tooltip {...tooltipStyle} formatter={(value) => [formatTokens(numericChartValue(value)), 'Tokens']} />
310
320
  <Line type="monotone" dataKey="tokens" stroke="#818CF8" strokeWidth={2} dot={false} activeDot={{ r: 4, fill: '#818CF8' }} />
311
321
  </LineChart>
312
322
  </ResponsiveContainer>
@@ -324,8 +334,8 @@ export default function UsagePage() {
324
334
  <BarChart data={providerData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
325
335
  <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
326
336
  <XAxis dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
327
- <YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v}`} />
328
- <Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [formatCost(value ?? 0), 'Cost']} />
337
+ <YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(value) => `$${numericChartValue(value)}`} />
338
+ <Tooltip {...tooltipStyle} formatter={(value) => [formatCost(numericChartValue(value)), 'Cost']} />
329
339
  <Bar dataKey="cost" radius={[4, 4, 0, 0]}>
330
340
  {providerData.map((_entry, i) => (
331
341
  <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
@@ -343,9 +353,9 @@ export default function UsagePage() {
343
353
  <ResponsiveContainer width="100%" height={280}>
344
354
  <BarChart data={agentData} layout="vertical" margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
345
355
  <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" horizontal={false} />
346
- <XAxis type="number" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v}`} />
356
+ <XAxis type="number" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(value) => `$${numericChartValue(value)}`} />
347
357
  <YAxis type="category" dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} width={100} />
348
- <Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [formatCost(value ?? 0), 'Cost']} />
358
+ <Tooltip {...tooltipStyle} formatter={(value) => [formatCost(numericChartValue(value)), 'Cost']} />
349
359
  <Bar dataKey="cost" radius={[0, 4, 4, 0]}>
350
360
  {agentData.map((_entry, i) => (
351
361
  <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
@@ -370,9 +380,9 @@ export default function UsagePage() {
370
380
  <YAxis type="category" dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} width={120} />
371
381
  <Tooltip
372
382
  {...tooltipStyle}
373
- formatter={(value: number | undefined, name?: string) => [
374
- formatTokens(value ?? 0),
375
- name === 'definitionTokens' ? 'Context (definitions)' : 'Invocations',
383
+ formatter={(value, name) => [
384
+ formatTokens(numericChartValue(value)),
385
+ String(name) === 'definitionTokens' ? 'Context (definitions)' : 'Invocations',
376
386
  ]}
377
387
  />
378
388
  <Bar dataKey="definitionTokens" fill="#818CF8" radius={[0, 0, 0, 0]} stackId="a" name="definitionTokens" />
@@ -381,9 +391,9 @@ export default function UsagePage() {
381
391
  verticalAlign="bottom"
382
392
  iconType="circle"
383
393
  iconSize={8}
384
- formatter={(value: string) => (
394
+ formatter={(value) => (
385
395
  <span style={{ color: '#a0a0b0', fontSize: 11 }}>
386
- {value === 'definitionTokens' ? 'Context (definitions)' : 'Invocations'}
396
+ {String(value) === 'definitionTokens' ? 'Context (definitions)' : 'Invocations'}
387
397
  </span>
388
398
  )}
389
399
  />
@@ -431,7 +441,7 @@ export default function UsagePage() {
431
441
  <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
432
442
  <XAxis dataKey="label" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
433
443
  <YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
434
- <Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [value ?? 0, 'Completed']} />
444
+ <Tooltip {...tooltipStyle} formatter={(value) => [numericChartValue(value), 'Completed']} />
435
445
  <Bar dataKey="count" fill="#34D399" radius={[4, 4, 0, 0]} />
436
446
  </BarChart>
437
447
  </ResponsiveContainer>
@@ -458,7 +468,7 @@ export default function UsagePage() {
458
468
  <Bar dataKey="completed" fill="#34D399" radius={[4, 4, 0, 0]} stackId="a" name="Completed" />
459
469
  <Bar dataKey="failed" fill="#F87171" radius={[4, 4, 0, 0]} stackId="a" name="Failed" />
460
470
  <Legend verticalAlign="bottom" iconType="circle" iconSize={8}
461
- formatter={(value: string) => <span style={{ color: '#a0a0b0', fontSize: 11 }}>{value}</span>} />
471
+ formatter={(value) => <span style={{ color: '#a0a0b0', fontSize: 11 }}>{String(value)}</span>} />
462
472
  </BarChart>
463
473
  </ResponsiveContainer>
464
474
  ) : (
@@ -58,3 +58,80 @@ test('findStandaloneServer recursively resolves nested standalone server paths',
58
58
  fs.rmSync(homeDir, { recursive: true, force: true })
59
59
  fs.rmSync(pkgRoot, { recursive: true, force: true })
60
60
  })
61
+
62
+ test('resolvePackageBuildRoot uses a versioned workspace for registry installs', () => {
63
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
64
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
65
+ const serverCmd = loadServerCmdForHome(homeDir)
66
+
67
+ fs.writeFileSync(
68
+ path.join(pkgRoot, 'package.json'),
69
+ JSON.stringify({ name: '@swarmclawai/swarmclaw', version: '1.0.2' }),
70
+ 'utf8',
71
+ )
72
+
73
+ assert.equal(
74
+ serverCmd.resolvePackageBuildRoot(pkgRoot),
75
+ path.join(homeDir, 'builds', 'package-1.0.2'),
76
+ )
77
+
78
+ fs.rmSync(homeDir, { recursive: true, force: true })
79
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
80
+ })
81
+
82
+ test('findStandaloneServer falls back to the external build workspace for registry installs', () => {
83
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
84
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
85
+ const serverCmd = loadServerCmdForHome(homeDir)
86
+
87
+ fs.writeFileSync(
88
+ path.join(pkgRoot, 'package.json'),
89
+ JSON.stringify({ name: '@swarmclawai/swarmclaw', version: '1.0.2' }),
90
+ 'utf8',
91
+ )
92
+
93
+ const nestedServer = path.join(
94
+ serverCmd.resolvePackageBuildRoot(pkgRoot),
95
+ '.next',
96
+ 'standalone',
97
+ 'Users',
98
+ 'wayde',
99
+ 'Dev',
100
+ 'swarmclaw',
101
+ 'server.js',
102
+ )
103
+ fs.mkdirSync(path.dirname(nestedServer), { recursive: true })
104
+ fs.writeFileSync(nestedServer, 'console.log("ok")\n', 'utf8')
105
+
106
+ assert.equal(serverCmd.findStandaloneServer({ pkgRoot }), nestedServer)
107
+
108
+ fs.rmSync(homeDir, { recursive: true, force: true })
109
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
110
+ })
111
+
112
+ test('prepareBuildWorkspace copies the package tree and links node_modules outside node_modules paths', () => {
113
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
114
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
115
+ const externalNodeModules = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-node-modules-'))
116
+ const serverCmd = loadServerCmdForHome(homeDir)
117
+
118
+ fs.writeFileSync(
119
+ path.join(pkgRoot, 'package.json'),
120
+ JSON.stringify({ name: '@swarmclawai/swarmclaw', version: '1.0.2' }),
121
+ 'utf8',
122
+ )
123
+ fs.mkdirSync(path.join(pkgRoot, 'src', 'app'), { recursive: true })
124
+ fs.writeFileSync(path.join(pkgRoot, 'src', 'app', 'page.tsx'), 'export default function Page() { return null }\n', 'utf8')
125
+
126
+ const buildRoot = serverCmd.resolvePackageBuildRoot(pkgRoot)
127
+ serverCmd.prepareBuildWorkspace({ pkgRoot, buildRoot, nodeModulesDir: externalNodeModules })
128
+
129
+ assert.equal(fs.readFileSync(path.join(buildRoot, 'package.json'), 'utf8'), fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8'))
130
+ assert.equal(fs.readFileSync(path.join(buildRoot, 'src', 'app', 'page.tsx'), 'utf8'), 'export default function Page() { return null }\n')
131
+ assert.equal(fs.lstatSync(path.join(buildRoot, 'node_modules')).isSymbolicLink(), true)
132
+ assert.equal(fs.realpathSync(path.join(buildRoot, 'node_modules')), fs.realpathSync(externalNodeModules))
133
+
134
+ fs.rmSync(homeDir, { recursive: true, force: true })
135
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
136
+ fs.rmSync(externalNodeModules, { recursive: true, force: true })
137
+ })
@@ -60,9 +60,9 @@ describe('data-dir resolution', () => {
60
60
 
61
61
  try {
62
62
  const env = { ...process.env, HOME: fakeHome, npm_lifecycle_event: 'build:ci' }
63
- delete (env as any).DATA_DIR
64
- delete (env as any).WORKSPACE_DIR
65
- delete (env as any).BROWSER_PROFILES_DIR
63
+ delete env.DATA_DIR
64
+ delete env.WORKSPACE_DIR
65
+ delete env.BROWSER_PROFILES_DIR
66
66
 
67
67
  const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', `
68
68
  const modNs = await import('./src/lib/server/data-dir')
@@ -98,9 +98,9 @@ describe('data-dir resolution', () => {
98
98
 
99
99
  try {
100
100
  const env = { ...process.env, HOME: fakeHome, SWARMCLAW_HOME: swarmclawHome }
101
- delete (env as any).DATA_DIR
102
- delete (env as any).WORKSPACE_DIR
103
- delete (env as any).BROWSER_PROFILES_DIR
101
+ delete env.DATA_DIR
102
+ delete env.WORKSPACE_DIR
103
+ delete env.BROWSER_PROFILES_DIR
104
104
 
105
105
  const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', `
106
106
  const modNs = await import('./src/lib/server/data-dir')