@simonyea/holysheep-cli 2.1.13 → 2.1.14

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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@simonyea/holysheep-cli",
3
- "version": "2.1.13",
3
+ "version": "2.1.14",
4
4
  "description": "Claude Code/Cursor/Cline API relay for China — ¥1=$1, WeChat/Alipay payment, no credit card, no VPN. One command setup for all AI coding tools.",
5
5
  "scripts": {
6
- "test": "node tests/droid.test.js && node tests/workspace-store.test.js && node tests/runtime-stale-upgrade.test.js",
6
+ "test": "node tests/droid.test.js && node tests/workspace-store.test.js && node tests/runtime-stale-upgrade.test.js && node tests/hermes.test.js",
7
7
  "prepublishOnly": "node scripts/check-tarball-size.js"
8
8
  },
9
9
  "keywords": [
@@ -15,9 +15,10 @@ async function balance() {
15
15
  const spinner = ora('获取账户信息...').start()
16
16
  try {
17
17
  const fetch = require('node-fetch')
18
- // shop /api/stats/overview 需要 JWT,这里通过 /internal 接口查余额
19
- // 或者通过 shop API 查询
20
- const res = await fetch(`${SHOP_URL}/api/stats/overview`, {
18
+ // Use the canonical www host directly. `${SHOP_URL}` returns a 301 redirect,
19
+ // and node-fetch drops the Authorization header on cross-origin redirects,
20
+ // which surfaced as "HTTP 404" on every `hs balance` call (2.1.13 bug).
21
+ const res = await fetch('https://www.holysheep.ai/api/stats/overview', {
21
22
  headers: { 'Authorization': `Bearer ${apiKey}` },
22
23
  timeout: 8000,
23
24
  })
@@ -138,8 +138,14 @@ function probeAionUiRuntimeLabel() {
138
138
  isVersionCurrent,
139
139
  } = require('../webui/aionui-runtime-fetcher')
140
140
 
141
- const forkDir = path.resolve(__dirname, '..', '..', 'aionui-fork')
142
- if (isValidRuntimeDir(forkDir)) return 'aionui-fork'
141
+ // aionui-fork/ label only appears when dev opted-in via HOLYSHEEP_DEV=1 —
142
+ // must match the resolver gating in aionui-runtime-fetcher.js, otherwise
143
+ // the status line would lie ("runtime=aionui-fork" when the actual resolver
144
+ // is about to return user-cache).
145
+ if (process.env.HOLYSHEEP_DEV === '1') {
146
+ const forkDir = path.resolve(__dirname, '..', '..', 'aionui-fork')
147
+ if (isValidRuntimeDir(forkDir)) return 'aionui-fork'
148
+ }
143
149
  if (isValidRuntimeDir(VENDOR_DIR)) return 'vendor'
144
150
  if (isValidRuntimeDir(USER_CACHE_DIR)) {
145
151
  return isVersionCurrent(USER_CACHE_DIR) ? 'installed' : 'installed-stale'
@@ -464,14 +470,33 @@ async function startAionUiMode(opts) {
464
470
 
465
471
  console.log(chalk.cyan(`▶ Starting AionUi v1.9.18 (HolySheep fork, source: ${runtime.source})`))
466
472
 
467
- // 3. Spawn server on the user-visible port directly no proxy wrapper
473
+ // 3. Spawn AionUi on an internal loopback port and wrap it with
474
+ // `aionui-wrapper.js` which listens on the user-visible port. This is the
475
+ // critical architectural piece that makes `/api/holysheep/balance`,
476
+ // `/api/holysheep/tools`, `/api/holysheep/setup`, etc. actually reachable
477
+ // from the browser: the wrapper in-process dispatches those routes to
478
+ // `src/webui/server.js`'s exported handlers, and proxies everything else
479
+ // (including /login, /api/auth/*, WebSocket /ws) to AionUi.
480
+ //
481
+ // Before 2.1.14 hs web spawned AionUi directly on :9876 and the wrapper
482
+ // was dead code — so the Dashboard couldn't fetch balance/tools at all.
483
+ const { startWrapper } = require('../webui/aionui-wrapper')
484
+
485
+ // Pick an internal port. We rely on the wrapper's own picker to avoid
486
+ // duplicating logic, but we need to know the public port upfront for the
487
+ // user-visible URL message.
488
+ let wrapper
468
489
  let aionuiProc
469
490
  try {
470
- aionuiProc = await spawnAionUiServer({ bunPath, runtimeDir: runtime.dir, port })
491
+ wrapper = await startWrapper({
492
+ port, // user-visible port (default 9876)
493
+ runtimeDir: runtime.dir,
494
+ runtimeVersion: runtime.version,
495
+ runtimeSource: runtime.source,
496
+ bunPath,
497
+ })
498
+ aionuiProc = wrapper.aionui
471
499
  } catch (e) {
472
- // `e.message` may now be multi-line (includes bun/AionUi stderr tail).
473
- // Print the first line in red (the reason), then any extra lines verbatim
474
- // so the user can see the real bun/AionUi error and paste it back to us.
475
500
  const [firstLine, ...rest] = String(e.message).split(/\r?\n/)
476
501
  console.log(chalk.red(`✗ AionUi server failed to start: ${firstLine}`))
477
502
  for (const line of rest) {
@@ -487,13 +512,16 @@ async function startAionUiMode(opts) {
487
512
  }
488
513
 
489
514
  const baseUrl = `http://127.0.0.1:${port}`
515
+ const internalPort = wrapper.internalPort
490
516
 
491
- // 4. Auto-login via HolySheep API key if available
517
+ // 4. Auto-login via HolySheep API key if available — talks to the INTERNAL
518
+ // AionUi port (bypasses the wrapper's rate limits / middleware), because
519
+ // this is just a cookie-warmer for the browser launch URL.
492
520
  const apiKey = readHolySheepApiKey()
493
521
  let launchUrl = baseUrl
494
522
  if (apiKey) {
495
523
  try {
496
- const { cookieLine } = await loginWithApiKey(port, apiKey)
524
+ const { cookieLine } = await loginWithApiKey(internalPort, apiKey)
497
525
  // We can't programmatically seed the browser with cookies easily. Best UX:
498
526
  // - AionUi /login already set the cookie in our HTTP client, not the browser
499
527
  // - We ask user to paste key once in UI, OR we display a one-shot magic link
@@ -160,10 +160,38 @@ function writeJsonConfigIfNeeded(apiKey, baseUrlOpenAI, model) {
160
160
  /**
161
161
  * 清除 auth.json 中的 ChatGPT OAuth 认证,避免干扰 holysheep provider
162
162
  * Codex RS bug: auth_mode=chatgpt 时 OAuth token 会覆盖自定义 provider 的 api_key
163
+ *
164
+ * Windows: Defender occasionally holds an open handle to auth.json during the
165
+ * first post-install scan, so an immediate `unlinkSync` returns EBUSY. Retry
166
+ * up to 5 times with a short backoff. On non-Windows a single pass suffices.
163
167
  */
164
168
  function neutralizeAuthJson() {
165
- // 直接删除 auth.json,避免干扰 config.toml http_headers 认证
166
- try { fs.unlinkSync(AUTH_FILE) } catch {}
169
+ const MAX_ATTEMPTS = process.platform === 'win32' ? 5 : 1
170
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
171
+ try {
172
+ fs.unlinkSync(AUTH_FILE)
173
+ return
174
+ } catch (e) {
175
+ if (e && e.code === 'ENOENT') return // already gone — success
176
+ if (attempt === MAX_ATTEMPTS) {
177
+ // Last try failed — on Windows fall back to truncating the file to
178
+ // an empty JSON object. Codex RS treats `{}` as "no chatgpt session",
179
+ // so the config.toml's `http_headers.Authorization` takes over.
180
+ try { fs.writeFileSync(AUTH_FILE, '{}\n', 'utf8') } catch {}
181
+ return
182
+ }
183
+ // Sleep ~200ms via a synchronous shell command. We avoid pulling in a
184
+ // new dep or using Atomics.wait here to keep the tool footprint small.
185
+ try {
186
+ require('child_process').execSync(
187
+ process.platform === 'win32'
188
+ ? 'powershell -NoProfile -Command "Start-Sleep -Milliseconds 200"'
189
+ : 'sleep 0.2',
190
+ { stdio: 'ignore' }
191
+ )
192
+ } catch {}
193
+ }
194
+ }
167
195
  }
168
196
 
169
197
  module.exports = {
@@ -20,6 +20,21 @@ const CONFIG_DIR = path.join(os.homedir(), '.factory')
20
20
  const SETTINGS_FILE = path.join(CONFIG_DIR, 'settings.json')
21
21
  const LEGACY_CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
22
22
 
23
+ // Platform-specific install command. Windows previously read the macOS-only
24
+ // `brew install --cask droid` which always fails on fresh Win10/11 boxes —
25
+ // users reported this explicitly in 2.1.12/13. Factory ships an official
26
+ // winget package; Linux gets the official shell installer. macOS keeps brew.
27
+ function installCmdForPlatform() {
28
+ if (process.platform === 'win32') {
29
+ return 'winget install --id Factory.Droid -e --accept-source-agreements --accept-package-agreements'
30
+ }
31
+ if (process.platform === 'darwin') {
32
+ return 'brew install --cask droid'
33
+ }
34
+ // Linux — Factory's official installer supports Linux amd64/arm64.
35
+ return 'curl -fsSL https://app.factory.ai/install.sh | bash'
36
+ }
37
+
23
38
  const DEFAULT_MODELS = [
24
39
  {
25
40
  model: 'gpt-5.4',
@@ -173,6 +188,21 @@ module.exports = {
173
188
  legacy.logoAnimation = 'off'
174
189
  writeLegacyConfig(legacy)
175
190
 
191
+ // Windows: also write FACTORY_API_KEY to user env via setx so a freshly
192
+ // opened PowerShell / cmd.exe picks it up without requiring the user to
193
+ // manually edit env vars. Non-Windows shells don't need this because the
194
+ // config.json → settings.json path already wires the key through Factory's
195
+ // own auth resolution.
196
+ if (process.platform === 'win32') {
197
+ try {
198
+ const { execSync } = require('child_process')
199
+ execSync(`setx FACTORY_API_KEY "${apiKey}"`, { stdio: 'ignore', timeout: 10000 })
200
+ } catch {
201
+ // setx can fail in locked-down corp images; best-effort only.
202
+ // The settings.json / config.json path still works inside Droid CLI itself.
203
+ }
204
+ }
205
+
176
206
  return {
177
207
  file: SETTINGS_FILE,
178
208
  hot: true,
@@ -194,6 +224,6 @@ module.exports = {
194
224
  getConfigPath() { return SETTINGS_FILE },
195
225
  hint: '已写入 ~/.factory/settings.json;重启 Droid 后可见 HolySheep 模型列表',
196
226
  launchCmd: 'droid',
197
- installCmd: 'brew install --cask droid',
227
+ installCmd: installCmdForPlatform(),
198
228
  docsUrl: 'https://docs.factory.ai/cli/getting-started/overview',
199
229
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Hermes Agent (Nous Research) 适配器
3
+ * Project: https://github.com/NousResearch/hermes-agent
4
+ *
5
+ * Hermes 是 Python 项目,上游推荐用 uv 安装:
6
+ * curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup
7
+ *
8
+ * 配置文件:~/.hermes/config.toml(Python 的 TOML 风格)。我们注入一个
9
+ * [providers.holysheep] block 并把 default_provider 设成 holysheep,让
10
+ * `hermes` 启动后默认使用 HolySheep。
11
+ *
12
+ * 写 TOML 是侵入式操作,宁可保守:
13
+ * 1. 如果文件不存在,整个写一个最小可用的 HolySheep-only 配置。
14
+ * 2. 如果已存在,只替换 `[providers.holysheep]` 段和一个 `default_provider = "..."`
15
+ * 行,其余内容(用户自己加的 other providers / tools / memory 配置)原样保留。
16
+ *
17
+ * Windows:官方安装脚本(bash + uv)不支持原生 Windows。hs setup 里我们把
18
+ * `installCmd` 返回 manual 标记,引导用户到 WSL2。
19
+ */
20
+ const fs = require('fs')
21
+ const path = require('path')
22
+ const os = require('os')
23
+ const { commandExists } = require('../utils/which')
24
+
25
+ const CONFIG_DIR = path.join(os.homedir(), '.hermes')
26
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.toml')
27
+
28
+ const PROVIDER_SECTION_RE = /^\[providers\.holysheep\]\s*$([\s\S]*?)(?=^\[|\Z)/m
29
+ const DEFAULT_PROVIDER_RE = /^default_provider\s*=\s*"[^"]*"\s*$/m
30
+
31
+ function readConfig() {
32
+ try {
33
+ if (fs.existsSync(CONFIG_FILE)) {
34
+ return fs.readFileSync(CONFIG_FILE, 'utf8')
35
+ }
36
+ } catch {}
37
+ return ''
38
+ }
39
+
40
+ function writeConfig(content) {
41
+ if (!fs.existsSync(CONFIG_DIR)) {
42
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })
43
+ }
44
+ fs.writeFileSync(CONFIG_FILE, content, { encoding: 'utf8', mode: 0o600 })
45
+ if (process.platform !== 'win32') {
46
+ try { fs.chmodSync(CONFIG_FILE, 0o600) } catch {}
47
+ }
48
+ }
49
+
50
+ /** Build the [providers.holysheep] block + default_provider pointing at it. */
51
+ function buildHolySheepBlock(apiKey, baseUrlOpenAI, primaryModel) {
52
+ const cleanBase = String(baseUrlOpenAI || 'https://api.holysheep.ai/v1').replace(/\/+$/, '')
53
+ const model = primaryModel || 'gpt-5.4'
54
+ return [
55
+ '[providers.holysheep]',
56
+ '# Managed by @simonyea/holysheep-cli. Do not edit by hand — run `hs setup`.',
57
+ 'type = "openai"',
58
+ `base_url = "${cleanBase}"`,
59
+ `api_key = "${apiKey}"`,
60
+ `default_model = "${model}"`,
61
+ '',
62
+ ].join('\n')
63
+ }
64
+
65
+ /**
66
+ * Strip a previous HolySheep-managed block from the TOML content. Everything
67
+ * else the user wrote is preserved verbatim. Non-TOML-aware line splicing —
68
+ * the hermes config uses standard TOML sections which are blank-line separated,
69
+ * so detecting a section header that starts with `[…]` is sufficient.
70
+ */
71
+ function stripExisting(content) {
72
+ const lines = content.replace(/\r\n/g, '\n').split('\n')
73
+ const out = []
74
+ let inBlock = false
75
+ for (const line of lines) {
76
+ if (/^\[providers\.holysheep\]\s*$/.test(line)) {
77
+ inBlock = true
78
+ continue
79
+ }
80
+ if (inBlock) {
81
+ if (/^\[[^\]]+\]\s*$/.test(line)) {
82
+ inBlock = false
83
+ out.push(line)
84
+ continue
85
+ }
86
+ continue
87
+ }
88
+ out.push(line)
89
+ }
90
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim()
91
+ }
92
+
93
+ function mergeConfig(apiKey, baseUrlOpenAI, primaryModel) {
94
+ const existing = stripExisting(readConfig())
95
+ const block = buildHolySheepBlock(apiKey, baseUrlOpenAI, primaryModel)
96
+
97
+ // Replace an existing `default_provider` line, or insert one near the top.
98
+ let withDefault = existing
99
+ if (DEFAULT_PROVIDER_RE.test(withDefault)) {
100
+ withDefault = withDefault.replace(DEFAULT_PROVIDER_RE, 'default_provider = "holysheep"')
101
+ } else {
102
+ withDefault = `default_provider = "holysheep"\n` + withDefault
103
+ }
104
+
105
+ const final = [withDefault.trim(), '', block].filter(Boolean).join('\n')
106
+ return final.endsWith('\n') ? final : final + '\n'
107
+ }
108
+
109
+ function isConfiguredInToml(content) {
110
+ if (!content) return false
111
+ return /\[providers\.holysheep\]/.test(content) &&
112
+ /api\.holysheep\.ai/.test(content) &&
113
+ /api_key\s*=\s*"cr_/.test(content)
114
+ }
115
+
116
+ function installCmdForPlatform() {
117
+ if (process.platform === 'win32') {
118
+ return '' // handled as manual in server.js
119
+ }
120
+ return 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup'
121
+ }
122
+
123
+ module.exports = {
124
+ name: 'Hermes Agent',
125
+ id: 'hermes',
126
+ checkInstalled() {
127
+ return commandExists('hermes')
128
+ },
129
+ isConfigured() {
130
+ return isConfiguredInToml(readConfig())
131
+ },
132
+ configure(apiKey, _baseUrlAnthropic, baseUrlOpenAI, primaryModel /*, _selectedModels */) {
133
+ const merged = mergeConfig(apiKey, baseUrlOpenAI, primaryModel)
134
+ writeConfig(merged)
135
+ return {
136
+ file: CONFIG_FILE,
137
+ hot: true, // next `hermes` run picks up the TOML on load
138
+ }
139
+ },
140
+ reset() {
141
+ const existing = readConfig()
142
+ if (!existing) return
143
+ const stripped = stripExisting(existing).replace(DEFAULT_PROVIDER_RE, '').replace(/\n{3,}/g, '\n\n').trim()
144
+ writeConfig(stripped ? stripped + '\n' : '')
145
+ },
146
+ getConfigPath() { return CONFIG_FILE },
147
+ hint: process.platform === 'win32'
148
+ ? 'Hermes 官方安装脚本仅支持 macOS/Linux/WSL2,Windows 请先启用 WSL2。'
149
+ : '已写入 ~/.hermes/config.toml;运行 `hermes` 默认使用 HolySheep。',
150
+ launchCmd: 'hermes',
151
+ installCmd: installCmdForPlatform(),
152
+ docsUrl: 'https://hermes-agent.nousresearch.com/docs/',
153
+ // Internal helpers — re-exported for tests / inspection.
154
+ _stripExisting: stripExisting,
155
+ _mergeConfig: mergeConfig,
156
+ _buildHolySheepBlock: buildHolySheepBlock,
157
+ }
@@ -7,5 +7,7 @@ module.exports = [
7
7
  require('./droid'),
8
8
  require('./opencode'),
9
9
  require('./openclaw'),
10
+ // 2.1.14: Hermes Agent (Nous Research) — Python/uv based, macOS/Linux/WSL2 only.
11
+ require('./hermes'),
10
12
  require('./env-config'),
11
13
  ]
@@ -8,9 +8,14 @@
8
8
  * cache is atomically replaced with the current version on next launch.
9
9
  * 2. <cli>/src/webui/vendor/aionui/ (dev checkout — not shipped to npm,
10
10
  * no version gate)
11
- * 3. <cli>/../aionui-fork/ (dev repo checkout not shipped to npm,
12
- * no version gate; lets local `bun run build` take precedence over
13
- * any cached runtime when you're iterating on the fork)
11
+ * 3. <cli>/../aionui-fork/ (ONLY when `HOLYSHEEP_DEV=1` is set when you
12
+ * are actively iterating on the fork and want `bun run build` to take
13
+ * precedence over any cached runtime). OFF BY DEFAULT so that even
14
+ * when the CLI is run from the source repo, it behaves exactly like a
15
+ * user install (user-cache → download). This removed a massive
16
+ * silent-drift risk: previously a dev commit that broke the fork would
17
+ * silently ship to developers' `hs web` sessions but not to end users,
18
+ * so bugs were invisible locally.
14
19
  * 4. DEFAULT_RUNTIME_URL + DEFAULT_RUNTIME_SHA256 (baked in; auto-download
15
20
  * when --setup-runtime is passed OR when cache is stale). Users can
16
21
  * override via env.
@@ -55,10 +60,10 @@ const VENDOR_DIR = path.join(__dirname, 'vendor', 'aionui')
55
60
  // new CLI release, the next `hs web` invocation on user machines will detect
56
61
  // the version drift and upgrade the cache in place.
57
62
  const DEFAULT_RUNTIME_URL =
58
- 'https://mail.holysheep.ai/app/cli/aionui-runtime-v1.9.18-holysheep-hs9.tar.gz'
63
+ 'https://mail.holysheep.ai/app/cli/aionui-runtime-v1.9.18-holysheep-hs10.tar.gz'
59
64
  const DEFAULT_RUNTIME_SHA256 =
60
- '901a7a3beb6bbac1104ea0e4a6dc3aedaf24281cd8a0614c3087a53346fe788f'
61
- const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-hs9'
65
+ '45dfa23db819ea57f445a5db7652b0e4455d822e07f60458e6713c7d3a28baec'
66
+ const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-hs10'
62
67
 
63
68
  function isValidRuntimeDir(dir) {
64
69
  if (!dir) return false
@@ -194,13 +199,15 @@ async function resolveRuntime({ allowDownload = false, logger = () => {} } = {})
194
199
  return { dir: VENDOR_DIR, version: readVersion(VENDOR_DIR), source: 'vendor' }
195
200
  }
196
201
 
197
- // 3. Dev repo aionui-fork/ (when running from the holysheep-cli source repo).
198
- // No version gate: whoever is building the fork locally is on the bleeding
199
- // edge by definition; comparing against DEFAULT_RUNTIME_VERSION would just
200
- // break local dev every time you bump the version bakeline.
201
- const forkDir = path.resolve(__dirname, '..', '..', 'aionui-fork')
202
- if (isValidRuntimeDir(forkDir)) {
203
- return { dir: forkDir, version: readVersion(forkDir), source: 'aionui-fork' }
202
+ // 3. Dev repo aionui-fork/ GATED BEHIND HOLYSHEEP_DEV=1. See resolver
203
+ // header docstring (order step 3) for rationale. Users (even devs running
204
+ // from the repo checkout) go through user-cache → download, so the
205
+ // production behavior is always what they see locally.
206
+ if (process.env.HOLYSHEEP_DEV === '1') {
207
+ const forkDir = path.resolve(__dirname, '..', '..', 'aionui-fork')
208
+ if (isValidRuntimeDir(forkDir)) {
209
+ return { dir: forkDir, version: readVersion(forkDir), source: 'aionui-fork' }
210
+ }
204
211
  }
205
212
 
206
213
  // 4. Fresh install — download from env override OR baked-in default
@@ -45,8 +45,11 @@ const BRIDGE_CRED_FILE = path.join(BRIDGE_DIR, 'aionui-bridge.json')
45
45
  const TOKEN_TTL_MS = 30_000
46
46
  const INTERNAL_PORT_START = 9877
47
47
  const INTERNAL_PORT_TRIES = 10
48
- const UPSTREAM_STARTUP_TIMEOUT_MS = 25_000
48
+ // First launch on Windows spends 30-40s in Defender + bun JIT + sqlite init,
49
+ // so 25s was flaking on cold boxes. Align with the old standalone spawn path.
50
+ const UPSTREAM_STARTUP_TIMEOUT_MS = Number(process.env.HS_WEB_STARTUP_TIMEOUT_MS) || 60_000
49
51
  const UPSTREAM_CONNECT_TIMEOUT_MS = 30_000
52
+ const AIONUI_LOG_TAIL_BYTES = 4096
50
53
 
51
54
  // Bootstrap token store — Map<token, { createdAt, used }>
52
55
  const bootstrapTokens = new Map()
@@ -525,6 +528,14 @@ function buildRouter(ctx) {
525
528
  if (route === '/api/holysheep/whoami' && req.method === 'GET') {
526
529
  return await legacy().handleWhoami(req, res)
527
530
  }
531
+ // One-click-all (SSE) — configure every installed tool in a single stream.
532
+ // Must sit next to `/api/holysheep/tool/*` so the Dashboard's
533
+ // "一键配置所有 CLI" button hits a real route instead of falling
534
+ // through to the default AionUi proxy (which returns 404/502 and
535
+ // surfaces as "Failed to load resource" in the console).
536
+ if (route === '/api/holysheep/setup' && req.method === 'POST') {
537
+ return await legacy().handleSetup(req, res)
538
+ }
528
539
  // POST handlers: install, configure, reset, launch for a named tool
529
540
  if (route === '/api/holysheep/tool/install' && req.method === 'POST') {
530
541
  return await legacy().handleToolInstall(req, res)
@@ -586,7 +597,12 @@ async function startWrapper({ port, runtimeDir, runtimeVersion, runtimeSource, b
586
597
  const internalPort = await pickInternalPort()
587
598
  log(`internal AionUi port: ${internalPort}`)
588
599
 
589
- // Spawn AionUi, bound 127.0.0.1 only (ALLOW_REMOTE never set)
600
+ // Spawn AionUi, bound 127.0.0.1 only (ALLOW_REMOTE never set).
601
+ // stdio is piped (not inherited) so that:
602
+ // 1. When startup fails we can surface the last ~4KB of bun/AionUi
603
+ // output via the rejected `startWrapper` promise (previously silent).
604
+ // 2. In HS_WEB_DEBUG=1 mode we stream live logs prefixed with [aionui].
605
+ const debug = process.env.HS_WEB_DEBUG === '1'
590
606
  const aionui = spawn(bunPath, ['dist-server/server.mjs'], {
591
607
  cwd: runtimeDir,
592
608
  env: {
@@ -596,14 +612,40 @@ async function startWrapper({ port, runtimeDir, runtimeVersion, runtimeSource, b
596
612
  ALLOW_REMOTE: '',
597
613
  NODE_ENV: 'production',
598
614
  },
599
- stdio: ['ignore', 'inherit', 'inherit'],
615
+ stdio: ['ignore', 'pipe', 'pipe'],
600
616
  })
617
+
618
+ let logTail = ''
619
+ const appendLog = (stream, chunk) => {
620
+ const s = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
621
+ logTail += s
622
+ if (logTail.length > AIONUI_LOG_TAIL_BYTES) {
623
+ logTail = logTail.slice(logTail.length - AIONUI_LOG_TAIL_BYTES)
624
+ }
625
+ if (debug) {
626
+ const target = stream === 'err' ? process.stderr : process.stdout
627
+ target.write(`[aionui] ${s}`)
628
+ }
629
+ }
630
+ aionui.stdout.on('data', (d) => appendLog('out', d))
631
+ aionui.stderr.on('data', (d) => appendLog('err', d))
601
632
  aionui.on('exit', (code) => {
602
633
  log(`AionUi upstream exited (code=${code})`)
603
634
  process.exit(code || 1)
604
635
  })
605
636
 
606
- await waitForUpstreamReady(internalPort)
637
+ try {
638
+ await waitForUpstreamReady(internalPort)
639
+ } catch (e) {
640
+ const tail = logTail.trim()
641
+ const msg = tail
642
+ ? `${e.message}\n\n --- last AionUi output (tail) ---\n${tail
643
+ .split(/\r?\n/)
644
+ .map((ln) => ` ${ln}`)
645
+ .join('\n')}\n ------------------------------------`
646
+ : e.message
647
+ throw new Error(msg)
648
+ }
607
649
  log(`AionUi upstream ready (version=${runtimeVersion}, source=${runtimeSource})`)
608
650
 
609
651
  const ctx = { internalPort, runtimeDir, runtimeVersion, runtimeSource, bunPath }
@@ -74,6 +74,7 @@ function getVersionAsync(tool) {
74
74
  'opencode': 'opencode --version',
75
75
  'openclaw': 'openclaw --version',
76
76
  'aider': 'aider --version',
77
+ 'hermes': 'hermes --version',
77
78
  }
78
79
  const cmd = cmds[tool.id]
79
80
  if (!cmd) return Promise.resolve(null)
@@ -106,6 +107,7 @@ function getToolCommand(toolId) {
106
107
  'opencode': 'opencode',
107
108
  'openclaw': 'openclaw',
108
109
  'aider': 'aider',
110
+ 'hermes': 'hermes',
109
111
  'env-config': null,
110
112
  }
111
113
  return cmds[toolId] ?? null
@@ -169,10 +171,28 @@ const AUTO_INSTALL = {
169
171
  : 'curl -fsSL https://claude.ai/install.sh | bash',
170
172
  },
171
173
  'codex': { cmd: 'npm install -g @openai/codex' },
174
+ 'droid': {
175
+ // 2.1.14 fix: Windows/Linux now have official installers; previously
176
+ // the WebUI piped the macOS-only brew command on every platform.
177
+ cmd: process.platform === 'win32'
178
+ ? 'winget install --id Factory.Droid -e --accept-source-agreements --accept-package-agreements'
179
+ : process.platform === 'darwin'
180
+ ? 'brew install --cask droid'
181
+ : 'curl -fsSL https://app.factory.ai/install.sh | bash',
182
+ },
172
183
  'gemini-cli': { cmd: 'npm install -g @google/gemini-cli' },
173
184
  'opencode': { cmd: 'npm install -g opencode-ai' },
174
185
  'openclaw': { cmd: 'npm install -g openclaw@latest' },
175
186
  'aider': { cmd: 'pip install aider-chat' },
187
+ // Hermes Agent (Nous Research). Python-based, installed via uv. Windows
188
+ // requires WSL2 (the installer explicitly rejects native Windows). On
189
+ // Windows the CLI's hermes tool reports manual steps instead of running
190
+ // this shell command — see src/tools/hermes.js.
191
+ 'hermes': {
192
+ cmd: process.platform === 'win32'
193
+ ? '' // unsupported — see hermes tool for manual steps
194
+ : 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup',
195
+ },
176
196
  }
177
197
 
178
198
  // ── UPGRADABLE_TOOLS (from upgrade.js) ───────────────────────────────────────
@@ -187,10 +207,26 @@ const UPGRADABLE_TOOLS = [
187
207
  : 'curl -fsSL https://claude.ai/install.sh | bash',
188
208
  },
189
209
  { name: 'Codex CLI', id: 'codex', command: 'codex', versionCmd: 'codex --version', npmPkg: '@openai/codex', installCmd: 'npm install -g @openai/codex@latest' },
190
- { name: 'Droid CLI', id: 'droid', command: 'droid', versionCmd: 'droid --version', npmPkg: null, installCmd: process.platform === 'win32' ? 'winget upgrade --id Droid.Droid' : 'brew install --cask droid' },
210
+ {
211
+ name: 'Droid CLI', id: 'droid', command: 'droid', versionCmd: 'droid --version', npmPkg: null,
212
+ // 2.1.14: Correct Factory winget package id is `Factory.Droid` (not `Droid.Droid`)
213
+ // and Linux/macOS get platform-native installers. Previously the Linux
214
+ // fallback was the macOS brew cmd which fails.
215
+ installCmd: process.platform === 'win32'
216
+ ? 'winget install --id Factory.Droid -e --accept-source-agreements --accept-package-agreements'
217
+ : process.platform === 'darwin'
218
+ ? 'brew install --cask droid'
219
+ : 'curl -fsSL https://app.factory.ai/install.sh | bash',
220
+ },
191
221
  { name: 'OpenCode', id: 'opencode', command: 'opencode', versionCmd: 'opencode --version', npmPkg: 'opencode-ai', installCmd: 'npm install -g opencode-ai@latest' },
192
222
  { name: 'OpenClaw', id: 'openclaw', command: 'openclaw', versionCmd: 'openclaw --version', npmPkg: 'openclaw', installCmd: 'npm install -g openclaw@latest' },
193
223
  { name: 'Gemini CLI', id: 'gemini-cli', command: 'gemini', versionCmd: 'gemini --version', npmPkg: '@google/gemini-cli', installCmd: 'npm install -g @google/gemini-cli@latest' },
224
+ {
225
+ name: 'Hermes Agent', id: 'hermes', command: 'hermes', versionCmd: 'hermes --version', npmPkg: null,
226
+ installCmd: process.platform === 'win32'
227
+ ? '' // unsupported on native Windows — see hermes tool hint
228
+ : 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup',
229
+ },
194
230
  ]
195
231
 
196
232
  // ── Update check (cached, refreshes every 30min) ────────────────────────────
@@ -295,8 +331,18 @@ async function handleWhoami(_req, res) {
295
331
  async function handleBalance(_req, res) {
296
332
  const apiKey = getApiKey()
297
333
  if (!apiKey) return json(res, { error: '未登录' }, 401)
334
+ // IMPORTANT (2.1.14): SHOP_URL is `https://holysheep.ai` which issues a
335
+ // 301 to `https://www.holysheep.ai`. node-fetch's default redirect flow
336
+ // drops the Authorization header on cross-origin redirects (even between
337
+ // same-reg-domain hosts), so the downstream request would hit the Next.js
338
+ // app unauthenticated and return 404/HTML. We hit www.* directly to keep
339
+ // the Bearer header intact.
340
+ //
341
+ // Response schema (verified 2026-04-22):
342
+ // { balance, todayCost, monthCost, totalCalls, totalCost, recentRecords:[…] }
343
+ const STATS_URL = 'https://www.holysheep.ai/api/stats/overview'
298
344
  try {
299
- const r = await fetchWithRetry(`${SHOP_URL}/api/stats/overview`, {
345
+ const r = await fetchWithRetry(STATS_URL, {
300
346
  headers: { Authorization: `Bearer ${apiKey}` },
301
347
  })
302
348
  if (r.status === 401) return json(res, { error: 'API Key 无效或已过期' }, 401)
@@ -307,6 +353,8 @@ async function handleBalance(_req, res) {
307
353
  todayCost: Number(data.todayCost || 0),
308
354
  monthCost: Number(data.monthCost || 0),
309
355
  totalCalls: Number(data.totalCalls || 0),
356
+ totalCost: Number(data.totalCost || 0),
357
+ plans: Array.isArray(data.plans) ? data.plans : [],
310
358
  })
311
359
  } catch (e) {
312
360
  const msg = e.code === 'EAI_AGAIN' ? 'DNS 解析失败,请检查网络' : e.message
@@ -597,6 +645,14 @@ async function handleToolInstall(req, res) {
597
645
  if (!toolId || !AUTO_INSTALL[toolId]) {
598
646
  return json(res, { error: '不支持自动安装此工具' }, 400)
599
647
  }
648
+ // Hermes on Windows: installer only supports Linux/macOS/WSL2. Instead of
649
+ // spawning an empty cmd (which hangs SSE), short-circuit with guidance.
650
+ if (toolId === 'hermes' && process.platform === 'win32') {
651
+ sseStart(res)
652
+ sseEmit(res, { type: 'output', text: 'Hermes Agent 官方安装脚本不支持原生 Windows。\n请先启用 WSL2(`wsl --install`)后,在 Ubuntu 终端里运行:\n curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup\n' })
653
+ sseEmit(res, { type: 'done', success: false, manual: true })
654
+ return res.end()
655
+ }
600
656
 
601
657
  sseStart(res)
602
658
  sseEmit(res, { type: 'progress', message: `正在安装 ${toolId}...` })
@@ -615,6 +671,21 @@ async function handleToolInstall(req, res) {
615
671
  child.on('error', () => { clearTimeout(timer); resolve(false) })
616
672
  })
617
673
 
674
+ // Windows: npm-global installs (codex, gemini-cli, opencode, openclaw) drop
675
+ // the binary under %APPDATA%\npm which isn't in the freshly-installed user's
676
+ // Path. Fix it now so the very next tool check sees the binary. No-op on
677
+ // non-Windows or when already present.
678
+ if (ok && process.platform === 'win32') {
679
+ try {
680
+ const { ensureWindowsUserPathHasNpmBin } = require('../utils/shell')
681
+ ensureWindowsUserPathHasNpmBin()
682
+ sseEmit(res, { type: 'output', text: '\n✓ 已更新 Windows 用户 PATH(包含 npm global bin)\n' })
683
+ } catch {}
684
+ // Bust the tool-check cache so the follow-up /api/holysheep/tools sees
685
+ // the new binary without waiting 10s for the TTL.
686
+ toolStateCache.delete(toolId)
687
+ }
688
+
618
689
  sseEmit(res, { type: 'done', success: ok })
619
690
  res.end()
620
691
  }