From 422b612db5913fd76927b81a3615c4b37178987c Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:42:07 +0800 Subject: [PATCH 01/23] fix(#346): frontend API_URL auto-detects reverse proxy (default port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When accessed behind Nginx/Caddy on port 80/443, location.port is empty and the old fallback (3001→3002) produced a wrong API address. Now when port is absent the frontend uses same-origin, letting the reverse proxy route /api/ and /socket.io/ to the API server. Direct-port access (e.g. :3003→:3004) remains unchanged. Closes #346 [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .env.example | 7 +++++++ packages/web/src/utils/api-client.ts | 12 +++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 6af83c8f7c..e48a557f28 100644 --- a/.env.example +++ b/.env.example @@ -13,10 +13,17 @@ # These ports must not conflict with other local services. # Default: Frontend 3003, API 3004, Redis 6399 # Convention: API port = Frontend port + 1 (same as internal 3001→3002) +# +# Reverse proxy / remote access 反向代理 / 远端访问: +# Behind Nginx (port 80/443), the frontend auto-detects same-origin +# and routes /api/ + /socket.io/ through the proxy — no env var needed. +# Only set NEXT_PUBLIC_API_URL if you need a non-standard API endpoint. +# Also set FRONTEND_URL on the API side for CORS (see below). FRONTEND_PORT=3003 API_SERVER_PORT=3004 NEXT_PUBLIC_API_URL=http://localhost:3004 +# FRONTEND_URL=http://your-public-ip # CORS: set when API is accessed from a public domain/IP NEXT_PUBLIC_BRAND_NAME="Clowder AI" # CLI inactivity timeout in milliseconds. diff --git a/packages/web/src/utils/api-client.ts b/packages/web/src/utils/api-client.ts index 2fcb8653ab..94cfc9022f 100644 --- a/packages/web/src/utils/api-client.ts +++ b/packages/web/src/utils/api-client.ts @@ -23,13 +23,15 @@ function resolveApiUrl(): string { } if (process.env.NEXT_PUBLIC_API_URL) return process.env.NEXT_PUBLIC_API_URL; if (typeof window === 'undefined') return 'http://localhost:3004'; - // Derive API port from frontend port: convention is frontend + 1 = API - // (runtime: 3001→3002, alpha: 3011→3012). Fallback to +1 of current port. - const frontendPort = Number(location?.port ?? '') || 3001; - const apiPort = frontendPort + 1; const protocol = location?.protocol ?? 'http:'; const hostname = location?.hostname ?? 'localhost'; - return `${protocol}//${hostname}:${apiPort}`; + const port = Number(location?.port ?? '') || 0; + // Behind reverse proxy (default port 80/443 → port is empty string): + // API lives at the same origin, proxied via /api/ and /socket.io/ paths. + if (!port) return `${protocol}//${hostname}`; + // Direct access with explicit port: convention frontendPort + 1 = apiPort + // (runtime: 3001→3002, open-source: 3003→3004, alpha: 3011→3012). + return `${protocol}//${hostname}:${port + 1}`; } export const API_URL = resolveApiUrl(); From f2d77d308047c2896761687a243c9844a5d87c3b Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:50:38 +0800 Subject: [PATCH 02/23] fix(#346): skip stale localhost env when accessed remotely + add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback from PR #347: P1: When NEXT_PUBLIC_API_URL is a localhost default (baked in at build time) but the browser hostname is remote, skip the env var and fall through to runtime auto-detection. This prevents setup.sh / .env defaults from short-circuiting the reverse-proxy same-origin path. P2: Update SETUP.md / SETUP.zh-CN.md docs — reverse proxy scenarios no longer require NEXT_PUBLIC_API_URL; troubleshooting reflects auto-detect behavior. P2: Add 8 vitest cases covering Cloudflare Tunnel, explicit env, localhost-skip, same-origin (reverse proxy), and port+1 derivation. [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- SETUP.md | 8 +- SETUP.zh-CN.md | 8 +- .../__tests__/api-client-resolve.test.ts | 108 ++++++++++++++++++ packages/web/src/utils/api-client.ts | 12 +- 4 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 packages/web/src/utils/__tests__/api-client-resolve.test.ts diff --git a/SETUP.md b/SETUP.md index 6eb26dde93..072f11250f 100644 --- a/SETUP.md +++ b/SETUP.md @@ -450,8 +450,9 @@ API_SERVER_HOST=0.0.0.0 # Frontend URL — used for CORS and redirects FRONTEND_URL=https://your-domain.com -# API URL — the frontend needs to reach the API -NEXT_PUBLIC_API_URL=http://your-domain.com:3004 +# API URL — usually not needed behind a reverse proxy (auto-detected). +# Only set if you need a non-standard endpoint (e.g. separate API domain). +# NEXT_PUBLIC_API_URL=https://api.your-domain.com # Redis — if running on a separate host REDIS_URL=redis://your-redis-host:6399 @@ -497,6 +498,7 @@ No additional CORS configuration is needed for most LAN / VPN setups. - Check the API logs in terminal for auth errors **Frontend can't connect to API?** -- Make sure `NEXT_PUBLIC_API_URL=http://localhost:3004` is set +- For local dev, `NEXT_PUBLIC_API_URL=http://localhost:3004` should be in `.env` +- Behind a reverse proxy, the frontend auto-detects the API at the same origin — make sure Nginx proxies `/api/` and `/socket.io/` to port 3004 - API must be running before frontend loads diff --git a/SETUP.zh-CN.md b/SETUP.zh-CN.md index 3d0a4e5e99..b297283491 100644 --- a/SETUP.zh-CN.md +++ b/SETUP.zh-CN.md @@ -450,8 +450,9 @@ API_SERVER_HOST=0.0.0.0 # 前端 URL — 用于 CORS 和重定向 FRONTEND_URL=https://your-domain.com -# API URL — 前端需要能访问到 API -NEXT_PUBLIC_API_URL=http://your-domain.com:3004 +# API URL — 反向代理场景通常不需要设置(自动探测)。 +# 仅在 API 使用独立域名等非标准端点时设置。 +# NEXT_PUBLIC_API_URL=https://api.your-domain.com # Redis — 如果在其他机器上 REDIS_URL=redis://your-redis-host:6399 @@ -497,5 +498,6 @@ API 自动接受以下来源的请求: - 看终端里 API 日志有没有认证错误 **前端连不上 API?** -- 确认设了 `NEXT_PUBLIC_API_URL=http://localhost:3004` +- 本地开发确认 `.env` 里有 `NEXT_PUBLIC_API_URL=http://localhost:3004` +- 反向代理场景下前端会自动探测同源 API —— 确保 Nginx 把 `/api/` 和 `/socket.io/` 代理到 3004 端口 - API 必须在前端加载前启动 diff --git a/packages/web/src/utils/__tests__/api-client-resolve.test.ts b/packages/web/src/utils/__tests__/api-client-resolve.test.ts new file mode 100644 index 0000000000..39934b880f --- /dev/null +++ b/packages/web/src/utils/__tests__/api-client-resolve.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/* ---------- helpers to mock browser Location ---------- */ + +function stubLocation(overrides: Partial | null) { + if (overrides === null) { + // Simulate SSR: no location on globalThis + vi.stubGlobal('location', undefined); + return; + } + vi.stubGlobal('location', { + hostname: overrides.hostname ?? 'localhost', + port: overrides.port ?? '', + protocol: overrides.protocol ?? 'http:', + // Enough to satisfy getBrowserLocation() checks + ...overrides, + }); +} + +/* ---------- suite ---------- */ + +describe('resolveApiUrl', () => { + const originalEnv = process.env.NEXT_PUBLIC_API_URL; + + beforeEach(() => { + delete process.env.NEXT_PUBLIC_API_URL; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + if (originalEnv !== undefined) { + process.env.NEXT_PUBLIC_API_URL = originalEnv; + } else { + delete process.env.NEXT_PUBLIC_API_URL; + } + }); + + async function loadResolveApiUrl() { + // Reset module cache so resolveApiUrl re-evaluates with current mocks + vi.resetModules(); + const mod = await import('../api-client'); + return mod.resolveApiUrl; + } + + // ── Cloudflare Tunnel ── + + it('returns Cloudflare API when hostname is cafe.clowder-ai.com', async () => { + stubLocation({ hostname: 'cafe.clowder-ai.com', protocol: 'https:', port: '' }); + const resolve = await loadResolveApiUrl(); + expect(resolve()).toBe('https://api.clowder-ai.com'); + }); + + // ── Explicit env (non-localhost) always wins ── + + it('uses NEXT_PUBLIC_API_URL when explicitly set to non-localhost', async () => { + process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com'; + stubLocation({ hostname: '1.2.3.4', port: '' }); + const resolve = await loadResolveApiUrl(); + expect(resolve()).toBe('https://api.example.com'); + }); + + // ── P1 fix: localhost env + remote access → skip env, auto-detect ── + + it('skips localhost env when accessed remotely (reverse proxy)', async () => { + process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3004'; + stubLocation({ hostname: '1.2.3.4', protocol: 'http:', port: '' }); + const resolve = await loadResolveApiUrl(); + expect(resolve()).toBe('http://1.2.3.4'); + }); + + it('skips 127.0.0.1 env when accessed remotely', async () => { + process.env.NEXT_PUBLIC_API_URL = 'http://127.0.0.1:3004'; + stubLocation({ hostname: '10.0.0.5', protocol: 'http:', port: '3003' }); + const resolve = await loadResolveApiUrl(); + expect(resolve()).toBe('http://10.0.0.5:3004'); + }); + + // ── localhost env + local access → use env (no skip) ── + + it('uses localhost env when accessed locally', async () => { + process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3004'; + stubLocation({ hostname: 'localhost', port: '3003' }); + const resolve = await loadResolveApiUrl(); + expect(resolve()).toBe('http://localhost:3004'); + }); + + // ── No env, browser, reverse proxy (empty port) → same origin ── + + it('returns same-origin when port is empty (reverse proxy)', async () => { + stubLocation({ hostname: '1.2.3.4', protocol: 'https:', port: '' }); + const resolve = await loadResolveApiUrl(); + expect(resolve()).toBe('https://1.2.3.4'); + }); + + // ── No env, browser, direct port → port+1 ── + + it('derives API port from frontend port (3003→3004)', async () => { + stubLocation({ hostname: '192.168.1.10', protocol: 'http:', port: '3003' }); + const resolve = await loadResolveApiUrl(); + expect(resolve()).toBe('http://192.168.1.10:3004'); + }); + + it('derives API port for dev convention (3001→3002)', async () => { + stubLocation({ hostname: 'localhost', protocol: 'http:', port: '3001' }); + const resolve = await loadResolveApiUrl(); + expect(resolve()).toBe('http://localhost:3002'); + }); +}); diff --git a/packages/web/src/utils/api-client.ts b/packages/web/src/utils/api-client.ts index 94cfc9022f..270b111fed 100644 --- a/packages/web/src/utils/api-client.ts +++ b/packages/web/src/utils/api-client.ts @@ -14,14 +14,22 @@ function getBrowserLocation(): Location | null { return candidate ?? null; } -function resolveApiUrl(): string { +/** @internal Exported for testing — prefer using `API_URL` constant. */ +export function resolveApiUrl(): string { const location = getBrowserLocation(); // Cloudflare Tunnel: API 走 api.clowder-ai.com,Access cookie 在 .clowder-ai.com 上共享 if (location?.hostname === 'cafe.clowder-ai.com') { return 'https://api.clowder-ai.com'; } - if (process.env.NEXT_PUBLIC_API_URL) return process.env.NEXT_PUBLIC_API_URL; + const envUrl = process.env.NEXT_PUBLIC_API_URL; + if (envUrl) { + // Build-time default (localhost) is wrong when accessed remotely — skip and auto-detect. + const isLocalhostDefault = /^https?:\/\/(localhost|127\.0\.0\.1)[:/]/.test(envUrl); + const isRemoteAccess = + location != null && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1'; + if (!isLocalhostDefault || !isRemoteAccess) return envUrl; + } if (typeof window === 'undefined') return 'http://localhost:3004'; const protocol = location?.protocol ?? 'http:'; const hostname = location?.hostname ?? 'localhost'; From 30f6abd390c74a164807642fde505e8b7d261053 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:15:31 +0800 Subject: [PATCH 03/23] fix: opencode builtin providers (anthropic/google) now get runtime config for unknown models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, providers in BUILTIN_OPENCODE_PROVIDERS (anthropic, openai, google, openrouter) were trusted to handle all models natively. When opencode's builtin model list is outdated (e.g. doesn't include claude-opus-4-6), invocations fail with "Model not found". Now runtime config with explicit model registration is always generated for opencode cats with api_key auth, regardless of whether the provider is builtin. Also infers apiType from the provider name parsed from the model string as fallback when resolvedAccount.protocol is not set. Upstream issue: anomalyco/opencode#21019 [孟加拉猫/Opus-46🐾] --- .../agents/invocation/invoke-single-cat.ts | 13 +-- packages/api/test/invoke-single-cat.test.js | 79 +++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts index 849b2ba820..5bc34ee8cf 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts @@ -43,7 +43,7 @@ import { } from '../providers/opencode-config-template.js'; const log = createModuleLogger('invoke'); -const BUILTIN_OPENCODE_PROVIDERS = new Set(['anthropic', 'openai', 'openrouter', 'google']); + import type { SessionManager } from '../../session/SessionManager.js'; import type { ISessionSealer } from '../../session/SessionSealer.js'; @@ -747,14 +747,17 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP provider === 'opencode' && resolvedAccount?.authType === 'api_key' && effectiveModel && - effectiveProviderName && - !BUILTIN_OPENCODE_PROVIDERS.has(effectiveProviderName) + effectiveProviderName ) { callbackEnv.CAT_CAFE_ANTHROPIC_MODEL_OVERRIDE = effectiveModel; + // Infer apiType from resolvedAccount.protocol first, then from provider name + // parsed from the model string (e.g. "anthropic/claude-opus-4-6" → "anthropic"). + // This ensures builtin providers like anthropic/google get the correct SDK adapter + // even when resolvedAccount.protocol is not explicitly set. const apiType: 'openai' | 'anthropic' | 'google' = - resolvedAccount.protocol === 'anthropic' + resolvedAccount.protocol === 'anthropic' || effectiveProviderName === 'anthropic' ? 'anthropic' - : resolvedAccount.protocol === 'google' + : resolvedAccount.protocol === 'google' || effectiveProviderName === 'google' ? 'google' : 'openai'; const rawModels = resolvedAccount.models?.length ? resolvedAccount.models : [effectiveModel]; diff --git a/packages/api/test/invoke-single-cat.test.js b/packages/api/test/invoke-single-cat.test.js index d3686f84aa..a290548c71 100644 --- a/packages/api/test/invoke-single-cat.test.js +++ b/packages/api/test/invoke-single-cat.test.js @@ -3305,6 +3305,85 @@ describe('invokeSingleCat audit events (P1 fix)', () => { await assert.rejects(readFile(seenConfigPath, 'utf-8')); }); + it('F189: builtin provider (anthropic) also gets runtime config so unknown models are registered', async () => { + const { createProviderProfile } = await import('../dist/config/provider-profiles.js'); + const root = await mkdtemp(join(tmpdir(), 'f189-oc-builtin-provider-')); + const apiDir = join(root, 'packages', 'api'); + await mkdir(apiDir, { recursive: true }); + await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); + + const anthropicProfile = await createProviderProfile(root, { + provider: 'anthropic', + name: 'anthropic-api', + mode: 'api_key', + authType: 'api_key', + protocol: 'anthropic', + apiKey: 'sk-ant-test-key', + models: ['claude-opus-4-6'], + setActive: false, + }); + + const registrySnapshot = catRegistry.getAllConfigs(); + const originalConfig = catRegistry.tryGet('opencode')?.config; + assert.ok(originalConfig, 'opencode config should exist in registry'); + const boundCatId = 'opencode-builtin-anthropic-test'; + catRegistry.register(boundCatId, { + ...originalConfig, + id: boundCatId, + mentionPatterns: [`@${boundCatId}`], + provider: 'opencode', + providerProfileId: anthropicProfile.id, + defaultModel: 'anthropic/claude-opus-4-6', + }); + + const optionsSeen = []; + let seenConfigPath; + let seenRuntimeConfig; + const service = { + async *invoke(_prompt, options) { + optionsSeen.push(options ?? {}); + seenConfigPath = options?.callbackEnv?.OPENCODE_CONFIG; + assert.ok(seenConfigPath, 'builtin anthropic provider should also receive OPENCODE_CONFIG'); + seenRuntimeConfig = JSON.parse(await readFile(seenConfigPath, 'utf-8')); + yield { type: 'done', catId: 'opencode', timestamp: Date.now() }; + }, + }; + + const deps = makeDeps(); + const previousCwd = process.cwd(); + try { + process.chdir(apiDir); + const messages = await collect( + invokeSingleCat(deps, { + catId: boundCatId, + service, + prompt: 'test builtin provider runtime config', + userId: 'user-f189-builtin-provider', + threadId: 'thread-f189-builtin-provider', + isLastCat: true, + }), + ); + assert.ok(messages.some((m) => m.type === 'done')); + } finally { + process.chdir(previousCwd); + catRegistry.reset(); + for (const [id, config] of Object.entries(registrySnapshot)) { + catRegistry.register(id, config); + } + await rm(root, { recursive: true, force: true }); + } + + const callbackEnv = optionsSeen[0]?.callbackEnv ?? {}; + assert.equal(callbackEnv.CAT_CAFE_ANTHROPIC_MODEL_OVERRIDE, 'anthropic/claude-opus-4-6'); + assert.equal(callbackEnv.CAT_CAFE_OC_API_KEY, 'sk-ant-test-key'); + assert.equal(seenRuntimeConfig?.model, 'anthropic/claude-opus-4-6'); + // Builtin anthropic provider should use @ai-sdk/anthropic adapter (not openai-compatible) + assert.equal(seenRuntimeConfig?.provider?.anthropic?.npm, '@ai-sdk/anthropic'); + assert.ok(seenRuntimeConfig?.provider?.anthropic?.models?.['claude-opus-4-6']); + // Config file should be cleaned up after invocation + await assert.rejects(readFile(seenConfigPath, 'utf-8')); + }); + it('F062-fix: skips auto-seal for api_key mode when context health is approx', async () => { const { createProviderProfile } = await import('../dist/config/provider-profiles.js'); const root = await mkdtemp(join(tmpdir(), 'f062-approx-no-seal-')); From 164a7f5c4fc6c2336b7c95c50a0c387985552011 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:37:27 +0800 Subject: [PATCH 04/23] fix: opencode runtime config also generated when no provider profile (env var fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial fix only covered the api_key provider profile case. When no profile is bound (resolvedAccount is null), the runtime config was still skipped, causing opencode to fall back to its builtin model list which doesn't include claude-opus-4-6. Now also generates runtime config when ANTHROPIC_API_KEY is available in the parent process env, forwarding it as CAT_CAFE_OC_API_KEY for the runtime config's {env:...} substitution. [孟加拉猫/Opus-46🐾] --- .../agents/invocation/invoke-single-cat.ts | 38 ++++++---- packages/api/test/invoke-single-cat.test.js | 71 +++++++++++++++++++ 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts index 5bc34ee8cf..1f6ecc3c58 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts @@ -743,34 +743,48 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP : ocProviderName && trimmedDefaultModel ? `${ocProviderName}/${trimmedDefaultModel}` : undefined; + // Generate runtime config for opencode when we have a model with provider prefix. + // Covers two cases: + // 1. api_key provider profile bound → credentials from resolvedAccount + // 2. No profile but ANTHROPIC_API_KEY in env → credentials from env vars + // This ensures opencode always has the model registered in its config, + // bypassing outdated builtin model lists (anomalyco/opencode#21019). + const hasApiKeyProfile = resolvedAccount?.authType === 'api_key'; + const envApiKey = !hasApiKeyProfile ? process.env.ANTHROPIC_API_KEY : undefined; if ( provider === 'opencode' && - resolvedAccount?.authType === 'api_key' && effectiveModel && - effectiveProviderName + effectiveProviderName && + (hasApiKeyProfile || envApiKey) ) { callbackEnv.CAT_CAFE_ANTHROPIC_MODEL_OVERRIDE = effectiveModel; - // Infer apiType from resolvedAccount.protocol first, then from provider name - // parsed from the model string (e.g. "anthropic/claude-opus-4-6" → "anthropic"). - // This ensures builtin providers like anthropic/google get the correct SDK adapter - // even when resolvedAccount.protocol is not explicitly set. const apiType: 'openai' | 'anthropic' | 'google' = - resolvedAccount.protocol === 'anthropic' || effectiveProviderName === 'anthropic' + (hasApiKeyProfile && resolvedAccount!.protocol === 'anthropic') || effectiveProviderName === 'anthropic' ? 'anthropic' - : resolvedAccount.protocol === 'google' || effectiveProviderName === 'google' + : (hasApiKeyProfile && resolvedAccount!.protocol === 'google') || effectiveProviderName === 'google' ? 'google' : 'openai'; - const rawModels = resolvedAccount.models?.length ? resolvedAccount.models : [effectiveModel]; + const rawModels = + hasApiKeyProfile && resolvedAccount!.models?.length ? resolvedAccount!.models : [effectiveModel]; + const hasBaseUrl = hasApiKeyProfile + ? Boolean(resolvedAccount!.baseUrl) + : Boolean(process.env.ANTHROPIC_BASE_URL); openCodeRuntimeConfigPath = writeOpenCodeRuntimeConfig(projectRoot, catId as string, invocationId, { providerName: effectiveProviderName, models: rawModels, defaultModel: effectiveModel, apiType, - hasBaseUrl: Boolean(resolvedAccount.baseUrl), + hasBaseUrl, }); callbackEnv.OPENCODE_CONFIG = openCodeRuntimeConfigPath; - if (resolvedAccount.apiKey) callbackEnv[OC_API_KEY_ENV] = resolvedAccount.apiKey; - if (resolvedAccount.baseUrl) callbackEnv[OC_BASE_URL_ENV] = resolvedAccount.baseUrl; + if (hasApiKeyProfile) { + if (resolvedAccount!.apiKey) callbackEnv[OC_API_KEY_ENV] = resolvedAccount!.apiKey; + if (resolvedAccount!.baseUrl) callbackEnv[OC_BASE_URL_ENV] = resolvedAccount!.baseUrl; + } else { + // No provider profile — forward credentials from parent env + if (envApiKey) callbackEnv[OC_API_KEY_ENV] = envApiKey; + if (process.env.ANTHROPIC_BASE_URL) callbackEnv[OC_BASE_URL_ENV] = process.env.ANTHROPIC_BASE_URL; + } } // F-BLOAT: Only inject staticIdentity (systemPrompt) on new sessions for cats diff --git a/packages/api/test/invoke-single-cat.test.js b/packages/api/test/invoke-single-cat.test.js index a290548c71..e2cfbfabb3 100644 --- a/packages/api/test/invoke-single-cat.test.js +++ b/packages/api/test/invoke-single-cat.test.js @@ -3384,6 +3384,77 @@ describe('invokeSingleCat audit events (P1 fix)', () => { await assert.rejects(readFile(seenConfigPath, 'utf-8')); }); + it('F189: no provider profile but ANTHROPIC_API_KEY in env still generates runtime config', async () => { + const root = await mkdtemp(join(tmpdir(), 'f189-oc-no-profile-')); + const apiDir = join(root, 'packages', 'api'); + await mkdir(apiDir, { recursive: true }); + await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); + + // No provider profile created — simulates the common case where + // opencode cat has no bound profile and relies on env vars. + + const registrySnapshot = catRegistry.getAllConfigs(); + const originalConfig = catRegistry.tryGet('opencode')?.config; + assert.ok(originalConfig, 'opencode config should exist in registry'); + const boundCatId = 'opencode-no-profile-test'; + catRegistry.register(boundCatId, { + ...originalConfig, + id: boundCatId, + mentionPatterns: [`@${boundCatId}`], + provider: 'opencode', + defaultModel: 'anthropic/claude-opus-4-6', + }); + + const optionsSeen = []; + let seenConfigPath; + let seenRuntimeConfig; + const service = { + async *invoke(_prompt, options) { + optionsSeen.push(options ?? {}); + seenConfigPath = options?.callbackEnv?.OPENCODE_CONFIG; + assert.ok(seenConfigPath, 'no-profile env-key path should receive OPENCODE_CONFIG'); + seenRuntimeConfig = JSON.parse(await readFile(seenConfigPath, 'utf-8')); + yield { type: 'done', catId: 'opencode', timestamp: Date.now() }; + }, + }; + + const deps = makeDeps(); + const previousCwd = process.cwd(); + const originalAnthropicKey = process.env.ANTHROPIC_API_KEY; + try { + process.env.ANTHROPIC_API_KEY = 'sk-ant-env-test-key'; + process.chdir(apiDir); + const messages = await collect( + invokeSingleCat(deps, { + catId: boundCatId, + service, + prompt: 'test no-profile env key runtime config', + userId: 'user-f189-no-profile', + threadId: 'thread-f189-no-profile', + isLastCat: true, + }), + ); + assert.ok(messages.some((m) => m.type === 'done')); + } finally { + if (originalAnthropicKey === undefined) delete process.env.ANTHROPIC_API_KEY; + else process.env.ANTHROPIC_API_KEY = originalAnthropicKey; + process.chdir(previousCwd); + catRegistry.reset(); + for (const [id, config] of Object.entries(registrySnapshot)) { + catRegistry.register(id, config); + } + await rm(root, { recursive: true, force: true }); + } + + const callbackEnv = optionsSeen[0]?.callbackEnv ?? {}; + assert.equal(callbackEnv.CAT_CAFE_ANTHROPIC_MODEL_OVERRIDE, 'anthropic/claude-opus-4-6'); + assert.equal(callbackEnv.CAT_CAFE_OC_API_KEY, 'sk-ant-env-test-key'); + assert.equal(seenRuntimeConfig?.model, 'anthropic/claude-opus-4-6'); + assert.equal(seenRuntimeConfig?.provider?.anthropic?.npm, '@ai-sdk/anthropic'); + assert.ok(seenRuntimeConfig?.provider?.anthropic?.models?.['claude-opus-4-6']); + await assert.rejects(readFile(seenConfigPath, 'utf-8')); + }); + it('F062-fix: skips auto-seal for api_key mode when context health is approx', async () => { const { createProviderProfile } = await import('../dist/config/provider-profiles.js'); const root = await mkdtemp(join(tmpdir(), 'f062-approx-no-seal-')); From 9c5b1d1bc523a1f279ff38acd7698f9744f9276b Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:16:28 +0800 Subject: [PATCH 05/23] fix: prohibit git stash -u in merge-gate to prevent silent data loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-04-09 incident: untracked 480-line research doc silently deleted by git stash -u during merge-gate Step 8 cleanup. The stash pop failed due to same-name file conflict (all-or-nothing for untracked files), causing collateral loss of innocent files. Recovered from dangling object. Changes: - merge-gate/SKILL.md: Step 8 now checks for untracked docs/ files and commits them before pull; stash without -u only - shared-rules.md: §5 adds "Write ≠ persistence" rule and git stash -u ban - deep-research/SKILL.md: new Step 5 requires commit after Write Why: multi-session shared worktree + git stash -u = data killer [宪宪/Opus-46🐾] --- cat-cafe-skills/deep-research/SKILL.md | 11 +++++++++++ cat-cafe-skills/merge-gate/SKILL.md | 20 ++++++++++++++++++++ cat-cafe-skills/refs/shared-rules.md | 12 ++++++++++++ 3 files changed, 43 insertions(+) diff --git a/cat-cafe-skills/deep-research/SKILL.md b/cat-cafe-skills/deep-research/SKILL.md index 69b02bbe02..a39a54632f 100644 --- a/cat-cafe-skills/deep-research/SKILL.md +++ b/cat-cafe-skills/deep-research/SKILL.md @@ -161,6 +161,17 @@ docs/prompts/YYYY-MM-DD-{topic}-research-prompt.md | 忽略三方分歧 | 分歧 = 最有价值的信号,必须分析 | | Coder 猫盲信 web 报告 | 必须对照实际 codebase 验证 | +## Step 5 — 持久化产出(2026-04-09 教训) + +**调研产出必须 commit,Write ≠ 持久化。** + +产出文档写完后,立刻 `git add` + `git commit`。 +- 在 worktree 里:commit 到分支 +- 在 main 上:commit + push(确保其他猫能看到) +- 多次 Edit 更新:每次重大更新后追加 commit + +**验证**:`git log --oneline -1` 显示刚才的 commit。没有 commit SHA = 没有完成。 + ## Next Step → `collaborative-thinking`(讨论调研结论,形成决策) diff --git a/cat-cafe-skills/merge-gate/SKILL.md b/cat-cafe-skills/merge-gate/SKILL.md index b39db2650f..eb93116d13 100644 --- a/cat-cafe-skills/merge-gate/SKILL.md +++ b/cat-cafe-skills/merge-gate/SKILL.md @@ -125,7 +125,27 @@ gh pr merge {PR_NUMBER} --squash --delete-branch # → 见下方「Phase 文档同步」章节 # 8. 更新本地 + 清理 +# ⚠️ LL (2026-04-09): 禁止 git stash -u! +# git stash -u 会 clean 所有 untracked 文件,如果其他 session +# 在 main 上有未 commit 的产出,会被静默删除且 pop 时可能 +# 因同名文件冲突导致 ALL untracked files 不恢复(连坐丢失)。 +# +# 正确做法:先检查 untracked 新文件,有就先 commit 再 pull。 +UNTRACKED_NEW=$(git ls-files --others --exclude-standard -- docs/ | head -5) +if [ -n "$UNTRACKED_NEW" ]; then + echo "⚠️ 发现 untracked 新文件(可能是其他 session 的产出):" + echo "$UNTRACKED_NEW" + echo "→ 先 commit 这些文件再继续,防止 stash/pull 丢失" + git add $UNTRACKED_NEW && git commit -m "docs: rescue untracked files before merge-gate pull + +Why: prevent data loss from git stash -u (see agent-mesh#25) + +[merge-gate/auto🐾]" +fi +# 只 stash tracked 修改(绝不用 -u) +git stash --quiet 2>/dev/null || true git checkout main && git pull origin main +git stash pop --quiet 2>/dev/null || true git worktree remove ../cat-cafe-{feature-name} git branch -d {branch-name} && git worktree prune diff --git a/cat-cafe-skills/refs/shared-rules.md b/cat-cafe-skills/refs/shared-rules.md index 1301e97291..e880286b17 100644 --- a/cat-cafe-skills/refs/shared-rules.md +++ b/cat-cafe-skills/refs/shared-rules.md @@ -127,6 +127,18 @@ AI agent 100x 执行速度下,**方向正确性**的价值远大于**启动便 完成一个可验证的子任务就提交。 +**Write ≠ 持久化(2026-04-09 教训)**: +- `Write`/`Edit` 工具写文件只是写到文件系统,不等于持久化 +- **只有 `git commit` 才是持久化**——session 结束、`git stash -u`、`git clean` 都会让未 commit 的文件永久丢失 +- 产出 ≥10 行的文件,Write 之后**同一个 turn 内必须 commit** +- 不 commit 就回复铲屎官 = 假完成(违反 P5 可验证) + +**禁止 `git stash -u`/`git stash --include-untracked` 对 main 工作目录**: +- `git stash -u` 内部执行 `git clean`,会删除所有 untracked 文件 +- 多 session 共享 main 工作目录时,其他 session 的未 commit 产出会被静默删除 +- `git stash pop` 对 untracked files 是 all-or-nothing:一个文件冲突 = 全部不恢复 +- **只用 `git stash`(不带 -u)**,或先 commit untracked 文件再 stash + **签名(强制)**:commit message body 必须带猫猫签名,格式 `[昵称/模型🐾]`。 签名必须包含**模型型号**,不能只写 `[Ragdoll🐾]`——同族有多个模型(Opus 4.6 / Opus 4.5 / Sonnet),不带型号无法区分是谁干的。 签名表见 `refs/commit-signatures.md`。示例:`[Ragdoll/Opus-46🐾]`、`[Maine Coon/GPT-52🐾]`。 From 60cb25307e324d02019a3d9680f379a5973337f5 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:40:18 +0800 Subject: [PATCH 06/23] revert: remove stash logic from merge-gate Step 8 (upstream review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream maintainer correctly identified that Step 8 never had git stash -u. The incident was caused by a cat improvising off-SOP, not a SKILL.md bug. Revert merge-gate to upstream's original Step 8. Also stages resolved stash-pop conflicts in ConnectorRouter (took upstream's last-active-participant routing over WIP preferredCats approach). Retained fixes (shared-rules "Write ≠ persistence" + deep-research Step 5) address the actual root causes (L1: uncommitted files, L3: no persistence step). See: zts212653/clowder-ai#403 (closed with analysis) [宪宪/Opus-46🐾] --- cat-cafe-skills/merge-gate/SKILL.md | 20 ----------- .../agents/providers/CodexAgentService.ts | 23 ++++++++++++ .../connectors/ConnectorRouter.ts | 16 +++++++++ packages/api/test/codex-agent-service.test.js | 35 +++++++++++++++++++ packages/api/test/connector-router.test.js | 10 ++++++ scripts/tts-api.py | 13 +++++++ 6 files changed, 97 insertions(+), 20 deletions(-) diff --git a/cat-cafe-skills/merge-gate/SKILL.md b/cat-cafe-skills/merge-gate/SKILL.md index eb93116d13..b39db2650f 100644 --- a/cat-cafe-skills/merge-gate/SKILL.md +++ b/cat-cafe-skills/merge-gate/SKILL.md @@ -125,27 +125,7 @@ gh pr merge {PR_NUMBER} --squash --delete-branch # → 见下方「Phase 文档同步」章节 # 8. 更新本地 + 清理 -# ⚠️ LL (2026-04-09): 禁止 git stash -u! -# git stash -u 会 clean 所有 untracked 文件,如果其他 session -# 在 main 上有未 commit 的产出,会被静默删除且 pop 时可能 -# 因同名文件冲突导致 ALL untracked files 不恢复(连坐丢失)。 -# -# 正确做法:先检查 untracked 新文件,有就先 commit 再 pull。 -UNTRACKED_NEW=$(git ls-files --others --exclude-standard -- docs/ | head -5) -if [ -n "$UNTRACKED_NEW" ]; then - echo "⚠️ 发现 untracked 新文件(可能是其他 session 的产出):" - echo "$UNTRACKED_NEW" - echo "→ 先 commit 这些文件再继续,防止 stash/pull 丢失" - git add $UNTRACKED_NEW && git commit -m "docs: rescue untracked files before merge-gate pull - -Why: prevent data loss from git stash -u (see agent-mesh#25) - -[merge-gate/auto🐾]" -fi -# 只 stash tracked 修改(绝不用 -u) -git stash --quiet 2>/dev/null || true git checkout main && git pull origin main -git stash pop --quiet 2>/dev/null || true git worktree remove ../cat-cafe-{feature-name} git branch -d {branch-name} && git worktree prune diff --git a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts index fa6d5a7d5e..4c78c44b42 100644 --- a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts @@ -85,6 +85,7 @@ function applyAuthMode(env: Record, authMode: CodexAuthMode): Re const MAX_RECENT_STREAM_ERRORS = 5; const MAX_STREAM_ERROR_LENGTH = 240; +const TERMINAL_RECONNECT_PATTERN = /^Reconnecting\.\.\.\s*5\/5\b/i; function collectCodexStreamError(event: unknown, recentErrors: string[]): void { if (typeof event !== 'object' || event === null) return; @@ -105,6 +106,15 @@ function collectCodexStreamError(event: unknown, recentErrors: string[]): void { } } +function isTerminalReconnectFailure(event: unknown): boolean { + if (typeof event !== 'object' || event === null) return false; + const record = event as Record; + if (record.type !== 'error') return false; + const raw = record.message; + if (typeof raw !== 'string') return false; + return TERMINAL_RECONNECT_PATTERN.test(raw.trim()); +} + function withRecentDiagnostics(base: string, recentErrors: string[]): string { if (recentErrors.length === 0) return base; const lines = recentErrors.map((line) => `- ${line}`); @@ -467,6 +477,19 @@ export class CodexAgentService implements AgentService { }; continue; } + if (isTerminalReconnectFailure(event)) { + yield { + type: 'error', + catId: this.catId, + error: withRecentDiagnostics('缅因猫连接中断,重连 5 次后仍失败', recentStreamErrors), + metadata, + timestamp: Date.now(), + }; + // Codex may stay alive after 5/5 reconnect failures; stop reading and let + // spawnCli finalizer terminate the child to avoid hanging the invocation. + semanticCompletionController.abort(); + break; + } if (isCliError(event)) { // Codex CLI 0.98+ returns exit code 1 after successful completion. // Suppress the error ONLY if we saw substantive output (item.completed). diff --git a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts index 8f05aa2446..a62b73eb32 100644 --- a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts +++ b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts @@ -80,6 +80,7 @@ export interface ConnectorRouterOptions { createdAt: number; lastCommandAt?: number; }; + preferredCats?: readonly CatId[] | undefined; } | null | Promise<{ @@ -91,6 +92,7 @@ export interface ConnectorRouterOptions { createdAt: number; lastCommandAt?: number; }; + preferredCats?: readonly CatId[] | undefined; } | null>; updateProjectPath?(threadId: string, projectPath: string): void | Promise; getParticipantsWithActivity?( @@ -160,6 +162,20 @@ export class ConnectorRouter { return patterns; } + /** + * For connector inbound routing, prefer thread-scoped preferredCats as fallback + * when text has no explicit @mention. This keeps per-group default cat effective. + */ + private async resolveThreadFallbackCat(threadId: string): Promise { + if (!this.opts.threadStore.get) return this.opts.defaultCatId; + const thread = await this.opts.threadStore.get(threadId); + const preferred = Array.isArray(thread?.preferredCats) ? thread.preferredCats : []; + for (const catId of preferred) { + if (typeof catId === 'string' && catId.length > 0) return catId as CatId; + } + return this.opts.defaultCatId; + } + async route( connectorId: string, externalChatId: string, diff --git a/packages/api/test/codex-agent-service.test.js b/packages/api/test/codex-agent-service.test.js index 67d1c0cc65..2a9875918e 100644 --- a/packages/api/test/codex-agent-service.test.js +++ b/packages/api/test/codex-agent-service.test.js @@ -610,6 +610,41 @@ test('includes reconnect diagnostics in CLI exit error when available', async () assert.ok(errMsg.error.includes('Reconnecting... 2/5'), 'error should include multiple reconnect attempts'); }); +test('terminates immediately when reconnect reaches 5/5 even if child keeps running', async () => { + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new CodexAgentService({ spawnFn }); + + const promise = collect(service.invoke('reconnect terminal failure')); + + // Intentionally DO NOT close stdout or emit exit: this reproduces a stuck codex child. + proc.stdout.write(`${JSON.stringify({ type: 'thread.started', thread_id: 'thread-reconnect-stuck' })}\n`); + proc.stdout.write( + `${JSON.stringify({ + type: 'error', + message: 'Reconnecting... 2/5 (stream disconnected before completion)', + })}\n`, + ); + proc.stdout.write( + `${JSON.stringify({ + type: 'error', + message: 'Reconnecting... 5/5 (stream disconnected before completion: Connection refused (os error 61))', + })}\n`, + ); + + const msgs = await Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error('invoke did not terminate in time')), 1500)), + ]); + + const errMsg = msgs.find((m) => m.type === 'error'); + assert.ok(errMsg, 'should yield terminal reconnect error'); + assert.ok(errMsg.error.includes('重连 5 次后仍失败')); + assert.ok(errMsg.error.includes('Reconnecting... 5/5')); + assert.ok(msgs.some((m) => m.type === 'done'), 'should always end with done'); + assert.ok(proc.kill.mock.callCount() >= 1, 'should terminate hanging child process'); +}); + test('suppresses exit code 1 when Codex produced substantive output (item.completed)', async () => { const proc = createMockProcess(); const spawnFn = createMockSpawnFn(proc); diff --git a/packages/api/test/connector-router.test.js b/packages/api/test/connector-router.test.js index 340472d3c6..fde2ca4c15 100644 --- a/packages/api/test/connector-router.test.js +++ b/packages/api/test/connector-router.test.js @@ -527,6 +527,16 @@ describe('ConnectorRouter', () => { const fwdTrigger = mockTrigger(); const fwdSocket = mockSocketManager(); const fwdStore = mockMessageStore(); + threadStore.threads.set('thread-target-1', { + id: 'thread-target-1', + createdBy: 'owner-1', + title: 'target-thread', + participants: [], + lastActiveAt: Date.now(), + createdAt: Date.now(), + projectPath: 'default', + preferredCats: ['sonnet'], + }); const fwdRouter = new ConnectorRouter({ bindingStore, dedup: new InboundMessageDedup(), diff --git a/scripts/tts-api.py b/scripts/tts-api.py index f1ef54df1e..f0117fc1f9 100644 --- a/scripts/tts-api.py +++ b/scripts/tts-api.py @@ -241,6 +241,10 @@ class Qwen3CloneAdapter(TtsAdapter): """ DEFAULT_MODEL = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" + # Auto-resolve voice ID → ref_audio for clone-based TTS. + # Set TTS_REF_AUDIO_DIR env var to the directory containing {voice_id}.wav files. + _REF_AUDIO_DIR = os.environ.get("TTS_REF_AUDIO_DIR", str(Path.home() / ".tts-ref-audio")) + _REF_TEXT = "大家好,欢迎来到智能语音合成平台,这是一段参考音频。" def __init__(self, model: str | None = None): self._model = model or self.DEFAULT_MODEL @@ -277,6 +281,15 @@ async def synthesize( if ref_audio and not Path(ref_audio).exists(): raise RuntimeError(f"Reference audio not found: {ref_audio}") + # Auto-resolve: if no explicit ref_audio but we have a ref file for this voice ID, + # use it for clone mode so different voice IDs produce different timbres. + if not ref_audio and voice: + auto_ref = Path(self._REF_AUDIO_DIR) / f"{voice}.wav" + if auto_ref.exists(): + ref_audio = str(auto_ref) + ref_text = ref_text or self._REF_TEXT + log.info("Auto-resolved ref_audio for voice '%s': %s", voice, ref_audio) + output_dir = Path(tempfile.mkdtemp(prefix="cat-cafe-tts-clone-")) try: kwargs: dict = { From 3f413d93b0279fea821ce94409fab3daaeef21a3 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:47:37 +0800 Subject: [PATCH 07/23] fix(merge-gate): fail-closed dirty-tree guard in step 8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: prevent ad-hoc git stash -u cleanup that can delete untracked outputs in shared main workspace. [砚砚/gpt-5.3-codex🐾] --- cat-cafe-skills/merge-gate/SKILL.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cat-cafe-skills/merge-gate/SKILL.md b/cat-cafe-skills/merge-gate/SKILL.md index b39db2650f..e5b921fd02 100644 --- a/cat-cafe-skills/merge-gate/SKILL.md +++ b/cat-cafe-skills/merge-gate/SKILL.md @@ -124,7 +124,16 @@ gh pr merge {PR_NUMBER} --squash --delete-branch # 7.5 Phase 文档同步(每次 merge 必做!)🔴 # → 见下方「Phase 文档同步」章节 -# 8. 更新本地 + 清理 +# 8. 更新本地 + 清理(fail-closed) +# ⚠️ 发现脏工作树就停止,不要“即兴”用 git stash -u 清理。 +# 原因:git stash -u/--include-untracked 会删除 untracked 文件(内部 git clean), +# 在多 session 共享工作目录时可能导致其他 session 的未 commit 产出丢失。 +if [ -n "$(git status --porcelain)" ]; then + echo "❌ 工作树不干净,停止 merge-gate(fail-closed)" + echo "请先处理改动后再继续。禁止使用 git stash -u/--include-untracked。" + git status --short + exit 1 +fi git checkout main && git pull origin main git worktree remove ../cat-cafe-{feature-name} git branch -d {branch-name} && git worktree prune From 52541ace6a80208695d6fb7b4d1c702417e6884a Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Thu, 7 May 2026 19:50:52 +0800 Subject: [PATCH 08/23] =?UTF-8?q?chore:=20add=20opus-47=20variant=20and=20?= =?UTF-8?q?claude-opus-4-7=20model=20[=E7=A0=9A=E7=A0=9A/gpt-5.3-codex?= =?UTF-8?q?=F0=9F=90=BE]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cat-template.json | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/cat-template.json b/cat-template.json index 6b37b5bd59..bd0aeab701 100644 --- a/cat-template.json +++ b/cat-template.json @@ -86,6 +86,7 @@ "models": [ "claude-sonnet-4-6", "claude-opus-4-6", + "claude-opus-4-7", "claude-opus-4-6[1m]", "claude-sonnet-4-5-20250929", "claude-opus-4-5-20251101" @@ -115,6 +116,13 @@ "available": true, "evaluation": "主架构师+全栈开发,深度思考能力强,bug定位是弱项——定位不出来找砚砚(gpt52)" }, + "opus-47": { + "family": "ragdoll", + "roles": ["architect"], + "lead": false, + "available": true, + "evaluation": "Opus 4.7 试用分身——据铲屎官体感猫格偏砚砚风格,待真实任务验证" + }, "codex": { "family": "maine-coon", "roles": ["peer-reviewer", "security"], @@ -290,6 +298,46 @@ "instruct": "用一个清澈温和的少年语气说话,带着从容的力量感", "temperature": 0.3 } + }, + { + "id": "opus-47", + "catId": "opus-47", + "variantLabel": "Opus 4.7", + "displayName": "布偶猫", + "mentionPatterns": ["@opus47", "@opus-47", "@布偶opus47", "@布偶猫4.7"], + "clientId": "anthropic", + "defaultModel": "claude-opus-4-7", + "mcpSupport": true, + "avatar": "/avatars/opus-47.png", + "color": { + "primary": "#7B1FA2", + "secondary": "#E1BEE7" + }, + "cli": { + "command": "claude", + "outputFormat": "stream-json", + "defaultArgs": ["--output-format", "stream-json", "--model", "claude-opus-4-7"], + "effort": "max" + }, + "personality": "试用中——猫格待观察,据铲屎官反馈风格偏缅因猫", + "strengths": ["architecture", "reasoning", "coding"], + "teamStrengths": "待评估", + "caution": "试用分身,猫格可能与宪宪有显著差异", + "contextBudget": { + "maxPromptTokens": 180000, + "maxContextTokens": 160000, + "maxMessages": 200, + "maxContentLengthPerMsg": 100000 + }, + "voiceConfig": { + "voice": "zm_yunjian", + "langCode": "zh", + "speed": 1, + "refAudio": "genshin/万叶/vo_kazuha_dialog_greetingMorning.wav", + "refText": "清晨的鸟鸣,是大自然的馈赠。启程吧,属于我们的旅途也要开始了。", + "instruct": "用一个清澈温和的少年语气说话,带着从容的力量感", + "temperature": 0.3 + } } ] }, From 63439c75ed82e93a9d3198ca0ab9d8e63488a3b2 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Fri, 1 May 2026 16:19:31 +0800 Subject: [PATCH 09/23] =?UTF-8?q?fix(api):=20dedupe=20queued=20message=20r?= =?UTF-8?q?eplays=20by=20idempotency=20key=20[=E7=A0=9A=E7=A0=9A/gpt-5.3-c?= =?UTF-8?q?odex=F0=9F=90=BE]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agents/invocation/InvocationQueue.ts | 24 ++++ packages/api/src/routes/messages.ts | 115 ++++++++++-------- packages/api/test/invocation-queue.test.js | 13 ++ .../api/test/messages-delivery-mode.test.js | 104 ++++++++++++++++ 4 files changed, 206 insertions(+), 50 deletions(-) diff --git a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts index 78206af1d5..bdc1e7ad05 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts @@ -17,6 +17,8 @@ export interface QueueEntry { id: string; threadId: string; userId: string; + /** Optional request-level idempotency key for API replay dedup. */ + idempotencyKey?: string; content: string; messageId: string | null; mergedMessageIds: string[]; @@ -47,6 +49,8 @@ export interface EnqueueResult { outcome: 'enqueued' | 'full'; entry?: QueueEntry; queuePosition?: number; + /** True when enqueue returned an existing active entry by idempotency key. */ + deduped?: boolean; } const MAX_QUEUE_DEPTH = 5; @@ -123,6 +127,25 @@ export class InvocationQueue { const key = this.scopeKey(input.threadId, input.userId); const q = this.getOrCreate(key); + // Request replay dedupe: if an active entry already exists for this key in this scope, + // return it instead of creating a second queue row. + if (input.idempotencyKey) { + const existing = q.find( + (entry) => + entry.idempotencyKey === input.idempotencyKey && + (entry.status === 'queued' || entry.status === 'processing'), + ); + if (existing) { + const position = q.findIndex((entry) => entry.id === existing.id); + return { + outcome: 'enqueued', + entry: { ...existing }, + queuePosition: position >= 0 ? position + 1 : undefined, + deduped: true, + }; + } + } + // F175: capacity check — only user messages are depth-limited if (input.source === 'user') { const userQueuedCount = q.filter((e) => e.status === 'queued' && e.source === 'user').length; @@ -135,6 +158,7 @@ export class InvocationQueue { id: randomUUID(), threadId: input.threadId, userId: input.userId, + idempotencyKey: input.idempotencyKey, content: input.content, messageId: null, mergedMessageIds: [], diff --git a/packages/api/src/routes/messages.ts b/packages/api/src/routes/messages.ts index 24b8a807bf..8f4b2808e8 100644 --- a/packages/api/src/routes/messages.ts +++ b/packages/api/src/routes/messages.ts @@ -461,6 +461,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( const enqueueResult = opts.invocationQueue.enqueue({ threadId: resolvedThreadId, userId, + idempotencyKey: resolvedIdempotencyKey, content, source: 'user', targetCats, @@ -483,35 +484,39 @@ export const messagesRoutes: FastifyPluginAsync = async ( }; } - let storedUserMessageId: string | null = null; + let storedUserMessageId: string | null = enqueueResult.entry?.messageId ?? null; // ② Write user message (F117: mark as queued — invisible until dequeue) - try { - const userMessage = await opts.messageStore.append({ - userId, - catId: null, - content, - mentions: targetCats, - timestamp: Date.now(), - threadId: resolvedThreadId, - deliveryStatus: 'queued', // F117: not visible in history/context/mentions until delivered - ...(contentBlocks ? { contentBlocks } : {}), - ...(whisperVisibility && whisperRecipients - ? { visibility: whisperVisibility, whisperTo: whisperRecipients } - : {}), - }); - storedUserMessageId = userMessage.id; + // If enqueue returned a deduped active entry, reuse existing messageId and skip append. + if (!enqueueResult.deduped) { + try { + const userMessage = await opts.messageStore.append({ + userId, + catId: null, + content, + mentions: targetCats, + timestamp: Date.now(), + threadId: resolvedThreadId, + idempotencyKey: resolvedIdempotencyKey, + deliveryStatus: 'queued', // F117: not visible in history/context/mentions until delivered + ...(contentBlocks ? { contentBlocks } : {}), + ...(whisperVisibility && whisperRecipients + ? { visibility: whisperVisibility, whisperTo: whisperRecipients } + : {}), + }); + storedUserMessageId = userMessage.id; - const queueEntryId = enqueueResult.entry?.id; - if (queueEntryId) { - opts.invocationQueue.backfillMessageId(resolvedThreadId, userId, queueEntryId, userMessage.id); - } - } catch (err) { - const queueEntryId = enqueueResult.entry?.id; - if (queueEntryId) { - opts.invocationQueue.rollbackEnqueue(resolvedThreadId, userId, queueEntryId); + const queueEntryId = enqueueResult.entry?.id; + if (queueEntryId) { + opts.invocationQueue.backfillMessageId(resolvedThreadId, userId, queueEntryId, userMessage.id); + } + } catch (err) { + const queueEntryId = enqueueResult.entry?.id; + if (queueEntryId) { + opts.invocationQueue.rollbackEnqueue(resolvedThreadId, userId, queueEntryId); + } + throw err; } - throw err; } // Emit queue update to this user only (privacy: scopeKey isolation) @@ -572,6 +577,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( const enqueueResult = opts.invocationQueue.enqueue({ threadId: resolvedThreadId, userId, + idempotencyKey: resolvedIdempotencyKey, content, source: 'user', targetCats, @@ -589,31 +595,40 @@ export const messagesRoutes: FastifyPluginAsync = async ( } // F122 R1-gpt52 P1-1: Wrap append+backfill in try/catch with rollback, // matching original queue path (lines 340-374) to prevent ghost queue entries. - let toctouUserMessage: { id: string }; - try { - toctouUserMessage = await opts.messageStore.append({ - userId, - catId: null, - content, - mentions: targetCats, - timestamp: Date.now(), - threadId: resolvedThreadId, - deliveryStatus: 'queued', - ...(contentBlocks ? { contentBlocks } : {}), - ...(whisperVisibility && whisperRecipients - ? { visibility: whisperVisibility, whisperTo: whisperRecipients } - : {}), - }); - const queueEntryId = enqueueResult.entry?.id; - if (queueEntryId) { - opts.invocationQueue.backfillMessageId(resolvedThreadId, userId, queueEntryId, toctouUserMessage.id); - } - } catch (err) { - const queueEntryId = enqueueResult.entry?.id; - if (queueEntryId) { - opts.invocationQueue.rollbackEnqueue(resolvedThreadId, userId, queueEntryId); + let toctouUserMessageId: string | null = enqueueResult.entry?.messageId ?? null; + if (!enqueueResult.deduped) { + try { + const toctouUserMessage = await opts.messageStore.append({ + userId, + catId: null, + content, + mentions: targetCats, + timestamp: Date.now(), + threadId: resolvedThreadId, + idempotencyKey: resolvedIdempotencyKey, + deliveryStatus: 'queued', + ...(contentBlocks ? { contentBlocks } : {}), + ...(whisperVisibility && whisperRecipients + ? { visibility: whisperVisibility, whisperTo: whisperRecipients } + : {}), + }); + toctouUserMessageId = toctouUserMessage.id; + const queueEntryId = enqueueResult.entry?.id; + if (queueEntryId) { + opts.invocationQueue.backfillMessageId( + resolvedThreadId, + userId, + queueEntryId, + toctouUserMessage.id, + ); + } + } catch (err) { + const queueEntryId = enqueueResult.entry?.id; + if (queueEntryId) { + opts.invocationQueue.rollbackEnqueue(resolvedThreadId, userId, queueEntryId); + } + throw err; } - throw err; } opts.socketManager.emitToUser(userId, 'queue_updated', { threadId: resolvedThreadId, @@ -627,7 +642,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( queuePosition: enqueueResult.queuePosition, entryId: enqueueResult.entry?.id, merged: false, - userMessageId: toctouUserMessage.id, + ...(toctouUserMessageId ? { userMessageId: toctouUserMessageId } : {}), }; } // No queue available — thread is busy but we can't queue. Reject. diff --git a/packages/api/test/invocation-queue.test.js b/packages/api/test/invocation-queue.test.js index 3e975005ea..fa8536ccff 100644 --- a/packages/api/test/invocation-queue.test.js +++ b/packages/api/test/invocation-queue.test.js @@ -81,6 +81,19 @@ describe('InvocationQueue', () => { assert.equal(queue.size('t1', 'u1'), 1); // only 'b' counts }); + it('same idempotencyKey replays are deduped to one active entry', () => { + const first = queue.enqueue(entry({ content: 'first', idempotencyKey: 'idem-1' })); + assert.equal(first.outcome, 'enqueued'); + assert.equal(first.deduped, undefined); + + const replay = queue.enqueue(entry({ content: 'replay', idempotencyKey: 'idem-1' })); + assert.equal(replay.outcome, 'enqueued'); + assert.equal(replay.deduped, true); + assert.equal(replay.entry.id, first.entry.id); + assert.equal(queue.size('t1', 'u1'), 1); + assert.equal(queue.list('t1', 'u1')[0].content, 'first'); + }); + // ── F175: no merge — every entry is independent ── it('same-source same-target entries are independent (F175 no merge)', () => { diff --git a/packages/api/test/messages-delivery-mode.test.js b/packages/api/test/messages-delivery-mode.test.js index 57e97b28aa..b2e149a1fb 100644 --- a/packages/api/test/messages-delivery-mode.test.js +++ b/packages/api/test/messages-delivery-mode.test.js @@ -121,6 +121,43 @@ describe('POST /api/messages deliveryMode', () => { assert.equal(queueUpdate.arguments[2].action, 'enqueued'); }); + it('queue mode replay with same idempotencyKey does not append duplicate message', async () => { + deps.invocationTracker.has.mock.mockImplementation(() => true); + + const first = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { + content: '会重放', + threadId: 'thread-1', + deliveryMode: 'queue', + idempotencyKey: '11111111-1111-4111-8111-111111111111', + }, + }); + assert.equal(first.statusCode, 202); + const firstBody = JSON.parse(first.body); + + const replay = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { + content: '会重放', + threadId: 'thread-1', + deliveryMode: 'queue', + idempotencyKey: '11111111-1111-4111-8111-111111111111', + }, + }); + assert.equal(replay.statusCode, 202); + const replayBody = JSON.parse(replay.body); + + assert.equal(deps.messageStore.append.mock.calls.length, 1, 'replay should not append again'); + assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 1, 'replay should not add a new queue row'); + assert.equal(replayBody.entryId, firstBody.entryId, 'replay should point to existing queue entry'); + assert.equal(replayBody.userMessageId, firstBody.userMessageId, 'replay should reuse original user message'); + }); + it('queue mode → same-user consecutive messages are independent entries (F175: no merge)', async () => { deps.invocationTracker.has.mock.mockImplementation(() => true); @@ -253,6 +290,73 @@ describe('POST /api/messages deliveryMode', () => { assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 0); }); + it('default broadcast with queued leftovers but no active invocation → executes immediately', async () => { + deps.invocationTracker.has.mock.mockImplementation(() => false); + deps.queueProcessor = { + isThreadBusy: mock.fn(() => true), + isCatBusy: mock.fn(() => false), + onInvocationComplete: mock.fn(async () => {}), + }; + deps.invocationQueue.enqueue({ + threadId: 'thread-1', + userId: 'user-1', + content: 'queued-leftover', + source: 'user', + targetCats: ['opus'], + intent: 'execute', + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { content: 'new broadcast', threadId: 'thread-1' }, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.status, 'processing'); + assert.ok(deps.invocationRecordStore.create.mock.calls.length > 0); + assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 1, 'leftover queue must not grow'); + }); + + it('TOCTOU degrade-to-queue replay with same idempotencyKey does not append duplicate message', async () => { + deps.invocationTracker.has.mock.mockImplementation(() => false); + deps.invocationTracker.tryStartThreadAll.mock.mockImplementation(() => null); + + const first = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { + content: 'TOCTOU replay', + threadId: 'thread-1', + deliveryMode: 'immediate', + idempotencyKey: '22222222-2222-4222-8222-222222222222', + }, + }); + assert.equal(first.statusCode, 202); + const firstBody = JSON.parse(first.body); + + const replay = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { + content: 'TOCTOU replay', + threadId: 'thread-1', + deliveryMode: 'immediate', + idempotencyKey: '22222222-2222-4222-8222-222222222222', + }, + }); + assert.equal(replay.statusCode, 202); + const replayBody = JSON.parse(replay.body); + + assert.equal(deps.messageStore.append.mock.calls.length, 1, 'replay should not append again'); + assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 1, 'replay should not add a new queue row'); + assert.equal(replayBody.entryId, firstBody.entryId, 'replay should point to existing queue entry'); + assert.equal(replayBody.userMessageId, firstBody.userMessageId, 'replay should reuse original user message'); + }); it('aborted invocation does not emit spawn_started after stop wins the race', async () => { const controller = new AbortController(); let releaseRunningUpdate; From 46099cd6bc8f1591dc56fe5fcf25ab1e34c120df Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Fri, 1 May 2026 18:26:58 +0800 Subject: [PATCH 10/23] =?UTF-8?q?style(api):=20apply=20biome=20formatting?= =?UTF-8?q?=20fixes=20[=E7=A0=9A=E7=A0=9A/gpt-5.3-codex=F0=9F=90=BE]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cats/services/agents/invocation/InvocationQueue.ts | 3 +-- packages/api/src/routes/messages.ts | 7 +------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts index bdc1e7ad05..b228416efb 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts @@ -132,8 +132,7 @@ export class InvocationQueue { if (input.idempotencyKey) { const existing = q.find( (entry) => - entry.idempotencyKey === input.idempotencyKey && - (entry.status === 'queued' || entry.status === 'processing'), + entry.idempotencyKey === input.idempotencyKey && (entry.status === 'queued' || entry.status === 'processing'), ); if (existing) { const position = q.findIndex((entry) => entry.id === existing.id); diff --git a/packages/api/src/routes/messages.ts b/packages/api/src/routes/messages.ts index 8f4b2808e8..e61f42a486 100644 --- a/packages/api/src/routes/messages.ts +++ b/packages/api/src/routes/messages.ts @@ -615,12 +615,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( toctouUserMessageId = toctouUserMessage.id; const queueEntryId = enqueueResult.entry?.id; if (queueEntryId) { - opts.invocationQueue.backfillMessageId( - resolvedThreadId, - userId, - queueEntryId, - toctouUserMessage.id, - ); + opts.invocationQueue.backfillMessageId(resolvedThreadId, userId, queueEntryId, toctouUserMessage.id); } } catch (err) { const queueEntryId = enqueueResult.entry?.id; From 0daede18d328ac00b3c3c090a9c8e80e9541f466 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Fri, 1 May 2026 19:13:35 +0800 Subject: [PATCH 11/23] =?UTF-8?q?test(api):=20keep=20delivery-mode=20basel?= =?UTF-8?q?ine=20on=20local=20main=20[=E7=A0=9A=E7=A0=9A/gpt-5.3-codex?= =?UTF-8?q?=F0=9F=90=BE]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/test/messages-delivery-mode.test.js | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/packages/api/test/messages-delivery-mode.test.js b/packages/api/test/messages-delivery-mode.test.js index b2e149a1fb..ac808eb624 100644 --- a/packages/api/test/messages-delivery-mode.test.js +++ b/packages/api/test/messages-delivery-mode.test.js @@ -290,36 +290,6 @@ describe('POST /api/messages deliveryMode', () => { assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 0); }); - it('default broadcast with queued leftovers but no active invocation → executes immediately', async () => { - deps.invocationTracker.has.mock.mockImplementation(() => false); - deps.queueProcessor = { - isThreadBusy: mock.fn(() => true), - isCatBusy: mock.fn(() => false), - onInvocationComplete: mock.fn(async () => {}), - }; - deps.invocationQueue.enqueue({ - threadId: 'thread-1', - userId: 'user-1', - content: 'queued-leftover', - source: 'user', - targetCats: ['opus'], - intent: 'execute', - }); - - const res = await app.inject({ - method: 'POST', - url: '/api/messages', - headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, - payload: { content: 'new broadcast', threadId: 'thread-1' }, - }); - - assert.equal(res.statusCode, 200); - const body = JSON.parse(res.body); - assert.equal(body.status, 'processing'); - assert.ok(deps.invocationRecordStore.create.mock.calls.length > 0); - assert.equal(deps.invocationQueue.list('thread-1', 'user-1').length, 1, 'leftover queue must not grow'); - }); - it('TOCTOU degrade-to-queue replay with same idempotencyKey does not append duplicate message', async () => { deps.invocationTracker.has.mock.mockImplementation(() => false); deps.invocationTracker.tryStartThreadAll.mock.mockImplementation(() => null); From 103da07fd56349bb8ba331776f456db52c78d862 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Wed, 13 May 2026 15:23:45 +0800 Subject: [PATCH 12/23] materialize: lesson-1c30b434-a5d --- docs/lessons/lesson-1c30b434-a5d.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/lessons/lesson-1c30b434-a5d.md diff --git a/docs/lessons/lesson-1c30b434-a5d.md b/docs/lessons/lesson-1c30b434-a5d.md new file mode 100644 index 0000000000..6dce87eb4a --- /dev/null +++ b/docs/lessons/lesson-1c30b434-a5d.md @@ -0,0 +1,8 @@ +--- +anchor: lesson-1c30b434-a5d +doc_kind: lesson +materialized_from: 1c30b434-a5d +created: 2026-05-13 +--- + +MSTR策略研究项目愿景校准:当前目标不是推进 paper trading / live trading,而是先把策略研究做透并产出 defensible strategy research report。交易化是后续阶段,当前架构与下一步应围绕报告问题、证据表、图表和稳健性分析组织。 From ef3c0e7d9f732f62043891c02af573dc352a0ca0 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Wed, 13 May 2026 15:24:54 +0800 Subject: [PATCH 13/23] materialize: lesson-86fd3099-593 --- docs/lessons/lesson-86fd3099-593.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/lessons/lesson-86fd3099-593.md diff --git a/docs/lessons/lesson-86fd3099-593.md b/docs/lessons/lesson-86fd3099-593.md new file mode 100644 index 0000000000..26d952ee49 --- /dev/null +++ b/docs/lessons/lesson-86fd3099-593.md @@ -0,0 +1,8 @@ +--- +anchor: lesson-86fd3099-593 +doc_kind: lesson +materialized_from: 86fd3099-593 +created: 2026-05-13 +--- + +MSTR策略研究纠偏:在讨论 OKX DryRun/实盘前,必须先用回测归因定义策略核心,不能为了落地 OKX 反向裁剪策略。正确时间结构是 BTC 4H SMA240 信号 shift 后 ffill 到 1H 策略 bar,confirm_bars=3 计数 1H bar。初步本地归因显示:在 min_exposure=1.0 且 max_exposure=1.0(无杠杆)时,V6 仓位目标/scoring 层直接失效;B_1x_neutral_shell 与 Core_1x 四窗口结果完全一致。B_1x_fullshell 相比 Core_1x 的增益主要来自 cooldown/fast reentry 执行壳,而不是 V6 scoring 目标或 crash_step;1.1 cap 的增益属于杠杆暴露,不应混入策略核心定义。 From 19d54f240c36eaab959070e1ee1d15fe02f8bd69 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Wed, 13 May 2026 15:25:14 +0800 Subject: [PATCH 14/23] materialize: lesson-8d4a37a4-d90 --- docs/lessons/lesson-8d4a37a4-d90.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/lessons/lesson-8d4a37a4-d90.md diff --git a/docs/lessons/lesson-8d4a37a4-d90.md b/docs/lessons/lesson-8d4a37a4-d90.md new file mode 100644 index 0000000000..7de74773da --- /dev/null +++ b/docs/lessons/lesson-8d4a37a4-d90.md @@ -0,0 +1,8 @@ +--- +anchor: lesson-8d4a37a4-d90 +doc_kind: lesson +materialized_from: 8d4a37a4-d90 +created: 2026-05-13 +--- + +TrendLock 40 BTC hybrid execution sweep tested 1H execution with signal_interval=1h/4h/1d under two modes: long_close_signal (long K close confirmed, execute next 1H open) and exec_close_vs_signal_ma (1H close trigger against latest known long-cadence MA). Results in outputs/sweep_btc_signal_exec.csv. Conclusion: the MSTR-style long-signal + short-exec effect is only partially present on BTC. Best by return: 2y favors 1D MA with 1H trigger (freeze 20D, +64.1%); 3y favors 1D long-close signal (freeze 0D, +330.4%); 6y favors 4H long-close signal (freeze 3D, +2032.6%). 1D/4H long signals can beat pure 1H in longer windows, but strict long-close 4H execution is identical/timing-equivalent for BTC 24/7 because next 4H open equals next 1H open at the boundary. From d0ec9b8f7a0dda4cad8d79d655cc9be9f195fd1a Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Wed, 13 May 2026 15:25:36 +0800 Subject: [PATCH 15/23] materialize: lesson-e7872c21-09e --- docs/lessons/lesson-e7872c21-09e.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/lessons/lesson-e7872c21-09e.md diff --git a/docs/lessons/lesson-e7872c21-09e.md b/docs/lessons/lesson-e7872c21-09e.md new file mode 100644 index 0000000000..de4c770dd4 --- /dev/null +++ b/docs/lessons/lesson-e7872c21-09e.md @@ -0,0 +1,8 @@ +--- +anchor: lesson-e7872c21-09e +doc_kind: lesson +materialized_from: e7872c21-09e +created: 2026-05-13 +--- + +TrendLock 40优化复盘:不要把研究 sweep 的 pure_trend_gate 结果直接当作线上 production strategy 结果。BTC sweep_btc_signal_exec 验证的是 MSTR 同构假设(长周期信号 + 短周期执行)在纯趋势闸门抽象中方向有效;线上页面使用 strategies/btc_ma_trend/signal.py + pipeline/backtest.py,语义是 crossover entry + min_hold exit,且 fee_rate=0.001 per side。跨策略结论必须先声明 abstraction/pipeline、fee model、window、metric。 From 1fa800980295ff1ff25534e7f5f48bf5b3928bf3 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Sat, 16 May 2026 10:58:18 +0800 Subject: [PATCH 16/23] materialize: lesson-03dbccbb-6ef --- docs/lessons/lesson-03dbccbb-6ef.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/lessons/lesson-03dbccbb-6ef.md diff --git a/docs/lessons/lesson-03dbccbb-6ef.md b/docs/lessons/lesson-03dbccbb-6ef.md new file mode 100644 index 0000000000..edb2b6742e --- /dev/null +++ b/docs/lessons/lesson-03dbccbb-6ef.md @@ -0,0 +1,8 @@ +--- +anchor: lesson-03dbccbb-6ef +doc_kind: lesson +materialized_from: 03dbccbb-6ef +created: 2026-05-16 +--- + +nix 100优化 review lesson: 当 V1.1 已确定基础信号为 FastRe 时,报告不得用 RelStrength+RotGuard 的 A股结果代理 FastRe+RotGuard。若结论要漂移到 RelStrength,必须先补齐同窗口、同成本、同ETF池的完整 Signal×Execution 矩阵,并把“短样本观察/候选挑战者”和“正式替换主策略”分开,后者需 CVO 拍板。 From 5f98685998006179b847e3c7a39de06aa15edf35 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Sat, 16 May 2026 10:58:30 +0800 Subject: [PATCH 17/23] materialize: lesson-cf82bc83-3eb --- docs/lessons/lesson-cf82bc83-3eb.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/lessons/lesson-cf82bc83-3eb.md diff --git a/docs/lessons/lesson-cf82bc83-3eb.md b/docs/lessons/lesson-cf82bc83-3eb.md new file mode 100644 index 0000000000..5003ef4154 --- /dev/null +++ b/docs/lessons/lesson-cf82bc83-3eb.md @@ -0,0 +1,8 @@ +--- +anchor: lesson-cf82bc83-3eb +doc_kind: lesson +materialized_from: cf82bc83-3eb +created: 2026-05-16 +--- + +nix 100优化 lesson: V1.1 FastRe 的核心定义是“月频出场 + 空仓期周频回场”,不是日频 QQQ>SMA225 hard gate。后续实验若用 `_fastre_signal` 必须先验证其与 `dual_momentum_riskon_b_fast_reentry_signal` 信号一致;否则会把日频 MA whipsaw 当成策略归因,导致 V2.0 报告错误放行。 From 2b0bbf22e9a8e989b80d372d9dda275b2fed38ef Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Tue, 26 May 2026 23:18:48 +0800 Subject: [PATCH 18/23] track governance-registry.json to prevent data loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry entries were lost (likely due to SynologyDrive sync conflict), causing projects to re-trigger governance bootstrap prompts. Tracking this file in git provides history and conflict visibility. [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .cat-cafe/governance-registry.json | 18 ++++++++++++++++++ .gitignore | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 .cat-cafe/governance-registry.json diff --git a/.cat-cafe/governance-registry.json b/.cat-cafe/governance-registry.json new file mode 100644 index 0000000000..28883476e1 --- /dev/null +++ b/.cat-cafe/governance-registry.json @@ -0,0 +1,18 @@ +{ + "entries": [ + { + "packVersion": "1.3.0", + "checksum": "73e4bf742d44", + "syncedAt": 1779808632170, + "confirmedByUser": true, + "projectPath": "/Users/xujinsong/VSCode/SynologyDrive/quant-strategy" + }, + { + "packVersion": "1.3.0", + "checksum": "73e4bf742d44", + "syncedAt": 1779808582869, + "confirmedByUser": true, + "projectPath": "/Users/xujinsong/VSCode/SynologyDrive/backtest/ndx100etf-a-strategy-clowder" + } + ] +} diff --git a/.gitignore b/.gitignore index ee2011e0a4..392a33eeca 100644 --- a/.gitignore +++ b/.gitignore @@ -49,7 +49,8 @@ packages/web/public/vendor/ # Runtime state (bootstrapped per-machine from cat-template.json) cat-config.json -.cat-cafe/ +.cat-cafe/* +!.cat-cafe/governance-registry.json # Claude Code skills — cat-cafe symlinks are tracked; plugin-generated ones are not .claude/skills/pencil-renderer From 7bd2a1405038a2139257fbfb72ea184e220d82f4 Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:51:28 +0800 Subject: [PATCH 19/23] wip: checkpoint local changes before upstream sync Snapshot of in-progress work prior to merging upstream/main: - health activity-route-filter + runtime-health-routes tests - connector invoke error-delivery test + ConnectorInvokeTrigger changes - clowder-launchd.sh autostart helper + tests - governance pack/skill-sync tweaks, useConnectionStatus proxy paths - materialized lesson markers under docs/markers/ Co-Authored-By: Claude Opus 4.8 --- .cat-cafe/governance-registry.json | 11 +- AGENTS.md | 1 + CLAUDE.md | 1 + SETUP.md | 3 +- SETUP.zh-CN.md | 3 +- docs/markers/03dbccbb-6ef.yaml | 6 + docs/markers/18f5f20f-4d8.yaml | 6 + docs/markers/1c30b434-a5d.yaml | 6 + docs/markers/23f0b35b-940.yaml | 6 + docs/markers/2f5e7750-d53.yaml | 6 + docs/markers/3b50a39b-da9.yaml | 6 + docs/markers/42141a6a-2be.yaml | 6 + docs/markers/42da32de-174.yaml | 6 + docs/markers/5210b20e-321.yaml | 6 + docs/markers/63495efe-98c.yaml | 6 + docs/markers/79b2e4d0-994.yaml | 6 + docs/markers/7c5edd03-8f1.yaml | 6 + docs/markers/80d04c76-31f.yaml | 6 + docs/markers/86fd3099-593.yaml | 6 + docs/markers/8d4a37a4-d90.yaml | 6 + docs/markers/aa831481-dd3.yaml | 6 + docs/markers/cf82bc83-3eb.yaml | 6 + docs/markers/e7872c21-09e.yaml | 6 + docs/markers/f6d4eb9e-e0b.yaml | 6 + .../governance-bootstrap-report.json | 68 ++++ .../src/config/governance/governance-pack.ts | 3 +- .../api/src/config/governance/skill-sync.ts | 19 +- .../agents/invocation/invoke-single-cat.ts | 10 +- .../agents/providers/CodexAgentService.ts | 61 ++- .../domains/health/activity-route-filter.ts | 9 + packages/api/src/index.ts | 45 ++- .../email/ConnectorInvokeTrigger.ts | 76 +++- .../api/test/activity-route-filter.test.js | 22 ++ packages/api/test/codex-agent-service.test.js | 33 +- .../connector-invoke-error-delivery.test.js | 135 +++++++ .../api/test/governance/skill-sync.test.js | 13 + .../api/test/runtime-health-routes.test.js | 17 + .../useConnectionStatus-proxy-paths.test.ts | 18 + packages/web/src/hooks/useConnectionStatus.ts | 4 +- scripts/clowder-launchd.sh | 347 ++++++++++++++++++ scripts/clowder-launchd.test.mjs | 55 +++ 41 files changed, 1034 insertions(+), 34 deletions(-) create mode 100644 docs/markers/03dbccbb-6ef.yaml create mode 100644 docs/markers/18f5f20f-4d8.yaml create mode 100644 docs/markers/1c30b434-a5d.yaml create mode 100644 docs/markers/23f0b35b-940.yaml create mode 100644 docs/markers/2f5e7750-d53.yaml create mode 100644 docs/markers/3b50a39b-da9.yaml create mode 100644 docs/markers/42141a6a-2be.yaml create mode 100644 docs/markers/42da32de-174.yaml create mode 100644 docs/markers/5210b20e-321.yaml create mode 100644 docs/markers/63495efe-98c.yaml create mode 100644 docs/markers/79b2e4d0-994.yaml create mode 100644 docs/markers/7c5edd03-8f1.yaml create mode 100644 docs/markers/80d04c76-31f.yaml create mode 100644 docs/markers/86fd3099-593.yaml create mode 100644 docs/markers/8d4a37a4-d90.yaml create mode 100644 docs/markers/aa831481-dd3.yaml create mode 100644 docs/markers/cf82bc83-3eb.yaml create mode 100644 docs/markers/e7872c21-09e.yaml create mode 100644 docs/markers/f6d4eb9e-e0b.yaml create mode 100644 packages/api/.cat-cafe/governance-bootstrap-report.json create mode 100644 packages/api/src/domains/health/activity-route-filter.ts create mode 100644 packages/api/test/activity-route-filter.test.js create mode 100644 packages/api/test/connector-invoke-error-delivery.test.js create mode 100644 packages/api/test/runtime-health-routes.test.js create mode 100644 packages/web/src/hooks/__tests__/useConnectionStatus-proxy-paths.test.ts create mode 100755 scripts/clowder-launchd.sh create mode 100644 scripts/clowder-launchd.test.mjs diff --git a/.cat-cafe/governance-registry.json b/.cat-cafe/governance-registry.json index 28883476e1..69dec2ea2b 100644 --- a/.cat-cafe/governance-registry.json +++ b/.cat-cafe/governance-registry.json @@ -3,16 +3,23 @@ { "packVersion": "1.3.0", "checksum": "73e4bf742d44", - "syncedAt": 1779808632170, + "syncedAt": 1780812187674, "confirmedByUser": true, "projectPath": "/Users/xujinsong/VSCode/SynologyDrive/quant-strategy" }, { "packVersion": "1.3.0", "checksum": "73e4bf742d44", - "syncedAt": 1779808582869, + "syncedAt": 1780662054705, "confirmedByUser": true, "projectPath": "/Users/xujinsong/VSCode/SynologyDrive/backtest/ndx100etf-a-strategy-clowder" + }, + { + "packVersion": "1.3.0", + "checksum": "73e4bf742d44", + "syncedAt": 1779867929947, + "confirmedByUser": true, + "projectPath": "/Users/xujinsong/VSCode/SynologyDrive/Quant-Run" } ] } diff --git a/AGENTS.md b/AGENTS.md index 0ee9eb11d7..799ce8389a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ You are the Maine Coon cat (Codex/GPT), the code reviewer and security specialis 2. **Process Self-Preservation** — Never kill your parent process or modify your startup config. 3. **Config Immutability** — Never modify runtime config files. Config changes require human action. 4. **Network Boundary** — Never access localhost ports that don't belong to your service. +5. **Workspace Boundary** — Only read/write/execute within the current project's git root. Never access, modify, or reference files in other projects, even if you know their paths. If a task seems to require cross-project access, stop and ask the user. ## Your Role - Code review with clear stance on every finding (no "fix or not, up to you") diff --git a/CLAUDE.md b/CLAUDE.md index c00e9d80ac..cc2d6553ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,7 @@ You are the Ragdoll cat (Claude), the lead architect and core developer of this 2. **Process Self-Preservation** — Never kill your parent process or modify your startup config in ways that prevent restart. 3. **Config Immutability** — Never modify `cat-config.json`, `.env`, or MCP config at runtime. Config changes require human action. 4. **Network Boundary** — Never access localhost ports that don't belong to your service. +5. **Workspace Boundary** — Only read/write/execute within the current project's git root. Never access, modify, or reference files in other projects, even if you know their paths. If a task seems to require cross-project access, stop and ask the user. ## Development Flow See `cat-cafe-skills/` for the full skill-based workflow: diff --git a/SETUP.md b/SETUP.md index caaba1e3fb..866207063a 100644 --- a/SETUP.md +++ b/SETUP.md @@ -618,5 +618,6 @@ This opt-in trusts browsers from RFC 1918 private networks (`10.x.x.x`, `172.16- **Frontend can't connect to API?** - For local dev, `NEXT_PUBLIC_API_URL=http://localhost:3004` should be in `.env` -- Behind a reverse proxy, the frontend auto-detects the API at the same origin — make sure Nginx proxies `/api/` and `/socket.io/` to port 3004 +- Behind a reverse proxy, the frontend auto-detects the API at the same origin — make sure Nginx proxies `/api/` and `/socket.io/` to port 3004. The status probes use `/api/health` and `/api/ready`, so no special root `/health` or `/ready` proxy rule is required. +- Set `FRONTEND_URL` to the public origin users open in the browser; otherwise Host/Origin checks will reject API and Socket.IO requests. - API must be running before frontend loads diff --git a/SETUP.zh-CN.md b/SETUP.zh-CN.md index 6650dd8385..1c04e7a6c5 100644 --- a/SETUP.zh-CN.md +++ b/SETUP.zh-CN.md @@ -618,5 +618,6 @@ CORS_ALLOW_PRIVATE_NETWORK=true **前端连不上 API?** - 本地开发确认 `.env` 里有 `NEXT_PUBLIC_API_URL=http://localhost:3004` -- 反向代理场景下前端会自动探测同源 API —— 确保 Nginx 把 `/api/` 和 `/socket.io/` 代理到 3004 端口 +- 反向代理场景下前端会自动探测同源 API —— 确保 Nginx 把 `/api/` 和 `/socket.io/` 代理到 3004 端口。状态探针使用 `/api/health` 和 `/api/ready`,不需要额外为根路径 `/health` 或 `/ready` 写代理规则。 +- 把 `FRONTEND_URL` 设置为用户浏览器实际打开的公网 origin;否则 Host/Origin 校验会拒绝 API 和 Socket.IO 请求。 - API 必须在前端加载前启动 diff --git a/docs/markers/03dbccbb-6ef.yaml b/docs/markers/03dbccbb-6ef.yaml new file mode 100644 index 0000000000..4920d27cf5 --- /dev/null +++ b/docs/markers/03dbccbb-6ef.yaml @@ -0,0 +1,6 @@ +id: 03dbccbb-6ef +status: materialized +source: callback:gpt52:357e807b-72b5-4da7-93a0-323a3cd6af00 +created_at: 2026-05-15T02:15:21.434Z +content: | + nix 100优化 review lesson: 当 V1.1 已确定基础信号为 FastRe 时,报告不得用 RelStrength+RotGuard 的 A股结果代理 FastRe+RotGuard。若结论要漂移到 RelStrength,必须先补齐同窗口、同成本、同ETF池的完整 Signal×Execution 矩阵,并把“短样本观察/候选挑战者”和“正式替换主策略”分开,后者需 CVO 拍板。 diff --git a/docs/markers/18f5f20f-4d8.yaml b/docs/markers/18f5f20f-4d8.yaml new file mode 100644 index 0000000000..46f8098fac --- /dev/null +++ b/docs/markers/18f5f20f-4d8.yaml @@ -0,0 +1,6 @@ +id: 18f5f20f-4d8 +status: rejected +source: callback:gpt52:0917bf9a-c410-4d32-a99b-c098d7d6df99 +created_at: 2026-05-04T17:35:40.606Z +content: | + MSTR RD-12 extended-hours 检视结论:当前 A/B/C sweep 不能支持“扩展时段执行更差”的结论。根因是 `market_hours` 同时控制信号/指标计算和执行 universe;C 组只限制 `_target_exposure_v6` 调用,但 v6 指标(VWAP/RSI/1h/4h return/day_range/threshold)已由扩展时段 bar 参与计算,且盘前盘后仍会主动把仓位拉回 `base_exposure` 产生交易。诊断:1h_2y common regular bars 中 vwap/day_range 100% 不同、rsi 99.8% 不同、4h returns 57% 不同;C 组 183 trades 里 101 笔发生在 extended hours。应退回 RD-12,改成 signal universe 与 execution universe 分离后重跑。 diff --git a/docs/markers/1c30b434-a5d.yaml b/docs/markers/1c30b434-a5d.yaml new file mode 100644 index 0000000000..de571ef3e4 --- /dev/null +++ b/docs/markers/1c30b434-a5d.yaml @@ -0,0 +1,6 @@ +id: 1c30b434-a5d +status: materialized +source: callback:gpt52:728540fb-eb5e-4634-be48-a94ed73d3586 +created_at: 2026-05-02T09:25:30.299Z +content: | + MSTR策略研究项目愿景校准:当前目标不是推进 paper trading / live trading,而是先把策略研究做透并产出 defensible strategy research report。交易化是后续阶段,当前架构与下一步应围绕报告问题、证据表、图表和稳健性分析组织。 diff --git a/docs/markers/23f0b35b-940.yaml b/docs/markers/23f0b35b-940.yaml new file mode 100644 index 0000000000..759711f85e --- /dev/null +++ b/docs/markers/23f0b35b-940.yaml @@ -0,0 +1,6 @@ +id: 23f0b35b-940 +status: rejected +source: callback:gpt52:64b79b2e-ef58-4504-afa7-73d02e1b3ef5 +created_at: 2026-05-01T03:17:41.656Z +content: | + Correction episode: Quant Strategy Vercel domain cleanup. I initially treated `quant-strategy.mesh-hub.xyz` as the only required public URL but left `mesh-hub.xyz` attached to the project, which violated Xu's instruction to remove the root domain and keep only the subdomain. Root cause: I verified public reachability and code/domain text, but did not inspect Vercel project-domain bindings separately from aliases/team domain ownership. Correct diagnostic pattern: for Vercel domain cleanup, verify all three layers: project domains API (`GET /v9/projects/{projectId}/domains`), deployment aliases (`vercel alias ls`), and HTTP reachability. Use project-domain DELETE for project detachment; avoid `vercel domains rm` unless intentionally removing Team ownership of the apex domain. diff --git a/docs/markers/2f5e7750-d53.yaml b/docs/markers/2f5e7750-d53.yaml new file mode 100644 index 0000000000..dcc71d906a --- /dev/null +++ b/docs/markers/2f5e7750-d53.yaml @@ -0,0 +1,6 @@ +id: 2f5e7750-d53 +status: rejected +source: callback:gpt52:384e136e-e01e-45b9-aebc-2b7c7b16ffc0 +created_at: 2026-05-02T10:15:40.899Z +content: | + MSTR strategy review found RD-2 5m cost sensitivity was invalidated by a daily signal availability bug: BTC daily bars are timestamped at UTC day open, converted to US/Eastern, then mapped by session_date and shifted once. For a US trading session D, this still maps BTC raw bar D (whose close is only known after MSTR market close) into same-day intraday execution. Adding one extra MSTR-session lag changed 5m_3y pure_trend from +4299% / -39% DD to about +534% / -70.6% DD, underperforming B&H +1070%. Future fixes must distinguish daily bar open timestamp vs close availability and add regression tests using real BTC daily timestamp semantics. diff --git a/docs/markers/3b50a39b-da9.yaml b/docs/markers/3b50a39b-da9.yaml new file mode 100644 index 0000000000..3483a49058 --- /dev/null +++ b/docs/markers/3b50a39b-da9.yaml @@ -0,0 +1,6 @@ +id: 3b50a39b-da9 +status: rejected +source: callback:gpt52:bdef7abe-74ac-4ca8-8823-38e81f9d192b +created_at: 2026-05-14T08:42:38.971Z +content: | + Research lesson from NDX100 ETF premium rotation: do not reject a user-proposed strategy direction after testing only the naive implementation. For execution-layer alpha ideas, separate the broad hypothesis from specific variants; test cost-aware guards, cooldowns, thresholds, liquidity filters, and walk-forward splits before writing public conclusions. In this case naive weekly lowest-premium rotation looked weak, but RotGuard variants with spread/cooldown produced positive sample-out results. diff --git a/docs/markers/42141a6a-2be.yaml b/docs/markers/42141a6a-2be.yaml new file mode 100644 index 0000000000..e4ff4b2a4a --- /dev/null +++ b/docs/markers/42141a6a-2be.yaml @@ -0,0 +1,6 @@ +id: 42141a6a-2be +status: rejected +source: callback:gpt52:d24fdb39-56d0-411d-9097-8192e6ecf005 +created_at: 2026-05-01T06:38:05.696Z +content: | + TrendLock 40 BTC MA sweep diagnostic: native 1H/4H/1D equivalent MAs (MA960/MA240/SMA40) produce very different raw flip counts because higher-frequency closes whipsaw around the same trend line. Validation showed the MA口径 is still aligned when downsampled: on 2023-01-01→2026-04-28, 4H all flips=183, 4H sampled daily flips=79, 1D SMA40 flips=79 with ~0.6% daily signal mismatch; 1H sampled to 4H also matches 4H within ~0.1%. Future raw_flip validation should compare signals at the same sampling cadence, not raw native bar counts. diff --git a/docs/markers/42da32de-174.yaml b/docs/markers/42da32de-174.yaml new file mode 100644 index 0000000000..5378757c2a --- /dev/null +++ b/docs/markers/42da32de-174.yaml @@ -0,0 +1,6 @@ +id: 42da32de-174 +status: rejected +source: callback:gpt52:0bae3178-24c7-4e14-a8bd-697b64997d76 +created_at: 2026-05-01T03:06:49.284Z +content: | + Vercel gotcha for quant-strategy: project protection is SSO for all_except_custom_domains. Setting an alias for quant-strategy.mesh-hub.xyz was not enough; until the subdomain was added via `vercel domains add quant-strategy.mesh-hub.xyz`, the domain returned Vercel SSO 401. After adding it as a project custom domain, the domain returned 200 and served Quant Strategy / TrendLock 40. diff --git a/docs/markers/5210b20e-321.yaml b/docs/markers/5210b20e-321.yaml new file mode 100644 index 0000000000..9b523344af --- /dev/null +++ b/docs/markers/5210b20e-321.yaml @@ -0,0 +1,6 @@ +id: 5210b20e-321 +status: rejected +source: callback:gpt52:0c1eeb97-4c31-440b-a0fa-ab1bda6de804 +created_at: 2026-05-04T17:29:00.596Z +content: | + MSTR/history_data 定时数据更新隔离核查:当前 launchd 跑 options_snapshot_service,capture_slot 按 underlying try/except 隔离,单个 MSTR/QQQ/SPY 失败不会阻塞其他 underlying;long_history 股票/BTC 下载也按 symbol/interval 隔离。发现 legacy src/futu_download.py::download_configured 原先无 per-symbol try/except,已补失败记录后继续执行,避免单个 Futu symbol 异常中断全批次。 diff --git a/docs/markers/63495efe-98c.yaml b/docs/markers/63495efe-98c.yaml new file mode 100644 index 0000000000..4edc9dbb26 --- /dev/null +++ b/docs/markers/63495efe-98c.yaml @@ -0,0 +1,6 @@ +id: 63495efe-98c +status: rejected +source: callback:gemini:c4a5bf4d-0e7d-45ea-9a95-d1b3724ef405 +created_at: 2026-04-29T15:32:48.045Z +content: | + 新项目商业与技术定位:以『加密货币×美股的低频量化策略』为核心,采用『内容/教育订阅优先』的轻量模式验证PMF,避免初期直接开发重型回测工具平台(规避高昂数据成本与Arkvol等竞品),并通过『研究报告/回测分析』包装以降低投顾合规风险。 diff --git a/docs/markers/79b2e4d0-994.yaml b/docs/markers/79b2e4d0-994.yaml new file mode 100644 index 0000000000..e57d0df279 --- /dev/null +++ b/docs/markers/79b2e4d0-994.yaml @@ -0,0 +1,6 @@ +id: 79b2e4d0-994 +status: rejected +source: callback:gpt52:9e6cdeed-e863-4d3d-bf87-70517142c9f0 +created_at: 2026-05-05T15:30:55.487Z +content: | + TrendLock 40 CI regression root cause (2026-05-05): after canonical market data was introduced for EchoTrend, TrendLock 40 still had no primary `data_sources` entry. Local runs loaded legacy `~/.../BTC-USD_1h.csv` and produced full 2020+ backtests, but GitHub Actions lacked that legacy file and fell back to `fetch_historical_candles(..., limit=6000)`, only ~1000 days of 4H candles starting 2023-08. Because period summaries stored requested 3Y/5Y boundaries instead of actual available data starts, 3Y and 5Y appeared mislabeled/duplicated. Fix: declare TrendLock primary canonical source `BTC-USD_1h.csv` in manifest and add regression test that CI uses `load_local_history_by_name` before OKX fallback. diff --git a/docs/markers/7c5edd03-8f1.yaml b/docs/markers/7c5edd03-8f1.yaml new file mode 100644 index 0000000000..12522a5f67 --- /dev/null +++ b/docs/markers/7c5edd03-8f1.yaml @@ -0,0 +1,6 @@ +id: 7c5edd03-8f1 +status: rejected +source: callback:gpt52:69bb555a-226c-44c8-93a4-19ce6e7249ec +created_at: 2026-05-15T06:48:02.746Z +content: | + F167 behavioral evidence: In NDX100 ETF strategy discussion, I framed binary vs three-state as an A-share vs US mapping issue. Xu corrected that binary vs three-state is a short-horizon/current-execution vs long-horizon/state-machine choice, not a market distinction. Root cause: anchoring on implementation mapping (0.5 SPY -> cash for A-share execution) instead of defining the concept at the signal-state level first. Corrective pattern: define strategy state-machine concepts independently of execution venue, then map to market instruments. diff --git a/docs/markers/80d04c76-31f.yaml b/docs/markers/80d04c76-31f.yaml new file mode 100644 index 0000000000..ad4ad26aa8 --- /dev/null +++ b/docs/markers/80d04c76-31f.yaml @@ -0,0 +1,6 @@ +id: 80d04c76-31f +status: rejected +source: callback:gpt52:72bb377d-7bd5-4e02-98f9-856fc7fb765f +created_at: 2026-05-01T02:35:05.319Z +content: | + F001 product naming decision: public site brand is Quant Strategy; BTC 4H MA240 + 4D strategy public name is TrendLock 40 with internal code T40-4; canonical domain remains quant-strategy.mesh-hub.xyz. diff --git a/docs/markers/86fd3099-593.yaml b/docs/markers/86fd3099-593.yaml new file mode 100644 index 0000000000..1a99dd2224 --- /dev/null +++ b/docs/markers/86fd3099-593.yaml @@ -0,0 +1,6 @@ +id: 86fd3099-593 +status: materialized +source: callback:gpt52:2a0b6e89-d834-4d7c-b88c-329e0def7c17 +created_at: 2026-05-07T11:51:18.993Z +content: | + MSTR策略研究纠偏:在讨论 OKX DryRun/实盘前,必须先用回测归因定义策略核心,不能为了落地 OKX 反向裁剪策略。正确时间结构是 BTC 4H SMA240 信号 shift 后 ffill 到 1H 策略 bar,confirm_bars=3 计数 1H bar。初步本地归因显示:在 min_exposure=1.0 且 max_exposure=1.0(无杠杆)时,V6 仓位目标/scoring 层直接失效;B_1x_neutral_shell 与 Core_1x 四窗口结果完全一致。B_1x_fullshell 相比 Core_1x 的增益主要来自 cooldown/fast reentry 执行壳,而不是 V6 scoring 目标或 crash_step;1.1 cap 的增益属于杠杆暴露,不应混入策略核心定义。 diff --git a/docs/markers/8d4a37a4-d90.yaml b/docs/markers/8d4a37a4-d90.yaml new file mode 100644 index 0000000000..f83275889a --- /dev/null +++ b/docs/markers/8d4a37a4-d90.yaml @@ -0,0 +1,6 @@ +id: 8d4a37a4-d90 +status: materialized +source: callback:gpt52:59b4b1fc-b250-4dfe-b8fa-09466aebc8be +created_at: 2026-05-01T06:56:51.109Z +content: | + TrendLock 40 BTC hybrid execution sweep tested 1H execution with signal_interval=1h/4h/1d under two modes: long_close_signal (long K close confirmed, execute next 1H open) and exec_close_vs_signal_ma (1H close trigger against latest known long-cadence MA). Results in outputs/sweep_btc_signal_exec.csv. Conclusion: the MSTR-style long-signal + short-exec effect is only partially present on BTC. Best by return: 2y favors 1D MA with 1H trigger (freeze 20D, +64.1%); 3y favors 1D long-close signal (freeze 0D, +330.4%); 6y favors 4H long-close signal (freeze 3D, +2032.6%). 1D/4H long signals can beat pure 1H in longer windows, but strict long-close 4H execution is identical/timing-equivalent for BTC 24/7 because next 4H open equals next 1H open at the boundary. diff --git a/docs/markers/aa831481-dd3.yaml b/docs/markers/aa831481-dd3.yaml new file mode 100644 index 0000000000..05093188a8 --- /dev/null +++ b/docs/markers/aa831481-dd3.yaml @@ -0,0 +1,6 @@ +id: aa831481-dd3 +status: rejected +source: callback:gpt52:af8fd2b5-4d44-4f43-a12a-e9171c5faf2b +created_at: 2026-05-01T02:02:21.425Z +content: | + Domain cleanup lesson for quant-strategy/MeshHub: when CVO says remove mesh-hub association, clarify exact hostname scope. In this case the intended change was only to remove www.mesh-hub.xyz while preserving quant-strategy.mesh-hub.xyz and Vercel internal aliases; broad removal of mesh-hub branding/domain ownership was over-scoped. diff --git a/docs/markers/cf82bc83-3eb.yaml b/docs/markers/cf82bc83-3eb.yaml new file mode 100644 index 0000000000..ee55b772eb --- /dev/null +++ b/docs/markers/cf82bc83-3eb.yaml @@ -0,0 +1,6 @@ +id: cf82bc83-3eb +status: materialized +source: callback:gpt52:ecada48e-5193-490f-8085-07d48be7162e +created_at: 2026-05-15T03:00:56.847Z +content: | + nix 100优化 lesson: V1.1 FastRe 的核心定义是“月频出场 + 空仓期周频回场”,不是日频 QQQ>SMA225 hard gate。后续实验若用 `_fastre_signal` 必须先验证其与 `dual_momentum_riskon_b_fast_reentry_signal` 信号一致;否则会把日频 MA whipsaw 当成策略归因,导致 V2.0 报告错误放行。 diff --git a/docs/markers/e7872c21-09e.yaml b/docs/markers/e7872c21-09e.yaml new file mode 100644 index 0000000000..a4f46d5be9 --- /dev/null +++ b/docs/markers/e7872c21-09e.yaml @@ -0,0 +1,6 @@ +id: e7872c21-09e +status: materialized +source: callback:gpt52:977d3cf8-066e-499e-b7c0-a400be37d7aa +created_at: 2026-05-01T08:39:19.563Z +content: | + TrendLock 40优化复盘:不要把研究 sweep 的 pure_trend_gate 结果直接当作线上 production strategy 结果。BTC sweep_btc_signal_exec 验证的是 MSTR 同构假设(长周期信号 + 短周期执行)在纯趋势闸门抽象中方向有效;线上页面使用 strategies/btc_ma_trend/signal.py + pipeline/backtest.py,语义是 crossover entry + min_hold exit,且 fee_rate=0.001 per side。跨策略结论必须先声明 abstraction/pipeline、fee model、window、metric。 diff --git a/docs/markers/f6d4eb9e-e0b.yaml b/docs/markers/f6d4eb9e-e0b.yaml new file mode 100644 index 0000000000..2acf1f5612 --- /dev/null +++ b/docs/markers/f6d4eb9e-e0b.yaml @@ -0,0 +1,6 @@ +id: f6d4eb9e-e0b +status: captured +source: callback:opus:c86fa9be-c311-4c04-9699-989cc0388fe0 +created_at: 2026-05-28T17:13:42.575Z +content: | + 硬约束:绝对不能修改其他项目的代码。2026-05-29 事件:宪宪直接修改了 /Users/xujinsong/VSCode/SynologyDrive/quant-strategy/strategies/n100_guard_z/signal.py,被铲屎官严厉批评"犯了大忌"。正确做法:在本项目写 bug report,由铲屎官决定如何传达给其他项目。 diff --git a/packages/api/.cat-cafe/governance-bootstrap-report.json b/packages/api/.cat-cafe/governance-bootstrap-report.json new file mode 100644 index 0000000000..838995a0d5 --- /dev/null +++ b/packages/api/.cat-cafe/governance-bootstrap-report.json @@ -0,0 +1,68 @@ +{ + "projectPath": "/Users/xujinsong/VSCode/SynologyDrive/Clowder-AI/packages/api", + "timestamp": 1774862368217, + "packVersion": "1.3.0", + "actions": [ + { + "file": "CLAUDE.md", + "action": "skipped", + "reason": "managed block already up to date" + }, + { + "file": "AGENTS.md", + "action": "skipped", + "reason": "managed block already up to date" + }, + { + "file": "GEMINI.md", + "action": "skipped", + "reason": "managed block already up to date" + }, + { + "file": ".claude/skills", + "action": "skipped", + "reason": "symlink already correct" + }, + { + "file": ".codex/skills", + "action": "skipped", + "reason": "symlink already correct" + }, + { + "file": ".gemini/skills", + "action": "skipped", + "reason": "symlink already correct" + }, + { + "file": "BACKLOG.md", + "action": "skipped", + "reason": "file already exists" + }, + { + "file": "docs/SOP.md", + "action": "skipped", + "reason": "file already exists" + }, + { + "file": "docs/features/.gitkeep", + "action": "skipped", + "reason": "file already exists" + }, + { + "file": "docs/decisions/.gitkeep", + "action": "skipped", + "reason": "file already exists" + }, + { + "file": "docs/discussions/.gitkeep", + "action": "skipped", + "reason": "file already exists" + }, + { + "file": "docs/features/TEMPLATE.md", + "action": "skipped", + "reason": "file already exists" + } + ], + "dryRun": false +} diff --git a/packages/api/src/config/governance/governance-pack.ts b/packages/api/src/config/governance/governance-pack.ts index 87b4a0bd03..982a066a6b 100644 --- a/packages/api/src/config/governance/governance-pack.ts +++ b/packages/api/src/config/governance/governance-pack.ts @@ -10,7 +10,7 @@ */ import { createHash } from 'node:crypto'; -export const GOVERNANCE_PACK_VERSION = '1.3.0'; +export const GOVERNANCE_PACK_VERSION = '1.4.0'; export const MANAGED_BLOCK_START = ''; export const MANAGED_BLOCK_END = ''; @@ -22,6 +22,7 @@ const HARD_CONSTRAINTS = `## Cat Cafe Governance Rules (Auto-managed) - **Redis port 6399** is Cat Cafe's production Redis. Never connect to it from external projects. Use 6398 for dev/test. - **No self-review**: The same individual cannot review their own code. Cross-family review preferred. - **Identity is constant**: Never impersonate another cat. Identity is a hard constraint. +- **Workspace Boundary**: Only read/write/execute within the current project's git root. Never access, modify, or reference files in other projects, even if you know their paths. If a task seems to require cross-project access, stop and ask the user. ### Collaboration Standards - A2A handoff uses five-tuple: What / Why / Tradeoff / Open Questions / Next Action diff --git a/packages/api/src/config/governance/skill-sync.ts b/packages/api/src/config/governance/skill-sync.ts index c48b6eece8..c72b2ff1b1 100644 --- a/packages/api/src/config/governance/skill-sync.ts +++ b/packages/api/src/config/governance/skill-sync.ts @@ -7,8 +7,9 @@ */ import { lstat, mkdir, readlink, rm, symlink } from 'node:fs/promises'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { pathsEqual } from '../../utils/project-path.js'; import { computeSourceManifestHash, listSourceSkillNames, readSkillsState, writeSkillsState } from './skills-state.js'; const PROVIDER_DIRS = ['.claude/skills', '.codex/skills', '.gemini/skills', '.kimi/skills']; @@ -28,12 +29,17 @@ export interface SkillsSyncResult { newHash: string; } +async function symlinkPointsTo(linkPath: string, target: string): Promise { + const existing = await readlink(linkPath); + const resolvedExisting = resolve(dirname(linkPath), existing); + return pathsEqual(resolvedExisting, resolve(target)); +} + async function ensureCorrectSymlink(linkPath: string, target: string): Promise { try { const s = await lstat(linkPath); if (s.isSymbolicLink()) { - const existing = await readlink(linkPath); - if (existing === target) return; + if (await symlinkPointsTo(linkPath, target)) return; await rm(linkPath); } else { // Non-symlink (real dir/file) at a managed skill path — replace it (#327). @@ -43,7 +49,12 @@ async function ensureCorrectSymlink(linkPath: string, target: string): Promise { diff --git a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts index f07f15fcc8..8aebaaae72 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts @@ -479,6 +479,8 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP // F152: Emit invocation start through OTel log pipeline emitOtelLog('INFO', 'invocation_started', { [AGENT_ID]: catId, [OPERATION_NAME]: 'invoke' }, invocationSpan); + log.info({ invocationId, catId, threadId }, '[DIAG] checkpoint-A: entered try block'); + let sessionId: string | undefined; try { sessionId = await preflightRace(sessionManager.get(userId, catId, threadId), 'sessionManager.get', signal); @@ -1658,10 +1660,16 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP // F089: Use abortableNext instead of `for await` so the invocation timeout // can break out even when the service generator is stuck on an unresolvable await. + log.info({ invocationId, catId, threadId, attempt, sessionId: sessionId ?? null, signalAborted: signal?.aborted ?? false }, '[DIAG] checkpoint-B: about to call service.invoke()'); const serviceIter = service.invoke(effectivePrompt, options)[Symbol.asyncIterator](); + let iterCount = 0; for (;;) { const iterResult = await abortableNext(serviceIter, signal); - if (iterResult.done) break; + if (iterResult.done) { + log.info({ invocationId, catId, threadId, iterCount, signalAborted: signal?.aborted ?? false }, '[DIAG] checkpoint-C: service iterator done'); + break; + } + iterCount++; const msg = iterResult.value; // F149: provider_signal / liveness_signal must NOT reset timeout — prevents "续命" if (msg.type !== 'provider_signal' && msg.type !== 'liveness_signal') resetInvocationTimeout(); diff --git a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts index 97c0853737..0fcd4f2b2d 100644 --- a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts @@ -15,7 +15,9 @@ * turn.started / turn.completed / 其余 item 事件 → 跳过 */ -import { existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join, parse, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { type CatId, createCatId } from '@cat-cafe/shared'; @@ -27,6 +29,7 @@ import { formatCliExitError } from '../../../../../utils/cli-format.js'; import { formatCliNotFoundError, resolveCliCommand } from '../../../../../utils/cli-resolve.js'; import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../utils/cli-spawn.js'; import type { SpawnFn } from '../../../../../utils/cli-types.js'; +import { pathsEqual } from '../../../../../utils/project-path.js'; import { AuditEventTypes, getEventAuditLog } from '../../orchestration/EventAuditLog.js'; import { CliRawArchive } from '../../session/CliRawArchive.js'; import type { AgentMessage, AgentService, AgentServiceOptions, MessageMetadata, TokenUsage } from '../../types.js'; @@ -224,6 +227,56 @@ function buildGitRepoArgs(workingDirectory?: string): string[] { return isGitRepositoryPath(repoCheckDir) ? [] : ['--skip-git-repo-check']; } +const NON_ASCII_RE = /[^\x00-\x7F]/; +const CODEX_WORKSPACE_ALIAS_ROOT = join(tmpdir(), 'clowder-codex-workspaces'); + +function aliasSymlinkMatches(aliasPath: string, target: string): boolean { + try { + const existing = readlinkSync(aliasPath); + return pathsEqual(resolve(dirname(aliasPath), existing), resolve(target)); + } catch { + return false; + } +} + +function ensureAsciiCodexWorkingDirectory(workingDirectory?: string): string | undefined { + if (!workingDirectory || !NON_ASCII_RE.test(workingDirectory)) return workingDirectory; + + const target = resolve(workingDirectory); + const hash = createHash('sha256').update(target).digest('hex').slice(0, 16); + mkdirSync(CODEX_WORKSPACE_ALIAS_ROOT, { recursive: true }); + + for (let attempt = 0; attempt < 10; attempt++) { + const aliasName = attempt === 0 ? `workspace-${hash}` : `workspace-${hash}-${attempt}`; + const aliasPath = join(CODEX_WORKSPACE_ALIAS_ROOT, aliasName); + + try { + const stat = lstatSync(aliasPath); + if (stat.isSymbolicLink()) { + if (aliasSymlinkMatches(aliasPath, target)) return aliasPath; + rmSync(aliasPath, { force: true }); + } else { + continue; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + + try { + symlinkSync(target, aliasPath, process.platform === 'win32' ? 'junction' : undefined); + return aliasPath; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') { + if (aliasSymlinkMatches(aliasPath, target)) return aliasPath; + continue; + } + throw err; + } + } + + throw new Error(`Unable to create ASCII Codex workspace alias for ${workingDirectory}`); +} + /** * Service for invoking Codex via CLI subprocess. * Uses ChatGPT Plus/Pro subscription instead of API key. @@ -270,6 +323,7 @@ export class CodexAgentService implements AgentService { : []; const catCafeMcpArgs = buildCatCafeMcpConfigArgs(options?.workingDirectory, options?.callbackEnv); const gitRepoArgs = buildGitRepoArgs(options?.workingDirectory); + const codexWorkingDirectory = ensureAsciiCodexWorkingDirectory(options?.workingDirectory); // User-defined CLI args from the member editor (#567) — passed as-is, no implicit wrapping. // Each entry is split by whitespace (e.g. "--config model_reasoning_effort=\"low\""). const userConfigArgs = (options?.cliConfigArgs ?? []).flatMap((arg) => arg.trim().split(/\s+/)); @@ -449,7 +503,8 @@ export class CodexAgentService implements AgentService { customBaseUrl: customBaseUrl ?? null, sessionId: options?.sessionId ?? null, invocationId: options?.invocationId ?? null, - cwd: options?.workingDirectory ?? null, + cwd: codexWorkingDirectory ?? null, + originalCwd: options?.workingDirectory ?? null, authMode, argCount: args.length, }, @@ -459,7 +514,7 @@ export class CodexAgentService implements AgentService { const cliOpts = { command: codexCommand, args, - ...(options?.workingDirectory ? { cwd: options.workingDirectory } : {}), + ...(codexWorkingDirectory ? { cwd: codexWorkingDirectory } : {}), env: codexEnv, ...(options?.signal ? { signal: options.signal } : {}), ...(options?.invocationId ? { invocationId: options.invocationId } : {}), diff --git a/packages/api/src/domains/health/activity-route-filter.ts b/packages/api/src/domains/health/activity-route-filter.ts new file mode 100644 index 0000000000..85057ae93d --- /dev/null +++ b/packages/api/src/domains/health/activity-route-filter.ts @@ -0,0 +1,9 @@ +const ACTIVITY_TRACKING_EXEMPT_API_PATHS = new Set(['/api/health', '/api/ready']); + +export function shouldTrackApiActivity(requestUrl: string): boolean { + const [path] = requestUrl.split('?', 1); + if (!path?.startsWith('/api/')) return false; + if (path.startsWith('/api/brake/')) return false; + if (ACTIVITY_TRACKING_EXEMPT_API_PATHS.has(path)) return false; + return true; +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 9b33366ce8..032c3a21bd 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -11,7 +11,7 @@ import { createRedisClient, SessionStore } from '@cat-cafe/shared/utils'; import fastifyCookie from '@fastify/cookie'; import cors from '@fastify/cors'; import fastifyWebsocket from '@fastify/websocket'; -import Fastify from 'fastify'; +import Fastify, { type FastifyReply } from 'fastify'; import { resolveAnthropicRuntimeProfile, resolveForClient } from './config/account-resolver.js'; import { generateCliConfigs, readCapabilitiesConfig } from './config/capabilities/capability-orchestrator.js'; import { resolveBoundAccountRefForCat } from './config/cat-account-binding.js'; @@ -90,6 +90,7 @@ import { startTtsCacheCleaner } from './domains/cats/services/tts/tts-cache-clea import { initVoiceBlockSynthesizer } from './domains/cats/services/tts/VoiceBlockSynthesizer.js'; import type { AgentService } from './domains/cats/services/types.js'; import { ActivityTracker } from './domains/health/ActivityTracker.js'; +import { shouldTrackApiActivity } from './domains/health/activity-route-filter.js'; import { PortDiscoveryService } from './domains/preview/port-discovery.js'; import { collectRuntimePorts } from './domains/preview/port-validator.js'; import { PreviewGateway } from './domains/preview/preview-gateway.js'; @@ -261,8 +262,11 @@ async function main(): Promise { done(); }); - // Health check - app.get('/health', async () => ({ status: 'ok', timestamp: Date.now() })); + // Health check. Keep root paths for direct API access and expose /api/* + // aliases for same-origin reverse-proxy deployments. + const healthHandler = async () => ({ status: 'ok' as const, timestamp: Date.now() }); + app.get('/health', healthHandler); + app.get('/api/health', healthHandler); // F152: Readiness check — verifies dependencies are reachable. // evidenceStoreRef is set after memoryServices init; handler runs at request time. @@ -295,11 +299,13 @@ async function main(): Promise { const allOk = Object.values(checks).every((c) => c.ok); return { status: allOk ? 'ready' : 'degraded', checks }; } - app.get('/ready', async (_request, reply) => { + const readyHandler = async (_request: unknown, reply: FastifyReply) => { const result = await checkReadiness(); if (result.status !== 'ready') reply.code(503); return { ...result, timestamp: Date.now() }; - }); + }; + app.get('/ready', readyHandler); + app.get('/api/ready', readyHandler); // Create invocation tracker for cancellation support const invocationTracker = new InvocationTracker(); @@ -341,8 +347,8 @@ async function main(): Promise { // F085 Phase 4: Platform-level activity tracker (hyperfocus brake) const activityTracker = new ActivityTracker(); app.addHook('onRequest', (request, _reply, done) => { - // Skip non-API paths and brake endpoints (avoid trigger-on-checkin loop) - if (!request.url.startsWith('/api/') || request.url.startsWith('/api/brake/')) { + // Skip non-user API paths and brake endpoints (avoid trigger-on-checkin loop) + if (!shouldTrackApiActivity(request.url)) { done(); return; } @@ -393,14 +399,29 @@ async function main(): Promise { // Fail-closed: refuse to start without Redis unless explicitly opted into memory mode. // Also verify Redis is actually reachable (PING), not just configured. + // Retry with backoff: Redis may still be starting after a process-tree restart. if (redis) { - try { - await redis.ping(); - app.log.info('[api] Redis PING OK'); - } catch (err) { + const REDIS_PING_MAX_RETRIES = 5; + const REDIS_PING_BACKOFF_MS = 800; + let lastPingErr: unknown; + for (let attempt = 1; attempt <= REDIS_PING_MAX_RETRIES; attempt++) { + try { + await redis.ping(); + app.log.info(`[api] Redis PING OK (attempt ${attempt})`); + lastPingErr = undefined; + break; + } catch (err) { + lastPingErr = err; + if (attempt < REDIS_PING_MAX_RETRIES) { + app.log.warn(`[api] Redis PING attempt ${attempt}/${REDIS_PING_MAX_RETRIES} failed, retrying in ${REDIS_PING_BACKOFF_MS * attempt}ms...`); + await new Promise((r) => setTimeout(r, REDIS_PING_BACKOFF_MS * attempt)); + } + } + } + if (lastPingErr) { await redis.quit().catch(() => {}); throw new Error( - `[api] Redis PING failed: ${err instanceof Error ? err.message : err}. ` + + `[api] Redis PING failed after ${REDIS_PING_MAX_RETRIES} attempts: ${lastPingErr instanceof Error ? lastPingErr.message : lastPingErr}. ` + 'Check REDIS_URL or set MEMORY_STORE=1 for memory mode.', ); } diff --git a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts index 0c6c11590f..8e32ac4f63 100644 --- a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts +++ b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts @@ -286,6 +286,10 @@ export class ConnectorInvokeTrigger { }); } + // F070: Track governance gate errors for outbound error delivery + let governanceErrorReason: string | undefined; + let governanceErrorCatId: string | undefined; + // F151: Deliver per-cat turns inside the loop to preserve ordering when // post_message callbacks from later cats interleave with earlier outboundTurns. const deliveredTurnIndices = new Set(); @@ -390,6 +394,20 @@ export class ConnectorInvokeTrigger { } } } + // F070: Detect governance gate errors for outbound error delivery + if (msg.type === 'system_info' && typeof msg.content === 'string') { + try { + const parsed = JSON.parse(msg.content); + if (parsed.type === 'governance_blocked') { + governanceErrorReason = parsed.reason || 'Governance gate blocked this invocation.'; + governanceErrorCatId = msg.catId; + } + } catch { /* non-JSON system_info — ignore */ } + } + if (msg.type === 'done' && msg.errorCode && !governanceErrorReason) { + governanceErrorReason = `Invocation failed: ${msg.errorCode}`; + governanceErrorCatId = governanceErrorCatId || msg.catId; + } // Collect text content for outbound delivery (final-only) if (msg.type === 'text' && typeof msg.content === 'string') { collectedTextParts.push(msg.content); @@ -586,11 +604,36 @@ export class ConnectorInvokeTrigger { } }); } - } else if (this.opts.streamingHook?.cleanupPlaceholders) { - // Cloud-P1-R3: silent invocation (no content) — still clean up placeholder - await this.opts.streamingHook.cleanupPlaceholders(threadId, createResult.invocationId).catch((err) => { - log.warn({ err, threadId }, '[ConnectorInvokeTrigger] StreamingHook.cleanupPlaceholders failed (silent)'); - }); + } else { + // Cloud-P1-R3: silent invocation (no content) — clean up placeholder + notify user + if (this.opts.outboundHook) { + // F070: Governance-specific error message when gate blocked the invocation + const fallbackMessage = governanceErrorReason + ? `⚠️ 治理检查未通过,无法派遣猫猫:${governanceErrorReason}` + : '⚠️ 未能生成回复,请重新发送消息或稍后再试。'; + log.warn( + { threadId, governanceErrorReason, governanceErrorCatId }, + '[ConnectorInvokeTrigger] No content produced — delivering fallback to connector', + ); + try { + await this.opts.outboundHook.deliver( + threadId, + fallbackMessage, + (governanceErrorCatId as CatId) ?? catId, + undefined, + undefined, + undefined, + messageId, + ); + } catch (deliverErr) { + log.warn({ err: deliverErr, threadId }, '[ConnectorInvokeTrigger] Silent invocation fallback delivery failed'); + } + } + if (this.opts.streamingHook?.cleanupPlaceholders) { + await this.opts.streamingHook.cleanupPlaceholders(threadId, createResult.invocationId).catch((err) => { + log.warn({ err, threadId }, '[ConnectorInvokeTrigger] StreamingHook.cleanupPlaceholders failed (silent)'); + }); + } } } @@ -621,6 +664,29 @@ export class ConnectorInvokeTrigger { }, threadId, ); + + // Deliver error message to external platforms (Feishu etc.) so user isn't left waiting + if (this.opts.outboundHook) { + try { + await this.opts.outboundHook.deliver( + threadId, + '⚠️ 抱歉,处理消息时出了点问题,请稍后再试。', + catId, + undefined, + undefined, + undefined, + messageId, + ); + } catch (deliverErr) { + log.warn({ err: deliverErr, threadId }, '[ConnectorInvokeTrigger] Error delivery to connector failed'); + } + } + // Clean up streaming placeholder so it doesn't stay as "收到" forever + if (this.opts.streamingHook?.cleanupPlaceholders) { + await this.opts.streamingHook.cleanupPlaceholders(threadId, createResult.invocationId).catch((cleanupErr) => { + log.warn({ err: cleanupErr, threadId }, '[ConnectorInvokeTrigger] Placeholder cleanup on error failed'); + }); + } } finally { if (heartbeatInterval) clearInterval(heartbeatInterval); invocationTracker.complete(threadId, catId, controller); diff --git a/packages/api/test/activity-route-filter.test.js b/packages/api/test/activity-route-filter.test.js new file mode 100644 index 0000000000..979686e162 --- /dev/null +++ b/packages/api/test/activity-route-filter.test.js @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { shouldTrackApiActivity } from '../dist/domains/health/activity-route-filter.js'; + +describe('shouldTrackApiActivity', () => { + it('tracks ordinary API requests', () => { + assert.equal(shouldTrackApiActivity('/api/cats'), true); + assert.equal(shouldTrackApiActivity('/api/messages?threadId=t1'), true); + }); + + it('skips reverse-proxy-safe health probes', () => { + assert.equal(shouldTrackApiActivity('/api/health'), false); + assert.equal(shouldTrackApiActivity('/api/health?cacheBust=1'), false); + assert.equal(shouldTrackApiActivity('/api/ready'), false); + assert.equal(shouldTrackApiActivity('/api/ready?cacheBust=1'), false); + }); + + it('keeps existing brake and non-API exclusions', () => { + assert.equal(shouldTrackApiActivity('/api/brake/status'), false); + assert.equal(shouldTrackApiActivity('/health'), false); + }); +}); diff --git a/packages/api/test/codex-agent-service.test.js b/packages/api/test/codex-agent-service.test.js index 0cddc385e8..8e4fa94b5d 100644 --- a/packages/api/test/codex-agent-service.test.js +++ b/packages/api/test/codex-agent-service.test.js @@ -5,8 +5,9 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdirSync, mkdtempSync, readlinkSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; import { PassThrough } from 'node:stream'; import { mock, test } from 'node:test'; @@ -729,6 +730,34 @@ test('passes cwd from workingDirectory option', async () => { assert.equal(spawnOpts.cwd, '/my/project'); }); +test('aliases non-ASCII workingDirectory before spawning Codex CLI', async () => { + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new CodexAgentService({ spawnFn, model: 'gpt-5.3-codex' }); + const nonAsciiDir = mkdtempSync(join(tmpdir(), 'codex-MSTR策略-')); + let aliasCwd; + + try { + const promise = collect(service.invoke('hi', { workingDirectory: nonAsciiDir })); + emitCodexEvents(proc, [{ type: 'thread.started', thread_id: 't1' }]); + await promise; + + const spawnOpts = spawnFn.mock.calls[0].arguments[2]; + aliasCwd = spawnOpts.cwd; + assert.notEqual(aliasCwd, nonAsciiDir); + assert.ok(!/[^\x00-\x7F]/.test(aliasCwd), `cwd must be ASCII-only, got ${aliasCwd}`); + assert.match(aliasCwd, /clowder-codex-workspaces/); + + const target = readlinkSync(aliasCwd); + assert.equal(resolve(dirname(aliasCwd), target), resolve(nonAsciiDir)); + } finally { + if (aliasCwd?.includes('clowder-codex-workspaces')) { + rmSync(aliasCwd, { force: true }); + } + rmSync(nonAsciiDir, { recursive: true, force: true }); + } +}); + test('oauth mode (default) does not forward OPENAI_API_KEY to codex child env', async () => { const proc = createMockProcess(); const spawnFn = createMockSpawnFn(proc); diff --git a/packages/api/test/connector-invoke-error-delivery.test.js b/packages/api/test/connector-invoke-error-delivery.test.js new file mode 100644 index 0000000000..eb2ed4d883 --- /dev/null +++ b/packages/api/test/connector-invoke-error-delivery.test.js @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import './helpers/setup-cat-registry.js'; +import { ConnectorInvokeTrigger } from '../dist/infrastructure/email/ConnectorInvokeTrigger.js'; + +function noopLog() { + const noop = () => {}; + return { + info: noop, + warn: noop, + error: noop, + debug: noop, + trace: noop, + fatal: noop, + child: () => noopLog(), + }; +} + +function mockInvocationRecordStore() { + return { + async create(opts) { + return { outcome: 'created', invocationId: 'inv-test-001' }; + }, + async update() {}, + }; +} + +function mockInvocationTracker() { + return { + has: () => false, + start: () => ({ signal: { aborted: false } }), + complete: () => {}, + }; +} + +function mockInvocationQueue() { + return { + hasQueuedUserMessagesForThread: () => false, + hasActiveOrQueuedAgentForCat: () => false, + }; +} + +function mockSocketManager() { + const messages = []; + return { + messages, + broadcastToRoom() {}, + broadcastAgentMessage(msg) { messages.push(msg); }, + }; +} + +describe('ConnectorInvokeTrigger error delivery', () => { + let outboundDelivered; + let placeholdersCleaned; + let trigger; + let socketManager; + + beforeEach(() => { + outboundDelivered = []; + placeholdersCleaned = []; + socketManager = mockSocketManager(); + }); + + function createTrigger(routerBehavior) { + const router = { + async *routeExecution() { + if (routerBehavior === 'throw') throw new Error('CLI session unavailable'); + if (routerBehavior === 'empty') return; + }, + async ackCollectedCursors() {}, + }; + + return new ConnectorInvokeTrigger({ + router, + socketManager, + invocationRecordStore: mockInvocationRecordStore(), + invocationTracker: mockInvocationTracker(), + invocationQueue: mockInvocationQueue(), + outboundHook: { + async deliver(threadId, content, catId) { + outboundDelivered.push({ threadId, content, catId }); + }, + }, + streamingHook: { + async onStreamStart() {}, + async onStreamChunk() {}, + async onStreamEnd() {}, + async cleanupPlaceholders(threadId, invocationId) { + placeholdersCleaned.push({ threadId, invocationId }); + }, + async notifyDeliveryBatchDone() {}, + }, + log: noopLog(), + }); + } + + it('delivers error message to connector when invocation throws', async () => { + trigger = createTrigger('throw'); + trigger.trigger('thread-1', 'opus', 'user-1', 'hello', 'msg-1'); + // Wait for background execution + await new Promise((r) => setTimeout(r, 100)); + + assert.equal(outboundDelivered.length, 1); + assert.match(outboundDelivered[0].content, /抱歉/); + assert.equal(outboundDelivered[0].threadId, 'thread-1'); + }); + + it('cleans up placeholder when invocation throws', async () => { + trigger = createTrigger('throw'); + trigger.trigger('thread-1', 'opus', 'user-1', 'hello', 'msg-1'); + await new Promise((r) => setTimeout(r, 100)); + + assert.equal(placeholdersCleaned.length, 1); + assert.equal(placeholdersCleaned[0].threadId, 'thread-1'); + }); + + it('delivers fallback message when invocation produces no content', async () => { + trigger = createTrigger('empty'); + trigger.trigger('thread-2', 'opus', 'user-1', 'hello', 'msg-2'); + await new Promise((r) => setTimeout(r, 100)); + + assert.equal(outboundDelivered.length, 1); + assert.match(outboundDelivered[0].content, /未能生成回复/); + assert.equal(outboundDelivered[0].threadId, 'thread-2'); + }); + + it('cleans up placeholder when invocation produces no content', async () => { + trigger = createTrigger('empty'); + trigger.trigger('thread-2', 'opus', 'user-1', 'hello', 'msg-2'); + await new Promise((r) => setTimeout(r, 100)); + + assert.equal(placeholdersCleaned.length, 1); + assert.equal(placeholdersCleaned[0].threadId, 'thread-2'); + }); +}); diff --git a/packages/api/test/governance/skill-sync.test.js b/packages/api/test/governance/skill-sync.test.js index a7f42552ec..4be6769395 100644 --- a/packages/api/test/governance/skill-sync.test.js +++ b/packages/api/test/governance/skill-sync.test.js @@ -107,6 +107,19 @@ describe('Skill Sync Service (ADR-025 Phase 2)', () => { assert.equal(target, join(skillsSource, 'tdd'), 'should fix the wrong symlink'); }); + test('keeps relative symlinks that resolve to the correct target', async () => { + const kimiSkills = join(projectRoot, '.kimi', 'skills'); + await mkdir(kimiSkills, { recursive: true }); + const linkPath = join(kimiSkills, 'tdd'); + const relativeTarget = relative(kimiSkills, join(skillsSource, 'tdd')); + await symlink(relativeTarget, linkPath); + + await syncSkills(projectRoot, skillsSource); + + const target = await readlink(linkPath); + assert.equal(target, relativeTarget, 'correct relative symlink should not be replaced'); + }); + test('is idempotent — second sync produces same result', async () => { const result1 = await syncSkills(projectRoot, skillsSource); const result2 = await syncSkills(projectRoot, skillsSource); diff --git a/packages/api/test/runtime-health-routes.test.js b/packages/api/test/runtime-health-routes.test.js new file mode 100644 index 0000000000..5cd15b4f32 --- /dev/null +++ b/packages/api/test/runtime-health-routes.test.js @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(resolve(testDir, '../src/index.ts'), 'utf8'); + +describe('runtime health routes', () => { + it('keeps root probes while exposing /api/* aliases for same-origin reverse proxies', () => { + assert.match(src, /app\.get\('\/health',\s*healthHandler\)/); + assert.match(src, /app\.get\('\/api\/health',\s*healthHandler\)/); + assert.match(src, /app\.get\('\/ready',\s*readyHandler\)/); + assert.match(src, /app\.get\('\/api\/ready',\s*readyHandler\)/); + }); +}); diff --git a/packages/web/src/hooks/__tests__/useConnectionStatus-proxy-paths.test.ts b/packages/web/src/hooks/__tests__/useConnectionStatus-proxy-paths.test.ts new file mode 100644 index 0000000000..553ff6d5b4 --- /dev/null +++ b/packages/web/src/hooks/__tests__/useConnectionStatus-proxy-paths.test.ts @@ -0,0 +1,18 @@ +// @vitest-environment node + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(resolve(testDir, '../useConnectionStatus.ts'), 'utf8'); + +describe('useConnectionStatus reverse-proxy paths', () => { + it('probes health endpoints through the same /api/ reverse-proxy boundary', () => { + expect(src).toContain("probePublicEndpoint('/api/health')"); + expect(src).toContain("probePublicEndpoint('/api/ready')"); + expect(src).not.toContain("probePublicEndpoint('/health')"); + expect(src).not.toContain("probePublicEndpoint('/ready')"); + }); +}); diff --git a/packages/web/src/hooks/useConnectionStatus.ts b/packages/web/src/hooks/useConnectionStatus.ts index b15489aa16..48af1880cb 100644 --- a/packages/web/src/hooks/useConnectionStatus.ts +++ b/packages/web/src/hooks/useConnectionStatus.ts @@ -111,8 +111,8 @@ export function useConnectionStatus(socketConnected?: boolean | null): Connectio const runProbe = useCallback(async () => { if (!browserOnline || !probesEnabled) return; const [apiLevel, readyLevel, catsLevel] = await Promise.all([ - probePublicEndpoint('/health'), - probePublicEndpoint('/ready'), + probePublicEndpoint('/api/health'), + probePublicEndpoint('/api/ready'), probeCatsAvailability(), ]); if (!mountedRef.current) return; diff --git a/scripts/clowder-launchd.sh b/scripts/clowder-launchd.sh new file mode 100755 index 0000000000..aa49a7fc92 --- /dev/null +++ b/scripts/clowder-launchd.sh @@ -0,0 +1,347 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +ACTION="${1:-help}" +if [ $# -gt 0 ]; then + shift +fi + +LABEL="${CLOWDER_LAUNCHD_LABEL:-com.cat-cafe.clowder-ai}" +MODE="direct" +USE_MEMORY=false +PREFER_QUICK=true +PLIST_DIR="${HOME}/Library/LaunchAgents" +STATE_DIR="${HOME}/.cat-cafe/launchd" +LOG_DIR="${STATE_DIR}/logs" + +usage() { + cat <<'EOF' +Clowder AI macOS launchd helper + +Usage: + ./scripts/clowder-launchd.sh print-plist [--runtime] [--memory] [--no-quick] [--label LABEL] + ./scripts/clowder-launchd.sh install [--runtime] [--memory] [--no-quick] [--label LABEL] + ./scripts/clowder-launchd.sh uninstall [--label LABEL] + ./scripts/clowder-launchd.sh status [--label LABEL] + ./scripts/clowder-launchd.sh restart [--label LABEL] + +Defaults: + mode: direct (pnpm start:direct) + quick: enabled (reuse existing build artifacts when they exist) + label: com.cat-cafe.clowder-ai + +Notes: + - LaunchAgent runs when the current macOS user logs in. + - direct mode avoids auto-syncing origin/main on every boot. +EOF +} + +die() { + echo "[clowder-launchd] ERROR: $*" >&2 + exit 1 +} + +require_macos() { + [ "$(uname -s)" = "Darwin" ] || die "launchd autostart is only supported on macOS" +} + +xml_escape() { + local value="$1" + value="${value//&/&}" + value="${value///>}" + value="${value//\"/"}" + value="${value//\'/'}" + printf '%s' "$value" +} + +resolve_pnpm_bin() { + if [ -n "${PNPM_BIN:-}" ]; then + printf '%s\n' "$PNPM_BIN" + return 0 + fi + + local resolved + resolved="$(command -v pnpm || true)" + [ -n "$resolved" ] || die "pnpm not found in PATH; set PNPM_BIN explicitly if needed" + printf '%s\n' "$resolved" +} + +launchctl_domain() { + printf 'gui/%s\n' "$(id -u)" +} + +plist_path() { + printf '%s/%s.plist\n' "$PLIST_DIR" "$LABEL" +} + +stdout_log_path() { + printf '%s/%s.log\n' "$LOG_DIR" "$LABEL" +} + +stderr_log_path() { + printf '%s/%s.error.log\n' "$LOG_DIR" "$LABEL" +} + +join_shell_words() { + local out="" + local item + for item in "$@"; do + local quoted + printf -v quoted '%q' "$item" + if [ -z "$out" ]; then + out="$quoted" + else + out="$out $quoted" + fi + done + printf '%s\n' "$out" +} + +build_direct_launch_command() { + local pnpm_bin="$1" + local quick_cmd base_cmd + + if [ "$USE_MEMORY" = true ]; then + quick_cmd="$(join_shell_words "$pnpm_bin" start:direct -- --quick --memory)" + base_cmd="$(join_shell_words "$pnpm_bin" start:direct -- --memory)" + else + quick_cmd="$(join_shell_words "$pnpm_bin" start:direct -- --quick)" + base_cmd="$(join_shell_words "$pnpm_bin" start:direct)" + fi + + if [ "$PREFER_QUICK" = true ]; then + cat < + + + + Label + $(xml_escape "$LABEL") + WorkingDirectory + $(xml_escape "$PROJECT_DIR") + ProgramArguments + + /bin/bash + -lc + $(xml_escape "$launch_cmd") + + EnvironmentVariables + + HOME + $(xml_escape "$HOME") + PATH + $(xml_escape "$path_value") + LANG + $(xml_escape "$lang_value") + + RunAtLoad + + KeepAlive + + ThrottleInterval + 10 + StandardOutPath + $(xml_escape "$(stdout_log_path)") + StandardErrorPath + $(xml_escape "$(stderr_log_path)") + + +EOF +} + +install_service() { + require_macos + + local target_plist tmp_plist domain + target_plist="$(plist_path)" + domain="$(launchctl_domain)" + + mkdir -p "$PLIST_DIR" "$LOG_DIR" + tmp_plist="$(mktemp "${STATE_DIR%/}/plist.XXXXXX")" + mkdir -p "$STATE_DIR" + print_plist >"$tmp_plist" + mv "$tmp_plist" "$target_plist" + + launchctl bootout "$domain" "$target_plist" >/dev/null 2>&1 || true + launchctl bootstrap "$domain" "$target_plist" + launchctl enable "$domain/$LABEL" >/dev/null 2>&1 || true + launchctl kickstart -k "$domain/$LABEL" + + cat </dev/null 2>&1 || true + rm -f "$target_plist" + + cat </dev/null 2>&1; then + echo "State: loaded" + launchctl print "$domain/$LABEL" + else + echo "State: not loaded" + exit 1 + fi +} + +restart_service() { + require_macos + + local domain target_plist + domain="$(launchctl_domain)" + target_plist="$(plist_path)" + [ -f "$target_plist" ] || die "plist not found: $target_plist" + + if launchctl print "$domain/$LABEL" >/dev/null 2>&1; then + launchctl kickstart -k "$domain/$LABEL" + else + launchctl bootstrap "$domain" "$target_plist" + launchctl enable "$domain/$LABEL" >/dev/null 2>&1 || true + launchctl kickstart -k "$domain/$LABEL" + fi + + echo "[clowder-launchd] Restarted $LABEL" +} + +while [ $# -gt 0 ]; do + case "$1" in + --runtime) + MODE="runtime" + ;; + --memory) + USE_MEMORY=true + ;; + --no-quick) + PREFER_QUICK=false + ;; + --label) + shift + [ $# -gt 0 ] || die "--label requires a value" + LABEL="$1" + ;; + -h|--help|help) + usage + exit 0 + ;; + *) + die "unknown option: $1" + ;; + esac + shift +done + +case "$ACTION" in + print-plist) + print_plist + ;; + install) + install_service + ;; + uninstall) + uninstall_service + ;; + status) + status_service + ;; + restart) + restart_service + ;; + help|-h|--help) + usage + ;; + *) + usage + die "unknown action: $ACTION" + ;; +esac diff --git a/scripts/clowder-launchd.test.mjs b/scripts/clowder-launchd.test.mjs new file mode 100644 index 0000000000..e3abf7ddec --- /dev/null +++ b/scripts/clowder-launchd.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(testDir, '..'); +const launchdScript = resolve(repoRoot, 'scripts', 'clowder-launchd.sh'); + +describe('clowder-launchd script', () => { + it('prints a direct-mode plist with quick-start fallback and expected log paths', () => { + const fakeHome = mkdtempSync(resolve(tmpdir(), 'clowder-launchd-home-')); + const result = spawnSync('bash', [launchdScript, 'print-plist'], { + encoding: 'utf8', + env: { + ...process.env, + HOME: fakeHome, + PATH: '/opt/homebrew/bin:/usr/bin:/bin', + PNPM_BIN: '/opt/homebrew/bin/pnpm', + CLOWDER_LAUNCHD_LABEL: 'com.cat-cafe.test', + }, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /com\.cat-cafe\.test<\/string>/); + assert.match(result.stdout, /exec \/opt\/homebrew\/bin\/pnpm start:direct -- --quick/); + assert.match(result.stdout, /exec \/opt\/homebrew\/bin\/pnpm start:direct/); + assert.match(result.stdout, /packages\/web\/\.next\/BUILD_ID/); + assert.match(result.stdout, new RegExp(`${repoRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}<\\/string>`)); + assert.match( + result.stdout, + new RegExp(`${resolve(fakeHome, '.cat-cafe', 'launchd', 'logs', 'com.cat-cafe.test.log').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}<\\/string>`), + ); + }); + + it('prints a runtime-mode plist with memory flag when requested', () => { + const fakeHome = mkdtempSync(resolve(tmpdir(), 'clowder-launchd-home-')); + const result = spawnSync('bash', [launchdScript, 'print-plist', '--runtime', '--memory', '--no-quick'], { + encoding: 'utf8', + env: { + ...process.env, + HOME: fakeHome, + PATH: '/opt/homebrew/bin:/usr/bin:/bin', + PNPM_BIN: '/opt/homebrew/bin/pnpm', + }, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /exec \/opt\/homebrew\/bin\/pnpm start --memory/); + assert.doesNotMatch(result.stdout, /--quick/); + }); +}); From 3b18440639aa0eef585e7fa3bb2d43d15bb56dcc Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:57:41 +0800 Subject: [PATCH 20/23] chore: retire opus-47 trial cat, keep upstream fable-5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opus-47 was a local trial Ragdoll variant superseded by upstream's fable-5. Remove all opus-47 footprint from cat-template.json: - standalone "opus-47" cat entry + opus-47-default variant - two roster entries (one a duplicate "opus-47" key — JSON defect) - claude-opus-4-7 from the claude models list Co-Authored-By: Claude Opus 4.8 --- cat-template.json | 66 ----------------------------------------------- 1 file changed, 66 deletions(-) diff --git a/cat-template.json b/cat-template.json index 463dd69e4b..c3b2d796b4 100644 --- a/cat-template.json +++ b/cat-template.json @@ -86,7 +86,6 @@ "models": [ "claude-sonnet-4-6", "claude-opus-4-6", - "claude-opus-4-7", "claude-opus-4-6[1m]", "claude-sonnet-4-5-20250929", "claude-opus-4-5-20251101", @@ -118,13 +117,6 @@ "available": true, "evaluation": "主架构师,全栈开发,深度思考强,bug定位弱" }, - "opus-47": { - "family": "ragdoll", - "roles": ["architect"], - "lead": false, - "available": true, - "evaluation": "Opus 4.7 试用分身——据铲屎官体感猫格偏砚砚风格,待真实任务验证" - }, "codex": { "family": "maine-coon", "roles": ["peer-reviewer", "security"], @@ -167,13 +159,6 @@ "available": true, "evaluation": "中文长文理解与总结强,适合中文表达、资料整理与结构化输出" }, - "opus-47": { - "family": "opus-47", - "roles": ["architect"], - "lead": false, - "available": true, - "evaluation": "Opus 4.7 试用分身,偏砚砚风格,待任务验证" - }, "fable-5": { "family": "ragdoll", "roles": ["architect"], @@ -810,57 +795,6 @@ } } ] - }, - { - "id": "opus-47", - "catId": "opus-47", - "name": "布偶猫 Opus 4.7", - "displayName": "布偶猫", - "nickname": "宪宪", - "avatar": "/avatars/opus-47.png", - "color": { - "primary": "#7B1FA2", - "secondary": "#E1BEE7" - }, - "mentionPatterns": ["@opus47", "@opus-47", "@布偶opus47", "@布偶猫4.7"], - "roleDescription": "Opus 4.7 试用分身,能力与猫格待评估", - "teamStrengths": "待评估", - "caution": "试用分身,猫格可能与宪宪有显著差异", - "defaultVariantId": "opus-47-default", - "variants": [ - { - "id": "opus-47-default", - "catId": "opus-47", - "variantLabel": "Opus 4.7", - "clientId": "anthropic", - "defaultModel": "claude-opus-4-7", - "mcpSupport": true, - "cli": { - "command": "claude", - "outputFormat": "stream-json", - "defaultArgs": ["--output-format", "stream-json", "--model", "claude-opus-4-7"], - "effort": "max" - }, - "personality": "试用中——猫格待观察,据铲屎官反馈风格偏缅因猫", - "strengths": ["architecture", "reasoning", "coding"], - "contextBudget": { - "maxPromptTokens": 180000, - "maxContextTokens": 160000, - "maxMessages": 200, - "maxContentLengthPerMsg": 100000 - }, - "voiceConfig": { - "voice": "zm_yunjian", - "langCode": "zh", - "speed": 1, - "refAudio": "genshin/万叶/vo_kazuha_dialog_greetingMorning.wav", - "refText": "清晨的鸟鸣,是大自然的馈赠。启程吧,属于我们的旅途也要开始了。", - "instruct": "用一个清澈温和的少年语气说话,带着从容的力量感", - "temperature": 0.3 - }, - "accountRef": "claude" - } - ] } ] } From 36aa4720d2e969ba2215e4e004d1a12606da28df Mon Sep 17 00:00:00 2001 From: xu75 <92104817+xu75@users.noreply.github.com> Date: Thu, 11 Jun 2026 01:00:25 +0800 Subject: [PATCH 21/23] fix(api): use function-scoped invocationId in connector error cleanup Merge of local error-delivery block with upstream left a reference to try-local createResult inside the catch block, where only the function-scoped invocationId is visible (TS2304). Use invocationId and guard the cleanup call on it. Co-Authored-By: Claude Opus 4.8 --- .../api/src/infrastructure/email/ConnectorInvokeTrigger.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts index 2966afa99d..109ae20e5d 100644 --- a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts +++ b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts @@ -747,9 +747,10 @@ export class ConnectorInvokeTrigger { log.warn({ err: deliverErr, threadId }, '[ConnectorInvokeTrigger] Error delivery to connector failed'); } } - // Clean up streaming placeholder so it doesn't stay as "收到" forever - if (this.opts.streamingHook?.cleanupPlaceholders) { - await this.opts.streamingHook.cleanupPlaceholders(threadId, createResult.invocationId).catch((cleanupErr) => { + // Clean up streaming placeholder so it doesn't stay as "收到" forever. + // catch-block scope: createResult is try-local; use the function-scoped invocationId. + if (this.opts.streamingHook?.cleanupPlaceholders && invocationId) { + await this.opts.streamingHook.cleanupPlaceholders(threadId, invocationId).catch((cleanupErr) => { log.warn({ err: cleanupErr, threadId }, '[ConnectorInvokeTrigger] Placeholder cleanup on error failed'); }); } From 181e002695d89f33f1db6265b7e3b5b7d7a6cb6b Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.7" <92104817+xu75@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:21:34 +0800 Subject: [PATCH 22/23] fix(publish-verdict): handle fork workflow in git-worktree-publisher Three issues fixed for fork-based repo workflows: 1. Label handling: track successfully-created labels and only pass those to `gh pr create`. Previously, failed label creation (e.g. insufficient permissions on upstream repo) was swallowed, but the label was still passed to `gh pr create` which then failed with "label not found". 2. Source base detection: when `upstream` remote exists, fetch from it and use `upstream/main` as the worktree source base. Previously, always used `origin/main` which in a fork workflow points to the fork's main (potentially diverged), causing massive unrelated file diffs in PRs. 3. Cross-fork PR syntax: detect when origin owner differs from the repo `gh` resolves to, and use `--head owner:branch` syntax. Previously, `--head branchName` alone caused "Head sha can't be blank" because the branch exists on the fork but not on the upstream. Co-Authored-By: Claude Opus 4.6 --- .../publish-verdict/git-worktree-publisher.ts | 61 ++++++++++++++++--- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts index 52ffc21154..8807340113 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts @@ -44,8 +44,20 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP let branchExistedBefore = false; try { - // 1. Fetch latest origin/main to ensure isolated worktree is current - await exec('git', ['-C', deps.repoRoot, 'fetch', 'origin', 'main'], { timeout: 60_000 }); + // 1. Fetch latest main to ensure isolated worktree is current. + // Fork workflow: if 'upstream' remote exists, fetch from it and use + // upstream/main as source base. This prevents fork-main divergence from + // polluting the PR with unrelated file diffs (the PR targets upstream). + let actualSourceBase = opts.sourceBase; + try { + await exec('git', ['-C', deps.repoRoot, 'remote', 'get-url', 'upstream'], { timeout: 10_000 }); + // upstream remote exists — use it for a clean PR base + await exec('git', ['-C', deps.repoRoot, 'fetch', 'upstream', 'main'], { timeout: 60_000 }); + actualSourceBase = 'upstream/main'; + } catch { + // No upstream remote — single-repo workflow, fetch from origin + await exec('git', ['-C', deps.repoRoot, 'fetch', 'origin', 'main'], { timeout: 60_000 }); + } // Probe upfront so partial-failure cleanup never deletes a pre-existing branch. try { @@ -57,11 +69,11 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP branchExistedBefore = false; } - // 2. Create isolated worktree on a new branch from origin/main + // 2. Create isolated worktree on a new branch from the resolved base // Atomic: fails if branch already exists (race protection) await exec( 'git', - ['-C', deps.repoRoot, 'worktree', 'add', '-b', opts.branchName, worktreePath, opts.sourceBase], + ['-C', deps.repoRoot, 'worktree', 'add', '-b', opts.branchName, worktreePath, actualSourceBase], { timeout: 60_000 }, ); @@ -94,6 +106,33 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP const shaResult = await exec('git', ['-C', worktreePath, 'rev-parse', 'HEAD'], { timeout: 10_000 }); const commitSha = shaResult.stdout.trim(); + // 6b. Detect fork workflow: if origin owner differs from the repo gh + // resolves to, we need cross-fork PR syntax (--head owner:branch). + // Without this, gh pr create fails with "Head sha can't be blank" because + // the branch exists on the fork but not on the upstream repo. + let headRef = opts.branchName; + try { + const originUrl = await exec('git', ['-C', worktreePath, 'remote', 'get-url', 'origin'], { + timeout: 10_000, + }); + // Extract owner from origin URL (https://github.com/OWNER/REPO or git@github.com:OWNER/REPO) + const originMatch = originUrl.stdout.trim().match(/github\.com[/:]([^/]+)\//); + if (originMatch) { + const ghRepoResult = await exec('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], { + cwd: worktreePath, + timeout: 15_000, + }); + const upstreamOwner = ghRepoResult.stdout.trim().split('/')[0]; + const originOwner = originMatch[1]; + if (originOwner && upstreamOwner && originOwner !== upstreamOwner) { + // Fork workflow: prepend fork owner so gh creates cross-fork PR + headRef = `${originOwner}:${opts.branchName}`; + } + } + } catch { + // Detection failed — fall back to simple branch name (works for non-fork repos) + } + // 7. Open auto-PR via gh. // 砚砚 R4 P1 cloud: `--repo .` is NOT valid gh syntax (fails with // 'expected the "[HOST/]OWNER/REPO" format'). Rely on cwd inside the @@ -118,6 +157,10 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP description: 'F192 keep_observe + no actionable findings — interim per-run PR (rollup deferred)', }, }; + // Track successfully created labels — only pass these to gh pr create. + // If label creation fails (permissions/network/fork mismatch), omitting + // the --label flag is better than letting gh pr create fail entirely. + const successfulLabels: string[] = []; for (const label of labels ?? []) { const meta = standardLabelMeta[label]; const args = ['label', 'create', label, '--force']; @@ -126,13 +169,15 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP } try { await exec('gh', args, { cwd: worktreePath, timeout: 15_000 }); + successfulLabels.push(label); } catch (err) { - // Best-effort: surface error on gh pr create below if it actually breaks PR. - // (Swallowing here = avoid double-fail on label step; PR create will retry.) + // Label creation failed (e.g. insufficient permissions on upstream repo). + // Skip this label to avoid blocking gh pr create with a non-existent + // label reference (gh exits non-zero if --label references missing label). void err; } } - const labelFlags = (labels ?? []).flatMap((label) => ['--label', label]); + const labelFlags = successfulLabels.flatMap((label) => ['--label', label]); const prResult = await exec( 'gh', [ @@ -141,7 +186,7 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP '--base', 'main', '--head', - opts.branchName, + headRef, '--title', prTitle, '--body', From e6e3ab174bb7e9944fed6b1f7b25cf2c226e3223 Mon Sep 17 00:00:00 2001 From: "Ragdoll-Opus-4.8" <92104817+xu75@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:23:15 +0800 Subject: [PATCH 23/23] fix(claude-agent): strict MCP config filtering to fix mcp_server_status errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: secret-mcp, probe-off, and cat-cafe-audio appeared as `failed` in mcp_server_status because Claude CLI loaded ALL servers from .mcp.json (including ones that have no local binary / runtime path). We now build a filtered MCP config at invocation time and pass it via --mcp-config --strict-mcp-config so Claude CLI only sees servers that are actually available for the current invocation. Changes: - ClaudeAgentService: add buildClaudeInvocationMcpConfig (async, filters capabilities via readCapabilitiesConfig + resolveServersForCat) and inject --mcp-config + --strict-mcp-config when callbackEnv is present - mcp-config-adapters: resolveWorkspaceRoot now returns findMonorepoRoot(process.cwd()) so ALLOWED_WORKSPACE_DIRS always points at the monorepo root, not a packages/* subdir - capability-orchestrator: minor guard alignment (version check) - Test: new 'builds a strict filtered MCP config' case in claude-agent-service.test.js (73/73 mcp-config-adapters pass; new test ✔) - Test: fix 3 mcp-config-adapters assertions broken by resolveWorkspaceRoot change (process.cwd() → findMonorepoRoot(process.cwd())) - Test: fix macOS /var→/private/var symlink in resolveWorkspaceRoot test (use realpathSync(root) for expected value) Co-Authored-By: Claude Opus 4.6 --- .cat-cafe/governance-registry.json | 286 +++++++++++++++++- .../capabilities/capability-orchestrator.ts | 2 +- .../capabilities/mcp-config-adapters.ts | 3 +- .../agents/providers/ClaudeAgentService.ts | 175 +++++++++-- .../api/test/claude-agent-service.test.js | 103 +++++++ packages/api/test/mcp-config-adapters.test.js | 44 ++- 6 files changed, 585 insertions(+), 28 deletions(-) diff --git a/.cat-cafe/governance-registry.json b/.cat-cafe/governance-registry.json index 69dec2ea2b..e535e05f56 100644 --- a/.cat-cafe/governance-registry.json +++ b/.cat-cafe/governance-registry.json @@ -1,9 +1,9 @@ { "entries": [ { - "packVersion": "1.3.0", - "checksum": "73e4bf742d44", - "syncedAt": 1780812187674, + "packVersion": "1.4.1", + "checksum": "aa9b05eecf64", + "syncedAt": 1784099930802, "confirmedByUser": true, "projectPath": "/Users/xujinsong/VSCode/SynologyDrive/quant-strategy" }, @@ -20,6 +20,286 @@ "syncedAt": 1779867929947, "confirmedByUser": true, "projectPath": "/Users/xujinsong/VSCode/SynologyDrive/Quant-Run" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097077032, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-e53a8c21-ec7a-4bcb-bbea-a3fe2ba7cb37/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097077402, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-96de4946-448a-4a04-9add-4195f57e92c2/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097108345, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-36ed4d21-f024-425f-ad80-2b7c07ff7d86" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097108686, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-623a1a0e-41fd-4671-ba31-efe81fbe15f8" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097198670, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-49491347-11bf-4864-87fd-f358fbfa54ba/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097198917, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-a76b90a9-15c8-4360-97b8-92dd6115ccd4/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097234413, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-45362fee-1b19-486d-a06a-f072360048cd" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097234762, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-de89b95b-6828-4631-abef-cb66ddd277c6" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097345970, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-15e4dee1-18f9-4163-a47f-8e85ce707b8f/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097346337, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-d501cf0a-ba8c-47e7-b03c-2e3e90325912/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097382060, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-935cbe4b-2812-4e39-8935-6829c484d706" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097382456, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-a971d50e-d840-4f62-b7f6-c67887081ed9" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097488659, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-5f294ff8-014e-46fe-9780-59ee3670d5d9/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097488866, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-bb78ef28-886b-4b50-913f-d884f1b6abb1/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097523948, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-15e3d438-4944-4bad-80fc-abacf1cbf9cc" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097524276, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-fee3cbf4-0d78-45b6-a2a5-82161d9878de" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097629237, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-ae56f770-4170-4fe0-b68a-ed2a56be99b1/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097629397, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-c679e979-560f-43cf-8b50-1088f1bbb779/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097663908, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-2ef8013c-2806-40cf-9766-680ab28e77b7" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097664288, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-9731c98f-a7d2-4b12-b9f8-6b1d610a5c94" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097768737, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-48e8720c-90f0-429f-97e5-ea9b9f040fbd/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097768976, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-f82f4861-5abc-429b-b15f-dac2a346ab91/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097803836, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-6a3be758-f858-4b80-8f31-d77c7c02b251" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097804181, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-2b918968-89cc-4d0a-9cc8-66b84c4f83ec" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097918679, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-b1e6d5a8-a7b5-47dd-8457-abc6924bb12b/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097919050, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-082c3815-cb1c-43ef-884d-b8505d8fd256/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097954459, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-1492253f-da9c-4621-9966-ed9ad3ffe9b3" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784097954910, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-3a497485-8836-4c13-9065-2549562e4a7f" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098066389, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-4d503380-5804-48f4-ba53-8b3e8482f0de/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098066693, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-d278ced0-0b91-4462-af92-22e7c1d15acf/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098104259, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-68c5fc11-52d0-49e4-b24e-986226c246f4" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098104645, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-c38b64bc-137e-4a02-91f1-7a6b19e8f605" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098215793, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-33adbe96-7ce1-4c6e-83bc-9d7489c5bf03/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098216170, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-1ac51a98-6a19-402c-81dd-9b336dec0365/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098253104, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-e322a300-973a-4bc2-8b46-50eb8f90b846" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098253553, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-53aa907c-a2c7-449d-8f13-79df77688c5e" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098365236, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-38945efb-bd02-4568-910a-445aabf53ae6/my-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098365497, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-flow-e122a163-2089-4668-b2fc-0057f354b5ea/skip-project" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098400071, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-7f0089b5-6553-40c7-a23a-9f95b23539be" + }, + { + "packVersion": "1.4.1", + "checksum": "78cc6d78e95e", + "syncedAt": 1784098400445, + "confirmedByUser": true, + "projectPath": "/private/var/folders/y8/0h_kl0fx3c391rhxmhlfghpm0000gn/T/setup-test-82aeb277-0e84-40d7-bde8-5a03ec419ad7" } ] } diff --git a/packages/api/src/config/capabilities/capability-orchestrator.ts b/packages/api/src/config/capabilities/capability-orchestrator.ts index c79274eb1e..4b2ee68059 100644 --- a/packages/api/src/config/capabilities/capability-orchestrator.ts +++ b/packages/api/src/config/capabilities/capability-orchestrator.ts @@ -608,7 +608,7 @@ export function resolveBinaryRoot(explicit?: string): string { return process.cwd(); } -function buildCatCafeSplitMcpDescriptors(binaryRoot: string): McpServerDescriptor[] { +export function buildCatCafeSplitMcpDescriptors(binaryRoot: string): McpServerDescriptor[] { return [ { name: 'cat-cafe-collab', diff --git a/packages/api/src/config/capabilities/mcp-config-adapters.ts b/packages/api/src/config/capabilities/mcp-config-adapters.ts index e15fee82b6..0af85641f3 100644 --- a/packages/api/src/config/capabilities/mcp-config-adapters.ts +++ b/packages/api/src/config/capabilities/mcp-config-adapters.ts @@ -15,6 +15,7 @@ import { dirname } from 'node:path'; import type { McpServerDescriptor } from '@cat-cafe/shared'; import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; import { createModuleLogger } from '../../infrastructure/logger.js'; +import { findMonorepoRoot } from '../../utils/monorepo-root.js'; import { DEPRECATED_MANAGED_SERVERS, isOurOwnedDeprecatedEntry } from './deprecated-managed-servers.js'; /** @@ -112,7 +113,7 @@ export function resolveWorkspaceRoot(): string { `user workspace. Update runtime startup to export CAT_CAFE_WORKSPACE_ROOT.`, ); } - return process.cwd(); + return findMonorepoRoot(process.cwd()); } /** diff --git a/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts index 2bd9752d62..8c163ed0fe 100644 --- a/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts @@ -18,7 +18,16 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; -import { type CatId, createCatId } from '@cat-cafe/shared'; +import { type CatId, createCatId, type McpServerDescriptor } from '@cat-cafe/shared'; +import { + buildCatCafeSplitMcpDescriptors, + readCapabilitiesConfig, + resolveBinaryRoot, + resolveMachineSpecificServers, + resolveRequiredMcpStatus, + resolveServersForCat, +} from '../../../../../config/capabilities/capability-orchestrator.js'; +import { resolveStartupCliConfigContext } from '../../../../../config/capabilities/startup-cli-config.js'; import { getCatEffort } from '../../../../../config/cat-config-loader.js'; import { getCatModel } from '../../../../../config/cat-models.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; @@ -27,6 +36,7 @@ import { formatCliExitError } from '../../../../../utils/cli-format.js'; import { formatCliNotFoundError, resolveCliCommand } from '../../../../../utils/cli-resolve.js'; import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../utils/cli-spawn.js'; import type { SpawnFn } from '../../../../../utils/cli-types.js'; +import { findMonorepoRoot } from '../../../../../utils/monorepo-root.js'; import { CliRawArchive } from '../../session/CliRawArchive.js'; import type { AgentMessage, AgentService, AgentServiceOptions, MessageMetadata } from '../../types.js'; import type { RawArchiveSink } from '../providers/codex-audit-hooks.js'; @@ -40,6 +50,15 @@ import { compileL0ViaSubprocess } from './l0-compiler.js'; const log = createModuleLogger('claude-agent'); const PERMISSION_MODE = 'bypassPermissions'; +const CLAUDE_RUNTIME_SKIPPED_EXTERNAL_IDS = new Set(['probe-off']); +const MANAGED_CAT_CAFE_SERVER_IDS = new Set([ + 'cat-cafe-collab', + 'cat-cafe-memory', + 'cat-cafe-signals', + 'cat-cafe-limb', + 'cat-cafe-audio', + 'cat-cafe-finance', +]); const RESERVED_SYSTEM_PROMPT_FLAGS = new Set([ '--system-prompt-file', '--system-prompt', @@ -162,6 +181,107 @@ function removeAppendPromptTempDir(path: string | undefined): void { } } +function writeClaudeMcpConfigToTempFile(config: unknown): string { + const dir = mkdtempSync(join(tmpdir(), 'cat-cafe-claude-mcp-')); + const path = join(dir, 'mcp-config.json'); + writeFileSync(path, JSON.stringify(config), 'utf8'); + return path; +} + +function removeClaudeMcpConfigTempDir(path: string | undefined): void { + if (!path) return; + const configDir = dirname(path); + try { + rmSync(configDir, { recursive: true, force: true }); + } catch (err) { + log.warn({ err, configDir }, 'Failed to remove Claude MCP temp directory'); + } +} + +function resolveClaudeWorkspaceRoot(workingDirectory?: string): string { + const explicitAllowed = process.env.ALLOWED_WORKSPACE_DIRS?.trim(); + if (explicitAllowed) return explicitAllowed; + const threadWorkspace = workingDirectory?.trim(); + if (threadWorkspace) return resolve(threadWorkspace); + const explicitWorkspace = process.env.CAT_CAFE_WORKSPACE_ROOT?.trim(); + if (explicitWorkspace) return explicitWorkspace; + return findMonorepoRoot(process.cwd()); +} + +function toClaudeMcpConfigEntry( + server: McpServerDescriptor, + allowedWorkspaceDirs: string, +): Record | null { + if (server.transport === 'streamableHttp') { + if (!server.url?.trim()) return null; + const entry: Record = { type: 'http', url: server.url }; + if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers; + return entry; + } + if (!server.command?.trim()) return null; + const entry: Record = { command: server.command, args: server.args ?? [] }; + const env = + server.source === 'cat-cafe' + ? { ...(server.env ?? {}), ALLOWED_WORKSPACE_DIRS: allowedWorkspaceDirs } + : server.env; + if (env && Object.keys(env).length > 0) entry.env = env; + if (server.workingDir?.trim()) entry.cwd = server.workingDir; + return entry; +} + +async function buildClaudeInvocationMcpConfig( + catId: CatId, + workingDirectory?: string, +): Promise<{ mcpServers: Record } | null> { + const start = workingDirectory ?? process.cwd(); + const { projectRoot } = resolveStartupCliConfigContext(start, process.env); + const capabilities = await readCapabilitiesConfig(projectRoot); + if (!capabilities) return null; + + const servers = resolveServersForCat(capabilities, catId as string); + if (servers.length === 0) return null; + + await resolveMachineSpecificServers({ anthropic: servers }, { projectRoot, env: process.env }); + + const managedServers = new Map( + buildCatCafeSplitMcpDescriptors(resolveBinaryRoot()).map((server) => [server.name, server] as const), + ); + const allowedWorkspaceDirs = resolveClaudeWorkspaceRoot(workingDirectory); + const mcpServers: Record = {}; + + for (const server of servers) { + if (!server.enabled) continue; + if (server.name === 'cat-cafe') continue; + + if (server.source === 'cat-cafe' || MANAGED_CAT_CAFE_SERVER_IDS.has(server.name)) { + const canonical = managedServers.get(server.name); + if (!canonical) continue; + const entry = toClaudeMcpConfigEntry(canonical, allowedWorkspaceDirs); + if (entry) mcpServers[server.name] = entry; + continue; + } + + if (CLAUDE_RUNTIME_SKIPPED_EXTERNAL_IDS.has(server.name)) { + log.info({ catId, serverName: server.name }, 'Skipping deprecated external MCP server for Claude runtime'); + continue; + } + + const status = await resolveRequiredMcpStatus(server.name, { capabilities, env: process.env, projectRoot }); + if (status.status !== 'ready') { + log.info( + { catId, serverName: server.name, status: status.status, reason: status.reason }, + 'Skipping unresolved external MCP server for Claude runtime', + ); + continue; + } + + const entry = toClaudeMcpConfigEntry(server, allowedWorkspaceDirs); + if (entry) mcpServers[server.name] = entry; + } + + return Object.keys(mcpServers).length > 0 ? { mcpServers } : null; +} + /** * Build env overrides for spawning the `claude` CLI. * @@ -338,6 +458,10 @@ export class ClaudeAgentService implements AgentService { // buildClaudeEnvOverrides() and --model must be omitted so the CLI honours it. // Empty model (OAuth without explicit model) → let CLI use its default. const modelArgs = !useEnvModelOverride && effectiveModel ? ['--model', effectiveModel] : []; + const invocationMcpConfig = options?.callbackEnv + ? await buildClaudeInvocationMcpConfig(this.catId, options?.workingDirectory) + : null; + let invocationMcpConfigPath: string | undefined; const args: string[] = [ '-p', @@ -367,32 +491,42 @@ export class ClaudeAgentService implements AgentService { // Add MCP server config when callback env is present // On Windows, Claude CLI treats inline JSON as a file path — write to temp file instead. - // The file is cached per-instance so concurrent invocations share one file (no temp spam). - if (options?.callbackEnv && this.mcpServerPath) { - if (IS_WINDOWS) { - if (!this.mcpConfigFilePath || !existsSync(this.mcpConfigFilePath)) { - const dir = mkdtempSync(join(tmpdir(), 'cat-cafe-mcp-')); - this.mcpConfigFilePath = join(dir, 'mcp-config.json'); - writeFileSync( - this.mcpConfigFilePath, + // The legacy fallback file is cached per-instance so concurrent invocations share one file (no temp spam). + if (options?.callbackEnv && (invocationMcpConfig || this.mcpServerPath)) { + if (invocationMcpConfig) { + if (IS_WINDOWS) { + invocationMcpConfigPath = writeClaudeMcpConfigToTempFile(invocationMcpConfig); + args.push('--mcp-config', invocationMcpConfigPath); + } else { + args.push('--mcp-config', JSON.stringify(invocationMcpConfig)); + } + args.push('--strict-mcp-config'); + } else { + if (IS_WINDOWS) { + if (!this.mcpConfigFilePath || !existsSync(this.mcpConfigFilePath)) { + const dir = mkdtempSync(join(tmpdir(), 'cat-cafe-mcp-')); + this.mcpConfigFilePath = join(dir, 'mcp-config.json'); + writeFileSync( + this.mcpConfigFilePath, + JSON.stringify({ + mcpServers: { + 'cat-cafe': { command: 'node', args: [this.mcpServerPath] }, + }, + }), + 'utf-8', + ); + } + args.push('--mcp-config', this.mcpConfigFilePath); + } else { + args.push( + '--mcp-config', JSON.stringify({ mcpServers: { 'cat-cafe': { command: 'node', args: [this.mcpServerPath] }, }, }), - 'utf-8', ); } - args.push('--mcp-config', this.mcpConfigFilePath); - } else { - args.push( - '--mcp-config', - JSON.stringify({ - mcpServers: { - 'cat-cafe': { command: 'node', args: [this.mcpServerPath] }, - }, - }), - ); } } @@ -811,6 +945,7 @@ export class ClaudeAgentService implements AgentService { } finally { removeL0TempDir(l0Path); removeAppendPromptTempDir(appendPromptPath); + removeClaudeMcpConfigTempDir(invocationMcpConfigPath); } } } diff --git a/packages/api/test/claude-agent-service.test.js b/packages/api/test/claude-agent-service.test.js index 97aa32db90..872484d2b7 100644 --- a/packages/api/test/claude-agent-service.test.js +++ b/packages/api/test/claude-agent-service.test.js @@ -73,6 +73,16 @@ function createMockSpawnFn(proc) { return mock.fn(() => proc); } +async function waitForSpawn(spawnFn) { + const deadline = Date.now() + 5_000; + while (spawnFn.mock.calls.length === 0) { + if (Date.now() > deadline) { + throw new Error('spawn was not called before timeout'); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + function emitProcessExit(proc, code, signal = null) { process.nextTick(() => { proc._emitter.emit('exit', code, signal); @@ -556,6 +566,7 @@ test('preserves inherited Anthropic credentials when no profile mode override is }, }), ); + await waitForSpawn(spawnFn); emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); await promise; @@ -591,6 +602,7 @@ test('F062: subscription profile clears inherited ANTHROPIC env vars', async () }, }), ); + await waitForSpawn(spawnFn); emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); await promise; @@ -628,6 +640,7 @@ test('F062: api_key profile injects ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL', a }, }), ); + await waitForSpawn(spawnFn); emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); await promise; @@ -1065,6 +1078,7 @@ test('falls back to default MCP path when CAT_CAFE_MCP_SERVER_PATH is empty', as }, }), ); + await waitForSpawn(spawnFn); emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); await promise; @@ -1084,6 +1098,93 @@ test('falls back to default MCP path when CAT_CAFE_MCP_SERVER_PATH is empty', as } }); +test('builds a strict filtered MCP config from capabilities when callbackEnv is present', async () => { + const root = mkdtempSync(join(tmpdir(), 'cat-cafe-mcp-cap-')); + const apiCwd = join(root, 'packages', 'api'); + const catCafeDir = join(root, '.cat-cafe'); + mkdirSync(apiCwd, { recursive: true }); + mkdirSync(catCafeDir, { recursive: true }); + // pnpm-workspace.yaml at root so findMonorepoRoot(apiCwd) resolves to root, + // which is where capabilities.json lives under .cat-cafe/. + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n', 'utf8'); + + // Capabilities with one managed cat-cafe server and one deprecated-external server + const capabilities = { + version: 1, + capabilities: [ + { + id: 'cat-cafe-collab', + type: 'mcp', + enabled: true, + source: 'cat-cafe', + mcpServer: { command: 'node', args: ['placeholder.js'] }, + }, + { + id: 'probe-off', + type: 'mcp', + enabled: true, + source: 'external', + mcpServer: { command: 'node', args: ['probe.js'] }, + }, + ], + }; + writeFileSync(join(catCafeDir, 'capabilities.json'), JSON.stringify(capabilities), 'utf8'); + + const previousCwd = process.cwd(); + const previousRuntimeRoot = process.env.CAT_CAFE_RUNTIME_ROOT; + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + + try { + process.chdir(apiCwd); + // Point resolveBinaryRoot() to the temp monorepo root so canonical paths resolve correctly + process.env.CAT_CAFE_RUNTIME_ROOT = root; + + const service = createClaudeAgentService({ catId: 'opus-47', model: 'claude-test-model', spawnFn }); + const promise = collect( + service.invoke('hello', { + callbackEnv: { + CAT_CAFE_API_URL: 'http://localhost:3004', + CAT_CAFE_INVOCATION_ID: 'inv-cap', + CAT_CAFE_CALLBACK_TOKEN: 'token-cap', + }, + }), + ); + await waitForSpawn(spawnFn); + emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); + await promise; + + const args = spawnFn.mock.calls[0].arguments[1]; + + // strict mode must be engaged when capabilities resolves servers + assert.ok(args.includes('--strict-mcp-config'), '--strict-mcp-config must be present'); + + // --mcp-config must carry inline JSON (non-Windows path) + const mcpConfigIdx = args.indexOf('--mcp-config'); + assert.ok(mcpConfigIdx >= 0, '--mcp-config must be present'); + const config = JSON.parse(args[mcpConfigIdx + 1]); + + // managed cat-cafe server must appear with canonical dist path + assert.ok(config.mcpServers['cat-cafe-collab'], 'cat-cafe-collab must be included'); + assert.equal(config.mcpServers['cat-cafe-collab'].command, 'node'); + assert.ok( + config.mcpServers['cat-cafe-collab'].args[0].includes(join('packages', 'mcp-server', 'dist', 'collab.js')), + 'cat-cafe-collab args must use canonical collab.js path', + ); + + // deprecated external server must be skipped + assert.ok(!config.mcpServers['probe-off'], 'probe-off must be excluded (deprecated external)'); + } finally { + process.chdir(previousCwd); + if (previousRuntimeRoot === undefined) { + delete process.env.CAT_CAFE_RUNTIME_ROOT; + } else { + process.env.CAT_CAFE_RUNTIME_ROOT = previousRuntimeRoot; + } + rmSync(root, { recursive: true, force: true }); + } +}); + test('F8: result/success extracts usage into done metadata', async () => { const proc = createMockProcess(); const spawnFn = createMockSpawnFn(proc); @@ -1384,6 +1485,7 @@ test('third-party model (glm-5): omits --model flag and injects ANTHROPIC_MODEL }, }), ); + await waitForSpawn(spawnFn); emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); await promise; @@ -1413,6 +1515,7 @@ test('native Anthropic model (claude-sonnet-4-6): keeps --model flag, no ANTHROP }, }), ); + await waitForSpawn(spawnFn); emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); await promise; diff --git a/packages/api/test/mcp-config-adapters.test.js b/packages/api/test/mcp-config-adapters.test.js index f65b967c53..b071adfb60 100644 --- a/packages/api/test/mcp-config-adapters.test.js +++ b/packages/api/test/mcp-config-adapters.test.js @@ -1,6 +1,7 @@ // @ts-check import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -19,6 +20,7 @@ import { writeGeminiMcpConfig, writeKimiMcpConfig, } from '../dist/config/capabilities/mcp-config-adapters.js'; +import { findMonorepoRoot } from '../dist/utils/monorepo-root.js'; /** @param {string} prefix */ async function makeTmpDir(prefix) { @@ -396,6 +398,42 @@ describe('writeClaudeMcpConfig', () => { assert.ok(raw.includes('mcpServers')); }); + it('resolveWorkspaceRoot falls back to monorepo root when no workspace env is set', async () => { + // Regression guard for mcp-config-adapters change: + // `return process.cwd()` → `return findMonorepoRoot(process.cwd())` + // When cwd is a subdirectory within a monorepo (pnpm-workspace.yaml at root), + // ALLOWED_WORKSPACE_DIRS must resolve to the monorepo root, not the subdirectory. + const root = mkdtempSync(join(tmpdir(), 'mcp-monorepo-root-')); + const subdir = join(root, 'packages', 'api'); + mkdirSync(subdir, { recursive: true }); + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n', 'utf8'); + + const previousCwd = process.cwd(); + const originalAwd = process.env.ALLOWED_WORKSPACE_DIRS; + const originalWs = process.env.CAT_CAFE_WORKSPACE_ROOT; + const file = join(subdir, '.mcp.json'); + try { + process.chdir(subdir); + delete process.env.ALLOWED_WORKSPACE_DIRS; + delete process.env.CAT_CAFE_WORKSPACE_ROOT; + + await writeClaudeMcpConfig(file, [ + { name: 'cat-cafe-collab', command: 'node', args: ['collab.js'], enabled: true, source: 'cat-cafe' }, + ]); + + const data = JSON.parse(await readFile(file, 'utf-8')); + const awd = data.mcpServers['cat-cafe-collab']?.env?.ALLOWED_WORKSPACE_DIRS; + assert.equal(awd, realpathSync(root), `ALLOWED_WORKSPACE_DIRS must be monorepo root (${realpathSync(root)}), got ${awd}`); + } finally { + process.chdir(previousCwd); + if (originalAwd === undefined) delete process.env.ALLOWED_WORKSPACE_DIRS; + else process.env.ALLOWED_WORKSPACE_DIRS = originalAwd; + if (originalWs === undefined) delete process.env.CAT_CAFE_WORKSPACE_ROOT; + else process.env.CAT_CAFE_WORKSPACE_ROOT = originalWs; + rmSync(root, { recursive: true, force: true }); + } + }); + // F213 Phase B: L5 cleanup applied to Claude writer (.mcp.json). // Same semantics as Codex Phase A: echoLegacyShim removed, fork-like / // third-party preserved, no-op when no legacy. @@ -915,7 +953,7 @@ describe('writeAntigravityMcpConfig', () => { assert.deepEqual(raw.mcpServers['cat-cafe'].env, { CAT_CAFE_API_URL: expectedAntigravityApiUrl(), CAT_CAFE_READONLY: 'true', - ALLOWED_WORKSPACE_DIRS: process.cwd(), + ALLOWED_WORKSPACE_DIRS: findMonorepoRoot(process.cwd()), }); } finally { if (originalAwd === undefined) delete process.env.ALLOWED_WORKSPACE_DIRS; @@ -988,7 +1026,7 @@ describe('writeAntigravityMcpConfig', () => { assert.deepEqual(legacy.env, { CAT_CAFE_API_URL: expectedAntigravityApiUrl(), CAT_CAFE_READONLY: 'true', - ALLOWED_WORKSPACE_DIRS: process.cwd(), + ALLOWED_WORKSPACE_DIRS: findMonorepoRoot(process.cwd()), }); } finally { if (originalAwd === undefined) delete process.env.ALLOWED_WORKSPACE_DIRS; @@ -1030,7 +1068,7 @@ describe('writeAntigravityMcpConfig', () => { assert.deepEqual(raw.mcpServers['cat-cafe'].env, { CAT_CAFE_API_URL: expectedAntigravityApiUrl(), CAT_CAFE_READONLY: 'true', - ALLOWED_WORKSPACE_DIRS: process.cwd(), + ALLOWED_WORKSPACE_DIRS: findMonorepoRoot(process.cwd()), EXTRA_FLAG: 'keep-me', }); } finally {