From c8ecb5b9cffbeb913db65bb055529721e73dffb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E9=A3=9E=E7=9A=84=E7=BE=8A?= <774070635@qq.com> Date: Tue, 15 Sep 2026 18:21:01 -0700 Subject: [PATCH 1/4] feat(fx): add fx agent integration with Herdr status and Grok peer sync - host: catalog/selection/prompts/contracts entry, Herdr-socket status (socket unlink, owner fail-closed, HERDR_PANE_ID scoped to fx, OSC fx gate), MCP/skills facts, lifecycle install/update hosts, resume unsupported, evidence matrix 27->28 - grok: peer-sync fx target ~/.fx/grok-auth.json (OIDC-only fail-closed, 0600 atomic), availability probe, UI labels, 4-locale keys, api_key gating + OIDC guidance error mapping - tests: cross-tool sync, availability, rpc handlers, format-error, herdr listener/socket, panel env --- .pier/dir-density.json | 2 +- ...-13-agent-status-adapter-contract-audit.md | 1 + .../plugin-api/src/peer-sync/availability.ts | 15 ++ packages/plugin-api/src/peer-sync/main.ts | 1 + .../src/peer-sync/notify-failures.ts | 1 + packages/plugin-api/src/peer-sync/shared.ts | 18 +- packages/plugin-grok/plugin.json | 12 ++ .../plugin-grok/src/main/cross-tool-sync.ts | 43 +++- .../src/renderer/accounts-settings-page.tsx | 1 + .../src/renderer/format-account-error.ts | 17 +- .../src/renderer/switch-confirm-dialog.tsx | 2 + packages/plugin-grok/src/shared/accounts.ts | 23 +-- src/main/ipc/foreground-activity.ts | 19 ++ .../foreground-activity/fx-herdr-socket.ts | 116 +++++++++++ src/main/ipc/terminal/create-launch.ts | 7 +- .../adapter-facts-extended.ts | 8 + .../agent-mcp-catalog/parse-server-decls.ts | 2 + .../integrations/evidence/matrix-rows-b-2.ts | 30 +++ .../integrations/evidence/matrix-types.ts | 2 +- src/main/services/agents/integrations/fx.ts | 53 +++++ .../services/agents/integrations/registry.ts | 2 + .../services/agents/lifecycle/latest-hosts.ts | 1 + .../agents/lifecycle/official-script.ts | 4 + .../services/agents/lifecycle/specs/tier-a.ts | 19 ++ src/main/services/agents/resume-adapters.ts | 1 + .../foreground-activity/fx-herdr-listener.ts | 195 ++++++++++++++++++ .../project-skills/adapter-facts-extended.ts | 33 +++ .../services/terminal-launch-wrap/apply.ts | 3 +- .../components/agent-icons/favicons/fx.png | Bin 0 -> 3336 bytes src/shared/agent-catalog.ts | 11 + src/shared/agent-selection.ts | 1 + src/shared/agent-surfaces/initial-prompts.ts | 5 + src/shared/contracts/agent.ts | 3 +- src/shared/contracts/agent/fx-herdr.ts | 160 ++++++++++++++ src/shared/project-skills-pier-channels.ts | 4 +- src/shared/skill-invoke.ts | 7 +- .../agent-status-trace-e2e.test.ts | 4 +- .../status-traces/fx-herdr-trace.ts | 113 ++++++++++ .../agent-integrations/status-traces/index.ts | 2 + .../agent-hook-runtime-semantics.test.ts | 2 +- .../main/agents/agent-mcp-adapters.test.ts | 1 + .../agents/agent-mcp-catalog-service.test.ts | 1 + .../grok/plugin-cross-tool-sync.test.ts | 41 ++++ .../agents/grok/plugin-rpc-handlers.test.ts | 1 + .../agents/peer-sync-availability.test.ts | 38 ++++ .../foreground-activity-fx-herdr.test.ts | 195 ++++++++++++++++++ .../unit/main/project-skills/adapters.test.ts | 9 +- .../grok-format-account-error.test.ts | 14 ++ tests/unit/shared/agent/fx-herdr.test.ts | 166 +++++++++++++++ tests/unit/terminal/panel-status-env.test.ts | 19 +- 50 files changed, 1397 insertions(+), 31 deletions(-) create mode 100644 src/main/ipc/foreground-activity/fx-herdr-socket.ts create mode 100644 src/main/services/agents/integrations/fx.ts create mode 100644 src/main/services/foreground-activity/fx-herdr-listener.ts create mode 100644 src/plugins/api/components/agent-icons/favicons/fx.png create mode 100644 src/shared/contracts/agent/fx-herdr.ts create mode 100644 tests/unit/agent-integrations/status-traces/fx-herdr-trace.ts create mode 100644 tests/unit/main/panel/foreground-activity-fx-herdr.test.ts create mode 100644 tests/unit/shared/agent/fx-herdr.test.ts diff --git a/.pier/dir-density.json b/.pier/dir-density.json index 71331de59..f011a37df 100644 --- a/.pier/dir-density.json +++ b/.pier/dir-density.json @@ -39,7 +39,7 @@ "reason": "debt: more surface vs chrome split beyond hooks/; composer paste edit bridge/dialog/actions pending structured-composer/; core comments status item" }, "src/main/services/agents/integrations": { - "max": 43, + "max": 44, "reason": "debt: provider files still flat; interactive-tool lifecycle helpers pending tools/ folder; evidence/transcript/hooks extracted" }, "src/plugins/builtin/files/renderer/tree": { diff --git a/docs/superpowers/specs/2026-07-13-agent-status-adapter-contract-audit.md b/docs/superpowers/specs/2026-07-13-agent-status-adapter-contract-audit.md index 8ec8115d5..8e5a47133 100644 --- a/docs/superpowers/specs/2026-07-13-agent-status-adapter-contract-audit.md +++ b/docs/superpowers/specs/2026-07-13-agent-status-adapter-contract-audit.md @@ -135,6 +135,7 @@ Grok:`turn_completed` cancelled/end_turn。 | openclaude | Claude 兼容嵌套 JSON hook | `full` | `SessionStart→SessionStart`;`UserPromptSubmit→PromptSubmit`;`PreToolUse→ToolStart/InteractionRequested`;`PostToolUse→ToolComplete/InteractionResolved`;`PostToolUseFailure→ToolComplete/InteractionResolved`;`PreCompact/PostCompact→processing`;`Stop→Stop`;`StopFailure→error`;`SubagentStart/SubagentStop/SessionEnd→同名` | `advisory` | 无 | | pi | JavaScript 扩展 | `coarse` | `session_start→SessionStart`;`before_agent_start→PromptSubmit`;`tool_execution_start→ToolStart`;`tool_execution_end→ToolComplete`;`ui_prompt_start→InteractionRequested`;`ui_prompt_end→InteractionResolved`;`agent_settled→Stop`;`session_shutdown→SessionEnd` | `authoritative` | 无 | | qodercli | Claude 兼容嵌套 JSON hook | `full` | `SessionStart→SessionStart`;`UserPromptSubmit→PromptSubmit`;`PreToolUse→ToolStart`;`PostToolUse/PostToolUseFailure→ToolComplete`;`Elicitation→InteractionRequested`;`ElicitationResult→InteractionResolved`;`PreCompact/PostCompact→processing`;`Stop→Stop`;`StopFailure→error`;`SubagentStart/SubagentStop/SessionEnd→同名` | `advisory` | 无 | +| fx | Herdr socket(宿主冒充 multiplexor;无配置文件) | `coarse` | `herdr.working→processing`;`herdr.idle→ActivityIdle`;`herdr.blocked.permission→processing`;`herdr.blocked.question→processing`;`herdr.blocked.recovery→processing` | `none` | 无 | | qwen-code | Qwen Code hook | `full` | `SessionStart/SessionEnd→同名`;`UserPromptSubmit→PromptSubmit`;`Stop→Stop`;`StopFailure→error`;`PreToolUse→ToolStart`;`PostToolUse/PostToolUseFailure→ToolComplete`;`PreCompact/PostCompact→processing`;`SubagentStart/SubagentStop→同名` | `advisory` | 无 | ### 仅启动识别 diff --git a/packages/plugin-api/src/peer-sync/availability.ts b/packages/plugin-api/src/peer-sync/availability.ts index 5005f1cc3..87debe300 100644 --- a/packages/plugin-api/src/peer-sync/availability.ts +++ b/packages/plugin-api/src/peer-sync/availability.ts @@ -82,6 +82,20 @@ function ompDbPath(opts: PeerAvailabilityOptions): string { return join(resolveHomeDir(opts), ".omp", "agent", "agent.db"); } +/** + * Sync-ready probe for fx. + * + * Evidence: `fx` on PATH or `~/.fx` exists. Matches the auth.json location + * written by fx peer sync. fx reads `~/.fx/grok-auth.json` when its provider + * is `grok` (vercel-labs/fx `src/core/auth/grok_session.zig`, + * `src/core/shared/profile_paths.zig`). + */ +export function isFxSyncReady(opts: PeerAvailabilityOptions = {}): boolean { + const home = resolveHomeDir(opts); + return ( + existsSync(join(home, ".fx")) || commandExistsOnPath("fx", opts.pathEnv) + ); +} /** * Sync-ready probe for OpenCode. * @@ -209,5 +223,6 @@ export function detectPeerAvailability( pi, piOauthCapable: pi ? isPiOauthCapable(opts) : false, omp: isOmpSyncReady(opts), + fx: isFxSyncReady(opts), }; } diff --git a/packages/plugin-api/src/peer-sync/main.ts b/packages/plugin-api/src/peer-sync/main.ts index 04da1c67d..ad399d40c 100644 --- a/packages/plugin-api/src/peer-sync/main.ts +++ b/packages/plugin-api/src/peer-sync/main.ts @@ -6,6 +6,7 @@ export { ALL_PEER_SYNC_TARGETS, detectPeerAvailability, + isFxSyncReady, isOmpSyncReady, isOpencodeSyncReady, isPeerTargetAvailable, diff --git a/packages/plugin-api/src/peer-sync/notify-failures.ts b/packages/plugin-api/src/peer-sync/notify-failures.ts index b6d3f3de9..a0f2f935b 100644 --- a/packages/plugin-api/src/peer-sync/notify-failures.ts +++ b/packages/plugin-api/src/peer-sync/notify-failures.ts @@ -25,6 +25,7 @@ function isPeerSyncResults( } const TARGET_FALLBACK_LABELS: Record = { + fx: "fx", omp: "OMP", opencode: "OpenCode", pi: "Pi", diff --git a/packages/plugin-api/src/peer-sync/shared.ts b/packages/plugin-api/src/peer-sync/shared.ts index f5d65cf52..a67ea1295 100644 --- a/packages/plugin-api/src/peer-sync/shared.ts +++ b/packages/plugin-api/src/peer-sync/shared.ts @@ -1,11 +1,18 @@ /** * Shared peer-tool helpers for official account plugins (Codex, Grok, …). * Renderer-safe: no Node builtins. Detection probes live in `./main`. + * + * fx is a peer sync target for Grok subscription credentials: fx reads the + * same `~/.fx/grok-auth.json` session file when its provider is `grok` + * (vercel-labs/fx `src/core/auth/grok_session.zig`, `src/core/shared/ + * profile_paths.zig`: `grok_auth_file_name = "grok-auth.json"`). */ -export type PeerSyncTarget = "opencode" | "pi" | "omp"; +export type PeerSyncTarget = "opencode" | "pi" | "omp" | "fx"; export interface PeerAvailability { + /** Grok-subscription peer; absent in older snapshots — treat as unavailable. */ + fx?: boolean | undefined; omp: boolean; opencode: boolean; /** @@ -26,6 +33,7 @@ const PEER_TARGETS = [ "opencode", "pi", "omp", + "fx", ] as const satisfies readonly PeerSyncTarget[]; export const ALL_PEER_SYNC_TARGETS: readonly PeerSyncTarget[] = PEER_TARGETS; @@ -34,18 +42,22 @@ export function isPeerTargetAvailable( target: PeerSyncTarget, availability: PeerAvailability ): boolean { - return availability[target]; + return availability[target] ?? false; } /** * Adjust install readiness for account credential kind. * OIDC / subscription login needs pi xAI OAuth; API keys only need install. + * fx has no xAI API-key path (OIDC session only), so API-key sync hides fx. */ export function effectivePeerAvailabilityForKind( accountKind: "api_key" | "oidc", availability: PeerAvailability ): PeerAvailability { - if (accountKind === "api_key" || availability.piOauthCapable) { + if (accountKind === "api_key") { + return { ...availability, fx: false }; + } + if (availability.piOauthCapable) { return availability; } return { diff --git a/packages/plugin-grok/plugin.json b/packages/plugin-grok/plugin.json index 0d1028e4a..fc7b2d870 100644 --- a/packages/plugin-grok/plugin.json +++ b/packages/plugin-grok/plugin.json @@ -115,6 +115,8 @@ "pier.grok.accounts.settings.syncPeersFailedOmp": "Couldn't sync credentials to OMP. Make sure OMP is installed and has been opened at least once on this device.", "pier.grok.accounts.settings.syncPeersFailedOpencode": "Couldn't sync credentials to OpenCode. Make sure OpenCode is installed on this device.", "pier.grok.accounts.settings.syncPeersFailedPi": "Couldn't sync credentials to Pi. Make sure Pi is installed on this device (0.80.8+ for login accounts).", + "pier.grok.accounts.settings.syncPeersFailedFx": "Couldn't sync credentials to fx. Make sure fx is installed on this device.", + "pier.grok.accounts.settings.syncPeersFailedFxApiKey": "fx can only receive login (OIDC) accounts. Switch to a login account to sync to fx.", "pier.grok.accounts.settings.syncPeersPiOauthHint": "Pi is installed but needs version 0.80.8 or later to receive login accounts. API-key accounts can still sync to older Pi.", "pier.grok.accounts.settings.syncPeersFailedNoActive": "Select a managed Grok account first, then try syncing again.", "pier.grok.accounts.settings.syncPeersFailedNoTargets": "Select at least one tool to sync.", @@ -122,6 +124,7 @@ "pier.grok.switch.syncTarget.opencode": "OpenCode", "pier.grok.switch.syncTarget.pi": "Pi", "pier.grok.switch.syncTarget.omp": "OMP", + "pier.grok.switch.syncTarget.fx": "fx", "pier.grok.accounts.settings.addDialogTabAccount": "Account login", "pier.grok.accounts.settings.addDialogTabApiKey": "API key", "pier.grok.accounts.settings.addDialogAccountDescription": "Sign in with a device code from the Grok CLI. The verification link and code appear here; the account is added automatically after authorization.", @@ -238,6 +241,8 @@ "pier.grok.accounts.settings.syncPeersFailedOmp": "无法同步凭证到 OMP。请确认本机已安装 OMP,并至少打开过一次。", "pier.grok.accounts.settings.syncPeersFailedOpencode": "无法同步凭证到 OpenCode。请确认本机已安装 OpenCode。", "pier.grok.accounts.settings.syncPeersFailedPi": "无法同步凭证到 Pi。请确认本机已安装 Pi(登录类账号需 0.80.8+)。", + "pier.grok.accounts.settings.syncPeersFailedFx": "无法同步凭证到 fx。请确认本机已安装 fx。", + "pier.grok.accounts.settings.syncPeersFailedFxApiKey": "fx 只能接收登录类(OIDC)账号。请切换到登录类账号后再同步到 fx。", "pier.grok.accounts.settings.syncPeersPiOauthHint": "已检测到 Pi,但登录类账号需要 0.80.8 或更高版本才能同步。API key 账号仍可同步到旧版 Pi。", "pier.grok.accounts.settings.syncPeersFailedNoActive": "请先选择一个受管 Grok 账号,再尝试同步。", "pier.grok.accounts.settings.syncPeersFailedNoTargets": "请至少选择一个要同步的工具。", @@ -245,6 +250,7 @@ "pier.grok.switch.syncTarget.opencode": "OpenCode", "pier.grok.switch.syncTarget.pi": "Pi", "pier.grok.switch.syncTarget.omp": "OMP", + "pier.grok.switch.syncTarget.fx": "fx", "pier.grok.accounts.settings.addDialogTabAccount": "账号登录", "pier.grok.accounts.settings.addDialogTabApiKey": "API key", "pier.grok.accounts.settings.addDialogAccountDescription": "使用 Grok CLI 的设备码登录。验证链接与设备码会显示在这里;授权完成后,账号会自动添加。", @@ -361,6 +367,8 @@ "pier.grok.accounts.settings.syncPeersFailedOmp": "OMP へ認証情報を同期できませんでした。OMP がインストールされ、このデバイスで一度は開かれていることを確認してください。", "pier.grok.accounts.settings.syncPeersFailedOpencode": "OpenCode へ認証情報を同期できませんでした。このデバイスに OpenCode がインストールされていることを確認してください。", "pier.grok.accounts.settings.syncPeersFailedPi": "Pi へ認証情報を同期できませんでした。このデバイスに Pi がインストールされていることを確認してください(ログインアカウントは 0.80.8 以上)。", + "pier.grok.accounts.settings.syncPeersFailedFx": "fxへ認証情報を同期できませんでした。このデバイスにfxがインストールされていることを確認してください。", + "pier.grok.accounts.settings.syncPeersFailedFxApiKey": "fxはログイン(OIDC)アカウントのみ受け付けます。ログインアカウントに切り替えてからfxに同期してください。", "pier.grok.accounts.settings.syncPeersPiOauthHint": "Pi はインストールされていますが、ログインアカウントを受け取るには 0.80.8 以降が必要です。API キーアカウントは古い Pi にも同期できます。", "pier.grok.accounts.settings.syncPeersFailedNoActive": "先に管理中の Grok アカウントを選んでから、もう一度同期してください。", "pier.grok.accounts.settings.syncPeersFailedNoTargets": "同期するツールを 1 つ以上選んでください。", @@ -368,6 +376,7 @@ "pier.grok.switch.syncTarget.opencode": "OpenCode", "pier.grok.switch.syncTarget.pi": "Pi", "pier.grok.switch.syncTarget.omp": "OMP", + "pier.grok.switch.syncTarget.fx": "fx", "pier.grok.accounts.settings.addDialogTabAccount": "アカウントでログイン", "pier.grok.accounts.settings.addDialogTabApiKey": "API キー", "pier.grok.accounts.settings.addDialogAccountDescription": "Grok CLI のデバイスコードでログインします。確認リンクとコードがここに表示され、認可が終わるとアカウントが自動で追加されます。", @@ -484,6 +493,8 @@ "pier.grok.accounts.settings.syncPeersFailedOmp": "OMP로 자격 증명을 동기화하지 못했습니다. OMP가 설치되어 있고 이 기기에서 한 번 이상 열렸는지 확인하세요.", "pier.grok.accounts.settings.syncPeersFailedOpencode": "OpenCode로 자격 증명을 동기화하지 못했습니다. 이 기기에 OpenCode가 설치되어 있는지 확인하세요.", "pier.grok.accounts.settings.syncPeersFailedPi": "Pi로 자격 증명을 동기화하지 못했습니다. 이 기기에 Pi가 설치되어 있는지 확인하세요(로그인 계정은 0.80.8 이상).", + "pier.grok.accounts.settings.syncPeersFailedFx": "fx로 자격 증명을 동기화하지 못했습니다. 이 기기에 fx가 설치되어 있는지 확인하세요.", + "pier.grok.accounts.settings.syncPeersFailedFxApiKey": "fx는 로그인(OIDC) 계정만 받을 수 있습니다. 로그인 계정으로 전환한 뒤 fx에 동기화하세요.", "pier.grok.accounts.settings.syncPeersPiOauthHint": "Pi는 설치되어 있지만 로그인 계정을 받으려면 0.80.8 이상이 필요합니다. API 키 계정은 이전 Pi에도 동기화할 수 있습니다.", "pier.grok.accounts.settings.syncPeersFailedNoActive": "먼저 관리 중인 Grok 계정을 선택한 다음 다시 동기화하세요.", "pier.grok.accounts.settings.syncPeersFailedNoTargets": "동기화할 도구를 하나 이상 선택하세요.", @@ -491,6 +502,7 @@ "pier.grok.switch.syncTarget.opencode": "OpenCode", "pier.grok.switch.syncTarget.pi": "Pi", "pier.grok.switch.syncTarget.omp": "OMP", + "pier.grok.switch.syncTarget.fx": "fx", "pier.grok.accounts.settings.addDialogTabAccount": "계정 로그인", "pier.grok.accounts.settings.addDialogTabApiKey": "API 키", "pier.grok.accounts.settings.addDialogAccountDescription": "Grok CLI의 디바이스 코드로 로그인합니다. 확인 링크와 코드가 여기에 나타나며, 승인이 끝나면 계정이 자동으로 추가됩니다.", diff --git a/packages/plugin-grok/src/main/cross-tool-sync.ts b/packages/plugin-grok/src/main/cross-tool-sync.ts index ad20b0a37..8d748bca9 100644 --- a/packages/plugin-grok/src/main/cross-tool-sync.ts +++ b/packages/plugin-grok/src/main/cross-tool-sync.ts @@ -17,12 +17,15 @@ import type { CrossToolSyncTarget } from "../shared/accounts.ts"; * - pi `~/.pi/agent/auth.json`: provider key `xai` * - oauth (pi ≥ 0.80.8): `{ type: "oauth", access, refresh, expires, accountId? }` * same xAI client_id as Grok CLI (`b1a00492-073a-47ea-816f-4c329264a828`) - * - api: `{ type: "api_key", key }` (not OpenCode's `"api"`) * - omp `~/.omp/agent/agent.db` `auth_credentials`: * - provider `xai-oauth`, credential_type `oauth`, * identity_key `account:`, * data `{ access, refresh, expires }` * - api_key rows use provider `xai`, credential_type `api_key`, data `{ key }` + * - fx `~/.fx/grok-auth.json`: `{ version: 1, access_token, refresh_token, + * expires_at_ms, account_id }` (vercel-labs/fx `src/core/auth/ + * grok_session.zig` + `src/core/shared/profile_paths.zig`). + * fx has no xAI API-key path: OIDC only; api_key sync to fx must fail closed. */ export type GrokSyncCredential = @@ -76,6 +79,41 @@ function ompDbPath(opts: CrossToolSyncOptions): string { return join(home, ".omp", "agent", "agent.db"); } +function fxGrokAuthPath(opts: CrossToolSyncOptions): string { + const home = opts.homeDir ?? defaultHomeDir(); + return join(home, ".fx", "grok-auth.json"); +} + +/** fx Grok session file: `{ version: 1, access_token, refresh_token, expires_at_ms, account_id }`. */ +function fxGrokSessionEntry( + credential: GrokSyncCredential +): Record { + if (credential.kind !== "oauth") { + // fx has no xAI API-key path: fail closed, never write a fake session. + throw new Error("fx has no xAI API-key path; sync an OIDC account instead"); + } + return { + version: 1, + access_token: credential.accessToken, + refresh_token: credential.refreshToken, + expires_at_ms: credential.expiresAtMs, + account_id: credential.accountId, + }; +} + +async function syncFx( + credential: GrokSyncCredential, + opts: CrossToolSyncOptions +): Promise { + const path = fxGrokAuthPath(opts); + await mkdir(dirname(path), { recursive: true }); + await writeFileAtomic( + path, + `${JSON.stringify(fxGrokSessionEntry(credential), null, 2)}\n`, + { mode: 0o600 } + ); +} + async function readJsonObject(path: string): Promise> { if (!existsSync(path)) return {}; try { @@ -325,6 +363,9 @@ export async function syncCrossToolCredentials( case "omp": await syncOmp(credential, opts); break; + case "fx": + await syncFx(credential, opts); + break; default: break; } diff --git a/packages/plugin-grok/src/renderer/accounts-settings-page.tsx b/packages/plugin-grok/src/renderer/accounts-settings-page.tsx index 0c8be38f8..ed65f91b5 100644 --- a/packages/plugin-grok/src/renderer/accounts-settings-page.tsx +++ b/packages/plugin-grok/src/renderer/accounts-settings-page.tsx @@ -70,6 +70,7 @@ function samePeerAvailability( right: PeerAvailability ): boolean { return ( + left.fx === right.fx && left.omp === right.omp && left.opencode === right.opencode && left.pi === right.pi && diff --git a/packages/plugin-grok/src/renderer/format-account-error.ts b/packages/plugin-grok/src/renderer/format-account-error.ts index 0999ffe82..f97941023 100644 --- a/packages/plugin-grok/src/renderer/format-account-error.ts +++ b/packages/plugin-grok/src/renderer/format-account-error.ts @@ -145,6 +145,21 @@ export function formatAccountError(err: unknown, t: Translate): string { "Couldn't sync credentials to OpenCode. Make sure OpenCode is installed on this device." ); } + if (lower.includes("fx has no xai api-key path")) { + return t( + "pier.grok.accounts.settings.syncPeersFailedFxApiKey", + "fx can only receive login (OIDC) accounts. Switch to a login account to sync to fx." + ); + } + if ( + /(^|;\s*)fx:/.test(raw) || + (lower.includes("fx ") && lower.includes("not found")) + ) { + return t( + "pier.grok.accounts.settings.syncPeersFailedFx", + "Couldn't sync credentials to fx. Make sure fx is installed on this device." + ); + } if ( /(^|;\s*)pi:/.test(raw) || (lower.includes("pi ") && lower.includes("not found")) @@ -169,7 +184,7 @@ export function formatAccountError(err: unknown, t: Translate): string { "Select at least one tool to sync." ); } - if (/opencode:|pi:|omp:/.test(raw)) { + if (/opencode:|pi:|omp:|fx:/.test(raw)) { return t( "pier.grok.accounts.settings.syncPeersFailed", "Couldn't sync credentials to the selected tools. Try again after opening those tools once." diff --git a/packages/plugin-grok/src/renderer/switch-confirm-dialog.tsx b/packages/plugin-grok/src/renderer/switch-confirm-dialog.tsx index 1bae47c45..880823028 100644 --- a/packages/plugin-grok/src/renderer/switch-confirm-dialog.tsx +++ b/packages/plugin-grok/src/renderer/switch-confirm-dialog.tsx @@ -33,6 +33,7 @@ function isPeerAvailability(value: unknown): value is PeerAvailability { } const record = value as Record; return ( + (record.fx === undefined || typeof record.fx === "boolean") && typeof record.omp === "boolean" && typeof record.opencode === "boolean" && typeof record.pi === "boolean" && @@ -114,6 +115,7 @@ function SwitchConfirmContent({ opencode: t("pier.grok.switch.syncTarget.opencode", "OpenCode"), pi: t("pier.grok.switch.syncTarget.pi", "Pi"), omp: t("pier.grok.switch.syncTarget.omp", "OMP"), + fx: t("pier.grok.switch.syncTarget.fx", "fx"), }; const sectionLabel = diff --git a/packages/plugin-grok/src/shared/accounts.ts b/packages/plugin-grok/src/shared/accounts.ts index 35dc7ac17..e65634a37 100644 --- a/packages/plugin-grok/src/shared/accounts.ts +++ b/packages/plugin-grok/src/shared/accounts.ts @@ -1,6 +1,10 @@ import type { AccountUsageMetric } from "@pier/plugin-api/account-usage"; import { z } from "zod/mini"; +export type { PeerAvailability } from "@pier/plugin-api/peer-sync"; + +import type { PeerAvailability as PeerAvailabilityType } from "@pier/plugin-api/peer-sync"; + /** * Plugin-local Grok account DTOs. Intentionally duplicated from any host * contracts so the plugin does not import host modules. @@ -79,24 +83,19 @@ export type AddAccountPayload = * Peer tools that can receive a mirrored Grok/xAI credential. * - `"grok"` is the primary switch (materialize), not a peer write target. * - opencode / pi / omp accept xAI oauth or api_key under tool-specific keys. + * - fx accepts the Grok subscription session (`~/.fx/grok-auth.json`) when its + * provider is `grok`; fx has no xAI API-key path. OIDC only. * - pi oauth requires pi ≥ 0.80.8 (same xAI client as Grok CLI); api_key works on older pi. */ -export type CrossToolSyncTarget = "grok" | "opencode" | "pi" | "omp"; +export type CrossToolSyncTarget = "grok" | "opencode" | "pi" | "omp" | "fx"; export const ALL_SYNC_TARGETS: readonly Exclude[] = - ["opencode", "pi", "omp"]; + ["opencode", "pi", "omp", "fx"]; export type PeerSyncTarget = (typeof ALL_SYNC_TARGETS)[number]; -export interface PeerAvailability { - omp: boolean; - opencode: boolean; - pi: boolean; - /** Pi ≥ 0.80.8 can consume xAI OAuth; API-key sync still keys off `pi`. */ - piOauthCapable: boolean; -} - -export const EMPTY_PEER_AVAILABILITY: PeerAvailability = { +export const EMPTY_PEER_AVAILABILITY: PeerAvailabilityType = { + fx: false, omp: false, opencode: false, pi: false, @@ -128,7 +127,7 @@ export interface UsagePollingPayload { } const nonEmptyStringSchema = z.string().check(z.minLength(1)); -const peerSyncTargetSchema = z.enum(["opencode", "pi", "omp"]); +const peerSyncTargetSchema = z.enum(["opencode", "pi", "omp", "fx"]); export const addAccountPayloadSchema = z.union([ z.strictObject({ diff --git a/src/main/ipc/foreground-activity.ts b/src/main/ipc/foreground-activity.ts index 98cfd3b81..1d729f90a 100644 --- a/src/main/ipc/foreground-activity.ts +++ b/src/main/ipc/foreground-activity.ts @@ -33,6 +33,7 @@ import { notifyAgentHookEventListeners } from "../services/foreground-activity/a import { createForegroundActivityAggregator } from "../services/foreground-activity/aggregator.ts"; import { isBlankShellCommandLine } from "../services/foreground-activity/blank-command-line.ts"; import { SUSPENDED_JOB_EXIT_CODES } from "../services/foreground-activity/entry.ts"; + import { createJsonlObserver, type JsonlObserver, @@ -49,6 +50,7 @@ import { listAppWindowIds, } from "../windows/identity.ts"; import { recordAgentResumeSession } from "./agent-resume-persist.ts"; +import { createFxHerdrSocketEnv } from "./foreground-activity/fx-herdr-socket.ts"; import { handleObservedAgentHookEvent } from "./foreground-activity/hook-pipeline.ts"; import { materializeForegroundActivityPublications } from "./foreground-activity-publication.ts"; import { forwardToWindow } from "./terminal/forwarding.ts"; @@ -79,6 +81,19 @@ function withResolvedOwner( let jsonlObserver: JsonlObserver | null = null; let agentTerminalReconciler: AgentTerminalReconciler | null = null; +let fxHerdrSocketEnvHolder: { + dispose: () => void; + env: () => Record; +} | null = null; + +function fxHerdrSocketEnv(): Record { + if (!fxHerdrSocketEnvHolder) { + fxHerdrSocketEnvHolder = createFxHerdrSocketEnv({ + aggregator: foregroundActivityAggregator, + }); + } + return fxHerdrSocketEnvHolder.env(); +} /** * 按 windowId 定向发送快照。Pier 窗口是 BaseWindow+WebContentsView(见 @@ -202,6 +217,8 @@ export const foregroundActivityService = { PIER_AGENT_HOOKS_DIR: pierHooksCurrentDir(), // 事件日志仍按实例 userData 隔离,避免多窗抢同一 JSONL。 PIER_AGENT_EVENT_LOG: eventsJsonlPath(userData), + // fx Herdr 状态监听 socket(实例隔离,与事件日志同 userData)。 + ...fxHerdrSocketEnv(), }; }, commandFinished(panelId: string, exitCode?: number, windowId?: string): void { @@ -341,6 +358,8 @@ export function closeForegroundActivityResources(): void { jsonlObserver = null; agentTerminalReconciler?.dispose(); agentTerminalReconciler = null; + fxHerdrSocketEnvHolder?.dispose(); + fxHerdrSocketEnvHolder = null; } export function registerForegroundActivityIpc(ipcMain: IpcMain): void { diff --git a/src/main/ipc/foreground-activity/fx-herdr-socket.ts b/src/main/ipc/foreground-activity/fx-herdr-socket.ts new file mode 100644 index 000000000..9725f4fd2 --- /dev/null +++ b/src/main/ipc/foreground-activity/fx-herdr-socket.ts @@ -0,0 +1,116 @@ +import { createLogger } from "@shared/logger.ts"; +import { app } from "electron"; +import { getAgentHookIntegration } from "../../services/agents/integrations/registry.ts"; +import { applyAgentSessionTitleFromHookEvent } from "../../services/agents/session-title/index.ts"; +import { isAgentStatusHooksIngestEnabled } from "../../services/agents/status-hooks-gate.ts"; +import { notifyAgentHookEventListeners } from "../../services/foreground-activity/agent-hook-event-fanout.ts"; +import { + createFxHerdrListener, + type FxHerdrListener, +} from "../../services/foreground-activity/fx-herdr-listener.ts"; +import type { ForegroundActivityAggregator } from "../../services/foreground-activity/types.ts"; +import { resolveOwner } from "../../services/panel-transfer/terminal-hook-owner-routing.ts"; +import { recordAgentResumeSession } from "../agent-resume-persist.ts"; +import { handleObservedAgentHookEvent } from "./hook-pipeline.ts"; + +const log = createLogger("foreground-activity.fx-herdr-socket"); + +/** + * fx Herdr socket env(实例 userData 隔离;监听惰性启动)。 + * + * 上游协议(vercel-labs/fx@e45d780933bfb42ae376aee54a49bd3ebe81f04d): + * Pier 在 PTY 注入 HERDR_SOCKET_PATH 后,fx 自动经 Unix socket 上报 + * idle/working/blocked。pane 归属由 HERDR_PANE_ID(= panelId,经 + * withPanelStatusEnv 注入)回传;windowId 由存活面板快照反查,跨窗同名 + * 经 owner 别名解决。 + */ +export function createFxHerdrSocketEnv(deps: { + aggregator: ForegroundActivityAggregator; +}): { + dispose: () => void; + env: () => Record; +} { + let listener: FxHerdrListener | null = null; + + function withResolvedOwner( + event: T + ): T { + const owner = resolveOwner(event.windowId, event.panelId); + if (owner.windowId === event.windowId && owner.panelId === event.panelId) { + return event; + } + return { ...event, panelId: owner.panelId, windowId: owner.windowId }; + } + + function resolveFxHerdrOwner( + panelId: string + ): { panelId: string; windowId: string } | null { + const snapshot = deps.aggregator.snapshot(); + let match: { panelId: string; windowId: string } | null = null; + for (const activity of snapshot.activities) { + // panelId 跨窗口不唯一(session.ts 身份 = windowId::panelId): + // 多窗同名必须 fail-closed,不能取首个非 idle 命中。 + if (activity.panelId === panelId && activity.kind !== "idle") { + if (match) { + return null; + } + match = { panelId: activity.panelId, windowId: activity.windowId }; + } + } + return match; + } + + return { + dispose() { + listener?.dispose(); + listener = null; + }, + env() { + try { + if (!listener) { + listener = createFxHerdrListener({ + onAgentEvent: (event) => { + if (!isAgentStatusHooksIngestEnabled()) { + return; + } + // 同用户伪造门:socket 在 userData 下同用户可写,frame 自称 + // source/agent 易伪造。要求面板 OSC 命令层已点亮为 fx, + // 与 hook ctty 门同纪律(hook-event-origin.ts)。 + const oscOwned = deps.aggregator.panelCommandOwnedAgent( + event.panelId, + event.windowId + ); + if (oscOwned !== "fx") { + return; + } + handleObservedAgentHookEvent( + { + aggregator: deps.aggregator, + applySessionTitle: (routed) => + applyAgentSessionTitleFromHookEvent({ + aggregator: deps.aggregator, + event: routed, + }), + notifyListeners: notifyAgentHookEventListeners, + observeTranscript: undefined, + recordResume: recordAgentResumeSession, + resolveRuntime: (agent) => + getAgentHookIntegration(agent)?.runtime, + }, + withResolvedOwner(event) + ).catch((err) => { + log.warn("fx herdr hook event pipeline failed", { err }); + }); + }, + resolveOwner: (panelId) => resolveFxHerdrOwner(panelId), + userData: app.getPath("userData"), + }); + } + return listener.env(); + } catch (err) { + log.warn("fx herdr listener unavailable", { err }); + return {}; + } + }, + }; +} diff --git a/src/main/ipc/terminal/create-launch.ts b/src/main/ipc/terminal/create-launch.ts index 3de3b8bdf..25700637e 100644 --- a/src/main/ipc/terminal/create-launch.ts +++ b/src/main/ipc/terminal/create-launch.ts @@ -333,7 +333,8 @@ export function withPanelStatusEnv( panelId: string, windowId: string, hookEnv: Record, - controlSocketPath?: string | undefined + controlSocketPath?: string | undefined, + agentId?: AgentKind | undefined ): ResolvedTerminalLaunchOptions { const { PIER_AGENT_CALLER_BINDING: _parentBinding, @@ -350,6 +351,10 @@ export function withPanelStatusEnv( ...hookEnv, PIER_PANEL_ID: panelId, PIER_WINDOW_ID: windowId, + // fx Herdr 上报的 pane 归属:fx 原样回传 HERDR_PANE_ID,监听以此 + // 路由回 panel。仅 fx 面板注入:非 fx 进程继承该 env 也可伪造 + // report_agent 帧点亮任意面板(socket 在 userData 下同用户可写)。 + ...(agentId === "fx" ? { HERDR_PANE_ID: panelId } : {}), ...(controlSocketPath ? { PIER_CONTROL_SOCKET: controlSocketPath } : {}), diff --git a/src/main/services/agent-mcp-catalog/adapter-facts-extended.ts b/src/main/services/agent-mcp-catalog/adapter-facts-extended.ts index 738dda8c2..870367a49 100644 --- a/src/main/services/agent-mcp-catalog/adapter-facts-extended.ts +++ b/src/main/services/agent-mcp-catalog/adapter-facts-extended.ts @@ -206,4 +206,12 @@ export const MCP_EXTENDED_ADAPTERS: readonly McpDiscoveryAdapter[] = [ "https://support.atlassian.com/rovo/docs/connect-to-an-mcp-server-in-rovo-dev-cli/", verifiedOn: "2026-08-30", }, + { + agentKind: "fx", + consumesMcp: true, + projectConfigs: [mcpProject(".mcp.json")], + userConfigs: [mcpUser(".fx/mcp.json", "opencode-json")], + officialDocsUrl: "https://fx.sh/docs/capabilities/mcp", + verifiedOn: "2026-09-15", + }, ]; diff --git a/src/main/services/agent-mcp-catalog/parse-server-decls.ts b/src/main/services/agent-mcp-catalog/parse-server-decls.ts index 3d6e8f31f..d84775612 100644 --- a/src/main/services/agent-mcp-catalog/parse-server-decls.ts +++ b/src/main/services/agent-mcp-catalog/parse-server-decls.ts @@ -141,6 +141,8 @@ export function parseOpencodeJsonMcpServerDecls(raw: string): McpServerDecl[] { } catch { return []; } + // fx profile(`~/.fx/mcp.json` canonical root `mcp`)与 opencode 同形, + // 复用同一解析器,不新增 format 分支。 return mergeDecls([ ...declsFromMap(nestedMap(parsed, "mcp")), ...declsFromMap(nestedMap(parsed, "mcpServers")), diff --git a/src/main/services/agents/integrations/evidence/matrix-rows-b-2.ts b/src/main/services/agents/integrations/evidence/matrix-rows-b-2.ts index 3787d6d43..415524594 100644 --- a/src/main/services/agents/integrations/evidence/matrix-rows-b-2.ts +++ b/src/main/services/agents/integrations/evidence/matrix-rows-b-2.ts @@ -272,4 +272,34 @@ export const AGENT_STATUS_EVIDENCE_ROWS_B_2 = { "Qoder CLI hooks documentation" ), }, + fx: { + integration: "active", + transport: ["herdr-socket", "host-terminal-escape"], + evidence: { + lifecycle: "unsupported", + ready: "native", + processing: "native", + tool: "unsupported", + waiting: "unsupported", + error: "unsupported", + completed: "unsupported", + interrupted: "unsupported", + subagent: "unsupported", + }, + eventMappings: facts( + nativeFact("processing", "herdr.working", "processing"), + nativeFact("ready", "herdr.idle", "ActivityIdle"), + nativeFact("processing", "herdr.blocked.permission", "processing"), + nativeFact("processing", "herdr.blocked.question", "processing"), + nativeFact("processing", "herdr.blocked.recovery", "processing") + ), + // Herdr 生命周期上报(interactive scope):`src/builtins/hooks/herdr.zig` + // + `src/builtins/hooks.zig`(vercel-labs/fx@e45d780933bfb42ae376aee54a49bd3ebe81f04d)。 + // ask one-shot(`src/core/cli/cli_ask.zig`)无 Herdr 引用,不在此通道产状态。 + // 无 tool 事件:tool/completed/interrupted/error 保持 unsupported,不伪造。 + upstream: upstream( + "https://fx.sh/docs/configure-fx/configuration", + "fx Herdr integration documentation" + ), + }, } as const satisfies Partial>; diff --git a/src/main/services/agents/integrations/evidence/matrix-types.ts b/src/main/services/agents/integrations/evidence/matrix-types.ts index 19de47cbc..84d93b88b 100644 --- a/src/main/services/agents/integrations/evidence/matrix-types.ts +++ b/src/main/services/agents/integrations/evidence/matrix-types.ts @@ -32,7 +32,7 @@ export type AgentStatusTransport = | "hook-command" | "hosted-plugin" | "transcript-reconciler" - /** 终端裸 Esc 旁路观察 → `pier.terminal.user_escape`(main FA 注入)。 */ + | "herdr-socket" | "host-terminal-escape" | "none"; diff --git a/src/main/services/agents/integrations/fx.ts b/src/main/services/agents/integrations/fx.ts new file mode 100644 index 000000000..b32787b62 --- /dev/null +++ b/src/main/services/agents/integrations/fx.ts @@ -0,0 +1,53 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { AgentKind } from "@shared/contracts/agent.ts"; +import { commandExistsOnPath } from "./shared.ts"; +import type { AgentHookIntegration } from "./types.ts"; + +const AGENT_ID: AgentKind = "fx"; + +/** + * fx 状态集成——Herdr socket 通道,无配置文件可写。 + * + * 上游事实(vercel-labs/fx@e45d780933bfb42ae376aee54a49bd3ebe81f04d): + * fx 的 hooks 是进程内 handler 注册(`src/core/hooks/`:PreToolUse / Stop / + * PostTurnEnd / AttentionRequired),`~/.fx/settings.json` 无外部 hook 配置 + * 面(settings_catalog 零 hook 引用)。唯一外部状态面是 Herdr 生命周期 + * 上报:`HERDR_SOCKET_PATH` + `HERDR_PANE_ID` 同时存在时 fx 自动经 Unix + * socket 上报 idle/working/blocked(`src/builtins/hooks/herdr.zig` + + * `src/builtins/hooks.zig`;ask one-shot 无 Herdr 引用)。 + * + * 因此本集成不写任何配置文件:状态面由 Pier 在 PTY 注入 + * HERDR_SOCKET_PATH/HERDR_PANE_ID 后、fx 自动上报驱动;install/uninstall + * 是 no-op(幂等、无落盘)。detect 只判断 fx 是否可用。 + */ + +export function fxDetect(): boolean { + return existsSync(join(homedir(), ".fx")) || commandExistsOnPath("fx"); +} + +async function noop(): Promise { + // 无配置文件可写:Herdr 通道由 PTY env 注入驱动,不落盘。 +} + +const FX_HERDR_MAPPINGS = [ + { nativeEvent: "herdr.working", pierEvent: "processing" }, + { nativeEvent: "herdr.idle", pierEvent: "ActivityIdle" }, + { nativeEvent: "herdr.blocked.permission", pierEvent: "processing" }, + { nativeEvent: "herdr.blocked.question", pierEvent: "processing" }, + { nativeEvent: "herdr.blocked.recovery", pierEvent: "processing" }, +] as const; + +export const FX_HERDR_NATIVE_EVENTS = FX_HERDR_MAPPINGS; + +export const fxIntegration: AgentHookIntegration = { + runtime: { + emittedMappings: [...FX_HERDR_MAPPINGS], + stopAuthority: "none", + }, + detect: fxDetect, + id: AGENT_ID, + install: noop, + uninstall: noop, +}; diff --git a/src/main/services/agents/integrations/registry.ts b/src/main/services/agents/integrations/registry.ts index 6200a9273..2c3618ab9 100644 --- a/src/main/services/agents/integrations/registry.ts +++ b/src/main/services/agents/integrations/registry.ts @@ -25,6 +25,7 @@ import { cursorIntegration } from "./cursor.ts"; import { devinIntegration } from "./devin.ts"; import { droidIntegration } from "./droid.ts"; import { AGENT_STATUS_EVIDENCE } from "./evidence/matrix.ts"; +import { fxIntegration } from "./fx.ts"; import { geminiIntegration } from "./gemini.ts"; import { gooseIntegration } from "./goose.ts"; import { grokIntegration } from "./grok.ts"; @@ -66,6 +67,7 @@ const AGENT_HOOK_INTEGRATION_IMPLEMENTATIONS: readonly AgentHookIntegration[] = cursorIntegration, devinIntegration, droidIntegration, + fxIntegration, geminiIntegration, gooseIntegration, grokIntegration, diff --git a/src/main/services/agents/lifecycle/latest-hosts.ts b/src/main/services/agents/lifecycle/latest-hosts.ts index 793ace5e3..3ba0ed915 100644 --- a/src/main/services/agents/lifecycle/latest-hosts.ts +++ b/src/main/services/agents/lifecycle/latest-hosts.ts @@ -7,6 +7,7 @@ export const ALLOWED_LATEST_HOSTS = new Set([ "downloads.claude.ai", "code.kimi.com", "formulae.brew.sh", + "releases.fx.sh", ]); export function assertLatestHttpsUrl(url: string): URL { diff --git a/src/main/services/agents/lifecycle/official-script.ts b/src/main/services/agents/lifecycle/official-script.ts index f3017a487..737d8a742 100644 --- a/src/main/services/agents/lifecycle/official-script.ts +++ b/src/main/services/agents/lifecycle/official-script.ts @@ -51,6 +51,10 @@ const ALLOWED_SCRIPT_HOSTS = new Set([ "www.github.com", // Qwen Code standalone installer (Aliyun OSS) "qwen-code-assets.oss-cn-hangzhou.aliyuncs.com", + // fx standalone installer (https://fx.sh/docs/getting-started/installation) + "fx.sh", + "www.fx.sh", + "releases.fx.sh", ]); export function assertAllowedScriptUrl(url: string): URL { diff --git a/src/main/services/agents/lifecycle/specs/tier-a.ts b/src/main/services/agents/lifecycle/specs/tier-a.ts index 3d7a86eb1..0d4d70474 100644 --- a/src/main/services/agents/lifecycle/specs/tier-a.ts +++ b/src/main/services/agents/lifecycle/specs/tier-a.ts @@ -273,4 +273,23 @@ export const TIER_A_SPECS: readonly AgentLifecycleSpec[] = [ // https://cursor.com/docs/cli/installation update: [{ kind: "reinstall" }, { kind: "self", argv: ["update"] }], }, + { + agentId: "fx", + expectedBins: ["fx"], + latestProbe: { + kind: "http-text", + url: "https://releases.fx.sh/latest.txt", + }, + support: "full", + // https://fx.sh/docs/getting-started/installation + install: [ + { + kind: "official-script", + platform: "posix", + url: "https://fx.sh/setup.sh", + }, + ], + // `fx upgrade` 服务脚本安装与源码构建;Windows 走同一 setup.sh(文档仅 macOS/Linux)。 + update: [{ kind: "self", argv: ["upgrade"] }, { kind: "reinstall" }], + }, ]; diff --git a/src/main/services/agents/resume-adapters.ts b/src/main/services/agents/resume-adapters.ts index 4f87b75ab..79dbf9011 100644 --- a/src/main/services/agents/resume-adapters.ts +++ b/src/main/services/agents/resume-adapters.ts @@ -76,6 +76,7 @@ export const AGENT_RESUME_ADAPTERS = { // Augment docs: `auggie --resume `. aug: sessionAdapter("aug", (args) => appendResumeFlag(args, "--resume")), autohand: unsupported("autohand"), + fx: unsupported("fx"), claude: sessionAdapter("claude", (args) => appendResumeFlag(args, "--resume") ), diff --git a/src/main/services/foreground-activity/fx-herdr-listener.ts b/src/main/services/foreground-activity/fx-herdr-listener.ts new file mode 100644 index 000000000..a20f251e3 --- /dev/null +++ b/src/main/services/foreground-activity/fx-herdr-listener.ts @@ -0,0 +1,195 @@ +import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { join } from "node:path"; +import { + FX_HERDR_AGENT, + FX_HERDR_SOURCE, + fxHerdrPanelId, + fxHerdrToAgentEvent, + parseFxHerdrFrame, +} from "@shared/contracts/agent/fx-herdr.ts"; +import type { AgentHookEventPayload } from "@shared/contracts/agent/session.ts"; +import { createLogger } from "@shared/logger.ts"; + +const log = createLogger("foreground-activity.fx-herdr"); + +/** + * fx Herdr 状态监听:Pier 冒充 Herdr multiplexor,fx 经 + * HERDR_SOCKET_PATH 上报 lifecycle(idle/working/blocked)。 + * + * 上游协议(vercel-labs/fx@e45d780933bfb42ae376aee54a49bd3ebe81f04d, + * `src/builtins/hooks/herdr.zig` + `src/builtins/hooks.zig`): + * - 短连接 Unix socket,每 report 一次 connect;单行 JSON-RPC + * `pane.report_agent`(id 为字符串);fx 等 ≤250ms 单行回包后关连接。 + * - 只在 interactive scope 上报;ask one-shot 无 Herdr 引用。 + * - 启动先 reportSession 再 idle+announce;prompt 入队 → working; + * PostTurnEnd → idle;AttentionRequired → blocked+reason + * (permission/question/route_recovery→recovery);exit 经 release 清理。 + * + * Pier 侧纪律: + * - socket 路径按实例 userData 隔离(`fx-herdr.sock`),0600;只接受 + * source=custom:fx 且 agent=fx 的帧,其余静默丢弃。 + * - HERDR_PANE_ID 即 Pier panelId(`terminal-*`);跨窗口同名 panel 经 + * owner 路由解决(与 JSONL observer 同入口)。 + * - working→processing、idle→ActivityIdle、blocked permission/question→ + * InteractionRequested、blocked recovery→processing;turnId 恒为空 + * (PromptSubmit 文件水位纪律)。 + * - 本监听只产生 hook 事件;转录对账不适用(session.json 快照式单文件, + * durableReplace 原子替换,无可 tail 的 JSONL 终态流)。 + */ + +export interface FxHerdrListener { + dispose(): void; + /** fx PTY 应注入的环境变量;未启动时返回空对象。 */ + env(): Record; + socketPath: string | null; +} + +export interface FxHerdrListenerOwner { + panelId: string; + windowId: string; +} + +export function fxHerdrSocketPath(userData: string): string { + // 实例 userData 内:短路径天然 ≤103B,无需 tmpdir fallback。 + return join(userData, "fx-herdr.sock"); +} + +export function createFxHerdrListener(options: { + onAgentEvent: (event: AgentHookEventPayload) => void; + resolveOwner: (panelId: string) => FxHerdrListenerOwner | null; + userData: string; +}): FxHerdrListener { + const sockets = new Set(); + let server: Server | null = null; + let disposed = false; + const socketPath = fxHerdrSocketPath(options.userData); + + function handleLine(line: string): void { + const report = parseFxHerdrFrame(line); + if (!report) { + return; + } + const panelId = fxHerdrPanelId(report.paneId); + if (!panelId) { + return; + } + const owner = options.resolveOwner(panelId); + if (!owner) { + return; + } + try { + options.onAgentEvent(fxHerdrToAgentEvent(report, owner)); + } catch (error) { + log.warn("fx herdr ingest failed", { + code: + error && typeof error === "object" && "code" in error + ? String(error.code) + : "unknown", + }); + } + } + + function attachConnection(socket: Socket): void { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk: string) => { + buffer += chunk; + let index = buffer.indexOf("\n"); + while (index !== -1) { + const line = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 1); + if (line) { + handleLine(line); + } + index = buffer.indexOf("\n"); + } + if (buffer.length > 64 * 1024) { + buffer = ""; + } + }); + socket.on("error", () => { + // fx best-effort 上报;单连接失败不影响监听。 + }); + // fx 等单行回包(≤250ms)后关连接;无回包则它自己超时关闭。 + socket.write('{"ok":true}\n'); + } + + function ensureStarted(): void { + if (server || disposed) { + return; + } + try { + mkdirSync(join(socketPath, ".."), { recursive: true }); + // Node 不在 close 时删 socket 文件:先清上次退出的残留,否则 EADDRINUSE + // 后监听静默死亡(与 local-control server 同纪律)。 + if (process.platform !== "win32" && existsSync(socketPath)) { + try { + unlinkSync(socketPath); + } catch { + // ignore:listen 失败会走 error 日志 + } + } + server = createServer(attachConnection); + server.on("error", (error) => { + log.warn("fx herdr listen failed", { + code: + error && typeof error === "object" && "code" in error + ? String(error.code) + : "unknown", + }); + server = null; + }); + server.listen(socketPath, () => { + try { + chmodSync(socketPath, 0o600); + } catch { + // ignore + } + }); + } catch (error) { + log.warn("fx herdr start failed", { + code: + error && typeof error === "object" && "code" in error + ? String(error.code) + : "unknown", + }); + server = null; + } + } + + return { + dispose() { + disposed = true; + for (const socket of sockets) { + socket.destroy(); + } + sockets.clear(); + server?.close(); + server = null; + // Node 不删 socket 文件:退出不清则下次 listen 必 EADDRINUSE。 + if (process.platform !== "win32") { + try { + if (existsSync(socketPath)) { + unlinkSync(socketPath); + } + } catch { + // ignore + } + } + }, + env() { + if (disposed) { + return {}; + } + ensureStarted(); + return { HERDR_SOCKET_PATH: socketPath }; + }, + socketPath, + }; +} + +export const FX_HERDR_LISTENER_SOURCE = FX_HERDR_SOURCE; +export const FX_HERDR_LISTENER_AGENT = FX_HERDR_AGENT; diff --git a/src/main/services/project-skills/adapter-facts-extended.ts b/src/main/services/project-skills/adapter-facts-extended.ts index daf5d2a1a..1a665a5a1 100644 --- a/src/main/services/project-skills/adapter-facts-extended.ts +++ b/src/main/services/project-skills/adapter-facts-extended.ts @@ -95,4 +95,37 @@ export const EXTENDED_SKILL_DISCOVERY_ADAPTERS: readonly SkillDiscoveryAdapter[] officialDocsUrl: "https://docs.factory.ai/harness/skills", verifiedOn: "2026-08-12", }, + { + agentKind: "fx", + // Discovery roots: https://fx.sh/docs/capabilities/skills(workspace 向上扫到 home 前; + // user 侧 `~/.fx/skills` 为 managed 安装位)。`skills/` 保留为 matrix 事实, + // Pier 枚举只收点根(见 listProjectDiscoveryRoots)。 + discoveryRoots: [ + ".agents/skills", + ".claude/skills", + ".codex/skills", + ".opencode/skills", + ".claw/skills", + "skills/", + ], + userDiscoveryRoots: [ + "~/.fx/skills", + "~/.agents/skills", + "~/.claude/skills", + "~/.codex/skills", + "~/.config/opencode/skills", + "~/.claw/skills", + ], + walkUpToRepoRoot: true, + consumesProjectSkills: true, + duplicateSemantics: "coexist", + duplicatePolicy: "report", + sessionRefresh: "new-session-recommended", + probeCaveats: [ + "workspace-level `skills/` root is excluded from Pier's unmanaged enumeration (non-dot root, too generic a directory name)", + "additional workspace directories do not contribute skills", + ], + officialDocsUrl: "https://fx.sh/docs/capabilities/skills", + verifiedOn: "2026-09-15", + }, ] as const; diff --git a/src/main/services/terminal-launch-wrap/apply.ts b/src/main/services/terminal-launch-wrap/apply.ts index 378facf01..b6251b917 100644 --- a/src/main/services/terminal-launch-wrap/apply.ts +++ b/src/main/services/terminal-launch-wrap/apply.ts @@ -266,7 +266,8 @@ export async function applyLaunchWrapForCreate(input: { input.panelId, input.windowId, input.hookEnv, - input.controlSocketPath + input.controlSocketPath, + input.agentId ); if (input.agentId && input.userData) { withIdentity = { diff --git a/src/plugins/api/components/agent-icons/favicons/fx.png b/src/plugins/api/components/agent-icons/favicons/fx.png new file mode 100644 index 0000000000000000000000000000000000000000..9d1c3762aa6b52d5e9ed27d309958000a3b2e271 GIT binary patch literal 3336 zcmV+j4fpbiP)n5 zZ7fv7838N?unHUzDF8H~b{ggI0q6u!1>i7%?*X()-AMv~wE!vr^Z}rl1SLKI=K*X0 zkQB1jd5{j^Kj5aO(gPf=;ssCwZiycjasd1{0tOT<(gI)|!dT`R0P6wVh=4&vo45dM z2QVgRHo%e56#>detMmbQ8$etzWaJ<=Gvp9AfQ@2gq$BpskW<7$9b8i7I9Ww8BmST_%Y2chGFyIn}s}Q6M z7Q=`k2Y`_Q$Bs>5Wnus_09YAt`IQufm5Bi`0C?EL*k=vuDpn zN=gd!dOaA1f!FJW)9FNWb2EPb{db%`eHxXOm1u2k1raH&dZ=Ikzz^jEBVL(5e?A>M zc8uI^Hw6QhW$D6&3zVImt+?)CisB0J%HxkePFJs94T^|c;dDA_!GZ;f>mOBwJn+B+ zVUa;Z^y7~|l3uS@d<;=TNK#S~oj7qKEHa3Q-gx5;#m5j;ggpQJ^W^nLfG{a33C}$94Ag3Mz~e-Ova&K17Z;my35+95`@*OeWKi>$F-e&6+if=FFL+_!x#M ziYqWkKK=AlUNV}RnkYRzU2z>pQu^y55rMnzx(g3J_#p4`g)#*G_CHk*y|^YiJapMD~{-7XjevMft( zx0^g3Pe5_I-Q;q)sHdlgy1TontE-E;ySwSHzy6|y3m1m0--|E4NOrrOoK9yz>FVmD zwzf9P$;px0Hu4e?A0JP7d3jV`UQP~&Lo^zf0w+(Nq~zq}pmm%(cP=$HHj1{pd-rbA zYPF-AkQp;(PUN*(d>3REnT{Fq--QFA+Nsrs$^sk z5pCMEi2zVuULLV5D|l{sc{xp(Fo9P$tJO+PO--VG?%cVPR4Ubo*hh9R%Wk*h$dM!P z`FsEXB7(_eLRMB5G@7C6`2BuVR#u{|tqm&G;LoV~d_GiEQ~&@93kzYfSn%YNPxAJ? zaN$Da<>lecH{TrMbr}o>Y}>XCsi~=g`j?iLqM)E)L<3Y=X9&YEq*kj31OTmCwTk!d zzJC2WSu7R;FzC8lukV-X)2CBSO^x8)?Ql3~*|KFr^m_T_mqnMVmX;PdDv|OM5=gdh z-_C1cBPg=FOw#=4Qcbyk0Nm<>kp?4`s>FXf$-_ z&>>z!96We%$V?Ctl}bh1wrwN7-!Ew3f`S5K7={uP6Y1M;zZE=}h^VNjNUpN5vV@Eu zKb|gLyvS>a{QUfo+e}PMq~hXYK?8SnbUzRiNuPh-Ji-mWI z>FevG6)RSR)oyxvI@QHhog z54-I(Yt~R#SC^o{#ZXvSI1-($uL_CF@J8)za?WyG6-hS(etWU8|T)6fNOqWMuFjyK?0U z+S}VD>#MJ?5AVG54u1LN7s2y18Vy#jUJZl6AX(p`LXkT`uh&yqSsCx0`q^inNz%`h zDN|_NxN!r2KlIQ;)YjG}I7@s!pOX87l_6yM^yz}B?se~+w+`~w zHf`D@nyT;U=uql2Cdv@<*kh0J7OJH9d4I=HvjT?f5ba!{tnl)>pn-HVXNL5u; zyhhlyYggE9$Hm3b=bwKbuwB33Pdj(+90>SSDi!7D=Zj{0>+0%g#*7)!OGtWpI(2k( z1RT82=NncW@Wc~OP)|<}uW_AD=b$^@U@*|(!-oY4AtL(ftFLJ6*s;+`$jX%~1xvP> znVDg=o0gVFb#-;3#yxfF)SzP1%$YOk(xpp+*RU*0FTM0qbP}>-#}3|shg!1jm-zVj zpggDE-rj&N_3G8DMN{?7&CO)9*`kXOjYcE5M1)$h?Uxr`c!7F*dqoL3aNqzXBqR*E zUaeNs`|rOmdVOhWX;?Q*#1b+RCELu*O#1upzeSCC<;oSx$jIPr$7C{5NlA%lJMX>s z-biMGQ`s<=@ zD-qG5Lx+O)lAe9`S@!YgpEpIGy zEB5Z)OFEry^b(@e=>*s4P)fEFCQP8yr%#I-&FAwC?Eei#CQh746%`eNZF{|5TEBk1 zq&AXuZ?D;G=KWA70Myjf1ifcACnpD!CQZVhfBwmPj#{n8&p-c+ef#!>tZPe43$|?8 zf~{M(LZ{OOJcnTz*lac&IdVj@+Kmb~r->X`v}h5&`|dl89Xoc&F}K@|6)RTYM&$VQ0CWP-(F;XQug!Ueco zF1gf+Sja|*#bQBPT3Wzke!m}Q&z_Y_m57EcglIGxJoVI5h>wpCc)Yc>6;)MLa;P#Y zAc_BMG#bfjwFV6Aa&vP9GpR*IMHCkoCtExe$s(DTHF@%66crVrwY3$Mm6fQit%cj| zhSh3CZf-90dVRp_SeC`tUw;jjWu;PO)I$5Bxx8-j;Xx7oQy`J;&*1Gh@{Vu z+k-T|fT|xZmkS?%{4wlyyOe5-YACvuw6L%cM~@y=QiX_wWJ{LVmYRO}{eIfJcW-Q| z3LuiEPQ;cdfcqk{FTVH!1qB7e$}vSN)Bv_Xf}EDe(_Dd z;zfM<<(GKtt+#OS;6e2E_71npsDujK%MHg%)u2wNLvnI5l9Q96)oS7Q`{8st(b3Tn zn-St56@Y30vy|2(1^~cy2Ege48tCXk0|Vd;0BNc#F$@=H832_400ry_-!{A5?4s|~=v5qr2Rh5ZQN z?Ir;zK?LM;?l7qaFgXxQ0QX+pmIzStP7x=7fANvgk97cC5uj|eix0pK1W^Lf0N4(o zF9MW~X4wNE@UFoC7z5yKa4xaZibtFHZjur5D{tcfYy_7LR(cT8B2IA2RM0ac5V8T3 zMZjQ2yHtaFfJ;&~Hv@PLKz#%ZY_v%ufOjF_+TIG4_$h-h6+q5S$pE0-mr6x8dcftT zj)IH8gD!D|N=QG90IUEW0bm1gAGlc*QSY>p5d`1{fCd0(0h|I?)(Ck!|Nj65Q<;76 S_AfsG0000 ["-p", prompt], }, + { + id: "fx", + initialPrompt: AGENT_INITIAL_PROMPTS.fx, + label: "fx", + launchCmd: "fx", + detectCmd: "fx", + expectedProcess: "fx", + faviconDomain: "fx.sh", + homepageUrl: "https://fx.sh/", + oneShotArgs: (prompt) => ["ask", prompt], + }, ]; const byId = new Map( diff --git a/src/shared/agent-selection.ts b/src/shared/agent-selection.ts index 289d83b6a..9e1cef1f0 100644 --- a/src/shared/agent-selection.ts +++ b/src/shared/agent-selection.ts @@ -39,6 +39,7 @@ export const AGENT_AUTO_PICK_ORDER: readonly AgentKind[] = [ "hermes", "openclaw", "devin", + "fx", ]; const AUTO_PICK_INDEX = new Map( diff --git a/src/shared/agent-surfaces/initial-prompts.ts b/src/shared/agent-surfaces/initial-prompts.ts index 8309c1eae..8cad2103a 100644 --- a/src/shared/agent-surfaces/initial-prompts.ts +++ b/src/shared/agent-surfaces/initial-prompts.ts @@ -217,4 +217,9 @@ export const AGENT_INITIAL_PROMPTS: Record< evidence: "Interactive entry checked with installed CLI --help (2026-09-07); rechecked for the installed binary before use.", }, + fx: { + mode: "draft", + evidence: + "Native interactive initial input not verified; preserve for manual submission. Headless options are not substitutes.", + }, }; diff --git a/src/shared/contracts/agent.ts b/src/shared/contracts/agent.ts index 39be7606e..ca622f00f 100644 --- a/src/shared/contracts/agent.ts +++ b/src/shared/contracts/agent.ts @@ -37,6 +37,7 @@ export const agentKindSchema = z.enum([ "openclaude", "codebuddy", "qodercli", + "fx", ]); export type AgentKind = z.infer; @@ -144,7 +145,7 @@ export const YOLO_FLAGS: Partial> = { "mimo-code": "--yolo", // OMP:`omp --auto-approve`(`omp --help` 核定)。 omp: "--auto-approve", - // 其余 agent 无全局一键 yolo flag:goose 走 YOLO_ENV,pi/aug/codebuff/openclaw 见各 CLI 特性说明。 + // 其余 agent 无全局一键 yolo flag:goose 走 YOLO_ENV,pi/aug/codebuff/openclaw/fx 见各 CLI 特性说明。 }; /** env-based yolo(goose 用环境变量而非 flag)。 */ diff --git a/src/shared/contracts/agent/fx-herdr.ts b/src/shared/contracts/agent/fx-herdr.ts new file mode 100644 index 000000000..b518f3535 --- /dev/null +++ b/src/shared/contracts/agent/fx-herdr.ts @@ -0,0 +1,160 @@ +import type { AgentHookEventPayload } from "@shared/contracts/agent/session.ts"; + +/** + * fx Herdr 生命周期上报 → Pier 规范事件。 + * + * 上游事实(vercel-labs/fx@e45d780933bfb42ae376aee54a49bd3ebe81f04d): + * - `src/builtins/hooks/herdr.zig`:短连接 Unix socket,每 report 一次 connect; + * JSON-RPC 单行帧 `{"id":"","method":"pane.report_agent","params":{ + * "pane_id","source":"custom:fx","agent":"fx","state","custom_status?"}}`; + * state ∈ idle|working|blocked;custom_status ≤32B(permission/question/ + * recovery 三种,见 `src/builtins/hooks.zig` attentionStatus)。 + * - `src/builtins/hooks.zig`:interactive scope 才上报;startup 先 reportSession + * 再 idle+announce;prompt 入队/恢复/compact 入队 → working;PostTurnEnd → + * idle;AttentionRequired → blocked+reason;exit 经 deinit release。 + * - ask 模式(`src/core/cli/cli_ask.zig`)无 Herdr 引用:Pier 走 `fx ask` + * one-shot,不在此通道产生状态。 + * + * Pier 映射纪律: + * - working → processing(显式 prompt 等价物;turnStartAuthority=none, + * 与 PromptSubmit 的 explicit-prompt 区分:Herdr working 只证明执行中, + * 不携带用户提问内容)。 + * - idle → ActivityIdle(native idle 事实:只在无活跃工作时恢复 ready, + * 不封回合;见 activity-idle.ts)。 + * - blocked 全种(permission/question/recovery)→ processing:上游只有请求帧、 + * 无配对解除帧,按等待治理不得进 waiting;原因保留在 nativeEvent + * (herdr.blocked.permission 等)供诊断。 + * - turnId 恒为空:Herdr 帧无回合身份,走 PromptSubmit 文件水位纪律 + * (transcriptTurnIdentity: "absent");禁止伪造 turn 身份。 + */ + +export const FX_HERDR_SOURCE = "custom:fx"; +export const FX_HERDR_AGENT = "fx"; + +export type FxHerdrState = "idle" | "working" | "blocked"; +export type FxHerdrBlockedReason = "permission" | "question" | "recovery"; + +export interface FxHerdrReport { + customStatus?: string | undefined; + paneId: string; + sessionId?: string | undefined; + state: FxHerdrState; +} + +const PANE_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/; +const SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/; + +/** 单帧 JSON-RPC → Herdr 上报;非 report_agent 方法/非法帧返回 null。 */ +export function parseFxHerdrFrame(line: string): FxHerdrReport | null { + let parsed: unknown; + try { + parsed = JSON.parse(line) as unknown; + } catch { + return null; + } + if (!(parsed && typeof parsed === "object" && !Array.isArray(parsed))) { + return null; + } + const record = parsed as Record; + if (record.method !== "pane.report_agent") { + return null; + } + const params = record.params; + if (!(params && typeof params === "object" && !Array.isArray(params))) { + return null; + } + const p = params as Record; + if (p.source !== FX_HERDR_SOURCE || p.agent !== FX_HERDR_AGENT) { + return null; + } + const paneId = p.pane_id; + const state = p.state; + if (typeof paneId !== "string" || !PANE_ID_RE.test(paneId)) { + return null; + } + if (state !== "idle" && state !== "working" && state !== "blocked") { + return null; + } + const out: FxHerdrReport = { paneId, state }; + const customStatus = p.custom_status; + // 三元 reason 外的 custom_status 只作 Herdr UI 标签,不进入状态机。 + if ( + customStatus === "permission" || + customStatus === "question" || + customStatus === "recovery" + ) { + out.customStatus = customStatus; + } + const sessionId = p.agent_session_id; + if (typeof sessionId === "string" && SESSION_ID_RE.test(sessionId)) { + out.sessionId = sessionId; + } + return out; +} + +export type FxHerdrPierEvent = + | { event: "ActivityIdle" } + | { event: "processing" }; + +/** Herdr 上报 → Pier 规范事件(无回合身份、无 tool 上下文)。 */ +export function fxHerdrReportToPierEvent( + report: FxHerdrReport +): FxHerdrPierEvent { + if (report.state === "idle") { + return { event: "ActivityIdle" }; + } + if (report.state === "working") { + return { event: "processing" }; + } + // blocked 全种(含 permission/question/recovery):上游只有请求帧、无配对 + // 解除帧,按等待治理(请求+解除成对)不得进 waiting;保持 processing, + // 以 nativeEvent 区分原因(herdr.blocked.permission 等)。 + return { event: "processing" }; +} + +/** Herdr pane_id(`terminal-`)→ Pier panelId;非本窗格式返回 null。 */ +export function fxHerdrPanelId(paneId: string): string | null { + return paneId.startsWith("terminal-") && paneId.length > "terminal-".length + ? paneId + : null; +} + +export interface FxHerdrAgentEventFields { + event: FxHerdrPierEvent["event"]; + nativeEvent: string; + sessionId?: string | undefined; +} + +/** 审计用:report → 事件字段(含 nativeEvent 命名),与 ingest 组装分离。 */ +export function fxHerdrEventFields( + report: FxHerdrReport +): FxHerdrAgentEventFields { + const pier = fxHerdrReportToPierEvent(report); + const nativeEvent = + report.state === "blocked" + ? `herdr.blocked.${report.customStatus ?? "unknown"}` + : `herdr.${report.state}`; + return { + event: pier.event, + nativeEvent, + ...(report.sessionId ? { sessionId: report.sessionId } : {}), + }; +} + +/** report → 严格 v3 agentEvent(panelId/windowId 由调用方路由后填入)。 */ +export function fxHerdrToAgentEvent( + report: FxHerdrReport, + route: { panelId: string; windowId: string } +): AgentHookEventPayload { + const fields = fxHerdrEventFields(report); + return { + agent: "fx", + event: fields.event, + kind: "agentEvent", + nativeEvent: fields.nativeEvent, + panelId: route.panelId, + ...(fields.sessionId ? { sessionId: fields.sessionId } : {}), + v: 3, + windowId: route.windowId, + } as AgentHookEventPayload; +} diff --git a/src/shared/project-skills-pier-channels.ts b/src/shared/project-skills-pier-channels.ts index e5adc226a..6080c27a2 100644 --- a/src/shared/project-skills-pier-channels.ts +++ b/src/shared/project-skills-pier-channels.ts @@ -47,6 +47,7 @@ export const PIER_DISCOVERY_CHANNELS: readonly PierDiscoveryChannel[] = [ "grok", "goose", "droid", + "fx", ], }, { @@ -61,14 +62,15 @@ export const PIER_DISCOVERY_CHANNELS: readonly PierDiscoveryChannel[] = [ "cline", "crush", "aug", + "autohand", "kilo", "codebuff", - "autohand", "mimo-code", "omp", "openclaude", "grok", "goose", + "fx", ], }, ]; diff --git a/src/shared/skill-invoke.ts b/src/shared/skill-invoke.ts index 61509e8aa..ca4725d2c 100644 --- a/src/shared/skill-invoke.ts +++ b/src/shared/skill-invoke.ts @@ -3,7 +3,7 @@ * * | Agent family | Insert form | Notes | * |--------------|-------------|--------| - * | Codex | `$skill-id` | CLI custom skills; `/` is for commands | + * | Codex / fx | `$skill-id` | CLI custom skills; `/` is for commands | * | Goose | `/skills id` | Official using-skills | * | OMP / Pi | `/skill:id` | enableSkillCommands namespace | * | Claude / most skill TUIs | `/skill-id` | Official slash invoke | @@ -21,9 +21,10 @@ export type SkillInvokePrefix = "/" | "$"; /** * Agents that force-invoke skills with `$name`. - * Keep tight: only verified dollar invokers. + * Keep tight: only verified dollar invokers (codex CLI custom skills; + * fx `/skills` catalog + `$` composer search). */ -const DOLLAR_INVOKE_AGENTS = new Set(["codex"]); +const DOLLAR_INVOKE_AGENTS = new Set(["codex", "fx"]); /** * Agents with verified slash-style skill force-invoke (`/id`). diff --git a/tests/unit/agent-integrations/agent-status-trace-e2e.test.ts b/tests/unit/agent-integrations/agent-status-trace-e2e.test.ts index feaf1c303..ddf9e9599 100644 --- a/tests/unit/agent-integrations/agent-status-trace-e2e.test.ts +++ b/tests/unit/agent-integrations/agent-status-trace-e2e.test.ts @@ -124,7 +124,7 @@ describe("智能体状态官方轨迹跨层验收", () => { ); }); - it("27 个主动集成的 fixture 与 provider 可测维度严格等集(不含仅 host-Esc 维)", () => { + it("28 个主动集成的 fixture 与 provider 可测维度严格等集(不含仅 host-Esc 维)", () => { const actualAgents = new Set( ACTIVE_AGENT_STATUS_TRACES.map((trace) => trace.agentId) ); @@ -135,7 +135,7 @@ describe("智能体状态官方轨迹跨层验收", () => { ); const matrixCoverage = expectedActiveCoverage(); - expect(actualAgents.size).toBe(27); + expect(actualAgents.size).toBe(28); // 每个已声明、可由提供方输入验证的维度都必须有完整轨迹,不设缺口白名单。 expect(actualCoverage).toEqual(matrixCoverage); }); diff --git a/tests/unit/agent-integrations/status-traces/fx-herdr-trace.ts b/tests/unit/agent-integrations/status-traces/fx-herdr-trace.ts new file mode 100644 index 000000000..88a0e2a7d --- /dev/null +++ b/tests/unit/agent-integrations/status-traces/fx-herdr-trace.ts @@ -0,0 +1,113 @@ +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { connect } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fxIntegration } from "@main/services/agents/integrations/fx.ts"; +import { createFxHerdrListener } from "@main/services/foreground-activity/fx-herdr-listener.ts"; +import { action } from "./remaining-hosted-trace-helpers.ts"; +import type { + AgentStatusTraceAction, + AgentStatusTraceCheckpoint, + AgentStatusTraceFixture, + AgentStatusTraceProducer, +} from "./status-trace-types.ts"; + +function herdr( + state: string, + nativeEvent: string, + eventName: AgentStatusTraceCheckpoint["expectedEvent"], + dimension: AgentStatusTraceCheckpoint["dimension"], + expected: Omit< + AgentStatusTraceCheckpoint, + "dimension" | "expectedEvent" | "expectedNativeEvent" + >, + customStatus?: string +): AgentStatusTraceAction { + return action(nativeEvent, eventName, dimension, expected, { + custom_status: customStatus ?? null, + state, + }); +} + +/** + * fx Herdr socket 轨迹:producer 直连真实监听 socket,帧格式与 + * `src/builtins/hooks/herdr.zig` 的 wire 样本逐字节一致。 + */ +export async function createFxHerdrProducer(): Promise { + const root = await mkdtemp(join(tmpdir(), "pier-fx-herdr-trace-")); + const userData = join(root, "userData"); + const events: Record[] = []; + const listener = createFxHerdrListener({ + onAgentEvent: (event) => { + events.push(event as unknown as Record); + }, + resolveOwner: (panelId) => ({ panelId, windowId: "w1" }), + userData, + }); + const consumed = { count: 0 }; + const socketPath = listener.env().HERDR_SOCKET_PATH ?? ""; + return { + close: () => { + listener.dispose(); + return rm(root, { force: true, recursive: true }); + }, + async run(action: AgentStatusTraceAction) { + const payload = action.payload as { + custom_status: string | null; + state: string; + }; + const socket = connect(socketPath); + const replied = once(socket, "data"); + socket.setEncoding("utf8"); + const params: Record = { + agent: "fx", + pane_id: "terminal-fx-1", + source: "custom:fx", + state: payload.state, + }; + if (payload.custom_status) { + params.custom_status = payload.custom_status; + } + socket.write( + `${JSON.stringify({ id: "1", method: "pane.report_agent", params })}\n` + ); + await replied; + socket.end(); + const next = events.slice(consumed.count); + consumed.count = events.length; + if (next.length === 0) { + throw new Error(`fx:${action.nativeEvent} 监听未收到事件`); + } + return next; + }, + }; +} + +const fxActions: AgentStatusTraceAction[] = [ + herdr("working", "herdr.working", "processing", "processing", { + expectedStatus: "processing", + }), + herdr( + "blocked", + "herdr.blocked.permission", + "processing", + "processing", + { expectedStatus: "processing" }, + "permission" + ), + herdr("working", "herdr.working", "processing", "processing", { + expectedStatus: "processing", + }), + herdr("idle", "herdr.idle", "ActivityIdle", "ready", { + expectedStatus: "ready", + }), +]; + +export const FX_HERDR_STATUS_TRACE = { + actions: fxActions, + agentId: "fx", + covers: ["processing", "ready"], + createProducer: createFxHerdrProducer, + stopAuthority: fxIntegration.runtime.stopAuthority, +} as const satisfies AgentStatusTraceFixture; diff --git a/tests/unit/agent-integrations/status-traces/index.ts b/tests/unit/agent-integrations/status-traces/index.ts index e23027b5a..cb931a4a3 100644 --- a/tests/unit/agent-integrations/status-traces/index.ts +++ b/tests/unit/agent-integrations/status-traces/index.ts @@ -13,6 +13,7 @@ import { AMP_PLUGIN_STATUS_TRACE } from "./amp-plugin-trace.ts"; import { CODEBUDDY_RECONCILER_STATUS_TRACE } from "./codebuddy-reconciler-trace.ts"; import { EXTENSION_PLUGIN_STATUS_TRACES } from "./extension-plugin-traces.ts"; import { FLAT_COMMAND_STATUS_TRACES } from "./flat-command-traces.ts"; +import { FX_HERDR_STATUS_TRACE } from "./fx-herdr-trace.ts"; import { HOSTED_PLUGIN_STATUS_TRACES } from "./hosted-plugin-traces.ts"; import { NESTED_HOOK_STATUS_TRACES } from "./nested-hook-traces.ts"; import { INTERRUPT_RECONCILER_TRACES } from "./reconciled/interrupt-traces.ts"; @@ -39,6 +40,7 @@ export const ACTIVE_AGENT_STATUS_TRACES: readonly AgentStatusTraceFixture[] = [ ...BRANCHING_COMMAND_STATUS_TRACES, AMP_PLUGIN_STATUS_TRACE, ...EXTENSION_PLUGIN_STATUS_TRACES, + FX_HERDR_STATUS_TRACE, ]; import { BRANCHING_COMMAND_STATUS_TRACES } from "./branching-command-traces.ts"; diff --git a/tests/unit/main/agents/agent-hook-runtime-semantics.test.ts b/tests/unit/main/agents/agent-hook-runtime-semantics.test.ts index f79718f8f..150a9c2e8 100644 --- a/tests/unit/main/agents/agent-hook-runtime-semantics.test.ts +++ b/tests/unit/main/agents/agent-hook-runtime-semantics.test.ts @@ -196,7 +196,7 @@ describe("agent hook runtime semantics", () => { "qwen-code", ], authoritative: ["autohand", "mimo-code", "omp", "pi"], - none: ["aider", "amp", "cline", "crush", "hermes", "kimi", "kiro"], + none: ["aider", "amp", "cline", "crush", "fx", "hermes", "kimi", "kiro"], } as const; for (const [authority, agentIds] of Object.entries(expected)) { diff --git a/tests/unit/main/agents/agent-mcp-adapters.test.ts b/tests/unit/main/agents/agent-mcp-adapters.test.ts index de185e183..320f2db39 100644 --- a/tests/unit/main/agents/agent-mcp-adapters.test.ts +++ b/tests/unit/main/agents/agent-mcp-adapters.test.ts @@ -134,6 +134,7 @@ describe("MCP discovery adapters", () => { "claude", "codebuddy", "copilot", + "fx", "omp", "openclaude", "qodercli", diff --git a/tests/unit/main/agents/agent-mcp-catalog-service.test.ts b/tests/unit/main/agents/agent-mcp-catalog-service.test.ts index 60e4f3c32..8b87596fa 100644 --- a/tests/unit/main/agents/agent-mcp-catalog-service.test.ts +++ b/tests/unit/main/agents/agent-mcp-catalog-service.test.ts @@ -140,6 +140,7 @@ describe("createAgentMcpCatalogService", () => { "claude", "codebuddy", "copilot", + "fx", "omp", "openclaude", "qodercli", diff --git a/tests/unit/main/agents/grok/plugin-cross-tool-sync.test.ts b/tests/unit/main/agents/grok/plugin-cross-tool-sync.test.ts index 1a8194473..50b6e3910 100644 --- a/tests/unit/main/agents/grok/plugin-cross-tool-sync.test.ts +++ b/tests/unit/main/agents/grok/plugin-cross-tool-sync.test.ts @@ -132,6 +132,47 @@ describe("grok cross-tool sync", () => { expect(piAuth.xai).toEqual({ key: "xai-secret", type: "api_key" }); }); + it("writes the Grok subscription session into fx grok-auth.json (OIDC only)", async () => { + const homeDir = join(dir, "home-fx"); + const credential = extractOauthFromGrokAuth(AUTH); + const results = await syncCrossToolCredentials(["fx"], credential, { + homeDir, + }); + expect(results).toEqual([{ ok: true, target: "fx" }]); + const fxAuth = JSON.parse( + await readFile(join(homeDir, ".fx", "grok-auth.json"), "utf8") + ) as { + access_token: string; + account_id: string; + expires_at_ms: number; + refresh_token: string; + version: number; + }; + expect(fxAuth).toMatchObject({ + access_token: "access-token-xyz", + account_id: "user-1", + refresh_token: "refresh-token-xyz", + version: 1, + }); + expect(fxAuth.expires_at_ms).toBe(credential.expiresAtMs); + }); + + it("fails closed when syncing an API key to fx (no xAI key path)", async () => { + const homeDir = join(dir, "home-fx-api"); + const results = await syncCrossToolCredentials( + ["fx"], + { apiKey: "xai-secret", kind: "api_key" }, + { homeDir } + ); + expect(results).toEqual([ + { + ok: false, + target: "fx", + error: expect.stringContaining("no xAI API-key path"), + }, + ]); + }); + it("rolls back an omp API-key selection when disabling another API-key row fails", async () => { const ompHome = join(dir, ".omp", "agent"); await mkdir(ompHome, { recursive: true }); diff --git a/tests/unit/main/agents/grok/plugin-rpc-handlers.test.ts b/tests/unit/main/agents/grok/plugin-rpc-handlers.test.ts index 1d1b6322c..c5f2c2560 100644 --- a/tests/unit/main/agents/grok/plugin-rpc-handlers.test.ts +++ b/tests/unit/main/agents/grok/plugin-rpc-handlers.test.ts @@ -145,6 +145,7 @@ describe("Grok plugin RPC handlers", () => { const snapshot = await handlers.get("accounts.peerAvailability")?.(null); expect(snapshot).toEqual( expect.objectContaining({ + fx: expect.any(Boolean), omp: expect.any(Boolean), opencode: expect.any(Boolean), pi: expect.any(Boolean), diff --git a/tests/unit/main/agents/peer-sync-availability.test.ts b/tests/unit/main/agents/peer-sync-availability.test.ts index d948feaa6..51aa6248f 100644 --- a/tests/unit/main/agents/peer-sync-availability.test.ts +++ b/tests/unit/main/agents/peer-sync-availability.test.ts @@ -58,6 +58,7 @@ describe("detectPeerAvailability", () => { it("reports all peers unavailable in an empty home", async () => { dir = await mkdtemp(join(tmpdir(), "pier-peer-availability-")); expect(detectPeerAvailability({ homeDir: dir, pathEnv: dir })).toEqual({ + fx: false, omp: false, opencode: false, pi: false, @@ -65,6 +66,22 @@ describe("detectPeerAvailability", () => { }); }); + it("detects fx via home dir or binary on PATH", async () => { + dir = await mkdtemp(join(tmpdir(), "pier-peer-availability-")); + await mkdir(join(dir, ".fx"), { recursive: true }); + expect( + detectPeerAvailability({ homeDir: dir, pathEnv: dir }) + ).toMatchObject({ fx: true }); + + const empty = await mkdtemp(join(tmpdir(), "pier-peer-availability-")); + const bin = join(dir, "bin"); + await mkdir(bin, { recursive: true }); + await writeFile(join(bin, "fx"), ""); + expect( + detectPeerAvailability({ homeDir: empty, pathEnv: bin }) + ).toMatchObject({ fx: true }); + }); + it("detects opencode via config, pi via home, and omp only with agent.db", async () => { dir = await mkdtemp(join(tmpdir(), "pier-peer-availability-")); const opencodeConfigDir = join(dir, ".config", "opencode"); @@ -75,6 +92,7 @@ describe("detectPeerAvailability", () => { // Agent dir alone cannot verify oauth capability. expect(detectPeerAvailability({ homeDir: dir, pathEnv: dir })).toEqual({ + fx: false, omp: false, opencode: true, pi: true, @@ -83,6 +101,7 @@ describe("detectPeerAvailability", () => { await writeFile(join(dir, ".omp", "agent", "agent.db"), ""); expect(detectPeerAvailability({ homeDir: dir, pathEnv: dir })).toEqual({ + fx: false, omp: true, opencode: true, pi: true, @@ -104,6 +123,7 @@ describe("detectPeerAvailability", () => { pathEnv: bin, }) ).toEqual({ + fx: false, omp: false, opencode: true, pi: true, @@ -164,6 +184,7 @@ describe("partitionPeerTargets", () => { describe("effectivePeerAvailabilityForKind", () => { it("hides pi for OIDC when oauth is not capable, keeps pi for API keys", () => { const base = { + fx: true, omp: true, opencode: true, pi: true, @@ -178,4 +199,21 @@ describe("effectivePeerAvailabilityForKind", () => { }).pi ).toBe(true); }); + + it("hides fx for API-key accounts (OIDC session only), keeps fx for OIDC", () => { + const base = { + fx: true, + omp: true, + opencode: true, + pi: true, + piOauthCapable: false, + }; + expect(effectivePeerAvailabilityForKind("api_key", base).fx).toBe(false); + expect( + effectivePeerAvailabilityForKind("oidc", { + ...base, + piOauthCapable: true, + }).fx + ).toBe(true); + }); }); diff --git a/tests/unit/main/panel/foreground-activity-fx-herdr.test.ts b/tests/unit/main/panel/foreground-activity-fx-herdr.test.ts new file mode 100644 index 000000000..f7ec6e082 --- /dev/null +++ b/tests/unit/main/panel/foreground-activity-fx-herdr.test.ts @@ -0,0 +1,195 @@ +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { connect, createServer, type Server } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AgentHookEventPayload } from "@shared/contracts/agent/session.ts"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createFxHerdrListener, + fxHerdrSocketPath, +} from "../../../../src/main/services/foreground-activity/fx-herdr-listener.ts"; + +let root = ""; +let listenerDispose: Array<() => void> = []; + +afterEach(async () => { + for (const dispose of listenerDispose) { + dispose(); + } + listenerDispose = []; + if (root) { + await rm(root, { force: true, recursive: true }); + root = ""; + } + vi.unstubAllEnvs(); +}); + +async function sendFrame(socketPath: string, line: string): Promise { + const socket = connect(socketPath); + const replied = once(socket, "data"); + socket.setEncoding("utf8"); + socket.write(line.endsWith("\n") ? line : `${line}\n`); + // 监听在首行后即回单行 ok(fx 语义:≤250ms 回包后关连接)。 + await replied; + socket.end(); +} + +describe("fx Herdr 监听", () => { + it("socket 路径按实例 userData 隔离", async () => { + root = await mkdtemp(join(tmpdir(), "pier-fx-herdr-")); + const userData = join(root, "userData"); + expect(fxHerdrSocketPath(userData)).toBe(join(userData, "fx-herdr.sock")); + }); + + it("HERDR_PANE_ID 随面板 env 注入(withPanelStatusEnv 与 PIER_PANEL_ID 同值)", async () => { + const { withPanelStatusEnv } = await import( + "../../../../src/main/ipc/terminal/create-launch.ts" + ); + const out = withPanelStatusEnv( + undefined, + "terminal-9", + "7", + {}, + undefined, + "fx" + ); + expect(out.env?.HERDR_PANE_ID).toBe("terminal-9"); + expect(out.env?.PIER_PANEL_ID).toBe("terminal-9"); + const plain = withPanelStatusEnv(undefined, "terminal-9", "7", {}); + expect(plain.env?.HERDR_PANE_ID).toBeUndefined(); + }); + + it("单行 report_agent → hook 事件(owner 路由 + 回包后关连接)", async () => { + root = await mkdtemp(join(tmpdir(), "pier-fx-herdr-")); + const events: AgentHookEventPayload[] = []; + const userData = join(root, "userData"); + const listener = createFxHerdrListener({ + onAgentEvent: (event) => { + events.push(event); + }, + resolveOwner: (panelId) => + panelId === "terminal-3" ? { panelId, windowId: "11" } : null, + userData, + }); + listenerDispose.push(() => listener.dispose()); + const env = listener.env(); + expect(env.HERDR_SOCKET_PATH).toBe(listener.socketPath); + await sendFrame( + env.HERDR_SOCKET_PATH ?? "", + '{"id":"1","method":"pane.report_agent","params":{"pane_id":"terminal-3","source":"custom:fx","agent":"fx","state":"working"}}' + ); + // 未知面板的帧静默丢弃,不抛错。 + await sendFrame( + env.HERDR_SOCKET_PATH ?? "", + '{"id":"2","method":"pane.report_agent","params":{"pane_id":"terminal-9","source":"custom:fx","agent":"fx","state":"working"}}' + ); + // 异源帧静默丢弃。 + await sendFrame( + env.HERDR_SOCKET_PATH ?? "", + '{"id":"3","method":"pane.report_agent","params":{"pane_id":"terminal-3","source":"other","agent":"fx","state":"working"}}' + ); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + agent: "fx", + event: "processing", + nativeEvent: "herdr.working", + panelId: "terminal-3", + windowId: "11", + }); + }); + + it("socket 残留文件不导致 EADDRINUSE(listen 前清、dispose 后删)", async () => { + const { writeFileSync } = await import("node:fs"); + root = await mkdtemp(join(tmpdir(), "pier-fx-herdr-")); + const userData = join(root, "userData"); + const stale = join(userData, "fx-herdr.sock"); + const { mkdirSync } = await import("node:fs"); + mkdirSync(userData, { recursive: true }); + writeFileSync(stale, "stale"); + const events: AgentHookEventPayload[] = []; + const listener = createFxHerdrListener({ + onAgentEvent: (event) => { + events.push(event); + }, + resolveOwner: (panelId) => ({ panelId, windowId: "11" }), + userData, + }); + listenerDispose.push(() => listener.dispose()); + const env = listener.env(); + await sendFrame( + env.HERDR_SOCKET_PATH ?? "", + '{"id":"1","method":"pane.report_agent","params":{"pane_id":"terminal-3","source":"custom:fx","agent":"fx","state":"working"}}' + ); + expect(events).toHaveLength(1); + const path = env.HERDR_SOCKET_PATH ?? ""; + listener.dispose(); + const { existsSync } = await import("node:fs"); + expect(existsSync(path)).toBe(false); + }); + + it("blocked 全种保持 processing(原因进 nativeEvent,不进 waiting)", async () => { + root = await mkdtemp(join(tmpdir(), "pier-fx-herdr-")); + const events: AgentHookEventPayload[] = []; + const userData = join(root, "userData"); + const listener = createFxHerdrListener({ + onAgentEvent: (event) => { + events.push(event); + }, + resolveOwner: (panelId) => ({ panelId, windowId: "11" }), + userData, + }); + listenerDispose.push(() => listener.dispose()); + const env = listener.env(); + const socketPath = env.HERDR_SOCKET_PATH ?? ""; + await sendFrame( + socketPath, + '{"id":"1","method":"pane.report_agent","params":{"pane_id":"terminal-3","source":"custom:fx","agent":"fx","state":"blocked","custom_status":"permission"}}' + ); + await sendFrame( + socketPath, + '{"id":"2","method":"pane.report_agent","params":{"pane_id":"terminal-3","source":"custom:fx","agent":"fx","state":"blocked","custom_status":"recovery"}}' + ); + await sendFrame( + socketPath, + '{"id":"3","method":"pane.report_agent","params":{"pane_id":"terminal-3","source":"custom:fx","agent":"fx","state":"idle"}}' + ); + expect(events.map((event) => event.event)).toEqual([ + "processing", + "processing", + "ActivityIdle", + ]); + expect(events[0]).toMatchObject({ + nativeEvent: "herdr.blocked.permission", + }); + }); + + it("上游回包语义:单连接单行,回包后可关", async () => { + // fx 等 ≤250ms 单行回包;Pier 在首行后即回 ok,不等连接关闭。 + root = await mkdtemp(join(tmpdir(), "pier-fx-herdr-")); + const seen: string[] = []; + const server: Server = createServer((socket) => { + socket.setEncoding("utf8"); + socket.once("data", (chunk: string) => { + seen.push(chunk); + socket.write('{"ok":true}\n'); + socket.end(); + }); + }); + await new Promise((resolve) => { + server.listen(join(root, "probe.sock"), resolve); + }); + const reply = await new Promise((resolve) => { + const socket = connect(join(root, "probe.sock")); + socket.setEncoding("utf8"); + socket.write('{"id":"1"}\n'); + socket.once("data", (chunk: string) => { + resolve(chunk); + socket.end(); + }); + }); + expect(reply).toContain('"ok"'); + expect(seen).toHaveLength(1); + server.close(); + }); +}); diff --git a/tests/unit/main/project-skills/adapters.test.ts b/tests/unit/main/project-skills/adapters.test.ts index 91976bcc3..30cfb3af4 100644 --- a/tests/unit/main/project-skills/adapters.test.ts +++ b/tests/unit/main/project-skills/adapters.test.ts @@ -45,6 +45,7 @@ describe("project-skills discovery adapters", { timeout: 30_000 }, () => { "grok", "goose", "droid", + "fx", // Verified skills support, private roots only (audit evidence): "kiro", "qwen-code", @@ -255,7 +256,13 @@ describe("project-skills discovery adapters", { timeout: 30_000 }, () => { // Priority-override / user-shadows scanners resolve same-name copies // deterministically; only multi-root scanners that read BOTH projection // targets surface duplicates. - expect(duplicateKinds).toEqual(["autohand", "copilot", "crush", "cursor"]); + expect(duplicateKinds).toEqual([ + "autohand", + "copilot", + "crush", + "cursor", + "fx", + ]); expect( listDuplicateDiscoveryAgentKinds({ diff --git a/tests/unit/renderer/accounts/grok-format-account-error.test.ts b/tests/unit/renderer/accounts/grok-format-account-error.test.ts index d25ff2ebd..c9bbaf17f 100644 --- a/tests/unit/renderer/accounts/grok-format-account-error.test.ts +++ b/tests/unit/renderer/accounts/grok-format-account-error.test.ts @@ -13,6 +13,20 @@ describe("Grok formatAccountError", () => { ).toBe("No valid local Grok login found. Sign in with the Grok CLI first."); }); + it("maps fx install failures and the api-key fail-closed to distinct copy", () => { + expect(formatAccountError(new Error("fx: write failed"), t)).toBe( + "Couldn't sync credentials to fx. Make sure fx is installed on this device." + ); + expect( + formatAccountError( + new Error("fx has no xAI API-key path; sync an OIDC account instead"), + t + ) + ).toBe( + "fx can only receive login (OIDC) accounts. Switch to a login account to sync to fx." + ); + }); + it("keeps a safe CLI reason and adds an actionable next step", () => { expect( formatAccountError( diff --git a/tests/unit/shared/agent/fx-herdr.test.ts b/tests/unit/shared/agent/fx-herdr.test.ts new file mode 100644 index 000000000..18207a857 --- /dev/null +++ b/tests/unit/shared/agent/fx-herdr.test.ts @@ -0,0 +1,166 @@ +import { + FX_HERDR_AGENT, + FX_HERDR_SOURCE, + fxHerdrEventFields, + fxHerdrPanelId, + fxHerdrReportToPierEvent, + fxHerdrToAgentEvent, + parseFxHerdrFrame, +} from "@shared/contracts/agent/fx-herdr.ts"; +import { describe, expect, it } from "vitest"; + +function frame(params: Record): string { + return JSON.stringify({ + id: "7", + method: "pane.report_agent", + params: { agent: "fx", source: "custom:fx", ...params }, + }); +} + +describe("fx Herdr 上报协议", () => { + it("解析上游单行帧(working/idle/blocked+reason)", () => { + expect( + parseFxHerdrFrame(frame({ pane_id: "terminal-1", state: "working" })) + ).toEqual({ paneId: "terminal-1", state: "working" }); + expect( + parseFxHerdrFrame(frame({ pane_id: "terminal-1", state: "idle" })) + ).toEqual({ paneId: "terminal-1", state: "idle" }); + expect( + parseFxHerdrFrame( + frame({ + agent_session_id: "session-42", + custom_status: "permission", + pane_id: "terminal-1", + state: "blocked", + }) + ) + ).toEqual({ + customStatus: "permission", + paneId: "terminal-1", + sessionId: "session-42", + state: "blocked", + }); + }); + + it("精确匹配上游 wire 样本(含 custom_status 省略形态)", () => { + // src/builtins/hooks/herdr.zig: report_agent serializes a single + // newline-delimited json line. + expect( + parseFxHerdrFrame( + '{"id":"7","method":"pane.report_agent","params":{"pane_id":"w1:p1","source":"custom:fx","agent":"fx","state":"working","custom_status":"editing"}}\n' + ) + ).toEqual({ paneId: "w1:p1", state: "working" }); + expect( + parseFxHerdrFrame( + '{"id":"1","method":"pane.report_agent","params":{"pane_id":"w1:p1","source":"custom:fx","agent":"fx","state":"idle"}}' + ) + ).toEqual({ paneId: "w1:p1", state: "idle" }); + }); + + it("拒绝非 report_agent 方法、异源帧与非法 pane/state", () => { + expect(parseFxHerdrFrame("not json")).toBeNull(); + expect( + parseFxHerdrFrame( + JSON.stringify({ id: "1", method: "pane.rename", params: {} }) + ) + ).toBeNull(); + // pane.report_agent_session 是会话身份帧,不进状态机。 + expect( + parseFxHerdrFrame( + JSON.stringify({ + id: "3", + method: "pane.report_agent_session", + params: { + agent: "fx", + agent_session_id: "session-42", + pane_id: "terminal-1", + source: "custom:fx", + }, + }) + ) + ).toBeNull(); + expect( + parseFxHerdrFrame( + frame({ agent: "other", pane_id: "terminal-1", state: "working" }) + ) + ).toBeNull(); + expect( + parseFxHerdrFrame( + frame({ pane_id: "terminal-1", source: "other", state: "working" }) + ) + ).toBeNull(); + expect( + parseFxHerdrFrame(frame({ pane_id: "../escape", state: "working" })) + ).toBeNull(); + expect( + parseFxHerdrFrame(frame({ pane_id: "terminal-1", state: "flying" })) + ).toBeNull(); + }); + + it("映射纪律:working→processing,idle→ActivityIdle", () => { + expect( + fxHerdrReportToPierEvent({ paneId: "terminal-1", state: "working" }) + ).toEqual({ event: "processing" }); + expect( + fxHerdrReportToPierEvent({ paneId: "terminal-1", state: "idle" }) + ).toEqual({ event: "ActivityIdle" }); + }); + + it("映射纪律:blocked 全种保持 processing(无配对解除,不进 waiting)", () => { + // 上游只有请求帧、无配对解除帧;按等待治理(请求+解除成对)不得进 + // waiting。原因保留在 nativeEvent(herdr.blocked.permission 等)。 + for (const customStatus of [ + "permission", + "question", + "recovery", + ] as const) { + expect( + fxHerdrReportToPierEvent({ + customStatus, + paneId: "terminal-1", + state: "blocked", + }) + ).toEqual({ event: "processing" }); + } + expect( + fxHerdrReportToPierEvent({ paneId: "terminal-1", state: "blocked" }) + ).toEqual({ event: "processing" }); + }); + + it("nativeEvent 命名与 panel 路由", () => { + expect( + fxHerdrEventFields({ paneId: "terminal-1", state: "working" }).nativeEvent + ).toBe("herdr.working"); + expect( + fxHerdrEventFields({ paneId: "terminal-1", state: "idle" }).nativeEvent + ).toBe("herdr.idle"); + expect( + fxHerdrEventFields({ + customStatus: "permission", + paneId: "terminal-1", + state: "blocked", + }).nativeEvent + ).toBe("herdr.blocked.permission"); + expect(fxHerdrPanelId("terminal-abc")).toBe("terminal-abc"); + expect(fxHerdrPanelId("w1:p1")).toBeNull(); + expect(FX_HERDR_SOURCE).toBe("custom:fx"); + expect(FX_HERDR_AGENT).toBe("fx"); + }); + + it("组装严格 v3 agentEvent(turnId 恒为空,走文件水位纪律)", () => { + const event = fxHerdrToAgentEvent( + { paneId: "terminal-1", state: "working" }, + { panelId: "terminal-1", windowId: "7" } + ); + expect(event).toMatchObject({ + agent: "fx", + event: "processing", + kind: "agentEvent", + nativeEvent: "herdr.working", + panelId: "terminal-1", + v: 3, + windowId: "7", + }); + expect("turnId" in event).toBe(false); + }); +}); diff --git a/tests/unit/terminal/panel-status-env.test.ts b/tests/unit/terminal/panel-status-env.test.ts index c1c6ec3c2..7698d8c84 100644 --- a/tests/unit/terminal/panel-status-env.test.ts +++ b/tests/unit/terminal/panel-status-env.test.ts @@ -17,6 +17,20 @@ describe("withPanelStatusEnv", () => { }); }); + it("仅 fx 面板注入 HERDR_PANE_ID(非 fx 不伪造 Herdr 归属)", () => { + const fx = withPanelStatusEnv( + undefined, + "terminal-1", + "7", + hookEnv, + undefined, + "fx" + ); + expect(fx.env?.HERDR_PANE_ID).toBe("terminal-1"); + const plain = withPanelStatusEnv(undefined, "terminal-1", "7", hookEnv); + expect(plain.env?.HERDR_PANE_ID).toBeUndefined(); + }); + it("保留已有 launch 的 command/cwd/env, PIER_* 覆盖同名键", () => { const out = withPanelStatusEnv( { command: "claude", cwd: "/w", env: { FOO: "1", PIER_PANEL_ID: "x" } }, @@ -33,7 +47,10 @@ describe("withPanelStatusEnv", () => { it("hookEnv 空时仍注入路由变量(无异常回退)", () => { const out = withPanelStatusEnv(undefined, "panel-3", "7", {}); - expect(out.env).toEqual({ PIER_PANEL_ID: "panel-3", PIER_WINDOW_ID: "7" }); + expect(out.env).toEqual({ + PIER_PANEL_ID: "panel-3", + PIER_WINDOW_ID: "7", + }); }); it("剥离父级历史 PIER_AGENT_CALLER_* 且不重新注入", () => { From 75886c2042b2cb524619010b164deab601be7ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E9=A3=9E=E7=9A=84=E7=BE=8A?= <774070635@qq.com> Date: Tue, 15 Sep 2026 18:49:51 -0700 Subject: [PATCH 2/4] fix(peer-sync): genericize partitionPeerTargets for narrower plugin target sets Codex/Claude keep local CrossToolSyncTarget without fx; shared PeerSyncTarget gained fx for Grok. Generic preserves their types. --- packages/plugin-api/src/peer-sync/shared.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/plugin-api/src/peer-sync/shared.ts b/packages/plugin-api/src/peer-sync/shared.ts index a67ea1295..ab9d65545 100644 --- a/packages/plugin-api/src/peer-sync/shared.ts +++ b/packages/plugin-api/src/peer-sync/shared.ts @@ -70,16 +70,18 @@ export function effectivePeerAvailabilityForKind( * Split protocol-eligible targets into sync-ready vs not-installed. * Callers may further filter by protocol (e.g. Grok OIDC uses * `effectivePeerAvailabilityForKind` so pi requires `piOauthCapable`). + * Generic over the caller's target union so plugins with a narrower + * protocol set (Codex/Claude without fx) keep their local types. */ -export function partitionPeerTargets( - protocolTargets: readonly PeerSyncTarget[], +export function partitionPeerTargets( + protocolTargets: readonly T[], availability: PeerAvailability ): { - available: PeerSyncTarget[]; - unavailable: PeerSyncTarget[]; + available: T[]; + unavailable: T[]; } { - const available: PeerSyncTarget[] = []; - const unavailable: PeerSyncTarget[] = []; + const available: T[] = []; + const unavailable: T[] = []; for (const target of protocolTargets) { if (isPeerTargetAvailable(target, availability)) { available.push(target); From 3ebaff208ebf98e2c6c5328f8a6eda205ed9c2ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E9=A3=9E=E7=9A=84=E7=BE=8A?= <774070635@qq.com> Date: Tue, 15 Sep 2026 18:53:15 -0700 Subject: [PATCH 3/4] fix(grok): refresh remaining-resets live fixture (tokens expired 2026-09-12) Live grpc-web fixture validityEnd passed; countValidTokens now drops both tokens so 4 tests fail on main and branch. Rebuilt fixture with 2030 expiry, same wire shape. --- tests/unit/main/agents/grok/plugin-reset-credits.test.ts | 4 ++-- tests/unit/main/agents/grok/plugin-usage-fetch.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/main/agents/grok/plugin-reset-credits.test.ts b/tests/unit/main/agents/grok/plugin-reset-credits.test.ts index e1fa9150f..35ca82f60 100644 --- a/tests/unit/main/agents/grok/plugin-reset-credits.test.ts +++ b/tests/unit/main/agents/grok/plugin-reset-credits.test.ts @@ -14,13 +14,13 @@ const NOW = Date.parse("2026-08-14T00:00:00Z"); /** Live grpc-web-text body from prod_mc_billing.ConsumerUiSvc/GetRemainingResets. */ const LIVE_GRPC_WEB_TEXT = - "AAAAACNSIVINcmVzdG9rX3ZwWURxb6IBBgicgPPTBvIBBgicvZbVBg==gAAAAA9ncnBjLXN0YXR1czowDQo="; + "AAAAABhSFlILcmVzdG9rX2xpdmXyAQYIgLHvhgeAAAAAD2dycGMtc3RhdHVzOjANCg=="; /** Same live payload as a raw grpc-web+proto frame. Node/Electron fetch only * returns this when the request body is the 5-byte empty protobuf frame. */ const LIVE_GRPC_WEB_PROTO = Uint8Array.from( Buffer.from( - "00000000235221520d726573746f6b5f76705944716fa20106089c80f3d306f20106089cbd96d506800000000f677270632d7374617475733a300d0a", + "00000000185216520b726573746f6b5f6c697665f201060880b1ef8607800000000f677270632d7374617475733a300d0a", "hex" ) ); diff --git a/tests/unit/main/agents/grok/plugin-usage-fetch.test.ts b/tests/unit/main/agents/grok/plugin-usage-fetch.test.ts index 015853aea..d3d1ec558 100644 --- a/tests/unit/main/agents/grok/plugin-usage-fetch.test.ts +++ b/tests/unit/main/agents/grok/plugin-usage-fetch.test.ts @@ -568,7 +568,7 @@ describe("fetchGrokUsage", () => { let body = "{}"; if (url === GROK_REMAINING_RESETS_URL) { body = - "AAAAACNSIVINcmVzdG9rX3ZwWURxb6IBBgicgPPTBvIBBgicvZbVBg==gAAAAA9ncnBjLXN0YXR1czowDQo="; + "AAAAABhSFlILcmVzdG9rX2xpdmXyAQYIgLHvhgeAAAAAD2dycGMtc3RhdHVzOjANCg=="; } else if (url === GROK_BILLING_CREDITS_URL) { body = JSON.stringify({ config: { From 2a42ec7b29015f62529cf6b181a98a8fb500c15d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E9=A3=9E=E7=9A=84=E7=BE=8A?= <774070635@qq.com> Date: Tue, 15 Sep 2026 19:38:59 -0700 Subject: [PATCH 4/4] fix(fx): deterministic herdr ack and skills duplicate semantics - herdr listener replies ok only after the first frame is processed, so receiving ok means the event landed; no timing guess in tests - trace producer + test client swallow post-reply ECONNRESET races - fx skills discovery is multi-root-scan (reads .agents + .claude), health duplicate-discovery expectation updated --- .../foreground-activity/fx-herdr-listener.ts | 22 +++++++++++++++++-- .../project-skills/adapter-facts-extended.ts | 3 ++- .../status-traces/fx-herdr-trace.ts | 2 ++ .../foreground-activity-fx-herdr.test.ts | 6 ++++- tests/unit/main/project-skills/health.test.ts | 2 +- 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/main/services/foreground-activity/fx-herdr-listener.ts b/src/main/services/foreground-activity/fx-herdr-listener.ts index a20f251e3..226e22596 100644 --- a/src/main/services/foreground-activity/fx-herdr-listener.ts +++ b/src/main/services/foreground-activity/fx-herdr-listener.ts @@ -95,6 +95,14 @@ export function createFxHerdrListener(options: { socket.once("close", () => sockets.delete(socket)); socket.setEncoding("utf8"); let buffer = ""; + let replied = false; + const replyOnce = () => { + if (replied) { + return; + } + replied = true; + socket.write('{"ok":true}\n'); + }; socket.on("data", (chunk: string) => { buffer += chunk; let index = buffer.indexOf("\n"); @@ -104,17 +112,27 @@ export function createFxHerdrListener(options: { if (line) { handleLine(line); } + // 首帧落定后再回包:客户端收到 ok 即代表本连接首帧已处理 + // (接纳或静默丢弃),调用方无需猜时延即可断言。 + // fx 只要求 ≤250ms 内回包,处理为同 tick 同步工作。 + replyOnce(); index = buffer.indexOf("\n"); } if (buffer.length > 64 * 1024) { buffer = ""; } }); + socket.on("end", () => { + // 无换行尾部帧:处理但不再回包(单连接只回一次)。 + const tail = buffer.trim(); + buffer = ""; + if (tail) { + handleLine(tail); + } + }); socket.on("error", () => { // fx best-effort 上报;单连接失败不影响监听。 }); - // fx 等单行回包(≤250ms)后关连接;无回包则它自己超时关闭。 - socket.write('{"ok":true}\n'); } function ensureStarted(): void { diff --git a/src/main/services/project-skills/adapter-facts-extended.ts b/src/main/services/project-skills/adapter-facts-extended.ts index 1a665a5a1..6e589bf54 100644 --- a/src/main/services/project-skills/adapter-facts-extended.ts +++ b/src/main/services/project-skills/adapter-facts-extended.ts @@ -118,12 +118,13 @@ export const EXTENDED_SKILL_DISCOVERY_ADAPTERS: readonly SkillDiscoveryAdapter[] ], walkUpToRepoRoot: true, consumesProjectSkills: true, - duplicateSemantics: "coexist", + duplicateSemantics: "multi-root-scan", duplicatePolicy: "report", sessionRefresh: "new-session-recommended", probeCaveats: [ "workspace-level `skills/` root is excluded from Pier's unmanaged enumeration (non-dot root, too generic a directory name)", "additional workspace directories do not contribute skills", + "dual-delivery `.agents` + `.claude` projections both scanned without deterministic resolution — same content surfaces twice", ], officialDocsUrl: "https://fx.sh/docs/capabilities/skills", verifiedOn: "2026-09-15", diff --git a/tests/unit/agent-integrations/status-traces/fx-herdr-trace.ts b/tests/unit/agent-integrations/status-traces/fx-herdr-trace.ts index 88a0e2a7d..a586b82ba 100644 --- a/tests/unit/agent-integrations/status-traces/fx-herdr-trace.ts +++ b/tests/unit/agent-integrations/status-traces/fx-herdr-trace.ts @@ -58,6 +58,8 @@ export async function createFxHerdrProducer(): Promise state: string; }; const socket = connect(socketPath); + // dispose 销毁服务端连接时的客户端 RST 竞态:回包已收到即算送达。 + socket.on("error", () => {}); const replied = once(socket, "data"); socket.setEncoding("utf8"); const params: Record = { diff --git a/tests/unit/main/panel/foreground-activity-fx-herdr.test.ts b/tests/unit/main/panel/foreground-activity-fx-herdr.test.ts index f7ec6e082..510625cc0 100644 --- a/tests/unit/main/panel/foreground-activity-fx-herdr.test.ts +++ b/tests/unit/main/panel/foreground-activity-fx-herdr.test.ts @@ -27,10 +27,14 @@ afterEach(async () => { async function sendFrame(socketPath: string, line: string): Promise { const socket = connect(socketPath); + // 监听 dispose/对端 RST 与回包到达的竞态:回包已收到即算送达, + // 后续的客户端错误不再让测试失败(回包本身仍由 once 保证)。 + socket.on("error", () => {}); const replied = once(socket, "data"); socket.setEncoding("utf8"); socket.write(line.endsWith("\n") ? line : `${line}\n`); - // 监听在首行后即回单行 ok(fx 语义:≤250ms 回包后关连接)。 + // 监听首帧落定后才回单行 ok(fx 语义:≤250ms 回包后关连接), + // 收到回包即代表帧已处理,可直接断言事件。 await replied; socket.end(); } diff --git a/tests/unit/main/project-skills/health.test.ts b/tests/unit/main/project-skills/health.test.ts index 047968271..b9b26bb3b 100644 --- a/tests/unit/main/project-skills/health.test.ts +++ b/tests/unit/main/project-skills/health.test.ts @@ -366,7 +366,7 @@ describe("project-skills doctor", () => { // (project .opencode highest) — not a duplicate. Only multi-root // scanners without deterministic resolution that read BOTH projection // targets report duplicates (registry facts, 2026-07-20). - expect(adapters).toEqual(["autohand", "copilot", "crush", "cursor"]); + expect(adapters).toEqual(["autohand", "copilot", "crush", "cursor", "fx"]); for (const issue of dupes) { expect(issue.severity).toBe("notice"); expect(issue.degradePolicy).toBe("allowed");