diff --git a/README.md b/README.md index f7bf135bcf3..b065056ce3b 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,7 @@ harnesses, models and tools into one agent that finishes real work in your projects and apps. Ready from day one, yours to shape over time. Cindy runs locally on your own machine, using your real files and logged-in -apps. The first supported harnesses are **Claude Code** and **Codex** — more are -being added, and a native harness is in the works. Models and harnesses mix +apps. The first supported harnesses are **Claude Code**, **Codex**, and **Pi**. **Grok Build** is a fourth Cindy-hosted harness (same loop as Pi) when SuperGrok (xAI) is connected. Models and harnesses mix freely and can switch mid-task while your workspace, memory, skills and tools stay continuous; one task can even be planned, executed in parallel, and reviewed by agents on different harness × model combos. She can drive your diff --git a/README.zh-CN.md b/README.zh-CN.md index 4f1caabe7fe..4e7d2e2120b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -26,8 +26,7 @@ Cindy 是一个开源、开箱即用的 AI Agent。她把多套 Harness、模型 持续成长的伙伴,在真实工程和软件里把任务做完。一开始就好用,任你打扮,任你培养。 Cindy 运行在你自己的电脑上,使用你本地的文件和已登录的应用。首批兼容 -**Claude Code** 与 **Codex** 两套 Agent Harness——更多 Harness 正在接入,自研 -Harness 也在酝酿。模型与 Harness 自由组合、同一任务中随时切换,工作现场、记忆、 +**Claude Code**、**Codex** 与 **Pi** 三套 Agent Harness;已连接 SuperGrok(xAI)时还可选 **Grok Build**(与 Pi 同一套 Cindy 托管循环)。模型与 Harness 自由组合、同一任务中随时切换,工作现场、记忆、 Skill 和工具始终连续;一个任务还可以由不同 Harness × 模型组合的多个 agent 规划、并行执行、独立 review。她能操作浏览器、电脑和手机,并支持从 IM 和 定时任务派活。 diff --git a/apps/desktop/src/main/__tests__/fork.test.ts b/apps/desktop/src/main/__tests__/fork.test.ts index 26dc1345e61..6eb22caab7a 100644 --- a/apps/desktop/src/main/__tests__/fork.test.ts +++ b/apps/desktop/src/main/__tests__/fork.test.ts @@ -632,6 +632,71 @@ describe('forkSessionAtMessage', () => { expect(result.parentSessionId).toBe('src-session'); }); + it('grok-build hosted loop: fork uses the Pi tail-turn path, not UNSUPPORTED_HISTORY', async () => { + const target = makeMessageRow({ id: 'target-user', role: 'user', createdAt: 3000 }); + const priorUser = makeMessageRow({ + id: 'user-1', + role: 'user', + content: '"hi"', + createdAt: 2000, + }); + const priorAssistant = makeMessageRow({ + id: 'asst-1', + role: 'assistant', + content: '"hi back"', + createdAt: 2500, + }); + + selectQueue.push([makeSourceRow({ + agentKind: 'grok-build', + model: 'grok-4.6', + providerId: 'xai', + sdkSessionId: 'grok-build-session-source', + })]); + selectQueue.push([target]); + selectQueue.push([priorUser, priorAssistant]); + selectQueue.push([makeSourceRow({ + agentKind: 'grok-build', + model: 'grok-4.6', + providerId: 'xai', + sdkSessionId: 'grok-build-session-forked', + parentSessionId: 'src-session', + forkedAtMessageId: 'target-user', + })]); + queryMock.mockResolvedValue([ + { role: 'user', content: '"later"' }, + { role: 'user', content: '"target"' }, + ]); + forkSdkSessionMock.mockResolvedValue({ + newSdkSessionId: 'grok-build-session-forked', + uuidMap: new Map(), + }); + + const result = await forkSessionAtMessage('src-session', 'target-user'); + + expect(forkSdkSessionMock).toHaveBeenCalledOnce(); + expect(forkSdkSessionMock).toHaveBeenCalledWith('grok-build', { + sourceSdkSessionId: 'grok-build-session-source', + upToMessageId: undefined, + tailTurnsToDrop: 2, + title: '[Fork] Project A', + workingDir: '/work', + remoteHostId: null, + }); + const txArgs = txCalls.find((call) => call.name === 'fork.session')?.args as { + newSession: Record; + }; + expect(txArgs.newSession).toMatchObject({ + agentKind: 'grok-build', + model: 'grok-4.6', + providerId: 'xai', + sdkSessionId: 'grok-build-session-forked', + parentSessionId: 'src-session', + forkedAtMessageId: 'target-user', + }); + expect(result.parentSessionId).toBe('src-session'); + }); + it('codex path: maps preparation or thread/fork failures to a diagnosable error', async () => { const target = makeMessageRow({ id: 'target-user', role: 'user', createdAt: 3000 }); selectQueue.push([ diff --git a/apps/desktop/src/main/__tests__/rewind.test.ts b/apps/desktop/src/main/__tests__/rewind.test.ts index c6d6721c63b..9aabc729dd8 100644 --- a/apps/desktop/src/main/__tests__/rewind.test.ts +++ b/apps/desktop/src/main/__tests__/rewind.test.ts @@ -43,7 +43,7 @@ const writeWorktreeTreeForPathsMock = vi.fn(); const gitExecMock = vi.fn(); type FakeSession = { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; sdkSessionId: string; workDir: string; remoteHostId: string | null; @@ -66,13 +66,13 @@ const getSessionMock = vi.fn(() => fakeSession); const getSessionMetaMock = vi.fn(async () => fakeSession ? { sdkSessionId: fakeSession.sdkSessionId } : null, ); -function useFakeSession(agentKind: 'claude-code' | 'codex' | 'pi') { +function useFakeSession(agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') { fakeSession = { agentKind, sdkSessionId: agentKind === 'codex' ? 'codex-thread-old' - : agentKind === 'pi' + : agentKind === 'pi' || agentKind === 'grok-build' ? 'pi-session-old' : 'sdk-uuid-old', workDir: '/repo', @@ -957,6 +957,30 @@ describe('commitRewindAtMessage', () => { expect(result.id).toBe('sess-1'); }); + it('Grok Build hosted loop: rewind uses the Pi tail-turn path, not ACP fail-closed', async () => { + useFakeSession('grok-build'); + commitRewindFilesMock.mockResolvedValueOnce({ sdkSessionId: 'grok-build-session-rewound' }); + selectQueue.push([makeUserMessageRow({ agentMeta: null })]); + selectQueue.push([]); + selectQueue.push([ + makeUserMessageRow({ clientId: 'client-id', createdAt: 3000 }), + makeUserMessageRow({ clientId: 'later-user', createdAt: 5000 }), + ]); + selectQueue.push([ + makeSessionRow({ agentKind: 'grok-build', sdkSessionId: 'grok-build-session-rewound' }), + ]); + + const result = await commitRewindAtMessage('sess-1', 'client-id'); + + expect(result.id).toBe('sess-1'); + expect(commitRewindFilesMock).toHaveBeenCalledOnce(); + expect(commitRewindFilesMock).toHaveBeenCalledWith('', '', { tailTurnsToDrop: 2 }); + expect(txCalls.find((call) => call.name === 'rewind.commit')?.args).toMatchObject({ + sessionId: 'sess-1', + sdkSessionId: 'grok-build-session-rewound', + }); + }); + it('Codex: previews file rewind savepoints before commit (legacy numstat 方向对调)', async () => { useFakeSession('codex'); detectCwdMock.mockResolvedValueOnce({ gitInstalled: true, isGitRepo: true, repoRoot: '/repo', isInsideWorktree: false }); diff --git a/apps/desktop/src/main/cindy-brain/errandPrefsStore.ts b/apps/desktop/src/main/cindy-brain/errandPrefsStore.ts index 9c479fa335a..8b549ad7576 100644 --- a/apps/desktop/src/main/cindy-brain/errandPrefsStore.ts +++ b/apps/desktop/src/main/cindy-brain/errandPrefsStore.ts @@ -26,7 +26,7 @@ import { ownerScopedUserDataPath } from '../appSessionState.js'; const log = desktopMakerLogger.child('errand-prefs-store'); /** errand 会话可选的 agent 种类(与 sessions.agent_kind 同词汇表)。 */ -export const GHOST_ERRAND_AGENT_KINDS = ['cc', 'codex', 'pi'] as const; +export const GHOST_ERRAND_AGENT_KINDS = ['cc', 'codex', 'pi', 'grok-build'] as const; export type GhostErrandAgentKind = (typeof GHOST_ERRAND_AGENT_KINDS)[number]; /** errand 会话可选的思考强度(与 worker 同集合;minimal 刻意不收)。 */ diff --git a/apps/desktop/src/main/device-link/__tests__/crossProcessLock.test.ts b/apps/desktop/src/main/device-link/__tests__/crossProcessLock.test.ts index 3de5f7eddc5..d10e83133f8 100644 --- a/apps/desktop/src/main/device-link/__tests__/crossProcessLock.test.ts +++ b/apps/desktop/src/main/device-link/__tests__/crossProcessLock.test.ts @@ -691,7 +691,11 @@ describe('接管陈旧锁', () => { await expect( withCrossProcessLock(lock, { label: 'churn', waitMs: 2_000 }, async (s) => s), ).resolves.toEqual({ held: false, reason: 'busy' }); - expect(performance.now() - started).toBeLessThan(1_000); + // Windows rename+utimes on three reclaim hops is slower than the + // POSIX 1s bound (CI measured 1372ms). Still well under waitMs. + expect(performance.now() - started).toBeLessThan( + process.platform === 'win32' ? 2_000 : 1_000, + ); expect(takeovers).toBe(3); } finally { spy.mockRestore(); diff --git a/apps/desktop/src/main/hook-control/__tests__/defaults.test.ts b/apps/desktop/src/main/hook-control/__tests__/defaults.test.ts index 5aa1f388803..8eecc2367cd 100644 --- a/apps/desktop/src/main/hook-control/__tests__/defaults.test.ts +++ b/apps/desktop/src/main/hook-control/__tests__/defaults.test.ts @@ -21,6 +21,7 @@ function deps(over?: Partial): HookDefaultsDeps { 'claude-code': { providerId: null, model: 'claude-opus-4-8', effort: 'xhigh' }, codex: { providerId: 'xd', model: 'gpt-5.5', effort: 'high' }, pi: { providerId: null, model: 'claude-sonnet-5', effort: 'high' }, + 'grok-build': { providerId: null, model: 'grok-4.6', effort: 'high' }, }, }), getModels: (agentKind) => @@ -116,6 +117,7 @@ describe('resolveHookSessionConfig', () => { 'claude-code': { providerId: null, model: 'claude-opus-4-8', effort: 'ultra-draft' }, codex: { providerId: null, model: 'gpt-5.5', effort: 'high' }, pi: { providerId: null, model: 'claude-sonnet-5', effort: 'high' }, + 'grok-build': { providerId: null, model: 'grok-4.6', effort: 'high' }, }, }), }), @@ -138,6 +140,7 @@ describe('resolveHookSessionConfig', () => { 'claude-code': { providerId: null, model: 'gone-model', effort: 'high' }, codex: { providerId: null, model: 'gpt-5.5', effort: 'high' }, pi: { providerId: null, model: 'claude-sonnet-5', effort: 'high' }, + 'grok-build': { providerId: null, model: 'grok-4.6', effort: 'high' }, }, }), }), @@ -203,6 +206,7 @@ describe('resolveHookSessionConfig', () => { 'claude-code': { providerId: null, model: 'claude-opus-4-8', effort: 'xhigh' }, codex: { providerId: ' ', model: 'gpt-5.5', effort: 'high' }, pi: { providerId: null, model: 'claude-sonnet-5', effort: 'high' }, + 'grok-build': { providerId: null, model: 'grok-4.6', effort: 'high' }, }, }), }), diff --git a/apps/desktop/src/main/hook-control/defaults.ts b/apps/desktop/src/main/hook-control/defaults.ts index 9ebc102f711..679f4bcc8cc 100644 --- a/apps/desktop/src/main/hook-control/defaults.ts +++ b/apps/desktop/src/main/hook-control/defaults.ts @@ -37,24 +37,24 @@ import type { Effort } from '@cindy/maker-core'; /** 依赖注入面: IM 默认值 + 各 agent 当前可用模型清单。 */ export interface HookDefaultsDeps { readDefaults: () => { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; agents: Record< - 'claude-code' | 'codex' | 'pi', + 'claude-code' | 'codex' | 'pi' | 'grok-build', { providerId: string | null; model: string; effort: string } >; }; - getModels: (agentKind: 'claude-code' | 'codex' | 'pi') => Array<{ + getModels: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Array<{ id: string; efforts: readonly string[]; defaultEffort: string | null; }>; /** 该 agent 支持的权限档 id 清单(capabilities.permissionModes)。 */ - getPermissionModes: (agentKind: 'claude-code' | 'codex' | 'pi') => readonly string[]; + getPermissionModes: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => readonly string[]; log: { warn(msg: string): void }; } export interface ResolvedHookSessionConfig { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; effort: Effort | undefined; permissionMode: string; @@ -66,7 +66,7 @@ export interface ResolvedHookSessionConfig { providerId: string | null; } -const AGENT_KINDS = new Set(['claude-code', 'codex', 'pi']); +const AGENT_KINDS = new Set(['claude-code', 'codex', 'pi', 'grok-build']); /** * 合成新 hook 会话的 agent/model/effort。 @@ -84,9 +84,9 @@ export function resolveHookSessionConfig( const defaults = deps.readDefaults(); // 1. agent: 显式合法值 > 草稿默认 - const agentKind: 'claude-code' | 'codex' | 'pi' = + const agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build' = overrides.agentKind !== null && AGENT_KINDS.has(overrides.agentKind) - ? (overrides.agentKind as 'claude-code' | 'codex' | 'pi') + ? (overrides.agentKind as 'claude-code' | 'codex' | 'pi' | 'grok-build') : defaults.agentKind; const models = deps.getModels(agentKind); diff --git a/apps/desktop/src/main/im/__tests__/defaultSettingsStore.test.ts b/apps/desktop/src/main/im/__tests__/defaultSettingsStore.test.ts index 2ae3df3d4a0..374dac7699a 100644 --- a/apps/desktop/src/main/im/__tests__/defaultSettingsStore.test.ts +++ b/apps/desktop/src/main/im/__tests__/defaultSettingsStore.test.ts @@ -168,12 +168,17 @@ describe('im default settings store', () => { model: 'gpt-5.5', effort: 'high', }, - // legacy root mirror 是 resolved 满射快照(global 才做 diff),pi 槽为系统默认。 + // legacy root mirror 是 resolved 满射快照(global 才做 diff),未改过的槽为系统默认。 pi: { providerId: null, model: 'claude-sonnet-5', effort: 'high', }, + 'grok-build': { + providerId: null, + model: 'grok-4.6', + effort: 'high', + }, }); }); diff --git a/apps/desktop/src/main/im/__tests__/parseDefaultSettingsPatch.test.ts b/apps/desktop/src/main/im/__tests__/parseDefaultSettingsPatch.test.ts index 9d28b37cc70..0752c30cc4b 100644 --- a/apps/desktop/src/main/im/__tests__/parseDefaultSettingsPatch.test.ts +++ b/apps/desktop/src/main/im/__tests__/parseDefaultSettingsPatch.test.ts @@ -75,6 +75,7 @@ describe('parseImDefaultSettingsPatch', () => { 'claude-code': { providerId: 'p-cc', model: 'm-cc', effort: 'low' }, codex: { providerId: 'p-codex', model: 'm-codex', effort: 'low' }, pi: { providerId: 'p-pi', model: 'm-pi', effort: 'low' }, + 'grok-build': { providerId: 'p-gb', model: 'm-gb', effort: 'low' }, }, }; for (const key of Object.keys(IM_DEFAULT_SETTINGS) as Array) { diff --git a/apps/desktop/src/main/im/defaultSessionSettings.ts b/apps/desktop/src/main/im/defaultSessionSettings.ts index 758badc031d..cec305b0539 100644 --- a/apps/desktop/src/main/im/defaultSessionSettings.ts +++ b/apps/desktop/src/main/im/defaultSessionSettings.ts @@ -25,6 +25,8 @@ import { import { IM_DEFAULT_EFFORT_OVERRIDES, IM_DEFAULT_SETTINGS, + isImDefaultAgentKind, + type ImDefaultAgentKind, type ImDefaultAgentSettings, type ImDefaultSettingsChannel, } from '../../shared/imDefaultSettings.js'; @@ -72,7 +74,10 @@ export async function resolveImSessionDefaults( const requestedSettings = raw.agents[requestedAgent]; const model = pickModel(requestedAgent, requestedSettings, config, providers); const agentKind = model.agentKind; - const agentSettings = raw.agents[agentKind] ?? requestedSettings; + // IM 默认设置按可选 harness 存 per-agent 拷贝(含 grok-build); + // 落到未知 agent 时沿用请求 agent 的那份,来源/档位随后仍按落地模型 reconcile。 + const agentSettings = + (isImDefaultAgentKind(agentKind) ? raw.agents[agentKind] : undefined) ?? requestedSettings; // 先定来源再定 effort:effort 支持是 per-(来源, 模型) 的,保存的来源被停用改道后, // 必须按**最终落地来源**的拷贝 reconcile —— 按第一份 connected 拷贝(可能正是那份 // 停用拷贝)算出的档位,启用替代来源未必支持,直建会话会被上游拒 @@ -122,7 +127,8 @@ export async function resolveDefaultProviderIdForModel( } function pickModel( - requestedAgent: AgentKind, + // 请求 agent 恒来自 IM 默认设置(三选一);兜底才可能落到渠道配置的其它 agent。 + requestedAgent: ImDefaultAgentKind, settings: ImDefaultAgentSettings, config: ImOrchestratorConfig, providers: ProviderView[] | null, diff --git a/apps/desktop/src/main/im/defaultSettingsStore.ts b/apps/desktop/src/main/im/defaultSettingsStore.ts index 885328a1b1a..8fe5762bc7e 100644 --- a/apps/desktop/src/main/im/defaultSettingsStore.ts +++ b/apps/desktop/src/main/im/defaultSettingsStore.ts @@ -10,6 +10,7 @@ import path from 'node:path'; import { app } from 'electron'; import { + IM_DEFAULT_AGENT_KINDS, IM_DEFAULT_SETTINGS, IM_DEFAULT_SETTINGS_CHANNELS, type ImDefaultAgentKind, @@ -81,6 +82,10 @@ function normalizeSettings(raw: unknown): ImDefaultSettings { 'pi', rawAgentOrLegacy(rawAgents, 'pi', agentKind, legacySettings), ), + 'grok-build': normalizeAgentSettings( + 'grok-build', + rawAgentOrLegacy(rawAgents, 'grok-build', agentKind, legacySettings), + ), }, }; } @@ -348,9 +353,10 @@ function settingsOverrides( overrides.groupPermissionMode = value.groupPermissionMode; } const agents: Partial> = {}; - for (const agentKind of ['claude-code', 'codex', 'pi'] as const) { - if (!agentSettingsEqual(value.agents[agentKind], defaults.agents[agentKind])) { - agents[agentKind] = value.agents[agentKind]; + for (const agentKind of IM_DEFAULT_AGENT_KINDS) { + const current = value.agents[agentKind] ?? defaults.agents[agentKind]; + if (!agentSettingsEqual(current, defaults.agents[agentKind])) { + agents[agentKind] = current; } } if (Object.keys(agents).length > 0) overrides.agents = agents; @@ -364,8 +370,9 @@ function settingsCustomizedKeys(value: ImDefaultSettings, defaults: ImDefaultSet if (value.groupPermissionMode !== defaults.groupPermissionMode) { keys.push('groupPermissionMode'); } - for (const agentKind of ['claude-code', 'codex', 'pi'] as const) { - if (!agentSettingsEqual(value.agents[agentKind], defaults.agents[agentKind])) { + for (const agentKind of IM_DEFAULT_AGENT_KINDS) { + const current = value.agents[agentKind] ?? defaults.agents[agentKind]; + if (!agentSettingsEqual(current, defaults.agents[agentKind])) { keys.push(`agents.${agentKind}`); } } @@ -385,6 +392,7 @@ function cloneSettings(settings: ImDefaultSettings): ImDefaultSettings { 'claude-code': { ...settings.agents['claude-code'] }, codex: { ...settings.agents.codex }, pi: { ...settings.agents.pi }, + 'grok-build': { ...settings.agents['grok-build'] }, }, }; } diff --git a/apps/desktop/src/main/im/parseDefaultSettingsPatch.ts b/apps/desktop/src/main/im/parseDefaultSettingsPatch.ts index 3c6c8322825..b90ddbcd661 100644 --- a/apps/desktop/src/main/im/parseDefaultSettingsPatch.ts +++ b/apps/desktop/src/main/im/parseDefaultSettingsPatch.ts @@ -13,6 +13,7 @@ */ import { + IM_DEFAULT_AGENT_KINDS, IM_DEFAULT_SETTINGS, isImDefaultAgentKind, isImDefaultEffort, @@ -57,7 +58,7 @@ export function parseImDefaultSettingsPatch(raw: unknown): ImDefaultSettingsPatc const agentsPatch: NonNullable = {}; // 三个 harness 必须对称解析;漏掉 pi 会让 IM 设置页切 Pi 后改模型静默丢弃 // (store 本身支持 pi,见 defaultSettingsStore / IM_DEFAULT_SETTINGS.agents.pi)。 - for (const kind of ['claude-code', 'codex', 'pi'] as const) { + for (const kind of IM_DEFAULT_AGENT_KINDS) { if (kind in agentInput) { agentsPatch[kind] = parseImDefaultAgentSettings(kind, agentInput[kind]); } diff --git a/apps/desktop/src/main/localDb/chatHistoryReader.ts b/apps/desktop/src/main/localDb/chatHistoryReader.ts index 1cabb16a7a1..98c5369ae89 100644 --- a/apps/desktop/src/main/localDb/chatHistoryReader.ts +++ b/apps/desktop/src/main/localDb/chatHistoryReader.ts @@ -29,7 +29,8 @@ const messageRowid = sql`"messages"."rowid"`; // ── Types ─────────────────────────────────────────────────────────────────── export type HistoryOrder = 'asc' | 'desc'; -export type HistoryAgentKind = 'cc' | 'codex' | 'pi'; +/** sessions.agent_kind 的历史筛选取值;'cc' 是 Claude Code 的历史存储形态。 */ +export type HistoryAgentKind = 'cc' | 'codex' | 'pi' | 'grok-build'; export interface HistoryCursor { createdAt: number; // unix ms diff --git a/apps/desktop/src/main/localDb/chatHistorySearch.ts b/apps/desktop/src/main/localDb/chatHistorySearch.ts index ed5aae74489..8ed03248515 100644 --- a/apps/desktop/src/main/localDb/chatHistorySearch.ts +++ b/apps/desktop/src/main/localDb/chatHistorySearch.ts @@ -37,6 +37,7 @@ import type { HistoryRole, } from '@cindy/mcps'; +import type { HistoryAgentKind } from './chatHistoryReader'; import { getDbClient } from './client/current'; import { messages as messagesTable, sessions as sessionsTable } from './schema'; import { messageToCamel } from './mapper'; @@ -85,7 +86,12 @@ interface HitMeta { type SearchSessionStatus = 'active' | 'archived' | 'deleted'; -interface SearchChatHistoryEngineArgs extends SearchChatHistoryArgs { +interface SearchChatHistoryEngineArgs extends Omit { + /** + * 桌面端会话搜索可按 grok-build 过滤;MCP wire 契约的 agentKind 只有三种,是这里的 + * 真子集,工具层照旧直接传。过滤值就是 sessions.agent_kind 的存储形态。 + */ + agentKind: HistoryAgentKind | null; /** * Optional host-side filters for product entry points that should only expose * desktop-visible conversations. MCP callers omit these and keep the original diff --git a/apps/desktop/src/main/localDb/ipc/history.ts b/apps/desktop/src/main/localDb/ipc/history.ts index e22a700159c..7edeabaa6b7 100644 --- a/apps/desktop/src/main/localDb/ipc/history.ts +++ b/apps/desktop/src/main/localDb/ipc/history.ts @@ -21,7 +21,7 @@ import { sessions } from '../schema'; import { readLatestSessionTerminal, type SessionTerminalHint } from '../sessionTerminal'; import { requireObject, requireString, throwIpcError } from '../../utils/ipcValidate'; -const VALID_AGENT_KINDS: readonly HistoryAgentKind[] = ['cc', 'codex', 'pi']; +const VALID_AGENT_KINDS: readonly HistoryAgentKind[] = ['cc', 'codex', 'pi', 'grok-build']; const VALID_ORDERS: readonly HistoryOrder[] = ['asc', 'desc']; const VALID_ROLES: readonly HistoryRole[] = [ 'user', diff --git a/apps/desktop/src/main/localDb/ipc/messages.ts b/apps/desktop/src/main/localDb/ipc/messages.ts index bd677b2dc14..cfe5598ba60 100644 --- a/apps/desktop/src/main/localDb/ipc/messages.ts +++ b/apps/desktop/src/main/localDb/ipc/messages.ts @@ -35,6 +35,7 @@ import { extractMessagePreview, } from '../mapper'; import { throwIpcError, requireString } from '../../utils/ipcValidate'; +import type { DbAgentKind } from '../../../shared/agentKindConversion'; import * as broadcastTap from '../../device-link/broadcast-tap'; import { createLogger } from '../../logger'; import { collectCindyMediaHashes, commitMessageMediaRefs } from '../../cindy-media/chatAttachments'; @@ -1016,7 +1017,7 @@ export async function commitContextRebuild( reason: 'context-overflow' | 'model-window-switch' | 'pi-prompt-timeout' | 'native-session-recovery'; sourceUserClientId: string | null; - sourceAgentKind?: 'cc' | 'codex' | 'pi'; + sourceAgentKind?: DbAgentKind; sourceModel?: string | null; sourceProviderId?: string | null; expectedClearedAt?: number | null; @@ -1439,7 +1440,7 @@ export async function createMessage( * agentMeta 需要它;main 侧 SDK 事件落库路径必传,renderer pending echo 等 * 无 SDK 元信息的行留空(null 回落 session.agentKind)。 */ - agentKind?: 'cc' | 'codex' | 'pi' | null; + agentKind?: DbAgentKind | null; createdAt?: number; }, opts?: { @@ -2532,7 +2533,7 @@ export interface ParkedEngineSession { */ export async function findParkedEngineSession( sessionId: string, - targetDbKind: 'cc' | 'codex' | 'pi', + targetDbKind: DbAgentKind, ): Promise { const db = getDbClient().drizzle; const [sessRow] = await db diff --git a/apps/desktop/src/main/localDb/ipc/pluginWorkspaceSessions.ts b/apps/desktop/src/main/localDb/ipc/pluginWorkspaceSessions.ts index c92b73e1e80..aca3b20476b 100644 --- a/apps/desktop/src/main/localDb/ipc/pluginWorkspaceSessions.ts +++ b/apps/desktop/src/main/localDb/ipc/pluginWorkspaceSessions.ts @@ -76,7 +76,7 @@ export async function createPluginDraftSession(params: { * 由 mapper 兜底)——让插件建的 draft 跟随用户当前的模型/强度选择。 */ defaults?: { - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; model?: string; effort?: string; fastMode?: boolean; @@ -167,7 +167,7 @@ export async function createPluginDraftSession(params: { export async function createGhostErrandSession(params: { ghostId: string; title: string | null; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; model?: string; effort?: string; fastMode?: boolean; diff --git a/apps/desktop/src/main/localDb/ipc/search.ts b/apps/desktop/src/main/localDb/ipc/search.ts index 67d03c3c24f..f5e7aa6ad0d 100644 --- a/apps/desktop/src/main/localDb/ipc/search.ts +++ b/apps/desktop/src/main/localDb/ipc/search.ts @@ -14,7 +14,7 @@ import { optionalEnum, requireObject, throwIpcError } from '../../utils/ipcValid const SORT_VALUES = ['relevance', 'activityDesc', 'activityAsc'] as const; const SEMANTIC_MODE_VALUES = ['hybrid', 'keyword'] as const; const STATUS_VALUES = ['active', 'archived', 'all'] as const; -const AGENT_VALUES = ['all', 'cc', 'codex', 'pi'] as const; +const AGENT_VALUES = ['all', 'cc', 'codex', 'pi', 'grok-build'] as const; const LAST_ACTIVITY_VALUES = ['all', '1d', '3d', '7d', '30d'] as const; export function registerSearchIpc(): void { diff --git a/apps/desktop/src/main/localDb/ipc/sessions.ts b/apps/desktop/src/main/localDb/ipc/sessions.ts index 6668304dc3e..73337f5a933 100644 --- a/apps/desktop/src/main/localDb/ipc/sessions.ts +++ b/apps/desktop/src/main/localDb/ipc/sessions.ts @@ -38,7 +38,12 @@ import { buildSessionListFlightKey, runSessionListSingleFlight } from './session import { throwIpcError, requireString, requireObject } from '../../utils/ipcValidate'; import { bindDeletedPiSubagentCleanupCancel } from './piSubagentDeletion'; import { resolveBusinessSessionId } from '../../sessionIds'; -import { normalizeDbAgentKind } from '../../../shared/agentKindConversion'; +import { + dbToMakerAgentKind, + normalizeDbAgentKind, + type DbAgentKind, + type MakerAgentKindWire, +} from '../../../shared/agentKindConversion'; import { projectSessionContextWindow, type ContextWindowSession, @@ -552,7 +557,7 @@ const REMOTE_PERSIST_FIELDS = new Set([ export async function applyAgentSwitchToSessionRow( sessionId: string, patch: { - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: DbAgentKind; model: string; providerId: string | null | undefined; sdkSessionId?: string | null; @@ -983,7 +988,7 @@ export interface OverwritableAutoTitleTarget { * `reconcileCreateOptsAgainstDb` 处理的正是同一类漂移),用错 agent 会让标题 * 走错供应商 —— 纯 Codex / 纯 Claude 用户会因此只拿到 fallback 标题。 */ - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: MakerAgentKindWire; /** * 是否仍停在建会话时的裸默认标题。合成占位(纯附件消息)只允许覆写这一种 —— * fork 占位与上一条附件写下的合成占位都要保留到用户真正打字为止。 @@ -998,8 +1003,9 @@ export async function getOverwritableAutoTitle( const db = getDbClient().drizzle; const row = await selectSessionWithCount(db, id); if (!row) return null; - const agentKind = - row.agentKind === 'codex' || row.agentKind === 'pi' ? row.agentKind : 'claude-code'; + // 走映射正本:就地 ternary 会把 'cc' 之外的新引擎(grok-build)误判成 claude-code, + // 起名就跑去了错的供应商(见上方 agentKind 注释)。 + const agentKind = dbToMakerAgentKind(row.agentKind); const overwritable = row.title === DEFAULT_DRAFT_SESSION_TITLE || (!!row.parentSessionId && row.title.startsWith(FORK_PLACEHOLDER_TITLE_PREFIX)) || @@ -1275,7 +1281,7 @@ export function registerSessionIpc( const id = resolveBusinessSessionId(bodyObj.id); const createBody = bodyObj as Parameters[1]; // M16: agentKind 白名单校验(防止 renderer 传非法值) - const ALLOWED_AGENT_KINDS = new Set(['cc', 'codex', 'pi']); + const ALLOWED_AGENT_KINDS = new Set(['cc', 'codex', 'pi', 'grok-build']); if (bodyObj.agentKind !== undefined && !ALLOWED_AGENT_KINDS.has(bodyObj.agentKind as string)) { throwIpcError('INVALID_PARAMS', `invalid agentKind: ${String(bodyObj.agentKind)}`); } diff --git a/apps/desktop/src/main/localDb/mapper.ts b/apps/desktop/src/main/localDb/mapper.ts index fb9fa0a2aee..a691e5aaaff 100644 --- a/apps/desktop/src/main/localDb/mapper.ts +++ b/apps/desktop/src/main/localDb/mapper.ts @@ -43,6 +43,7 @@ import type { ScriptCapability, PreRunHookRunResult, } from '@cindy/maker-scheduler'; +import type { DbAgentKind } from '../../shared/agentKindConversion.js'; import { normalizeSessionSource } from '../../shared/sessionSource.js'; import type { SessionSource } from '../../shared/sessionSource.js'; import { normalizeWorkingDirForStorage } from '../../shared/workingDir.js'; @@ -468,7 +469,7 @@ export function messageCreateToRow( content: unknown; toolUseId?: string; agentMeta?: AgentMeta | null; - agentKind?: 'cc' | 'codex' | 'pi' | null; + agentKind?: DbAgentKind | null; createdAt?: number; }, now: number, diff --git a/apps/desktop/src/main/localDb/schema.ts b/apps/desktop/src/main/localDb/schema.ts index 03235f456d3..e960c618cc1 100644 --- a/apps/desktop/src/main/localDb/schema.ts +++ b/apps/desktop/src/main/localDb/schema.ts @@ -724,7 +724,7 @@ export const subagentRuns = sqliteTable( sessionId: text('session_id') .notNull() .references((): AnySQLiteColumn => sessions.id, { onDelete: 'cascade' }), - provider: text('provider', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + provider: text('provider', { enum: ['claude-code', 'codex', 'pi', 'grok-build'] }).notNull(), logicalAgentId: text('logical_agent_id').notNull(), parentToolUseId: text('parent_tool_use_id'), /** JSON string[] containing task/tool aliases observed for this logical child. */ @@ -794,7 +794,7 @@ export const subagentRunAliases = sqliteTable( sessionId: text('session_id') .notNull() .references((): AnySQLiteColumn => sessions.id, { onDelete: 'cascade' }), - provider: text('provider', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + provider: text('provider', { enum: ['claude-code', 'codex', 'pi', 'grok-build'] }).notNull(), alias: text('alias').notNull(), runId: text('run_id') .notNull() @@ -1144,7 +1144,7 @@ export const schedules = sqliteTable( * 引擎 fireOne 优先用 intervalMs 算 nextFireAt;旧 cron 数据 0015 migration 自动回填。 */ intervalMs: integer('interval_ms'), - agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi', 'grok-build'] }).notNull(), model: text('model'), /** * 显式选定的供应商(来源)id。NULL = 回落该 agent 原生默认来源(no-break, @@ -1263,7 +1263,7 @@ export const sessionGoals = sqliteTable( /** usageLimited 时记录的限额重置时刻(unix ms);到点自动续跑。其它状态为 null。 */ usageResetAt: integer('usage_reset_at'), lastReason: text('last_reason'), - agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi', 'grok-build'] }).notNull(), startedAt: integer('started_at').notNull(), updatedAt: integer('updated_at').notNull(), }, diff --git a/apps/desktop/src/main/maker-host/__tests__/catalogDerivedModels.test.ts b/apps/desktop/src/main/maker-host/__tests__/catalogDerivedModels.test.ts index 2b31d39b717..2639bd89609 100644 --- a/apps/desktop/src/main/maker-host/__tests__/catalogDerivedModels.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/catalogDerivedModels.test.ts @@ -18,6 +18,7 @@ import type { ModelDescriptor } from '@cindy/maker-core'; import { deriveAvailableModels, + deriveGrokBuildAvailableModels, refreshCatalogDerivedModels, resolvePiGatewayDescriptorProviderId, resolvePiRuntimeModelDescriptor, @@ -679,12 +680,17 @@ describe('deriveAvailableModels — dynamic-first catalog contract', () => { const piModels: ModelDescriptor[] = [ { id: 'stale-pi', displayName: 'Stale', contextWindow: 1, efforts: [], defaultEffort: null }, ]; + const grokBuildModels: ModelDescriptor[] = [ + { id: 'stale-grok-build', displayName: 'Stale', contextWindow: 1, efforts: [], defaultEffort: null }, + ]; const claudeRef = claudeModels; const codexRef = codexModels; const piRef = piModels; + const grokBuildRef = grokBuildModels; const target = { - getCapabilities(agent: 'claude-code' | 'codex' | 'pi') { + getCapabilities(agent: 'claude-code' | 'codex' | 'pi' | 'grok-build') { if (agent === 'pi') return { availableModels: piModels }; + if (agent === 'grok-build') return { availableModels: grokBuildModels }; return { availableModels: agent === 'claude-code' ? claudeModels : codexModels }; }, }; @@ -694,9 +700,11 @@ describe('deriveAvailableModels — dynamic-first catalog contract', () => { expect(claudeModels).toBe(claudeRef); expect(codexModels).toBe(codexRef); expect(piModels).toBe(piRef); + expect(grokBuildModels).toBe(grokBuildRef); expect(claudeModels).toEqual(deriveAvailableModels(injectedCatalog(), 'claude-code')); expect(codexModels).toEqual(deriveAvailableModels(injectedCatalog(), 'codex')); expect(piModels).toEqual(deriveAvailableModels(injectedCatalog(), 'pi')); + expect(grokBuildModels).toEqual(deriveGrokBuildAvailableModels(injectedCatalog())); }); }); @@ -827,3 +835,21 @@ describe('resolveModelDefaultContextWindow — settings defaults configure the s ).toBeNull(); }); }); + +describe('deriveGrokBuildAvailableModels', () => { + it('projects exclusive Grok catalog slugs and never a grok-build model row', () => { + const catalog = JSON.parse(JSON.stringify(BUNDLED_CATALOG)) as Catalog; + for (const provider of catalog.providers) { + if (provider.id !== 'xai') continue; + provider.models.pi = [ + model('xai/grok-4.6', { name: 'Grok 4.6', group: 'grok' }), + model('grok-4.5', { name: 'Grok 4.5', group: 'grok' }), + model('claude-opus-5', { name: 'Opus 5' }), + ]; + } + const models = deriveGrokBuildAvailableModels(catalog); + expect(models.some((entry) => entry.id === 'grok-build')).toBe(false); + expect(models.some((entry) => entry.id === 'claude-opus-5')).toBe(false); + expect(models.map((entry) => entry.id)).toEqual(expect.arrayContaining(['xai/grok-4.6', 'grok-4.5'])); + }); +}); diff --git a/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts b/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts index 2eafa660376..be9a32b3a6a 100644 --- a/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts @@ -7,6 +7,12 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { TEST_XD_GATEWAY_BASE_URL as XD_GATEWAY_BASE_URL } from '../../../test/vitest/clientEndpointsFixture'; +// Side-effect import so vite-node transforms the SUT graph during collection. +// Tests still isolate via vi.resetModules() + dynamic import below. Without this, +// the first test paid a cold maker-core transform (including grok-build ACP when +// it lived on the barrel) against Linux CI's 5s testTimeout and timed out; +// later tests in the same file passed once the cache was warm. +import '../codex-proxy-host.js'; type Registry = { set(threadId: string, text: string): void; diff --git a/apps/desktop/src/main/maker-host/__tests__/model-route-guard.test.ts b/apps/desktop/src/main/maker-host/__tests__/model-route-guard.test.ts index bc51bf84f47..75af636ad48 100644 --- a/apps/desktop/src/main/maker-host/__tests__/model-route-guard.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/model-route-guard.test.ts @@ -559,6 +559,21 @@ describe('materializeExclusiveProviderRoute', () => { .toEqual({ kind: 'pass' }); }); + it('Grok Build hosted harness pins exclusive Grok to SuperGrok', () => { + expect(materializeExclusiveProviderRoute(xaiViews(), 'grok-build', 'xai/grok-4.5', null)) + .toEqual({ kind: 'pin', providerId: 'xai' }); + expect(materializeExclusiveProviderRoute(xaiViews({ xd: true, xai: false }), 'grok-build', 'xai/grok-4.5', null)) + .toEqual({ kind: 'reject' }); + expect(checkModelRoute(xaiViews({ xd: true, xai: false }), 'grok-build', 'xai/grok-4.5', null)) + .toEqual({ kind: 'reject', reason: 'exclusive-source-unavailable' }); + expect(checkModelRoute(xaiViews(), 'grok-build', 'xai/grok-4.5', null)) + .toEqual({ kind: 'reroute', providerId: 'xai' }); + expect(materializeExclusiveProviderRoute(xaiViews(), 'grok-build', 'grok-4.6', null)) + .toEqual({ kind: 'reject' }); + expect(checkModelRoute(xaiViews(), 'claude-code', 'grok-4.6', null)) + .toEqual({ kind: 'reroute', providerId: 'xai' }); + }); + it('Claude/GPT 双来源保持 keep,不打断默认队列', () => { expect(materializeExclusiveProviderRoute(views(), 'claude-code', 'claude-opus-5', null)) .toEqual({ kind: 'keep' }); diff --git a/apps/desktop/src/main/maker-host/__tests__/newMakerDefaultsCache.test.ts b/apps/desktop/src/main/maker-host/__tests__/newMakerDefaultsCache.test.ts index 96fcf633cf0..204ba5e7a30 100644 --- a/apps/desktop/src/main/maker-host/__tests__/newMakerDefaultsCache.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/newMakerDefaultsCache.test.ts @@ -100,20 +100,24 @@ describe('getRemoteNewMakerDefaults (device-link 远程草稿镜像)', () => { }); }); - it('草稿变更广播快照始终包含 claude-code、codex、pi 三个槽', () => { + it('草稿变更广播快照始终包含 claude-code、codex、pi、grok-build 四个槽', () => { seed({ - lastByVendor: { pi: { model: 'claude-sonnet-4-6' } }, - modelChosenByVendor: { pi: false }, + lastByVendor: { pi: { model: 'claude-sonnet-4-6' }, 'grok-build': { model: 'grok-build' } }, + modelChosenByVendor: { pi: false, 'grok-build': true }, fastModeByModel: {}, effortByModel: {}, }); const snapshot = getRemoteNewMakerDefaultsByVendor(); - expect(Object.keys(snapshot)).toEqual(['claudeCode', 'codex', 'pi']); + expect(Object.keys(snapshot)).toEqual(['claudeCode', 'codex', 'pi', 'grokBuild']); expect(snapshot.pi).toMatchObject({ model: 'claude-sonnet-4-6', modelChosenByUser: false, }); + expect(snapshot.grokBuild).toMatchObject({ + model: 'grok-build', + modelChosenByUser: true, + }); }); it('reads thinkingEnabled from the provider model memory mirror', () => { diff --git a/apps/desktop/src/main/maker-host/active-catalog.ts b/apps/desktop/src/main/maker-host/active-catalog.ts index b914d5954a6..ef0a8ca71b7 100644 --- a/apps/desktop/src/main/maker-host/active-catalog.ts +++ b/apps/desktop/src/main/maker-host/active-catalog.ts @@ -185,8 +185,13 @@ export interface XdGatewayModelInfo { /** AIGateway 缓存 token 单价(per token);参与「免费」判定与价格展示。 */ cacheReadInputTokenCost?: number; cacheCreationInputTokenCost?: number; - /** 进哪些 runtime tab;v3 由服务端完整下发。 */ - agents?: AgentKind[]; + /** + * 进哪些 runtime tab;v3 由服务端完整下发。这里跟 shared/modelAccess 的 + * `ModelAccessGatewayModel.agents` 逐字对齐(同一份服务端协议):网关目录只服务 + * cc / codex / pi 三个 tab,grok-build 是本机 CLI、自带唯一内置模型,不进网关目录, + * 所以**不能**写成 `AgentKind[]` —— 那样两边协议类型会漂移。 + */ + agents?: ('claude-code' | 'codex' | 'pi')[]; name?: string; group?: string; description?: string; @@ -674,8 +679,9 @@ function modelRegistryMetaFields( modelId: string, ): RegistryMetaFields | undefined { // 模型 registry 的路由与 perAgent 覆盖只按 claude-code / codex 建键;Pi 是动态 BYOM, - // 无 registry per-agent 覆盖,按 agent 无关处理(取条目基线元数据)。 - const registryAgent = agent === 'pi' ? undefined : agent; + // 无 registry per-agent 覆盖,按 agent 无关处理(取条目基线元数据)。Grok Build + // 只有一个内置模型、不进模型平面,同样按 agent 无关处理。 + const registryAgent = agent === 'pi' || agent === 'grok-build' ? undefined : agent; const catalog = base ?? BUNDLED_CATALOG; const matched = findModelRegistryRoute(catalog.modelRegistry, providerId, modelId, registryAgent); if (!matched) return undefined; diff --git a/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts b/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts index c49c1a9e495..5d88c439278 100644 --- a/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts +++ b/apps/desktop/src/main/maker-host/catalog-to-descriptors.ts @@ -24,6 +24,7 @@ import { isLegacyGptContextProfile } from './legacy-context-profiles.js'; import { PI_REASONING_EFFORTS, isAgentSelectableModel, + isExclusiveXaiModelId, isModelSelectableForNewRoute, type Catalog, type CatalogModel, @@ -61,6 +62,17 @@ function hasValidPiReasoningCapabilities(m: CatalogModel): boolean { ); } +/** ModelDescriptor.newSessionDefault 的元素类型(maker-core 只为三个 wire agent 记种子)。 */ +type NewSessionDefaultAgent = NonNullable[number]; + +/** + * grok-build 不进新对话默认种子:独占 Grok 由 SuperGrok 供货,目录里出现该标记 + * 只能是脏数据。这里丢弃而不是投影,避免下游按不存在的目录默认改路由。 + */ +function isNewSessionDefaultAgent(agent: AgentKind): agent is NewSessionDefaultAgent { + return agent !== 'grok-build'; +} + /** CatalogModel → ModelDescriptor。仅透传 ModelDescriptor 需要的字段;可选字段缺省时不写键。 */ function toDescriptor( m: CatalogModel, @@ -99,7 +111,9 @@ function toDescriptor( if (m.defaultEnabled !== undefined) d.defaultEnabled = m.defaultEnabled; // 新对话默认种子标记要透传:渲染层 getDefaultModelForVendor 据它优先选中被标记的模型。 // v3 可携带 Pi 自己的标记;消费端按 Agent 严格解释,不跨 Agent 借用默认策略。 - if (m.newSessionDefault !== undefined) d.newSessionDefault = m.newSessionDefault; + if (m.newSessionDefault !== undefined) { + d.newSessionDefault = m.newSessionDefault.filter(isNewSessionDefaultAgent); + } if (m.cost !== undefined) d.cost = m.cost; if (m.maxOutput !== undefined) d.maxOutputTokens = m.maxOutput; const supportsImageInput = @@ -138,6 +152,7 @@ function mergeNewSessionDefaultMarker( next: ModelDescriptor, agent: AgentKind, ): ModelDescriptor { + if (!isNewSessionDefaultAgent(agent)) return first; const hasNewMarker = next.newSessionDefault?.includes(agent) === true && first.newSessionDefault?.includes(agent) !== true; @@ -191,6 +206,15 @@ export function deriveAvailableModels(catalog: Catalog, agent: AgentKind): Model return out; } +/** Exclusive Grok catalog slugs for the Grok Build harness (Cindy model plane). */ +export function deriveGrokBuildAvailableModels(catalog: Catalog): ModelDescriptor[] { + const fromPi = deriveAvailableModels(catalog, 'pi').filter((model) => isExclusiveXaiModelId(model.id)); + if (fromPi.length > 0) return fromPi; + return deriveAvailableModels(catalog, 'claude-code').filter((model) => + isExclusiveXaiModelId(model.id), + ); +} + /** * 解析 Pi 当前持久化选择所需的运行时描述符,不参与公开模型清单或新路由准入。 * 只使用 Pi 自己目录中的实际来源实体(允许 disabled/retired 供续跑)。缺少 Pi 条目时 @@ -289,4 +313,10 @@ export function refreshCatalogDerivedModels( } availableModels.splice(0, availableModels.length, ...deriveAvailableModels(catalog, agent)); } + try { + const grokModels = target.getCapabilities('grok-build').availableModels; + grokModels.splice(0, grokModels.length, ...deriveGrokBuildAvailableModels(catalog)); + } catch { + // grok-build 与 pi 共用 hosted loop;未注册时跳过。 + } } diff --git a/apps/desktop/src/main/maker-host/grok-build-host.ts b/apps/desktop/src/main/maker-host/grok-build-host.ts new file mode 100644 index 00000000000..2f6bd64132c --- /dev/null +++ b/apps/desktop/src/main/maker-host/grok-build-host.ts @@ -0,0 +1,41 @@ +/** + * Grok Build desktop host — Cindy-hosted harness (same loop as Pi / bar as Claude Code). + * + * Registration follows the Cindy Pi runtime, not PATH `grok`. Auth is SuperGrok / + * Cindy gateway via `desktopPiAuthAdapter`. Sessions live in a sibling agent-home + * so Pi JSONL is not mixed. + */ + +import path from 'node:path'; +import { app } from 'electron'; + +import { GrokBuildAgent } from '@cindy/maker-core/grok-build'; +import type { Logger } from '@cindy/maker-core'; + +import { buildDesktopPiLoopDeps, type BuildPiAgentOpts } from './pi-host.js'; + +function createLogger(base: Logger): Logger { + return base.child('grok-build-host'); +} + +export function resolveGrokBuildAgentHome(remoteHostId?: string | null): string { + if (remoteHostId) return '$HOME/.xdt-server/v1/grok-build-agent-home'; + return path.join(app.getPath('userData'), 'grok-build-agent-home'); +} + +export function buildGrokBuildAgent(opts: BuildPiAgentOpts): GrokBuildAgent | null { + const log = createLogger(opts.logger); + const deps = buildDesktopPiLoopDeps({ + ...opts, + logger: log, + resolvePiAgentHome: opts.resolvePiAgentHome ?? resolveGrokBuildAgentHome, + }); + if (!deps) { + log.info('Cindy hosted loop unavailable; grok-build harness disabled for this launch'); + return null; + } + log.info('grok-build harness enabled (Cindy hosted loop)', { + binaryPath: deps.binaryPath, + }); + return new GrokBuildAgent(deps); +} diff --git a/apps/desktop/src/main/maker-host/index.ts b/apps/desktop/src/main/maker-host/index.ts index 937e16bfa60..62d14fa2d87 100644 --- a/apps/desktop/src/main/maker-host/index.ts +++ b/apps/desktop/src/main/maker-host/index.ts @@ -135,6 +135,7 @@ import { readAgentResourceSettings } from './agent-resource-settings-store.js'; import { createCommandConcurrencyGate } from './command-concurrency-gate.js'; import { deriveAvailableModels, + deriveGrokBuildAvailableModels, refreshCatalogDerivedModels, resolvePiRuntimeModelDescriptor, resolvePiGatewayDescriptorProviderId, @@ -145,12 +146,13 @@ import { readModelContextLimit } from './model-context-limit-store.js'; import { prepareCodexCustomContextCatalog, } from './codex-custom-context-catalog.js'; -import { buildPiAgent } from './pi-host.js'; +import { buildPiAgent, type BuildPiAgentOpts } from './pi-host.js'; import { captureLocalPiPackageRuntimeInvalidationSnapshot, invalidateLocalPiPackageRuntimeSnapshot, type PiPackageRuntimeInvalidationSnapshot, } from './pi-package-runtime-invalidation.js'; +import { buildGrokBuildAgent } from './grok-build-host.js'; import { clearChatgptBridgeCredentialCache } from './anthropic-responses-bridge-host.js'; import { getDesktopSelectableCatalog, @@ -1981,7 +1983,7 @@ export function getMaker(): Maker { // Store mutations are serialized; each settled callback consumes the exact // latest-byte-edge runtime snapshot for its durable mutation. const pendingPiPackageRuntimeSnapshots: PiPackageRuntimeInvalidationSnapshot[] = []; - const buildPiAgentForDesktop = () => buildPiAgent({ + const desktopHostedLoopOpts: BuildPiAgentOpts = { logger: desktopMakerLogger, turnChangeCapture: { beforeKnownFileWrite: captureKnownFileBefore, @@ -2255,8 +2257,8 @@ export function getMaker(): Maker { } return getRemoteAgentProxyEnv(remoteHost); }, - }); - const piAgent = buildPiAgentForDesktop(); + }; + const piAgent = buildPiAgent(desktopHostedLoopOpts); if (piAgent) makerAgents.pi = piAgent; setVisionGatewayKeyReader(readClaudeApiKey); @@ -2288,6 +2290,13 @@ export function getMaker(): Maker { }, }); + const grokBuildAgent = buildGrokBuildAgent({ + ...desktopHostedLoopOpts, + capabilityAdditions: { + availableModels: deriveGrokBuildAvailableModels(getDesktopSelectableCatalog()), + }, + }); + if (grokBuildAgent) makerAgents['grok-build'] = grokBuildAgent; const buildBotRuntimeDeps = (skillLinksChanged = false): BotProfileRuntimeDeps => ({ listSkills: async ({ agentKind, workingDir, remoteHostId }) => { if (!_maker) throw new Error('Maker is not ready while hydrating Bot runtime'); @@ -2590,7 +2599,7 @@ export function getMaker(): Maker { }; _registerPiAgent = () => { if (!_maker || _maker.listAvailableAgents().includes('pi')) return false; - const next = buildPiAgentForDesktop(); + const next = buildPiAgent(desktopHostedLoopOpts); if (!next) return false; const registered = _maker.registerAgent('pi', next); if (!registered) { diff --git a/apps/desktop/src/main/maker-host/model-plane/modelPlanePolicy.ts b/apps/desktop/src/main/maker-host/model-plane/modelPlanePolicy.ts index 3cbd1fc481c..4bbddb4d659 100644 --- a/apps/desktop/src/main/maker-host/model-plane/modelPlanePolicy.ts +++ b/apps/desktop/src/main/maker-host/model-plane/modelPlanePolicy.ts @@ -81,6 +81,8 @@ export function isRegistryTombstoneForConsumer( if (!registry || !policy) return false; if (agent === 'pi') return false; + // Grok Build reuses the hosted loop; it is not a Registry consumer. + if (agent === 'grok-build') return false; const registryAgent = policy.roots.includes(agent) || policy.membershipGatedBridges.includes(agent) ? agent : null; if (!registryAgent) return false; diff --git a/apps/desktop/src/main/maker-host/model-route-guard-live.ts b/apps/desktop/src/main/maker-host/model-route-guard-live.ts index a86c719c6f5..8f0420cf641 100644 --- a/apps/desktop/src/main/maker-host/model-route-guard-live.ts +++ b/apps/desktop/src/main/maker-host/model-route-guard-live.ts @@ -319,6 +319,8 @@ const DEFAULT_ONESHOT_MODEL: Record = { codex: 'gpt-5.4-mini', // pi oneShot 未实现(BaseAgent 默认抛 NotSupported);占位与 claude 同款网关小模型。 pi: 'claude-haiku-4-5', + // grok-build oneShot 未实现;占位用内置模型 id。 + 'grok-build': 'grok-build', }; /** diff --git a/apps/desktop/src/main/maker-host/model-route-guard.ts b/apps/desktop/src/main/maker-host/model-route-guard.ts index 49f0103b2b8..49c64dffdb0 100644 --- a/apps/desktop/src/main/maker-host/model-route-guard.ts +++ b/apps/desktop/src/main/maker-host/model-route-guard.ts @@ -110,12 +110,15 @@ export function materializeExclusiveProviderRoute( providerId: string | null, ): ExclusiveProviderRoute { if (!isExclusiveXaiModelId(modelId)) return { kind: 'keep' }; + // Grok Build is a Cindy hosted harness, not a catalog agent. Exclusive Grok + // still requires SuperGrok; look up the xAI copy on the Pi model plane. + const planeAgent: AgentKind = agent === 'grok-build' ? 'pi' : agent; const xai = views.find( (provider) => provider.id === 'xai' && provider.connected && provider.suspended !== true - && provider.agents.includes(agent), + && provider.agents.includes(planeAgent), ); if (!xai) { return providerId && !shouldApplyExclusiveProviderReroute(providerId, views) && providerId !== 'xai' @@ -124,8 +127,13 @@ export function materializeExclusiveProviderRoute( } const catalogId = exclusiveXaiCatalogModelId(modelId); const copy = - (catalogId ? getModel(xai, catalogId, agent) : undefined) - ?? getModel(xai, modelId.replace(/\[1m\]$/i, ''), agent); + (catalogId ? getModel(xai, catalogId, planeAgent) : undefined) + ?? getModel(xai, modelId.replace(/\[1m\]$/i, ''), planeAgent) + ?? (agent === 'grok-build' + ? (catalogId ? getModel(xai, catalogId, 'claude-code') : undefined) + ?? getModel(xai, modelId.replace(/\[1m\]$/i, ''), 'claude-code') + : undefined); + if (agent === 'grok-build' && !copy) return { kind: 'reject' }; if (copy && !isModelSelectableForNewRoute(copy, { userProvider: false })) { return { kind: 'reject' }; } diff --git a/apps/desktop/src/main/maker-host/newMakerDefaultsCache.ts b/apps/desktop/src/main/maker-host/newMakerDefaultsCache.ts index c86af1ed094..b65052aebb8 100644 --- a/apps/desktop/src/main/maker-host/newMakerDefaultsCache.ts +++ b/apps/desktop/src/main/maker-host/newMakerDefaultsCache.ts @@ -14,10 +14,18 @@ import { * 不再用 hardcode 默认值,优先读这份缓存 —— worker 实际启动参数 = "用户在 New Maker * 面板里该 vendor 当前的选择";旧 renderer 未推 providerId 时,创建服务才回退 Lead 来源。 * - * Vendor 名称差异: renderer 用 'cc' / 'codex' / 'pi'; worker spawn 路径用 - * 'claude-code' / 'codex' / 'pi'。getWorkerDefaultsFromNewMaker 内部做映射。 + * Vendor 名称差异: renderer 用 'cc' / 'codex' / 'pi' / 'grok-build'; + * worker spawn / 草稿镜像路径用 'claude-code' / 'codex' / 'pi' / 'grok-build'。 */ -type VendorKey = 'cc' | 'codex' | 'pi'; +type VendorKey = 'cc' | 'codex' | 'pi' | 'grok-build'; +type DraftAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; + +function vendorKeyOf(agentKind: DraftAgentKind): VendorKey { + if (agentKind === 'claude-code') return 'cc'; + if (agentKind === 'pi') return 'pi'; + if (agentKind === 'grok-build') return 'grok-build'; + return 'codex'; +} interface VendorPrefsSnapshot { model?: string; @@ -116,10 +124,10 @@ export function getThinkingEnabledFromMemory( * 缓存未就绪 / 该 vendor 没有偏好 → 返回空对象, 调用方按自己的兜底规则处理。 */ export function getWorkerDefaultsFromNewMaker( - workerAgent: 'claude-code' | 'codex' | 'pi', + workerAgent: DraftAgentKind, ): WorkerDefaultsFromNewMaker { if (!cache) return {}; - const vendor: VendorKey = workerAgent === 'claude-code' ? 'cc' : workerAgent === 'pi' ? 'pi' : 'codex'; + const vendor = vendorKeyOf(workerAgent); const prefs = cache.lastByVendor[vendor]; if (!prefs?.model) return {}; const model = prefs.model; @@ -168,10 +176,9 @@ export interface RemoteNewMakerDefaults { } export function getRemoteNewMakerDefaults( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: DraftAgentKind, ): RemoteNewMakerDefaults { - const vendor: VendorKey = - agentKind === 'claude-code' ? 'cc' : agentKind === 'pi' ? 'pi' : 'codex'; + const vendor = vendorKeyOf(agentKind); // providerModelMemory(草稿列表行真实读源)与「该 vendor 是否选过模型」无关:即便 cache 未就绪 / // 该 vendor 无选中模型(lastByVendor 空),只要被控端有模型级预设就要全量回给控制端, // 否则 req1「完整镜像被控端草稿模型列表」在这条边界上回落 capabilities 默认。故在所有早返回里都带上它。 @@ -205,10 +212,12 @@ export function getRemoteNewMakerDefaultsByVendor(): { claudeCode: RemoteNewMakerDefaults; codex: RemoteNewMakerDefaults; pi: RemoteNewMakerDefaults; + grokBuild: RemoteNewMakerDefaults; } { return { claudeCode: getRemoteNewMakerDefaults('claude-code'), codex: getRemoteNewMakerDefaults('codex'), pi: getRemoteNewMakerDefaults('pi'), + grokBuild: getRemoteNewMakerDefaults('grok-build'), }; } diff --git a/apps/desktop/src/main/maker-host/pi-host.ts b/apps/desktop/src/main/maker-host/pi-host.ts index 5bf3b142a4b..4c7579e8818 100644 --- a/apps/desktop/src/main/maker-host/pi-host.ts +++ b/apps/desktop/src/main/maker-host/pi-host.ts @@ -988,6 +988,8 @@ export interface BuildPiAgentOpts { ) => Promise<{ url: string; close: () => void }>; /** 远端 agent-proxy env(HTTPS_PROXY/HTTP_PROXY/NO_PROXY 经 SSH remote-forward)。 */ getRemotePiAgentProxyEnv?: AgentDeps['getRemotePiAgentProxyEnv']; + /** Override session home. Grok Build uses a sibling directory so Pi JSONL does not mix. */ + resolvePiAgentHome?: AgentDeps['resolvePiAgentHome']; } /** Cindy wire protocol → pi models.json api 形态。 */ @@ -1817,15 +1819,20 @@ function isLoopbackUrl(baseUrl: string): boolean { } } -/** pi 二进制缺失时返回 null(调用方跳过注册);其余情况构造 PiAgent。 */ -export function buildPiAgent(opts: BuildPiAgentOpts): PiAgent | null { +function defaultPiAgentHome(remoteHostId?: string | null): string { + if (remoteHostId) return '$HOME/.xdt-server/v1/pi-agent-home'; + return path.join(app.getPath('userData'), 'pi-agent-home'); +} + +/** Cindy-hosted Pi loop deps. Used by Pi and Grok Build (same tools / model plane). */ +export function buildDesktopPiLoopDeps(opts: BuildPiAgentOpts): AgentDeps | null { const binaryPath = resolvePiBinaryPath(); if (!binaryPath) { - log.warn('pi binary unavailable after managed prepare; pi agent disabled for this launch'); + log.warn('pi binary unavailable after managed prepare; hosted loop disabled for this launch'); return null; } - log.info('pi agent enabled', { binaryPath }); - return new PiAgent({ + log.info('cindy hosted loop enabled', { binaryPath }); + return { resolveModelContextLimit: (providerId, modelId) => providerId ? readModelContextLimit('pi', providerId, modelId) : null, auth: desktopPiAuthAdapter, @@ -1844,15 +1851,7 @@ export function buildPiAgent(opts: BuildPiAgentOpts): PiAgent | null { // 可信 server 的工具落进 Auto-review 灰区被模型静默 block(详见 pi/index.ts 权限门)。 getMcpToolApprovalPolicy: getDesktopMcpToolApprovalPolicy, getMcpToolApprovalPresentation: getDesktopMcpToolApprovalPresentation, - resolvePiAgentHome: (remoteHostId) => { - // 轮 40-w4-t3 CRITICAL:远端 agentHome 承载 session 历史(sessions/*.jsonl, - // 与 DB sdk_session_id 持久关联)—— 必须落远端持久目录, 不能用本机 - // userData(远端 fileOps 会创建含反斜杠的字面目录)或 /tmp(重启即丢)。 - // $HOME 由远端 fileOps 的 bash 统一展开;DB 里存 $HOME/... 字面, 跨会话 - // 一致。run-tmp 等短生命周期内容仍走 agentHome/run-tmp。 - if (remoteHostId) return '$HOME/.xdt-server/v1/pi-agent-home'; - return path.join(app.getPath('userData'), 'pi-agent-home'); - }, + resolvePiAgentHome: opts.resolvePiAgentHome ?? defaultPiAgentHome, resolvePiGlobalContextHome: (remoteHostId) => { if (remoteHostId) return '$HOME/.pi/agent'; const override = process.env.PI_CODING_AGENT_DIR; @@ -1976,5 +1975,11 @@ export function buildPiAgent(opts: BuildPiAgentOpts): PiAgent | null { resolveRemotePiBinaryPath: opts.resolveRemotePiBinaryPath, remotePiSkipMcpBridge: opts.remotePiSkipMcpBridge, getRemotePiAgentProxyEnv: opts.getRemotePiAgentProxyEnv, - }); + }; +} + +/** pi 二进制缺失时返回 null(调用方跳过注册);其余情况构造 PiAgent。 */ +export function buildPiAgent(opts: BuildPiAgentOpts): PiAgent | null { + const deps = buildDesktopPiLoopDeps(opts); + return deps ? new PiAgent(deps) : null; } diff --git a/apps/desktop/src/main/maker-host/session-storage.ts b/apps/desktop/src/main/maker-host/session-storage.ts index e83a4abbe82..cf4439580a9 100644 --- a/apps/desktop/src/main/maker-host/session-storage.ts +++ b/apps/desktop/src/main/maker-host/session-storage.ts @@ -12,7 +12,11 @@ import { and, eq, inArray, ne } from 'drizzle-orm'; -import { dbToMakerAgentKind, makerToDbAgentKind } from '../../shared/agentKindConversion.js'; +import { + dbToMakerAgentKind, + makerToDbAgentKind, + type DbAgentKind, +} from '../../shared/agentKindConversion.js'; import type { AgentKind, @@ -27,8 +31,6 @@ import { normalizeRemoteHostId } from '../localDb/mapper.js'; import { DESKTOP_VISIBLE_SESSION_SOURCES } from '../../shared/sessionSource.js'; import { normalizeWorkingDirForStorage } from '../../shared/workingDir.js'; -type DbAgentKind = 'cc' | 'codex' | 'pi'; - // 形态映射走 shared/agentKindConversion 正本(支持 pi;此前 pi 被误落成 codex)。 function toDbKind(k: AgentKind): DbAgentKind { return makerToDbAgentKind(k); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/agentKindGate.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/agentKindGate.test.ts new file mode 100644 index 00000000000..e4c73fc571d --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/agentKindGate.test.ts @@ -0,0 +1,93 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + AGENT_KINDS, + DRAFT_AGENT_KINDS, + isAgentKind, + requireAgentKind, + requireDraftAgentKind, +} from '../agentKindGate'; + +const registerSource = readFileSync(resolve(__dirname, '..', 'register.ts'), 'utf8'); + +/** 取某个 wire channel 的 handler 源码片段(到下一个 ipcMain.handle 为止)。 */ +function handlerSource(channel: string): string { + const start = registerSource.indexOf(`MAKER_INVOKE.${channel},`); + expect(start, `${channel} handler not found in register.ts`).toBeGreaterThan(-1); + const next = registerSource.indexOf('ipcMain.handle(', start); + return registerSource.slice(start, next > start ? next : registerSource.length); +} + +/** 会话面 wire 入口:grok-build 会话要靠它们拿能力、命令、技能、@ 资源与定制。 */ +const SESSION_FACING_CHANNELS = [ + 'GET_CAPABILITIES', + 'LIST_AGENT_COMMANDS', + 'LIST_AGENT_SKILLS', + 'SCAN_AT_RESOURCES', + 'LIST_CUSTOMIZATIONS', +] as const; + +/** New Maker 草稿面 wire 入口:全部可选 harness(含 Grok Build)都有草稿槽。 */ +const DRAFT_FACING_CHANNELS = ['GET_NEW_MAKER_DEFAULTS', 'APPLY_NEW_MAKER_DRAFT_PREF'] as const; + +describe('agentKind IPC gate', () => { + it('accepts every AgentKind including Grok Build at the session-facing gate', () => { + expect([...AGENT_KINDS].sort()).toEqual(['claude-code', 'codex', 'grok-build', 'pi']); + for (const kind of AGENT_KINDS) { + expect(requireAgentKind(kind)).toBe(kind); + expect(isAgentKind(kind)).toBe(true); + } + }); + + it('rejects values that are not agent kinds', () => { + // 'grok' 是 xAI catalog provider 名,harness 的 UI vendor 是 'grok-build'。 + for (const bogus of ['grok', 'cc', 'Codex', '', undefined, null, 42, {}]) { + expect(() => requireAgentKind(bogus)).toThrow('[INVALID_PARAMS]'); + expect(isAgentKind(bogus)).toBe(false); + } + }); + + it('accepts every selectable harness including Grok Build at the draft gate', () => { + expect([...DRAFT_AGENT_KINDS]).toEqual(['claude-code', 'codex', 'pi', 'grok-build']); + for (const kind of DRAFT_AGENT_KINDS) { + expect(requireDraftAgentKind(kind)).toBe(kind); + } + expect(() => requireDraftAgentKind('grok')).toThrow('[INVALID_PARAMS]'); + // 草稿 pref 的字段叫 agent,报错要指回调用方的参数名。 + expect(() => requireDraftAgentKind('grok', 'agent')).toThrow('invalid agent: grok'); + }); + + it('routes the session-facing register.ts channels through the full-union gate', () => { + expect(registerSource).toContain( + "import { requireAgentKind, requireDraftAgentKind } from './agentKindGate.js';", + ); + const authSource = readFileSync(resolve(__dirname, '..', 'authHandlers.ts'), 'utf8'); + expect(authSource).toContain("import { requireAgentKind } from './agentKindGate.js';"); + expect(authSource).not.toContain('const AGENT_KINDS'); + // 本地再定义一份就会遮蔽共享 helper,闸门会重新与 AgentKind 漂移。 + expect(registerSource).not.toContain('function requireAgentKind('); + for (const channel of SESSION_FACING_CHANNELS) { + const handler = handlerSource(channel); + expect(handler, `${channel} must use the full agentKind gate`).toContain('requireAgentKind('); + expect(handler, `${channel} must not use the draft-only gate`).not.toContain( + 'requireDraftAgentKind(', + ); + } + }); + + it('keeps the New Maker draft channels on the draft-only gate', () => { + for (const channel of DRAFT_FACING_CHANNELS) { + const handler = handlerSource(channel); + expect(handler, `${channel} must use the draft-only gate`).toContain( + 'requireDraftAgentKind(', + ); + expect( + handler.replace(/requireDraftAgentKind\(/g, ''), + `${channel} must not fall back to the full gate`, + ).not.toContain('requireAgentKind('); + } + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts index 58323106ca1..1d9d487f2a9 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts @@ -36,6 +36,75 @@ describe('maker auth IPC handlers', () => { expect(getAgentAuthState).toHaveBeenCalledWith('pi'); }); + it('accepts Grok Build across the auth IPC boundary', async () => { + const harness = new IpcHarness(); + const broadcast = vi.fn(); + const getAgentAuthState = vi.fn().mockResolvedValue({ authenticated: false }); + const triggerAgentLogin = vi.fn().mockResolvedValue({ authenticated: true }); + const logoutAgent = vi.fn().mockResolvedValue(undefined); + + registerMakerAuthHandlers( + harness, + createMakerStub({ getAgentAuthState, triggerAgentLogin, logoutAgent }), + broadcast, + () => null, + ); + + await expect(harness.invoke(MAKER_INVOKE.AUTH_GET_STATE, 'grok-build')).resolves.toEqual({ + authenticated: false, + }); + expect(getAgentAuthState).toHaveBeenCalledWith('grok-build'); + + await expect(harness.invoke(MAKER_INVOKE.AUTH_TRIGGER_LOGIN, 'grok-build')).resolves.toEqual({ + authenticated: true, + }); + expect(triggerAgentLogin).toHaveBeenCalledWith( + 'grok-build', + expect.objectContaining({ mode: 'browser' }), + ); + + await expect(harness.invoke(MAKER_INVOKE.AUTH_LOGOUT, 'grok-build')).resolves.toBeUndefined(); + expect(logoutAgent).toHaveBeenCalledWith('grok-build'); + expect(broadcast).toHaveBeenCalledWith(MAKER_PUSH.AUTH_STATE_CHANGED, { + agentKind: 'grok-build', + authenticated: false, + }); + }); + + it('keeps device-code login and owner tokens Codex-only for Grok Build', async () => { + const harness = new IpcHarness(); + const triggerAgentLogin = vi.fn(); + registerMakerAuthHandlers(harness, createMakerStub({ triggerAgentLogin }), vi.fn(), () => null); + + await expect( + harness.invoke(MAKER_INVOKE.AUTH_TRIGGER_LOGIN, 'grok-build', { mode: 'device-code' }), + ).rejects.toMatchObject({ code: 'INVALID_PARAMS' }); + await expect( + harness.invokeFrom(202, MAKER_INVOKE.AUTH_TRIGGER_LOGIN, 'grok-build', { + ownerId: 'window-1', + }), + ).rejects.toMatchObject({ code: 'INVALID_PARAMS' }); + await expect( + harness.invokeFrom(202, MAKER_INVOKE.AUTH_CANCEL_LOGIN, 'grok-build', { + releaseOwner: true, + ownerId: 'window-1', + }), + ).rejects.toMatchObject({ code: 'INVALID_PARAMS' }); + expect(triggerAgentLogin).not.toHaveBeenCalled(); + }); + + it('still rejects a non-agent kind at the auth IPC boundary', async () => { + const harness = new IpcHarness(); + const getAgentAuthState = vi.fn(); + registerMakerAuthHandlers(harness, createMakerStub({ getAgentAuthState }), vi.fn(), () => null); + + // 'grok' 是 xAI catalog provider 名,不是 harness 的 UI vendor。 + await expect(harness.invoke(MAKER_INVOKE.AUTH_GET_STATE, 'grok')).rejects.toMatchObject({ + code: 'INVALID_PARAMS', + }); + expect(getAgentAuthState).not.toHaveBeenCalled(); + }); + it('normalizes login progress and broadcasts final auth state', async () => { const harness = new IpcHarness(); const broadcast = vi.fn(); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts index 0df4cef812e..640884f964a 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/orcaWorkerCreationService.test.ts @@ -22,7 +22,7 @@ const WORKER_SESSION_ID = '123e4567-e89b-42d3-a456-426614174000'; describe('buildNoProviderMessage (pi first-class)', () => { const snap = (name: string): OrcaWorkerProviderSnapshot => ({ name }) as OrcaWorkerProviderSnapshot; it('names Pi (not Claude Code) when pi has no connected provider', () => { - const msg = buildNoProviderMessage('pi', { 'claude-code': [], codex: [], pi: [] }); + const msg = buildNoProviderMessage('pi', { 'claude-code': [], codex: [], pi: [], 'grok-build': [] }); expect(msg).toContain('Pi 当前没有可用的模型供应商'); expect(msg).not.toContain('Claude Code 当前没有'); }); @@ -31,6 +31,7 @@ describe('buildNoProviderMessage (pi first-class)', () => { 'claude-code': [], codex: [], pi: [snap('Cindy AI')], + 'grok-build': [], }); expect(msg).toContain('Pi(已连接:Cindy AI)'); }); @@ -59,6 +60,7 @@ function providerRoutingContext( 'claude-code': partial['claude-code'] ?? [], codex: partial.codex ?? [], pi: partial.pi ?? [], + 'grok-build': partial['grok-build'] ?? [], }; return { availability, @@ -1786,6 +1788,7 @@ describe('OrcaWorkerCreationService', () => { { id: 'xd', name: 'XD Gateway', models: ['gpt-5.4'] }, ], pi: [], + 'grok-build': [], } satisfies Record; const { deps, service } = createDeps({ getWorkerDefaults: vi.fn(() => ({ model: 'gpt-5.5', providerId: 'custom-codex' })), @@ -2094,6 +2097,7 @@ describe('buildNoProviderMessage', () => { 'claude-code': [{ id: 'xd', name: 'XD Gateway', models: ['claude-sonnet-4-6'] }], pi: [], codex: [], + 'grok-build': [], }); expect(msg).toContain('Codex 当前没有可用的模型供应商'); expect(msg).toContain('改用'); @@ -2101,7 +2105,7 @@ describe('buildNoProviderMessage', () => { }); it('omits the agent suggestion when no agent has a connected provider', () => { - const msg = buildNoProviderMessage('claude-code', { 'claude-code': [], codex: [], pi: [] }); + const msg = buildNoProviderMessage('claude-code', { 'claude-code': [], codex: [], pi: [], 'grok-build': [] }); expect(msg).toContain('Claude Code 当前没有可用的模型供应商'); expect(msg).toContain('设置 → 模型供应商'); expect(msg).not.toContain('改用'); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/queuedMessageGate.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/queuedMessageGate.test.ts new file mode 100644 index 00000000000..22b021c4892 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/queuedMessageGate.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import type { AgentInputQueuedMessage } from '../../../shared/agentInputQueue'; +import { requireQueuedMessageShape } from '../queuedMessageGate'; + +const registerSource = readFileSync(resolve(__dirname, '..', 'register.ts'), 'utf8'); + +function queuedMessage(agentKind: unknown): unknown { + return { + clientId: 'client-1', + text: 'hello', + persistedContent: 'hello', + model: 'grok-code', + effort: '', + permissionMode: 'ask', + workingDir: 'C:\\repo', + chatMessage: { clientId: 'client-1', role: 'user', content: 'hello' }, + createOpts: { agentKind, workingDir: 'C:\\repo', model: 'grok-code' }, + }; +} + +describe('queued message IPC gate', () => { + it('accepts every agent kind the queued createOpts contract declares', () => { + for (const kind of ['claude-code', 'codex', 'pi', 'grok-build'] as const) { + const item = queuedMessage(kind); + const parsed: AgentInputQueuedMessage = requireQueuedMessageShape(item); + expect(parsed).toBe(item); + expect(parsed.createOpts.agentKind).toBe(kind); + } + }); + + it('rejects a non-agent createOpts.agentKind', () => { + for (const bogus of ['grok', 'cc', '', undefined, null, 7]) { + expect(() => requireQueuedMessageShape(queuedMessage(bogus))).toThrow('[INVALID_PARAMS]'); + } + }); + + it('still enforces the rest of the queued message shape', () => { + expect(() => requireQueuedMessageShape(null)).toThrow('[INVALID_PARAMS]'); + expect(() => + requireQueuedMessageShape({ ...(queuedMessage('grok-build') as object), clientId: '' }), + ).toThrow('[INVALID_PARAMS]'); + expect(() => + requireQueuedMessageShape({ ...(queuedMessage('grok-build') as object), text: 42 }), + ).toThrow('[INVALID_PARAMS]'); + expect(() => + requireQueuedMessageShape({ ...(queuedMessage('grok-build') as object), chatMessage: null }), + ).toThrow('[INVALID_PARAMS]'); + expect(() => + requireQueuedMessageShape({ ...(queuedMessage('grok-build') as object), createOpts: 'x' }), + ).toThrow('[INVALID_PARAMS]'); + }); + + it('routes INPUT_ENQUEUE through the shared shape gate', () => { + expect(registerSource).toContain( + "import { requireQueuedMessageShape } from './queuedMessageGate.js';", + ); + + const validatorStart = registerSource.indexOf('const requireQueuedMessage = ('); + expect(validatorStart).toBeGreaterThan(-1); + const validator = registerSource.slice(validatorStart, validatorStart + 600); + expect(validator).toContain('const msg = requireQueuedMessageShape(value);'); + + const enqueueStart = registerSource.indexOf('MAKER_INVOKE.INPUT_ENQUEUE,'); + expect(enqueueStart).toBeGreaterThan(-1); + const enqueue = registerSource.slice( + enqueueStart, + registerSource.indexOf('MAKER_INVOKE.INPUT_STEER,', enqueueStart), + ); + expect(enqueue).toContain('requireQueuedMessage(item)'); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sendToSessionExecutionConfig.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sendToSessionExecutionConfig.test.ts index 649bcd03238..c34a4e9201e 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/sendToSessionExecutionConfig.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/sendToSessionExecutionConfig.test.ts @@ -82,6 +82,8 @@ const providerRouting = ( 'pi-model': { efforts: ['low', 'high', 'max'], defaultEffort: 'high' }, }, }], + // grok-build 走 Cindy hosted loop + SuperGrok;本 fixture 不挂 xAI。 + 'grok-build': [], }, resolveDefaultProviderIdForModel: (agent: AgentKind) => defaults[agent] ?? ( agent === 'claude-code' ? 'anthropic' : agent === 'codex' ? 'openai' : 'xd' @@ -311,6 +313,7 @@ describe('resolveSendToSessionExecutionConfig', () => { }, }], pi: [], + 'grok-build': [], }, resolveDefaultProviderIdForModel: () => 'xd', }, diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts index 9de6a450d0a..47f9d3df7e4 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/sessionAgentSwitchHandler.test.ts @@ -505,16 +505,7 @@ describe('performSessionAgentSwitch', () => { describe('deferred switch (turn running)', () => { function makeDepsWithPending(overrides: Partial = {}) { const base = makeDeps(overrides); - const store = new Map< - string, - { - targetAgentKind: 'claude-code' | 'codex' | 'pi'; - model: string; - providerId: string | null | undefined; - effort?: string; - fastMode?: boolean; - } - >(); + const store = new Map(); base.deps.pendingSwitches = { set: (id, intent) => void store.set(id, intent), get: (id) => store.get(id), diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sessionSendHandler.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sessionSendHandler.test.ts index 43cdd170124..a3e956d4914 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/sessionSendHandler.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/sessionSendHandler.test.ts @@ -40,6 +40,24 @@ describe('maker session SEND IPC handler', () => { expect(sendToAgentAccepted).toHaveBeenCalledWith('session-1', message, createOpts, sendOpts); }); + it('forwards Grok Build create opts unchanged', async () => { + const harness = new IpcHarness(); + const result = { accepted: true }; + const sendToAgentAccepted = vi.fn().mockResolvedValue(result); + const createOpts = { + agentKind: 'grok-build', + workingDir: 'C:\\repo', + model: 'grok-code-fast-1', + }; + + registerMakerSessionSendHandler(harness, { sendToAgentAccepted }); + + await expect( + harness.invoke(MAKER_INVOKE.SEND, 'session-1', 'hello', createOpts, undefined), + ).resolves.toBe(result); + expect(sendToAgentAccepted).toHaveBeenCalledWith('session-1', 'hello', createOpts, undefined); + }); + it('runs the clear-boundary fence before a legacy direct send', async () => { const harness = new IpcHarness(); const sendToAgentAccepted = vi.fn().mockResolvedValue({ accepted: true }); diff --git a/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts b/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts index 129416bf511..53424c8b481 100644 --- a/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts +++ b/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts @@ -24,6 +24,7 @@ * 它只提交 intent payload;排序、投递模式、回滚和持久化由本模块决定。 */ +import type { AgentKind } from '@cindy/maker-core'; import { redactSensitiveText } from '@cindy/maker-shared/error-redaction'; import { isUnsupportedResponsesImageErrorPayload } from '@cindy/responses-chat-bridge'; import { isPiImageInputUnsupportedError } from '../../shared/inputError.js'; @@ -318,7 +319,8 @@ export interface AgentInputCoordinatorDeps { */ reconcileTurnIdle?: (sessionId: string) => boolean; hasPendingInteraction: (sessionId: string) => boolean; - getAgentKind: (sessionId: string) => AgentInputCreateOpts['agentKind'] | null; + /** 活跃 session 的 agent;读的是实时会话,可能是队列 createOpts 之外的 agent。 */ + getAgentKind: (sessionId: string) => AgentKind | null; getSdkSessionId: (sessionId: string) => Promise; /** Read a bounded, durable progress snapshot before a retry is re-enqueued. */ getRecoveryContextSnapshot?: ( @@ -5287,7 +5289,7 @@ export class AgentInputCoordinator { return; } - let agentKind: AgentInputCreateOpts['agentKind'] | null = null; + let agentKind: AgentKind | null = null; try { agentKind = this.deps.getAgentKind(sessionId); } catch (err) { diff --git a/apps/desktop/src/main/maker-ipc/agentHandoff.ts b/apps/desktop/src/main/maker-ipc/agentHandoff.ts index ffe5d864889..4b15901d812 100644 --- a/apps/desktop/src/main/maker-ipc/agentHandoff.ts +++ b/apps/desktop/src/main/maker-ipc/agentHandoff.ts @@ -13,8 +13,21 @@ import { projectPersistedAgentFacingUserText } from '@cindy/maker-shared/agent-input-projection'; -/** DB 层引擎标识(sessions.agent_kind / messages.agent_kind 的值域)。 */ -export type DbAgentKind = 'cc' | 'codex' | 'pi'; +import type { DbAgentKind } from '../../shared/agentKindConversion.js'; + +/** DB 层引擎标识(sessions.agent_kind / messages.agent_kind 的值域),正本在 shared。 */ +export type { DbAgentKind }; + +/** + * 交接 framing 与边界卡展示用的引擎名。放在这里(而不是各调用点自己 ternary)是因为 + * 漏一个分支就会把别家引擎的会话写成 Claude Code —— 新增 agent 只改这一处。 + */ +export function agentEngineLabel(dbKind: DbAgentKind): string { + if (dbKind === 'codex') return 'Codex'; + if (dbKind === 'pi') return 'Pi'; + if (dbKind === 'grok-build') return 'Grok Build'; + return 'Claude Code'; +} /** 构造交接文本所需的最小消息投影(content 已 JSON.parse,即 camel Message.content)。 */ export interface HandoffSourceMessage { diff --git a/apps/desktop/src/main/maker-ipc/agentKindGate.ts b/apps/desktop/src/main/maker-ipc/agentKindGate.ts new file mode 100644 index 00000000000..30643060d14 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/agentKindGate.ts @@ -0,0 +1,58 @@ +/** + * IPC 入口的 agentKind 闸门。 + * + * 两个口径必须分开: + * - **会话面**(capabilities / 命令 / 技能 / @ 资源 / 定制 / 排队输入)认全部 + * `AgentKind`,含本机可选 harness Grok Build —— 这些入口是会话能不能开口说话的 + * 前置,拒了 grok-build 会话就是死的。 + * - **New Maker 草稿面**认全部可选 harness(含 Grok Build):统一模型选择器 + * 把 grok-build 当第四个引擎选中后,GET_NEW_MAKER_DEFAULTS / + * APPLY_NEW_MAKER_DRAFT_PREF 必须能写回草稿槽。 + */ + +import type { AgentKind } from '@cindy/maker-core'; + +import { requireEnum } from '../utils/ipcValidate.js'; + +/** + * 运行时枚举不能靠 TypeScript 强转替代,但也不该再手抄一份联合体:用 + * `Record` 建表,`AgentKind` 新增成员时这里先编译不过,wire 闸门 + * 不会与类型声明漂移。 + */ +const AGENT_KIND_KEYS: Record = { + 'claude-code': true, + codex: true, + pi: true, + 'grok-build': true, +}; + +/** wire 上合法的全部 agent 种类。 */ +export const AGENT_KINDS = Object.keys(AGENT_KIND_KEYS) as readonly AgentKind[]; + +/** 有 New Maker 草稿 vendor 槽的 agent(与 SELECTABLE_VENDORS 对齐)。 */ +export const DRAFT_AGENT_KINDS = [ + 'claude-code', + 'codex', + 'pi', + 'grok-build', +] as const satisfies readonly AgentKind[]; + +export type DraftAgentKind = (typeof DRAFT_AGENT_KINDS)[number]; + +/** 会话面 wire 入口的 agentKind 校验:认全部 AgentKind(含 Grok Build)。 */ +export function requireAgentKind(value: unknown): AgentKind { + return requireEnum(value, AGENT_KINDS, 'agentKind'); +} + +/** + * 草稿面 wire 入口的 agentKind 校验:认全部可选 harness(含 Grok Build)。 + * `name` 供调用点保留自己的参数名(草稿 pref 的字段叫 `agent`)。 + */ +export function requireDraftAgentKind(value: unknown, name = 'agentKind'): DraftAgentKind { + return requireEnum(value, DRAFT_AGENT_KINDS, name); +} + +/** 纯判定:给已有自己错误文案的调用点(如排队消息)做类型收窄。 */ +export function isAgentKind(value: unknown): value is AgentKind { + return typeof value === 'string' && (AGENT_KINDS as readonly string[]).includes(value); +} diff --git a/apps/desktop/src/main/maker-ipc/authHandlers.ts b/apps/desktop/src/main/maker-ipc/authHandlers.ts index f7e4a2799c7..c788e9b5a18 100644 --- a/apps/desktop/src/main/maker-ipc/authHandlers.ts +++ b/apps/desktop/src/main/maker-ipc/authHandlers.ts @@ -7,9 +7,10 @@ import type { AgentKind, AgentLoginMode, AuthState, Maker } from '@cindy/maker-core'; -import { optionalEnum, requireEnum, requireObject, throwIpcError } from '../utils/ipcValidate.js'; +import { optionalEnum, requireObject, throwIpcError } from '../utils/ipcValidate.js'; import { createLogger } from '../logger.js'; import { MAKER_INVOKE, MAKER_PUSH } from './channels.js'; +import { requireAgentKind } from './agentKindGate.js'; import type { IpcHandlerRegistry } from './ipcHandlerRegistry.js'; const log = createLogger('maker-ipc:authHandlers'); @@ -17,8 +18,6 @@ const log = createLogger('maker-ipc:authHandlers'); /** main → renderer 的 push 广播能力。 */ export type MakerIpcBroadcast = (channel: string, payload: unknown) => void; -/** IPC 允许的 agent 种类;运行时枚举校验不能靠 TypeScript 强转替代。 */ -const AGENT_KINDS = ['claude-code', 'codex', 'pi'] as const satisfies readonly AgentKind[]; const AGENT_LOGIN_MODES = ['browser', 'device-code'] as const satisfies readonly AgentLoginMode[]; const MAX_LOGIN_PROGRESS_CHARS = 16_384; const LOGIN_OWNER_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; @@ -550,10 +549,6 @@ function cancelledAuthState(): AuthState { return { authenticated: false, errorReason: 'login_cancelled' }; } -function requireAgentKind(value: unknown): AgentKind { - return requireEnum(value, AGENT_KINDS, 'agentKind'); -} - function requireLoginOptions( agentKind: AgentKind, value: unknown, diff --git a/apps/desktop/src/main/maker-ipc/ghostErrandRunner.ts b/apps/desktop/src/main/maker-ipc/ghostErrandRunner.ts index 0fd6f8e1c79..ee295076e64 100644 --- a/apps/desktop/src/main/maker-ipc/ghostErrandRunner.ts +++ b/apps/desktop/src/main/maker-ipc/ghostErrandRunner.ts @@ -58,7 +58,7 @@ export interface GhostErrandRunnerDeps { createSession(params: { ghostId: string; title: string | null; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; model?: string; effort?: string; fastMode?: boolean; @@ -69,7 +69,7 @@ export interface GhostErrandRunnerDeps { /** 该插件的展示名(errand 会话默认标题用)。 */ getGhostName(ghostId: string): string | null; /** 缺省选型来源:New Maker 草稿偏好快照(与 Orca worker 同源)。 */ - getDraftDefaults(vendor: 'claude-code' | 'codex' | 'pi'): { + getDraftDefaults(vendor: 'claude-code' | 'codex' | 'pi' | 'grok-build'): { model?: string; effort?: string; fastMode?: boolean; @@ -184,7 +184,14 @@ export function createGhostErrandRunner(deps: GhostErrandRunnerDeps): GhostErran if (!sessionId) { // 缺省选型跟随 New Maker 草稿偏好(与 Orca worker / workspace 槽同源); // 配置项逐字段覆盖。model/effort 缺省最终由 mapper 兜底,这里不硬编码。 - const vendor = cfg.agentKind === 'codex' ? 'codex' : cfg.agentKind === 'pi' ? 'pi' : 'claude-code'; + const vendor = + cfg.agentKind === 'codex' + ? 'codex' + : cfg.agentKind === 'pi' + ? 'pi' + : cfg.agentKind === 'grok-build' + ? 'grok-build' + : 'claude-code'; const draft = deps.getDraftDefaults(vendor); const ghostName = deps.getGhostName(request.ghostId); try { diff --git a/apps/desktop/src/main/maker-ipc/help.ts b/apps/desktop/src/main/maker-ipc/help.ts index bb9c3dde309..8c3f777f7b4 100644 --- a/apps/desktop/src/main/maker-ipc/help.ts +++ b/apps/desktop/src/main/maker-ipc/help.ts @@ -291,6 +291,7 @@ async function getMostRecentSessionAgent(): Promise { if (row.agentKind === 'cc' || row.agentKind === 'claude-code') return 'claude-code'; if (row.agentKind === 'codex') return 'codex'; if (row.agentKind === 'pi') return 'pi'; + if (row.agentKind === 'grok-build') return 'grok-build'; return null; } catch (err) { log.debug('help recent-agent probe failed', { error: String(err) }); @@ -306,8 +307,8 @@ export async function pickHelpAgent( preferredAgent: AgentKind | null, ): Promise { const candidates: AgentKind[] = preferredAgent - ? [...new Set([preferredAgent, 'claude-code', 'codex', 'pi'])] - : ['claude-code', 'codex', 'pi']; + ? [...new Set([preferredAgent, 'claude-code', 'codex', 'pi', 'grok-build'])] + : ['claude-code', 'codex', 'pi', 'grok-build']; const ordered = candidates.filter((agentKind) => agentSupportsOneShot(agentKind)); const available = new Set(maker.listAvailableAgents()); for (const agentKind of ordered) { diff --git a/apps/desktop/src/main/maker-ipc/orcaProviderRoutingContext.ts b/apps/desktop/src/main/maker-ipc/orcaProviderRoutingContext.ts index fab30923dd5..e889eeb59ae 100644 --- a/apps/desktop/src/main/maker-ipc/orcaProviderRoutingContext.ts +++ b/apps/desktop/src/main/maker-ipc/orcaProviderRoutingContext.ts @@ -52,7 +52,9 @@ export async function readOrcaWorkerProviderRoutingContext(deps: { modelRegistry, provider.id, model.id, - agent === 'pi' ? undefined : agent, + // Registry routes only key claude-code/codex; Pi is client-projected and + // Grok Build has no provider routing, so both look up agent-agnostically. + agent === 'pi' || agent === 'grok-build' ? undefined : agent, ); return matched ? [[model.id, matched.entry.id]] : []; }), @@ -82,6 +84,7 @@ export async function readOrcaWorkerProviderRoutingContext(deps: { 'claude-code': availabilityFor('claude-code'), codex: availabilityFor('codex'), pi: availabilityFor('pi'), + 'grok-build': availabilityFor('grok-build'), }, resolveDefaultProviderIdForModel: (agent, model) => effectiveSourceIdForModel(views, null, model, agent), diff --git a/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts b/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts index a584106585c..f0ca2253286 100644 --- a/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts +++ b/apps/desktop/src/main/maker-ipc/orcaWorkerCreationService.ts @@ -524,7 +524,10 @@ export function budgetModelRequiresApiKeyMessage(model: string): string { /** agent 的人类可读名,用于 preflight 失败信息。 */ function agentDisplayName(agent: AgentKind): string { - return agent === 'codex' ? 'Codex' : agent === 'pi' ? 'Pi' : 'Claude Code'; + if (agent === 'codex') return 'Codex'; + if (agent === 'pi') return 'Pi'; + if (agent === 'grok-build') return 'Grok Build'; + return 'Claude Code'; } /** @@ -546,7 +549,7 @@ export function buildNoProviderMessage( availability: Record, ): string { const base = `${agentDisplayName(agent)} 当前没有可用的模型供应商(provider)。请在「设置 → 模型供应商」连接一个支持 ${agentDisplayName(agent)} 的供应商后重试`; - const others = (['claude-code', 'codex', 'pi'] as AgentKind[]).filter( + const others = (['claude-code', 'codex', 'pi', 'grok-build'] as AgentKind[]).filter( (a) => a !== agent && (availability[a]?.length ?? 0) > 0, ); if (others.length === 0) return `${base}。`; diff --git a/apps/desktop/src/main/maker-ipc/queuedMessageGate.ts b/apps/desktop/src/main/maker-ipc/queuedMessageGate.ts new file mode 100644 index 00000000000..8d0c560d4c9 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/queuedMessageGate.ts @@ -0,0 +1,36 @@ +/** + * INPUT_ENQUEUE / INPUT_STEER 收到的排队消息形状闸门。 + * + * wire 只能保证是 JSON,这里把 renderer / device-link 控制端传来的排队项收敛成 + * `AgentInputQueuedMessage` 的最小合法形状。`createOpts.agentKind` 与 + * `AgentInputCreateOpts` 声明的联合体同源(含 Grok Build):类型放宽了、运行时闸门 + * 还停在三个 agent 的话,composer 发送会直接被 INVALID_PARAMS 打掉。 + * + * 只做形状校验;device-link 的会话引用可信度判定留在 register.ts 的调用点。 + */ + +import type { AgentInputQueuedMessage } from '../../shared/agentInputQueue.js'; +import { throwIpcError } from '../utils/ipcValidate.js'; +import { isAgentKind } from './agentKindGate.js'; + +export function requireQueuedMessageShape(value: unknown): AgentInputQueuedMessage { + if (!value || typeof value !== 'object') + throwIpcError('INVALID_PARAMS', 'queued message required'); + const msg = value as AgentInputQueuedMessage; + if (typeof msg.clientId !== 'string' || !msg.clientId) { + throwIpcError('INVALID_PARAMS', 'queued.clientId required'); + } + if (typeof msg.text !== 'string') throwIpcError('INVALID_PARAMS', 'queued.text required'); + if (typeof msg.persistedContent !== 'string') + throwIpcError('INVALID_PARAMS', 'queued.persistedContent required'); + if (!msg.chatMessage || typeof msg.chatMessage !== 'object') { + throwIpcError('INVALID_PARAMS', 'queued.chatMessage required'); + } + if (!msg.createOpts || typeof msg.createOpts !== 'object') { + throwIpcError('INVALID_PARAMS', 'queued.createOpts required'); + } + if (!isAgentKind(msg.createOpts.agentKind)) { + throwIpcError('INVALID_PARAMS', 'queued.createOpts.agentKind invalid'); + } + return msg; +} diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index b43d70ddc06..19606d86c65 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -550,12 +550,18 @@ import { } from '../maker-host/pi-package-mutation-grant.js'; import { requireEnum, requireObject, throwIpcError } from '../utils/ipcValidate.js'; +import { requireAgentKind, requireDraftAgentKind } from './agentKindGate.js'; +import { requireQueuedMessageShape } from './queuedMessageGate.js'; import { isIpcError, type IpcErrorCode } from '../../shared/ipc-errors.js'; import { runPiPackageListIpcBoundary, runPiPackageMutationIpcBoundary, } from './piPackageMutationIpc.js'; -import { dbToMakerAgentKind, makerToDbAgentKind } from '../../shared/agentKindConversion.js'; +import { + dbToMakerAgentKind, + makerToDbAgentKind, + normalizeDbAgentKind, +} from '../../shared/agentKindConversion.js'; import { readWorkflowProgressForSession } from '../workflow-progress/reader.js'; import { AgentInputCoordinator } from './agent-input-coordinator.js'; import { notePromptPredictionSessionStopped } from './promptPredictionStopLedger.js'; @@ -2136,11 +2142,6 @@ export function stopOrcaIdleWatcher(): void { idleReleaseWatcher = null; } -function requireAgentKind(value: unknown): AgentKind { - if (value === 'claude-code' || value === 'codex' || value === 'pi') return value; - throwIpcError('INVALID_PARAMS', 'agentKind required'); -} - type IpcUserMessage = string | { type: 'user'; content: string | Array<{ type: string; [k: string]: unknown }> }; @@ -5061,7 +5062,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) // (model/effort/fast/permission/source/是否显式选过模型)。控制端经隧道调用 → seed 远程项目草稿。 // 缓存未就绪 / 该 vendor 无草稿 model → 返回 {},控制端按 capabilities 默认兜底。 ipcMain.handle(MAKER_INVOKE.GET_NEW_MAKER_DEFAULTS, (_e, agentKind: unknown) => { - return getRemoteNewMakerDefaults(requireAgentKind(agentKind)); + return getRemoteNewMakerDefaults(requireDraftAgentKind(agentKind)); }); // device-link 草稿「模型 effort/fast」写穿:控制端经隧道调用 → 跑在**被控端**。被控端不直接改 @@ -5080,9 +5081,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) active?: unknown; markModelChoice?: unknown; }; - if (p.agent !== 'claude-code' && p.agent !== 'codex' && p.agent !== 'pi') { - throwIpcError('INVALID_PARAMS', 'agent must be claude-code|codex|pi'); - } + const draftAgent = requireDraftAgentKind(p.agent, 'agent'); if (p.providerId !== undefined && typeof p.providerId !== 'string') { throwIpcError('INVALID_PARAMS', 'providerId must be string'); } @@ -5105,7 +5104,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) throwIpcError('INVALID_PARAMS', 'markModelChoice must be boolean'); } broadcastToAllWindows(MAKER_PUSH.DRAFT_PREF_APPLY, { - agent: p.agent, + agent: draftAgent, providerId: p.providerId ?? '', modelId: p.modelId, active: p.active === true, @@ -5624,10 +5623,14 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) : undefined; const sessionMeta = sessionId ? await maker.getSessionMeta(sessionId) : null; const builtins = maker.listAgentCommands(kind); + // Pi 包体系只存在于 Pi 会话;其它 agent(含 grok-build)按「不是本机普通 Pi + // 任务」传 null,与 shouldListPiPackageCommands 内部的 fail-closed 判定同义。 + const piSessionMeta = + sessionMeta?.agentKind === 'pi' ? { ...sessionMeta, agentKind: 'pi' as const } : null; const mayListPackageCommands = shouldListPiPackageCommands( kind, sessionId !== undefined, - sessionMeta, + piSessionMeta, params.allowManagedPiPackagePreview !== false, ); let packageCommands: Array<{ name: string; description: string }> = []; @@ -11870,7 +11873,9 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) ? 'codex' : sessionKindRow?.agentKind === 'pi' ? 'pi' - : 'cc'; + : sessionKindRow?.agentKind === 'grok-build' + ? 'grok-build' + : 'cc'; broadcastSessionPatched( sessionId, { @@ -13618,28 +13623,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) value: unknown, opts?: { allowMissingTrustedContexts?: boolean }, ): AgentInputQueuedMessage => { - if (!value || typeof value !== 'object') - throwIpcError('INVALID_PARAMS', 'queued message required'); - const msg = value as AgentInputQueuedMessage; - if (typeof msg.clientId !== 'string' || !msg.clientId) { - throwIpcError('INVALID_PARAMS', 'queued.clientId required'); - } - if (typeof msg.text !== 'string') throwIpcError('INVALID_PARAMS', 'queued.text required'); - if (typeof msg.persistedContent !== 'string') - throwIpcError('INVALID_PARAMS', 'queued.persistedContent required'); - if (!msg.chatMessage || typeof msg.chatMessage !== 'object') { - throwIpcError('INVALID_PARAMS', 'queued.chatMessage required'); - } - if (!msg.createOpts || typeof msg.createOpts !== 'object') { - throwIpcError('INVALID_PARAMS', 'queued.createOpts required'); - } - if ( - msg.createOpts.agentKind !== 'claude-code' && - msg.createOpts.agentKind !== 'codex' && - msg.createOpts.agentKind !== 'pi' - ) { - throwIpcError('INVALID_PARAMS', 'queued.createOpts.agentKind invalid'); - } + const msg = requireQueuedMessageShape(value); const normalized: AgentInputQueuedMessage = { ...msg }; const refs = requireSessionRefs(normalized.sessionRefs); if (!isDeviceLinkInvoke()) { @@ -17299,7 +17283,9 @@ async function checkWorkDirExists( if (remoteHostId) return true; if (!workingDir?.trim()) return true; workingDir = workingDirectoryRecovery.resolve(sessionId, workingDir); - const source: AgentKind = agentKind === 'codex' || agentKind === 'pi' ? agentKind : 'claude-code'; + // 已知 agent 原样透传(否则 grok-build 会被写成 claude-code);只有老 session 的 + // 未知来源才按注释里的 'claude-code' 兜底。 + const source: AgentKind = agentKind ?? 'claude-code'; // suppressMissingBroadcast: 调用方(SEND 事务)手里还有 DB 权威值可兜底时, // 首检失败只记日志不广播错误横幅——兜底成功的话用户不该看到假错误。 const suppress = opts?.suppressMissingBroadcast === true; diff --git a/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts b/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts index 51d76f7390e..fc1082a9a18 100644 --- a/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts +++ b/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts @@ -31,6 +31,7 @@ import type { AgentKind } from '@cindy/maker-core'; import { MAKER_INVOKE } from './channels.js'; import type { IpcHandlerRegistry } from './ipcHandlerRegistry.js'; import { + agentEngineLabel, buildHandoffText, type DbAgentKind, type HandoffSourceMessage, @@ -61,12 +62,8 @@ export function toMakerAgentKind(dbKind: string): AgentKind { return dbToMakerAgentKind(dbKind); } -/** 交接 framing 与边界卡展示用的引擎名。 */ -export function agentEngineLabel(dbKind: DbAgentKind): string { - if (dbKind === 'codex') return 'Codex'; - if (dbKind === 'pi') return 'Pi'; - return 'Claude Code'; -} +/** 交接 framing 与边界卡展示用的引擎名(正本在 agentHandoff.ts)。 */ +export { agentEngineLabel }; /** role='agent_switch' 边界行的 content 结构(与 renderer AgentSwitchContent 对齐)。 */ export interface AgentSwitchBoundaryContent { diff --git a/apps/desktop/src/main/maker-ipc/sessionRequest.ts b/apps/desktop/src/main/maker-ipc/sessionRequest.ts index 1d50b26c9c9..bd89d58e89d 100644 --- a/apps/desktop/src/main/maker-ipc/sessionRequest.ts +++ b/apps/desktop/src/main/maker-ipc/sessionRequest.ts @@ -68,7 +68,7 @@ export interface ReadCreateSessionOptsDeps { } function readAgentKind(value: unknown): AgentKind { - if (value === 'claude-code' || value === 'codex' || value === 'pi') return value; + if (value === 'claude-code' || value === 'codex' || value === 'pi' || value === 'grok-build') return value; throwIpcError('INVALID_PARAMS', 'agentKind required'); } diff --git a/apps/desktop/src/main/maker-ipc/title.ts b/apps/desktop/src/main/maker-ipc/title.ts index 5b6a48bf174..f21fc66abb8 100644 --- a/apps/desktop/src/main/maker-ipc/title.ts +++ b/apps/desktop/src/main/maker-ipc/title.ts @@ -256,7 +256,12 @@ const AUTO_TITLE_TEXT_MAX = 2000; /** sessionId 长度上限(UUID / cuid 都远小于此)。 */ const SESSION_ID_MAX = 128; -const TITLE_AGENT_KINDS = ['claude-code', 'codex', 'pi'] as const satisfies readonly AgentKind[]; +const TITLE_AGENT_KINDS = [ + 'claude-code', + 'codex', + 'pi', + 'grok-build', +] as const satisfies readonly AgentKind[]; interface GenerateTitleRequest { message: string; diff --git a/apps/desktop/src/main/maker-orchestration/fork.ts b/apps/desktop/src/main/maker-orchestration/fork.ts index c468521540a..3de5ab364f7 100644 --- a/apps/desktop/src/main/maker-orchestration/fork.ts +++ b/apps/desktop/src/main/maker-orchestration/fork.ts @@ -22,9 +22,17 @@ import { commitContextRebuild, createMessage } from '../localDb/ipc/messages.js' import { getMaker } from '../maker-host/index.js'; import { inferProviderIdForModel } from '../maker-host/provider-route.js'; import { createBusinessSessionId } from '../sessionIds.js'; -import { dbToMakerAgentKind, normalizeDbAgentKind } from '../../shared/agentKindConversion.js'; +import { + dbToMakerAgentKind, + normalizeDbAgentKind, + type DbAgentKind, +} from '../../shared/agentKindConversion.js'; import type { AgentMeta, Session } from '../../renderer/lib/ccAgent.types'; -import { buildHandoffText, type HandoffSourceMessage } from '../maker-ipc/agentHandoff.js'; +import { + agentEngineLabel, + buildHandoffText, + type HandoffSourceMessage, +} from '../maker-ipc/agentHandoff.js'; import { type ClaudeTranscriptAnchorIndex, loadClaudeTranscriptAnchorIndex, @@ -65,8 +73,6 @@ function normalizePositiveInt(value: unknown): number { const messageRowid = sql`rowid`; -type DbAgentKind = 'cc' | 'codex' | 'pi'; - interface MessagePosition { createdAt: number; rowid: number | null; @@ -175,8 +181,8 @@ async function seedForkHandoffAfterSameEngineRebuild(opts: { toolUseId: row.toolUseId, })); const lastUser = [...opts.rows].reverse().find((row) => row.role === 'user'); - const label = - opts.agentKind === 'codex' ? 'Codex' : opts.agentKind === 'pi' ? 'Pi' : 'Claude Code'; + // 同引擎重建的交接 framing 用真实引擎名 —— 落到默认分支等于把会话写成 Claude Code。 + const label = agentEngineLabel(opts.agentKind); const handoff = buildHandoffText(handoffMessages, { fromLabel: label, toLabel: label, @@ -827,10 +833,11 @@ export async function forkSessionAtMessage( // Codex: 从当前时间线倒扫 agent_switch,把 copy boundary 之后、确实写入所选 // 原生 thread 的 user turn 计为 rollback 数;其它引擎片段不能混算。 const isCodex = forkSource.agentKind === 'codex'; - // pi 复用 codex 的粗粒度 tail-turn fork:countCodexTailTurns 只按 sdkSessionId - // 数边界后的 user turn(引擎无关),pi 的 forkSdkSession 按 tailTurnsToDrop rewind - // 到目标 user 消息。只有 Claude(cc)走 message-uuid 锚点路径。 - const usesTailTurnFork = isCodex || forkSource.agentKind === 'pi'; + // pi / grok-build 复用 codex 的粗粒度 tail-turn fork:countCodexTailTurns 只按 + // sdkSessionId 数边界后的 user turn(引擎无关),hosted loop 的 forkSdkSession 按 + // tailTurnsToDrop rewind 到目标 user 消息。只有 Claude(cc)走 message-uuid 锚点。 + const usesTailTurnFork = + isCodex || forkSource.agentKind === 'pi' || forkSource.agentKind === 'grok-build'; let assistantUuid: string | undefined; let lastTurnId: string | undefined; let tailTurnsToDrop: number | undefined; diff --git a/apps/desktop/src/main/maker-orchestration/rewind.ts b/apps/desktop/src/main/maker-orchestration/rewind.ts index c4753303b8a..4ededaf2094 100644 --- a/apps/desktop/src/main/maker-orchestration/rewind.ts +++ b/apps/desktop/src/main/maker-orchestration/rewind.ts @@ -184,9 +184,11 @@ async function loadRewindContext( if (makerSession.isTurnRunning()) { throw rewindError('SESSION_RUNNING', '会话进行中,无法回滚'); } + // grok-build 是 Cindy hosted Pi loop:rewind 走 tail-turn,不能落到 Claude + // checkpoint,也不能再按 ACP 时代 fail-closed。 const agentKind = makerSession.agentKind === 'codex' ? 'codex' - : makerSession.agentKind === 'pi' + : makerSession.agentKind === 'pi' || makerSession.agentKind === 'grok-build' ? 'pi' : 'claude-code'; diff --git a/apps/desktop/src/main/messagePersistBroadcaster.ts b/apps/desktop/src/main/messagePersistBroadcaster.ts index 68c3f192cee..ba4be07606c 100644 --- a/apps/desktop/src/main/messagePersistBroadcaster.ts +++ b/apps/desktop/src/main/messagePersistBroadcaster.ts @@ -55,6 +55,7 @@ import { createLogger } from './logger.js'; import * as broadcastTap from './device-link/broadcast-tap.js'; import { commitMessageMediaRefs } from './cindy-media/chatAttachments.js'; import { takeMediaToolResult } from './mcp-integrations/mediaToolResultFallback.js'; +import type { DbAgentKind } from '../shared/agentKindConversion.js'; import { capToolResultTextForPersist } from '../shared/toolResultPersistCap.js'; import { redactSensitiveText } from '@cindy/maker-shared/error-redaction'; import { @@ -184,13 +185,13 @@ type OwnerScope = ReturnType * session.agent_kind 只代表"当前引擎",历史行的 agent_meta 必须按写入时引擎解析。 * clearSessionPersistState 时清理。 */ -const dbAgentKindBySession = new Map(); +const dbAgentKindBySession = new Map(); -export function noteSessionAgentKind(sessionId: string, dbAgentKind: 'cc' | 'codex' | 'pi'): void { +export function noteSessionAgentKind(sessionId: string, dbAgentKind: DbAgentKind): void { dbAgentKindBySession.set(sessionId, dbAgentKind); } -export function getSessionDbAgentKind(sessionId: string): 'cc' | 'codex' | 'pi' | null { +export function getSessionDbAgentKind(sessionId: string): DbAgentKind | null { return dbAgentKindBySession.get(sessionId) ?? null; } diff --git a/apps/desktop/src/main/process-monitor/agent-scan.ts b/apps/desktop/src/main/process-monitor/agent-scan.ts index a9aa07af338..6d49ff43322 100644 --- a/apps/desktop/src/main/process-monitor/agent-scan.ts +++ b/apps/desktop/src/main/process-monitor/agent-scan.ts @@ -27,7 +27,7 @@ import { runWindowsProcessScanWorker } from './windowsProcessScanWorkerClient.js const execFileAsync = promisify(execFile); -export type MonitoredAgentKind = 'claude' | 'codex' | 'pi'; +export type MonitoredAgentKind = 'claude' | 'codex' | 'pi' | 'grok-build'; export interface OsProcessRow { pid: number; diff --git a/apps/desktop/src/main/process-monitor/sampler.ts b/apps/desktop/src/main/process-monitor/sampler.ts index f3dbd8393ea..961d9a0aacf 100644 --- a/apps/desktop/src/main/process-monitor/sampler.ts +++ b/apps/desktop/src/main/process-monitor/sampler.ts @@ -69,6 +69,7 @@ const AGENT_KIND_TO_USAGE_KIND: Record = { claude: 'agent-claude', codex: 'agent-codex', pi: 'agent-pi', + 'grok-build': 'agent-grok-build', }; export interface ProcessMonitorSampler { diff --git a/apps/desktop/src/main/sessionTaskSummary.ts b/apps/desktop/src/main/sessionTaskSummary.ts index 7e06312a229..3c4cb37c771 100644 --- a/apps/desktop/src/main/sessionTaskSummary.ts +++ b/apps/desktop/src/main/sessionTaskSummary.ts @@ -25,6 +25,7 @@ import { BrowserWindow } from 'electron'; import { and, count, desc, eq, gt, isNotNull, isNull, lt, or, sql } from 'drizzle-orm'; +import { dbToMakerAgentKind } from '../shared/agentKindConversion.js'; import { getMaker } from './maker-host/index.js'; import { isAgentOneShotRouteDisabled } from './maker-host/model-route-guard-live.js'; import { activeOwnerScopeKey, isAppSessionBoundaryPending } from './appSessionState.js'; @@ -341,10 +342,9 @@ async function generateSummaryOnce(sessionId: string): Promise { const inactiveMs = Date.now() - (session.userSendAt ?? session.updatedAt); const tier = pickTier({ inactiveMs, messageCount, isScheduled }); - const agentKind = - session.agentKind === 'codex' || session.agentKind === 'pi' - ? session.agentKind - : 'claude-code'; + // 走映射正本:就地 ternary 会把新引擎(grok-build)当成 claude-code,摘要就跑去了 + // 错的 oneShot 兜底(agentSupportsOneShot 的判定也随之失真)。 + const agentKind = dbToMakerAgentKind(session.agentKind); const prompt = SUMMARY_PROMPT(session.title, userMsg, assistantMsg, tier); // 模型走系统统一配置:优先用"轻量任务模型链"(utility-model,与起标题同源, // 由 getUtilityModelChainProfiles 决定),配置缺失/不可用时再回退到 agent 自带的 diff --git a/apps/desktop/src/main/turn-change-set/store.ts b/apps/desktop/src/main/turn-change-set/store.ts index f4ef9ab4255..cc82dd2d04a 100644 --- a/apps/desktop/src/main/turn-change-set/store.ts +++ b/apps/desktop/src/main/turn-change-set/store.ts @@ -85,7 +85,7 @@ interface TurnChangeActionStateV1 { states: Record; } -const PROVIDERS = new Set(['codex', 'claude-code', 'pi']); +const PROVIDERS = new Set(['codex', 'claude-code', 'pi', 'grok-build']); const STATES = new Set(['complete', 'partial']); const WORKSPACE_STATES = new Set(['applied', 'undone']); const INCOMPLETE_REASONS = new Set([ diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c9986e33af9..39a6b0c55cd 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -383,7 +383,7 @@ type VoiceInputModelSelectionPatchWire = { type DiscordBotSessionAuthCheckWire = { ok: boolean; missing: 'gateway-key' | 'agent-oauth' | 'provider-key' | 'provider-disconnected' | null; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; providerLabel: string | null; @@ -2412,12 +2412,12 @@ contextBridge.exposeInMainWorld('electronAPI', { syncNewMakerDraft: (snapshot: { lastByVendor: Partial< Record< - 'cc' | 'codex' | 'pi', + 'cc' | 'codex' | 'pi' | 'grok-build', { model?: string; effort?: string; permissionMode?: string; providerId?: string | null } > >; /** 每个 vendor 是否由用户在 New Maker 中明确选过模型;device-link 默认校准据此保护显式选择。 */ - modelChosenByVendor: Partial>; + modelChosenByVendor: Partial>; fastModeByModel: Record; effortByModel: Record; /** 「新建会话默认启用 worktree」勾选记忆(vendor 无关根字段,远程草稿播种用)。 */ @@ -5472,10 +5472,10 @@ contextBridge.exposeInMainWorld('electronAPI', { // ─── Maker Core IPC ───────────────────────────────────────────────────── // renderer 通过统一 maker API 按 agentKind 调用 Claude Code / Codex / Pi。 maker: { - listAvailableAgents: (): Promise> => + listAvailableAgents: (): Promise> => ipcRenderer.invoke('maker:list-available-agents'), onAgentsChanged: fanOutMakerAgentsChanged, - getCapabilities: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getCapabilities: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): Promise => ipcRenderer.invoke('maker:get-capabilities', agentKind), listBotDelegations: ( parentSessionId: string, @@ -5544,13 +5544,13 @@ contextBridge.exposeInMainWorld('electronAPI', { // 自定义供应商配置 CRUD(配置与 runtime 密钥均由 main 原子排队)。 createCustomProvider: ( config: import('@cindy/model-providers').CustomProviderConfig, - keys: Partial>, + keys: Partial>, options?: CustomProviderUpdateOptions, ): Promise => ipcRenderer.invoke('maker:provider:custom:create', config, keys, options), updateCustomProvider: ( config: import('@cindy/model-providers').CustomProviderConfig, - keys: Partial>, + keys: Partial>, options?: CustomProviderUpdateOptions, ): Promise => ipcRenderer.invoke('maker:provider:custom:update', config, keys, options), @@ -5566,11 +5566,11 @@ contextBridge.exposeInMainWorld('electronAPI', { */ testProviderConnection: ( input: - | { kind: 'saved'; providerId: string; agent: 'claude-code' | 'codex' | 'pi' } + | { kind: 'saved'; providerId: string; agent: 'claude-code' | 'codex' | 'pi' | 'grok-build' } | { kind: 'adhoc'; spec: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; baseUrl: string; modelId: string; authMethod: 'apiKey' | 'oauth' | 'none'; @@ -5592,7 +5592,7 @@ contextBridge.exposeInMainWorld('electronAPI', { * 结构化结果:ok=true 带 models;失败 code 走 providerError.* i18n。 */ fetchProviderModels: (input: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; baseUrl: string; authMethod: 'apiKey' | 'oauth' | 'none'; wireProtocol?: import('@cindy/model-providers').ProviderWireProtocol; @@ -5897,7 +5897,7 @@ contextBridge.exposeInMainWorld('electronAPI', { }): Promise<{ ok: true; runId: string; reviewerSessionId: string }> => ipcRenderer.invoke('maker:review:start', input), listAgentCommands: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', params: { sessionId?: string; allowManagedPiPackagePreview?: boolean } = {}, ): Promise<{ success: boolean; @@ -5907,7 +5907,7 @@ contextBridge.exposeInMainWorld('electronAPI', { }> => ipcRenderer.invoke('maker:list-agent-commands', agentKind, params), listAgentSkills: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', params: { workingDir?: string; remoteHostId?: string; @@ -6026,7 +6026,7 @@ contextBridge.exposeInMainWorld('electronAPI', { onGoalStatusChanged: fanOutGoalStatusChanged, scanAtResources: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', params: { workingDir: string; cap?: number; query?: string }, ): Promise<{ success: boolean; @@ -6059,7 +6059,7 @@ contextBridge.exposeInMainWorld('electronAPI', { createSession: (opts: { /** 可选: 复用外部 sessionId(本端 chat 用 local-db:sessions:create 拿到的 id) */ id?: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; title?: string; @@ -6169,7 +6169,7 @@ contextBridge.exposeInMainWorld('electronAPI', { message: string | { type: 'user'; content: string | Array<{ type: string; [k: string]: unknown }> }, createOpts?: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; orcaRole?: 'lead' | 'worker' | null; @@ -6214,7 +6214,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getContextUsage: ( sessionId: string, createOpts?: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; orcaRole?: 'lead' | 'worker' | null; @@ -6253,7 +6253,7 @@ contextBridge.exposeInMainWorld('electronAPI', { listActive: (): Promise< Array<{ sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workDir: string; capabilities: unknown; isTurnRunning: boolean; @@ -6316,14 +6316,14 @@ contextBridge.exposeInMainWorld('electronAPI', { // switched=false 且无 deferred = 同引擎 no-op(用户选回当前引擎,意图已清)。 switchSessionAgent: ( sessionId: string, - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', model: string, providerId?: string | null, effort?: string, fastMode?: boolean, ): Promise<{ switched: boolean; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; engineReady: boolean; deferred?: boolean; @@ -6344,7 +6344,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessionAgentSwitchIntent: ( sessionId: string, ): Promise<{ - targetAgentKind: 'claude-code' | 'codex' | 'pi'; + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; effort?: string; @@ -6391,14 +6391,14 @@ contextBridge.exposeInMainWorld('electronAPI', { // Memory 控制 (Personalization → Memory section)。 // 由 BaseAgent 子类落地; UI 层负责 Reset 前 confirm dialog。 memoryGet: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', ): Promise<{ enabled: boolean; source: 'agent-default' | 'host-runtime' | 'user-config'; stats?: { entryCount?: number; sizeBytes?: number; storagePath?: string }; }> => ipcRenderer.invoke('maker:memory:get', agentKind), memorySet: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', enabled: boolean, ): Promise<{ effective: 'immediate' | 'next-session'; @@ -6407,7 +6407,7 @@ contextBridge.exposeInMainWorld('electronAPI', { defaults: { maker: boolean; claudeCode: boolean; codex: boolean; pi: boolean }; }> => ipcRenderer.invoke('maker:memory:set', agentKind, enabled), memoryReset: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', ): Promise<{ removedEntries?: number; removedBytes?: number; @@ -6889,7 +6889,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // Stage 2 C1: chat utility (前身 cc-agent:generate-title / cc-agent:plan-file-write) generateTitle: ( message: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', sessionId?: string, ): Promise<{ title: string | null }> => ipcRenderer.invoke('maker:generate-title', { message, agentKind, sessionId }), @@ -6900,14 +6900,14 @@ contextBridge.exposeInMainWorld('electronAPI', { autoTitle: (request: { sessionId: string; text: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; isUserText?: boolean; }): Promise<{ applied: boolean; done: boolean }> => ipcRenderer.invoke('maker:auto-title', request), /** 输入框推荐提示词:turn 结束后预测用户下一步输入(turn 完成 → 调 IPC → 返回预测文本)。 */ predictNextPrompt: (request: { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; messages: Array<{ role: string; content: string }>; workingDir?: string; turnGen: number; @@ -6970,17 +6970,17 @@ contextBridge.exposeInMainWorld('electronAPI', { // ── Agent 鉴权 (取代老 electronAPI.codex.auth.*) ──────────────────────── auth: { - getState: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getState: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): Promise => ipcRenderer.invoke('maker:auth:get-state', agentKind), triggerLogin: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', options?: { mode?: 'browser' | 'device-code'; ownerId?: string }, ): Promise => ipcRenderer.invoke('maker:auth:trigger-login', agentKind, options), cancelLogin: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', options?: { releaseOwner?: boolean; ownerId?: string }, ): Promise => ipcRenderer.invoke('maker:auth:cancel-login', agentKind, options), - logout: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + logout: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): Promise => ipcRenderer.invoke('maker:auth:logout', agentKind), onStateChanged: fanOutMakerAuthStateChanged, onLoginProgress: fanOutMakerAuthLoginProgress, @@ -6988,13 +6988,13 @@ contextBridge.exposeInMainWorld('electronAPI', { // ── Agent 联合状态 (取代老 electronAPI.codex.binary.getStatus) ────────── agent: { - getStatus: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getStatus: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): Promise => ipcRenderer.invoke('maker:agent:status', agentKind), /** spawn 当前应用使用的 binary `--version`, 进程内缓存。About 面板用。 */ getBinaryVersion: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', ): Promise<{ - kind: 'claude-code' | 'codex' | 'pi'; + kind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; binaryPath: string | null; version: string | null; error?: string; @@ -7003,9 +7003,9 @@ contextBridge.exposeInMainWorld('electronAPI', { // ── Agent 今日累计 (取代老 electronAPI.codex.usage.* + electronAPI.onUsageTodaySpendChanged) ─ usage: { - getToday: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getToday: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): Promise => ipcRenderer.invoke('maker:usage:today', agentKind), - getAccount: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => + getAccount: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): Promise => ipcRenderer.invoke('maker:usage:account', agentKind), /** Codex app-server authoritative windows and banked reset-credit metadata. */ getCodexRateLimits: (): Promise => diff --git a/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx b/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx index b612044a24f..74cc11f15a5 100644 --- a/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx +++ b/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx @@ -698,7 +698,7 @@ describe('AddProviderWizard — preset 直达', () => { // 同一 model id 在两端窗口可以不同(如 cc=1M / codex=272K):共享一个发现值 // 会让其中一端显示与压缩阈值双错,必须按 agent 分槽各取各的端点上报值。 vi.mocked(window.electronAPI.maker.fetchProviderModels).mockImplementation( - async ({ agent }: { agent: 'claude-code' | 'codex' | 'pi' }) => ({ + async ({ agent }: { agent: 'claude-code' | 'codex' | 'pi' | 'grok-build' }) => ({ ok: true, models: [ { diff --git a/apps/desktop/src/renderer/__tests__/agentCapabilitiesDeviceCache.test.ts b/apps/desktop/src/renderer/__tests__/agentCapabilitiesDeviceCache.test.ts index 32ecd7b0f2c..97d81e78ad6 100644 --- a/apps/desktop/src/renderer/__tests__/agentCapabilitiesDeviceCache.test.ts +++ b/apps/desktop/src/renderer/__tests__/agentCapabilitiesDeviceCache.test.ts @@ -120,6 +120,7 @@ describe('useAgentCapabilities deviceId-aware cache', () => { await expect(mod.loadLocalCapabilitiesSnapshot()).resolves.toEqual([ ['claude-code', caps('local:claude-code')], ['codex', caps('local:codex')], + ['grok-build', caps('local:grok-build')], ]); expect(getCapabilities).toHaveBeenCalledWith('pi'); }); @@ -522,8 +523,8 @@ describe('useAgentCapabilities deviceId-aware cache', () => { mod.prefetchDeviceCapabilities('dev-1'), mod.prefetchDeviceCapabilities('dev-1'), ]); - // cc + codex + pi 各一次 = 3 次,而非 6 次 - expect(invoke).toHaveBeenCalledTimes(3); + // cc + codex + pi + grok-build 各一次 = 4 次,而非 8 次 + expect(invoke).toHaveBeenCalledTimes(4); }); it('驱逐:evict 只清该设备,本地与其它设备保留', async () => { @@ -621,15 +622,17 @@ describe('useAgentCapabilities deviceId-aware cache', () => { const stale = mod.prefetchDeviceCapabilities('dev-1'); mod.evictDeviceCapabilities('dev-1'); const fresh = mod.prefetchDeviceCapabilities('dev-1'); - // 每轮按 ALL_AGENT_KINDS 顺序 push 三个 resolver(cc/codex/pi): - // 第一轮(stale)= [0][1][2],第二轮(fresh)= [3][4][5]。 - resolvers[3](caps('fresh:claude')); - resolvers[4](caps('fresh:codex')); - resolvers[5](caps('fresh:pi')); + // 每轮按 ALL_AGENT_KINDS 顺序 push resolver(cc/codex/pi/grok-build): + // 第一轮(stale)= [0][1][2][3],第二轮(fresh)= [4][5][6][7]。 + resolvers[4](caps('fresh:claude')); + resolvers[5](caps('fresh:codex')); + resolvers[6](caps('fresh:pi')); + resolvers[7](caps('fresh:grok-build')); await fresh; resolvers[0](caps('stale:claude')); resolvers[1](caps('stale:codex')); resolvers[2](caps('stale:pi')); + resolvers[3](caps('stale:grok-build')); await stale; expect(claudeListener).toHaveBeenNthCalledWith(1, { status: 'loading' }); diff --git a/apps/desktop/src/renderer/__tests__/agentSelect.test.tsx b/apps/desktop/src/renderer/__tests__/agentSelect.test.tsx index 3a5ef743e39..1a532a45a66 100644 --- a/apps/desktop/src/renderer/__tests__/agentSelect.test.tsx +++ b/apps/desktop/src/renderer/__tests__/agentSelect.test.tsx @@ -31,7 +31,12 @@ import { describe, expect, it, vi } from 'vitest'; import { AgentSelect } from '@/components/new-chat/AgentSelect'; import { AGENT_OPTIONS } from '@/components/new-chat/agentOptions'; -import { SELECTABLE_VENDORS, isSelectableVendor } from '@/lib/agentVendors'; +import { + SELECTABLE_AGENT_KINDS, + SELECTABLE_VENDORS, + agentKindOfVendor, + isSelectableVendor, +} from '@/lib/agentVendors'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -103,6 +108,7 @@ describe('AgentSelect', () => { const options = screen.getAllByRole('option'); expect(options).toHaveLength(AGENT_OPTIONS.length); expect(options.map((o) => o.textContent)).toEqual(AGENT_OPTIONS.map((o) => o.label)); + expect(options.map((o) => o.textContent)).toContain('Grok Build'); const selected = options.filter((o) => o.getAttribute('aria-selected') === 'true'); expect(selected).toHaveLength(1); @@ -430,4 +436,9 @@ describe('引擎选项表(单一来源)', () => { expect(isSelectableVendor(v)).toBe(false); } }); + + it('SELECTABLE_AGENT_KINDS 是 SELECTABLE_VENDORS 的 AgentKind 投影且含 grok-build', () => { + expect(SELECTABLE_AGENT_KINDS).toEqual(['claude-code', 'codex', 'pi', 'grok-build']); + expect([...SELECTABLE_AGENT_KINDS]).toEqual(SELECTABLE_VENDORS.map(agentKindOfVendor)); + }); }); diff --git a/apps/desktop/src/renderer/__tests__/composerMorphScope.test.ts b/apps/desktop/src/renderer/__tests__/composerMorphScope.test.ts index 294c3333d0d..274aada95a6 100644 --- a/apps/desktop/src/renderer/__tests__/composerMorphScope.test.ts +++ b/apps/desktop/src/renderer/__tests__/composerMorphScope.test.ts @@ -91,3 +91,37 @@ describe('+ 菜单 embedded 宽度契约', () => { expect(morph).toContain("content.style.overflowX = 'hidden'"); }); }); + +describe('Grok Build 出现在与 cc/codex/pi 相同的 harness 选择入口', () => { + it('统一选择器候选引擎表派生自 SELECTABLE_AGENT_KINDS,不再手抄三引擎', () => { + expect(chatInput).toContain( + "const UNIFIED_AGENT_KINDS: readonly AgentKind[] = SELECTABLE_AGENT_KINDS;", + ); + expect(chatInput).not.toMatch( + /UNIFIED_AGENT_KINDS: readonly AgentKind\[\] = \['claude-code', 'codex', 'pi'\]/, + ); + expect(modelSelector).toContain('SELECTABLE_AGENT_KINDS'); + expect(chatInput).toContain("const OPT_IN_UNIFIED_AGENTS: ReadonlySet = new Set(['grok-build'])"); + expect(chatInput).toContain( + 'const catalogKinds = UNIFIED_AGENT_KINDS.filter((kind) => !OPT_IN_UNIFIED_AGENTS.has(kind));', + ); + expect(chatInput).toContain('if (!runtimeAgentsLoaded) return catalogKinds;'); + }); + + it('Hook 工作目录偏好不再隐藏 grok-build,并认它为合法 agent', () => { + expect(workspacePrefs).not.toContain("HOOK_HIDDEN_VENDORS"); + expect(workspacePrefs).toContain("if (vendor === 'grok-build') return 'grok-build'"); + const hookLogic = read('components/settings/hookWorkspacePrefsLogic.ts'); + expect(hookLogic).toContain("'grok-build'"); + expect(hookLogic).toMatch( + /export const AGENT_KINDS = \['claude-code', 'codex', 'pi', 'grok-build'\]/, + ); + }); + + it('IM 默认设置把 grok-build vendor 映射成 grok-build harness,不再误写成 Codex', () => { + expect(settingsModel).toContain("if (vendor === 'grok-build') return 'grok-build'"); + expect(settingsModel).not.toMatch( + /function agentKindOfVendor\(vendor: string\): ImDefaultAgentKind \{\n return vendor === 'cc' \? 'claude-code' : vendor === 'pi' \? 'pi' : 'codex';/, + ); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/imDefaultSettingsLogic.test.ts b/apps/desktop/src/renderer/__tests__/imDefaultSettingsLogic.test.ts index ad88839b5da..508e83ac7ea 100644 --- a/apps/desktop/src/renderer/__tests__/imDefaultSettingsLogic.test.ts +++ b/apps/desktop/src/renderer/__tests__/imDefaultSettingsLogic.test.ts @@ -51,6 +51,7 @@ describe('im default settings logic', () => { effort: 'high', }, pi: IM_DEFAULT_SETTINGS.agents.pi, + 'grok-build': IM_DEFAULT_SETTINGS.agents['grok-build'], }); }); }); diff --git a/apps/desktop/src/renderer/__tests__/modelSelectorTriggerVariant.test.ts b/apps/desktop/src/renderer/__tests__/modelSelectorTriggerVariant.test.ts index cb4209c45dd..8e18d8a6933 100644 --- a/apps/desktop/src/renderer/__tests__/modelSelectorTriggerVariant.test.ts +++ b/apps/desktop/src/renderer/__tests__/modelSelectorTriggerVariant.test.ts @@ -2158,6 +2158,99 @@ describe('ModelSelector trigger variants', () => { } }); + it('shows the empty-source card for grok-build when SuperGrok is not connected', () => { + const previous = providersRef.providers; + providersRef.providers = []; + try { + render( + React.createElement(ModelSelectorContent, { + modelId: 'grok-4.6', + effort: 'high' as Effort, + onModelChange: vi.fn(), + onEffortChange: vi.fn(), + vendorKey: 'grok-build' as const, + currentProviderId: null, + onProviderChange: vi.fn(), + onNavigateToProviders: vi.fn(), + }), + ); + expect(screen.getByText('newChat.modelSelector.source.emptyTitle')).toBeTruthy(); + } finally { + providersRef.providers = previous; + } + }); + + it('keeps the grok-build trigger on the Grok model when SuperGrok is connected without a catalog grok-build provider', () => { + const previousProviders = providersRef.providers; + const previousModels = visibleModelsRef.models; + providersRef.providers = [ + { + id: 'xai', + name: 'xAI', + source: 'builtin', + agents: ['claude-code', 'codex', 'pi'], + auth: { method: 'oauth' }, + routing: { pi: {} }, + connected: true, + models: { pi: [{ id: 'grok-4.6', name: 'Grok 4.6', contextWindow: 500000, efforts: [], defaultEffort: null }] }, + }, + ]; + visibleModelsRef.models = [ + { + id: 'grok-4.6', + displayName: 'Grok 4.6', + contextWindow: 500000, + efforts: [], + defaultEffort: null, + }, + ]; + try { + render( + React.createElement(ModelSelector, { + modelId: 'grok-4.6', + effort: 'high' as Effort, + onModelChange: vi.fn(), + onEffortChange: vi.fn(), + vendorKey: 'grok-build' as const, + currentProviderId: null, + onProviderChange: vi.fn(), + onNavigateToProviders: vi.fn(), + }), + ); + expect(screen.queryByText('newChat.modelSelector.source.emptyTitle')).toBeNull(); + const trigger = screen.getByRole('button', { name: /Grok 4\.6/ }); + expect(trigger.textContent).toContain('Grok 4.6'); + expect(trigger.textContent).not.toContain('newChat.modelSelector.source.connect'); + expect(trigger.getAttribute('aria-label')).not.toContain( + 'newChat.modelSelector.source.connect', + ); + } finally { + providersRef.providers = previousProviders; + visibleModelsRef.models = previousModels; + } + }); + + it('still shows the empty-source title for Claude Code with zero connected providers', () => { + const previous = providersRef.providers; + providersRef.providers = []; + try { + render( + React.createElement(ModelSelectorContent, { + modelId: 'claude-opus-4-8', + effort: 'high' as Effort, + onModelChange: vi.fn(), + onEffortChange: vi.fn(), + vendorKey: 'cc' as const, + currentProviderId: 'anthropic', + onProviderChange: vi.fn(), + }), + ); + expect(screen.getByText('newChat.modelSelector.source.emptyTitle')).toBeTruthy(); + } finally { + providersRef.providers = previous; + } + }); + it('binds the pane observer when providers arrive after the empty state', async () => { type ObserverInstance = { callback: ResizeObserverCallback; diff --git a/apps/desktop/src/renderer/__tests__/newMakerDraft.test.ts b/apps/desktop/src/renderer/__tests__/newMakerDraft.test.ts index b08a8545aad..4d5b96a6ac8 100644 --- a/apps/desktop/src/renderer/__tests__/newMakerDraft.test.ts +++ b/apps/desktop/src/renderer/__tests__/newMakerDraft.test.ts @@ -70,6 +70,24 @@ describe('newMakerDraft store', () => { expect(d.defaultTupleSelectionCustomized).toBe(false); }); + it('rewrites leftover grok-build model sentinel to a real Grok catalog slug', async () => { + const { coldStartModelIdForVendor } = await import('@/lib/modelDefinitions'); + memStorage.setItem( + 'xdt:newMakerDraft:v1', + JSON.stringify({ + vendor: 'grok-build', + lastByVendor: { + 'grok-build': { model: 'grok-build', providerId: 'grok-build', effort: 'high' }, + }, + }), + ); + vi.resetModules(); + const { getDraft } = await loadModule(); + expect(getDraft().lastByVendor['grok-build'].model).toBe(coldStartModelIdForVendor('grok-build')); + expect(getDraft().lastByVendor['grok-build'].model).not.toBe('grok-build'); + expect(getDraft().lastByVendor['grok-build'].providerId).toBeNull(); + }); + it('产品默认原子写入完整组合,但不伪装成用户显式选模', async () => { const { applySuggestedDefaultTuple, getDraft } = await loadModule(); expect( diff --git a/apps/desktop/src/renderer/__tests__/newMakerProjectPicker.test.ts b/apps/desktop/src/renderer/__tests__/newMakerProjectPicker.test.ts index 2f9f979470a..07ba581fb02 100644 --- a/apps/desktop/src/renderer/__tests__/newMakerProjectPicker.test.ts +++ b/apps/desktop/src/renderer/__tests__/newMakerProjectPicker.test.ts @@ -580,8 +580,9 @@ describe('Shared create project picker', () => { expect(newMakerDraftRouteSource).toContain('hiddenVendors={hiddenSwitcherVendors}'); expect(chatInputSource).toMatch(/useAvailableAgents\(deviceLinkDeviceId\)/); expect(chatInputSource).toContain('unifiedAgents={effectiveUnifiedAgents}'); - // fail-open:注册结果没回来之前不隐藏任何引擎;当前引擎恒在列。 - expect(chatInputSource).toContain('if (!runtimeAgentsLoaded) return undefined;'); + // fail-open:注册结果没回来之前只露出随包分发的引擎;grok-build 跟 hosted + // loop,未确认前不 fail-open。当前引擎恒在列。 + expect(chatInputSource).toContain('if (!runtimeAgentsLoaded) return catalogKinds;'); expect(chatInputSource).toContain( 'kind === agentKind || runtimeAvailableVendors.has(agentKindToVendor(kind)),', ); diff --git a/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx b/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx index 6419eef9e2c..6998b91311e 100644 --- a/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx +++ b/apps/desktop/src/renderer/__tests__/unifiedModelPanelRendering.test.tsx @@ -3382,7 +3382,7 @@ describe('统一面板 · 行内折扣徽标', () => { isFavoriteRow: false, justFavorited: false, interactionDisabled: false, - effortLabelOf: (_agent: 'claude-code' | 'codex' | 'pi', effort: string) => effort, + effortLabelOf: (_agent: 'claude-code' | 'codex' | 'pi' | 'grok-build', effort: string) => effort, providers: [], onReveal: vi.fn(), onRevealForKeyboard: vi.fn(), diff --git a/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts b/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts index 6aa716a162f..691408f8dea 100644 --- a/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts +++ b/apps/desktop/src/renderer/__tests__/unifiedModelSelection.test.ts @@ -502,8 +502,8 @@ describe('会话内形态(同引擎过滤 / pinnedEngine)', () => { describe('同引擎视图:生效引擎是排序优先级,不是隐藏条件', () => { /** 注入侧的真实形态:调用方给的是 resolveUnifiedRowConfig / resolveFavoriteRowConfig 的 engine。 */ const engineOfRow = ( - overrides: Record = {}, - pinnedEngine: 'cc' | 'codex' | 'pi' = 'cc', + overrides: Record = {}, + pinnedEngine: 'cc' | 'codex' | 'pi' | 'grok-build' = 'cc', ) => (entry: UnifiedModelEntry, favorite?: ModelFavoriteItem) => favorite ? resolveFavoriteRowConfig({ entry, item: favorite }).engine @@ -743,10 +743,30 @@ describe('buildUnifiedRail', () => { { kind: 'all' }, { kind: 'provider', providerId: 'xd' }, ]); - // 草稿场景不出现这一格。 + // 草稿场景、且没有 grok-build 行时不出现引擎格。 expect(buildUnifiedRail(entries).some((item) => item.kind === 'engine')).toBe(false); }); + it('Grok catalog rows with a grok-build chip expose the harness engine rail, not a grok-build provider', () => { + const entries = [ + entryOf({ + providerId: 'xai', + modelId: 'grok-4.6', + candidates: ['claude-code', 'codex', 'pi', 'grok-build'], + recommended: 'claude-code', + }), + ]; + expect(buildUnifiedRail(entries)).toEqual([ + { kind: 'favorites' }, + { kind: 'engine', agent: 'grok-build' }, + { kind: 'all' }, + { kind: 'provider', providerId: 'xai' }, + ]); + expect(buildUnifiedRail(entries).some((item) => item.kind === 'provider' && item.providerId === 'grok-build')).toBe( + false, + ); + }); + it('传 providerOrder 时供应商图标按设置页拖动序排,未收录供应商按首见序追加', () => { const entries = [ entryOf({ providerId: 'xd', modelId: 'a' }), diff --git a/apps/desktop/src/renderer/__tests__/vendorAuthGateRemoteReadiness.test.ts b/apps/desktop/src/renderer/__tests__/vendorAuthGateRemoteReadiness.test.ts index b4ae699138c..d9db388b6cf 100644 --- a/apps/desktop/src/renderer/__tests__/vendorAuthGateRemoteReadiness.test.ts +++ b/apps/desktop/src/renderer/__tests__/vendorAuthGateRemoteReadiness.test.ts @@ -87,6 +87,18 @@ describe('deriveRemoteReadiness(被控端就绪推导)', () => { ).toBe('binary-missing'); }); + it('grok-build:sourceReady 是唯一真相,不再走 grok CLI authReady', () => { + expect( + deriveRemoteReadiness('grok-build', { binaryReady: true, sourceReady: true, authReady: false }), + ).toBe('ready'); + expect( + deriveRemoteReadiness('grok-build', { binaryReady: true, sourceReady: false, authReady: true }), + ).toBe('unauthenticated'); + expect( + deriveRemoteReadiness('grok-build', { binaryReady: false, sourceReady: true, authReady: true }), + ).toBe('binary-missing'); + }); + it('cc:binary 随包,binaryReady 不参与判定', () => { expect( deriveRemoteReadiness('cc', { binaryReady: false, sourceReady: true, authReady: null }), @@ -120,6 +132,22 @@ describe('sourceReadyFromProviderList(隧道 provider:list 响应解析)', ( ).toBe(false); }); + it('grok-build 以 SuperGrok/xAI 已连接为准,不要求目录声明 grok-build', () => { + expect( + sourceReadyFromProviderList( + { providers: [provider({ id: 'xai', agents: ['pi'], connected: true })] }, + 'grok-build', + ), + ).toBe(true); + expect( + sourceReadyFromProviderList( + { providers: [provider({ id: 'xai', agents: ['pi'], connected: false })] }, + 'grok-build', + ), + ).toBe(false); + expect(sourceReadyFromProviderList({ providers: [provider({})] }, 'grok-build')).toBe(false); + }); + it('协议异常(providers 缺失 / 非数组 / 响应为 null)→ null(判定不可用,回退旧口径)', () => { expect(sourceReadyFromProviderList(null, 'codex')).toBe(null); expect(sourceReadyFromProviderList({}, 'codex')).toBe(null); @@ -166,6 +194,8 @@ describe('pickVoiceInputDialogCopy(语音输入缺认证文案)', () => { 'codex-voice-unauth': { title: 'codex', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, 'codex-binary-missing': { title: 'binary', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, 'pi-binary-missing': { title: 'pi-binary', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, + 'grok-build-binary-missing': { title: 'grok-build-binary', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, + 'grok-build-unauth': { title: 'grok-build-unauth', description: '', confirmText: '', cancelText: '', settingsTab: 'providers' }, }; it('api-key + providers 使用 XD Gateway 文案', () => { diff --git a/apps/desktop/src/renderer/cindy-brain/GhostErrandPrefs.tsx b/apps/desktop/src/renderer/cindy-brain/GhostErrandPrefs.tsx index 7c5884e62bd..454601eaba4 100644 --- a/apps/desktop/src/renderer/cindy-brain/GhostErrandPrefs.tsx +++ b/apps/desktop/src/renderer/cindy-brain/GhostErrandPrefs.tsx @@ -41,7 +41,7 @@ const PERMISSION_ALLOWED = new Set(['plan', 'acceptEdits', 'auto']); const ERRAND_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']); interface ErrandConfig { - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; model?: string; effort?: string; fastMode?: boolean; @@ -92,9 +92,12 @@ export function GhostErrandPrefs({ // 保证非空,种子默认兜底)。不能用 getPersistedVendorModel:那是调度专用的严格口径, // 仅当用户在新建对话里显式选过该 vendor 模型才返回,否则返回 '',会让 trigger 落到 // 「选择模型」占位(2026-07-31 Lizi 反馈:应像草稿一样直接显示当前模型)。 - const followVendor: 'cc' | 'codex' | 'pi' = - draft.vendor === 'pi' ? 'pi' : draft.vendor === 'codex' ? 'codex' : 'cc'; - const vendor: 'cc' | 'codex' | 'pi' = config.agentKind ?? followVendor; + const followVendor: 'cc' | 'codex' | 'pi' | 'grok-build' = + draft.vendor === 'pi' ? 'pi' + : draft.vendor === 'codex' ? 'codex' + : draft.vendor === 'grok-build' ? 'grok-build' + : 'cc'; + const vendor: 'cc' | 'codex' | 'pi' | 'grok-build' = config.agentKind ?? followVendor; const shownModel = config.model ?? draft.lastByVendor[vendor].model; const shownEffort = (config.effort ?? getEffortForModel(shownModel) ?? @@ -162,7 +165,7 @@ export function GhostErrandPrefs({ // 值钉进本插件配置(未选过时才实时跟随草稿)。 save({ ...config, - agentKind: next === 'pi' ? 'pi' : next === 'codex' ? 'codex' : 'cc', + agentKind: next === 'pi' ? 'pi' : next === 'codex' ? 'codex' : next === 'grok-build' ? 'grok-build' : 'cc', model: undefined, effort: undefined, fastMode: undefined, diff --git a/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx b/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx index 529e069db8c..87a31062b81 100644 --- a/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx @@ -63,7 +63,7 @@ interface AgentTaskCardProps { sessionId?: string; /** Current owning harness. Pi's durable-detail sidebar must never surface * after the session has switched to Claude Code or Codex. */ - sessionAgentKind?: 'cc' | 'codex' | 'pi'; + sessionAgentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; } function readInputString(input: unknown, keys: string[]): string | undefined { diff --git a/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx b/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx index f526e57c727..56504ec226f 100644 --- a/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/ErrorBanner.tsx @@ -77,7 +77,7 @@ interface ErrorBannerProps { usageLimitRecovery?: UsageLimitRecoveryHint | null; /** 当前 session 的 agent kind。codex 的 401 / Missing bearer 必须 hide Retry, * 否则 retry 撞同一个 in-memory auth retry-loop 产生重复失败 turn。 */ - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; /** 当前 session 的远端 host id;非空 + agentKind='codex' 时显「同步登录态」按钮。 * 本地 codex 401 仍 hide Retry, 但只能提示用户去自己 fix login (没有 sync 入口)。 */ remoteHostId?: string; diff --git a/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx b/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx index bb85edd686b..bdf0c4074f5 100644 --- a/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx @@ -137,7 +137,7 @@ export function ErrorTailErrorBanner({ errorText: string; onContinue: () => Promise | void; onDismiss: () => void; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; remoteHostId?: string; deviceLinkDeviceId?: string | null; modelId?: string; diff --git a/apps/desktop/src/renderer/components/chat/MessageStream.tsx b/apps/desktop/src/renderer/components/chat/MessageStream.tsx index 5b5361ea87c..9ba1f3af509 100644 --- a/apps/desktop/src/renderer/components/chat/MessageStream.tsx +++ b/apps/desktop/src/renderer/components/chat/MessageStream.tsx @@ -377,7 +377,7 @@ interface MessageStreamProps { sessionTitle?: string | null; /** Owning agent kind — propagated to UserMessage so capability gates * (fork/rewind icon visibility) can read the right agent's capabilities. */ - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; /** Owning session's remote SSH host id (null for local sessions). Forwarded * so message-level controls can gate features unsupported on remote * (e.g. rewind on cc-remote daemon sessions). */ @@ -2369,7 +2369,7 @@ function renderWorkGroupChild( workingDir: string; sessionId?: string; sessionTitle?: string | null; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; remoteHostId?: string | null; isSessionStreaming: boolean; firstUserMessageClientId: string | null; @@ -5863,7 +5863,7 @@ const MessageItem = memo(function MessageItem({ remoteHostId?: string | null; /** Forwarded to User/AssistantMessage so they can read this agent's * capabilities (gates Fork/Rewind icon visibility). */ - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; /** Whether this session currently has an in-flight SDK turn. Rewind uses it * to require an idle live query; fork can still target stable history. */ sessionRunning?: boolean; diff --git a/apps/desktop/src/renderer/components/icons/GrokBuildMark.tsx b/apps/desktop/src/renderer/components/icons/GrokBuildMark.tsx new file mode 100644 index 00000000000..bcb67e4d7e3 --- /dev/null +++ b/apps/desktop/src/renderer/components/icons/GrokBuildMark.tsx @@ -0,0 +1,36 @@ +/** + * GrokBuildMark — Grok Build (xAI terminal coding agent) identity mark. + * + * Geometric "G" / chevron mark at 13-14px, visual weight aligned with PiMark / + * ClaudeMark / CodexMark. Not SuperGrok OAuth branding. + */ + +interface GrokBuildMarkProps { + size?: number; + className?: string; + variant?: 'mono' | 'brand'; +} + +export function GrokBuildMark({ size = 14, className }: GrokBuildMarkProps) { + return ( + + + + + + + ); +} diff --git a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx index 89638cbb70b..b927d8c2737 100644 --- a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx @@ -67,6 +67,7 @@ import { SlashCommandDecoration, } from './SlashCommandDecoration'; +import { SELECTABLE_AGENT_KINDS, type SelectableVendor } from '@/lib/agentVendors'; import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; import { toast } from '@/lib/toast'; @@ -323,7 +324,7 @@ import { useAvailableAgents } from '@/hooks/useAvailableAgents'; import { useConnectedSource } from '@/hooks/useConnectedSource'; import { useProviders } from '@/hooks/useProviders'; import { useDeviceProviders } from '@/hooks/useDeviceProviders'; -import { chatEligibleSourcesForModel, effectiveSourceIdForModel } from '@cindy/model-providers'; +import { effectiveSourceIdForModel, hasUsableConnectedSource } from '@cindy/model-providers'; import { deriveModelsFromProviders, filterChatBridgedCodexProviders, @@ -677,7 +678,7 @@ interface ChatInputProps { * M35: Vendor lock — when provided, ModelSelector only shows models * belonging to this vendor ('cc' for Claude, 'codex' for OpenAI Codex). */ - vendorKey?: 'cc' | 'codex' | 'pi'; + vendorKey?: SelectableVendor; /** * Optional override for the composerDraftStore key used to persist editor * content (and via attachmentState, attachments) across mount/unmount. @@ -801,7 +802,7 @@ interface ChatInputProps { * `lastByVendor.model` 并原样进 createSession,写错就是首条请求路由到一个不存在的模型。 */ onUnifiedDraftSelect?: (selection: { - vendor: 'cc' | 'codex' | 'pi'; + vendor: SelectableVendor; providerId: string; /** 选中引擎的 **wire model id**。 */ modelId: string; @@ -819,17 +820,23 @@ interface ChatInputProps { } /** 统一模型选择器联合列表的候选引擎全集(与 SELECTABLE_VENDORS 同一顺序)。 */ -const UNIFIED_AGENT_KINDS: readonly AgentKind[] = ['claude-code', 'codex', 'pi']; +const UNIFIED_AGENT_KINDS: readonly AgentKind[] = SELECTABLE_AGENT_KINDS; +/** 需 Cindy hosted loop 才注册的 runtime:未拉到注册表时不 fail-open,避免建出 not-registered 会话。 */ +const OPT_IN_UNIFIED_AGENTS: ReadonlySet = new Set(['grok-build']); /** AgentKind → NewMaker vendor(useAvailableAgents 用 vendor 口径)。 */ -function agentKindToVendor(kind: AgentKind): 'cc' | 'codex' | 'pi' { - return kind === 'codex' ? 'codex' : kind === 'pi' ? 'pi' : 'cc'; +function agentKindToVendor(kind: AgentKind): SelectableVendor { + if (kind === 'codex') return 'codex'; + if (kind === 'pi') return 'pi'; + if (kind === 'grok-build') return 'grok-build'; + return 'cc'; } -function vendorKeyToAgentKind(v?: 'cc' | 'codex' | 'pi'): AgentKind | null { +function vendorKeyToAgentKind(v?: SelectableVendor): AgentKind | null { if (v === 'cc') return 'claude-code'; if (v === 'codex') return 'codex'; if (v === 'pi') return 'pi'; + if (v === 'grok-build') return 'grok-build'; return null; } @@ -1917,15 +1924,19 @@ export function ChatInput({ // 联合列表参与哪些引擎 —— 以**运行时注册结果**为准(device-link 取被控端的)。 // 撤掉新会话工具条的 AgentSelect 后,它的 hiddenVendors 门禁就落到这里:Pi 二进制缺失 // 时模型目录照样投影 Pi 模型,只看目录会让用户一路选到 requireAgent 的 not-registered。 - // 未加载完成 → 传 undefined(fail-open,不隐藏任何引擎);当前引擎恒在列。 + // 未加载完成 → 只 fail-open 随包分发的引擎;grok-build 跟 Cindy hosted loop + // 走,Pi runtime 未确认前不提前露出。 + // 当前引擎恒在列。 const { availableVendors: runtimeAvailableVendors, loaded: runtimeAgentsLoaded } = useAvailableAgents(deviceLinkDeviceId); const unifiedAgents = useMemo(() => { - if (!runtimeAgentsLoaded) return undefined; + // grok-build 跟 Pi hosted loop:未加载完成时不 fail-open 露出它。 + const catalogKinds = UNIFIED_AGENT_KINDS.filter((kind) => !OPT_IN_UNIFIED_AGENTS.has(kind)); + if (!runtimeAgentsLoaded) return catalogKinds; const kinds = UNIFIED_AGENT_KINDS.filter( (kind) => kind === agentKind || runtimeAvailableVendors.has(agentKindToVendor(kind)), ); - return kinds.length > 0 ? kinds : undefined; + return kinds.length > 0 ? kinds : catalogKinds; }, [runtimeAgentsLoaded, runtimeAvailableVendors, agentKind]); // 已有 device-link 任务在断链时仍有 pinned deviceId + renderer outbox 可接住发送, // 不能因为被控端 provider 目录暂时拉不到就禁用 composer。远程草稿没有既有 session @@ -1939,12 +1950,15 @@ export function ChatInput({ // (sessionId 在)按实际路由口径判(includeDisabled):运行中的会话不因停用打断, // 请求仍走原路由,把停用当「无来源」会误禁 Send(PR #744 review 第十轮)。草稿是 // 新路由选择,保持准入口径(停用拷贝不算可发送来源)。 - const hasConnectedSendSource = currentModelAgentKind - ? chatEligibleSourcesForModel(sendProviders, activeModel, currentModelAgentKind, { - onlyConnected: true, - includeDisabled: !!sessionId, - }).length > 0 - : false; + const hasConnectedSendSource = hasUsableConnectedSource( + sendProviders, + currentModelAgentKind, + activeModel, + { + onlyConnected: true, + includeDisabled: !!sessionId, + }, + ); const noConnectedSource = enforceConnectedSourceGate && !!currentModelAgentKind && @@ -5316,16 +5330,16 @@ export function ChatInput({ // 已建会话按实际路由口径判(includeDisabled,与上方 hasConnectedSendSource // 同则):运行中会话不因停用打断,最终 preflight 若按准入 rail 判会在全停时 // 弹「去连接来源」把继续发送挡死(PR #744 review 第十八轮)。草稿保持准入口径。 - const connectedSources = chatEligibleSourcesForModel( + const hasSendSource = hasUsableConnectedSource( providers, - activeModel, currentModelAgentKind, + activeModel, { onlyConnected: true, includeDisabled: !!sessionId, }, ); - if (connectedSources.length === 0) { + if (!hasSendSource) { const goConnect = await confirmDialog({ title: t('newChat.noProvider.title'), description: t('newChat.noProvider.description'), @@ -5992,7 +6006,7 @@ export function ChatInput({ ? [targetAgentKind] : currentModelAgentKind ? [currentModelAgentKind] - : ['claude-code', 'codex', 'pi']; + : UNIFIED_AGENT_KINDS; if (providerId) { for (const kind of kinds) { const scoped = resolveProviderModelEfforts({ @@ -6431,7 +6445,7 @@ export function ChatInput({ ) => void | boolean | Promise; }>({ byProvider: () => {}, byModel: () => {} }); const confirmAgentBrowseSwitch = useCallback( - (targetAgent: 'claude-code' | 'codex' | 'pi' | null) => + (targetAgent: AgentKind | null) => confirmAgentSwitchRisk({ // 不必再问的两种:回原引擎(same-engine no-op),或点的就是已经确认过的意图目标 // Harness(只换模型,不换引擎)。换到第三家仍要问(Chris 2026-08-20:Claude 任务里 @@ -6455,7 +6469,7 @@ export function ChatInput({ ); const performAgentSwitch = useCallback( async ( - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: AgentKind, newModelId: string, providerId: string | null = null, // 意图期内的档位/Fast 改动经此显式覆盖(用户手选优先于记忆/默认解析)。 @@ -6930,7 +6944,7 @@ export function ChatInput({ /** 选中引擎的 **wire model id** —— 唯一可发送、可当记忆键的那个 id。 */ modelId: string; effort?: Effort; - engine: 'cc' | 'codex' | 'pi'; + engine: SelectableVendor; fast: boolean; favoriteUid: string | null; /** 行的归一化 id(面板行身份)。草稿层不消费,更不作为发送 id。 */ @@ -8834,7 +8848,7 @@ export function ChatInput({ currentVendor: vendorKey, // 两步分段的目标是 vendor 口径,确认门按 AgentKind 判(与意图 // 记录同形),在边界上转一次 —— 见 confirmAgentBrowseSwitch。 - confirmBrowseSwitch: (targetVendor: 'cc' | 'codex' | 'pi') => + confirmBrowseSwitch: (targetVendor: SelectableVendor) => confirmAgentBrowseSwitch(vendorKeyToAgentKind(targetVendor)), onSwitch: performAgentSwitch, } diff --git a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx index 01aa7697f7d..012d728a4c8 100644 --- a/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx @@ -51,7 +51,7 @@ import { OpenAIMark } from '@/components/icons/OpenAIMark'; import { XDIncMark } from '@/components/icons/XDIncMark'; import { hasProviderLogo, ProviderLogoMark } from '@/components/icons/ProviderLogoMark'; import { agentOptionOf } from './agentOptions'; -import type { SelectableVendor } from '@/lib/agentVendors'; +import { SELECTABLE_AGENT_KINDS, type SelectableVendor } from '@/lib/agentVendors'; import { FastModeToggle } from './FastModeToggle'; import { UnifiedModelPanel, @@ -104,7 +104,9 @@ import { chatEligibleSourcesForModel, actualSourceIdForModel, effectiveSourceIdForModel, + exclusiveXaiCatalogModelId, getModel, + hasUsableConnectedSource, modelSupportsFastMode, providerOffersModel, resolveModelIconKind, @@ -112,6 +114,7 @@ import { sourcesForModel, unifiedModelEntries, visibleModelUnion, + GROK_BUILD_HARNESS_PROVIDER_ID, type ProviderView, } from '@cindy/model-providers'; import { isProviderLogoKind } from '@cindy/model-providers/branding'; @@ -551,7 +554,7 @@ function RemoteModelLoadNotice({ } export interface ModelSelectorAgentIdentity { - vendorKey: 'cc' | 'codex' | 'pi'; + vendorKey: SelectableVendor; /** * current = 已由会话/runtime 元数据确认的当前 Agent; * pending = 已登记、将在下一条消息应用的切换目标。 @@ -563,8 +566,8 @@ export function resolveModelSelectorAgentIdentity( runtimeAgentKind: AgentKind | null | undefined, pendingTarget: AgentKind | null | undefined, ): ModelSelectorAgentIdentity | undefined { - const toVendorKey = (kind: AgentKind): 'cc' | 'codex' | 'pi' => - kind === 'codex' ? 'codex' : kind === 'pi' ? 'pi' : 'cc'; + const toVendorKey = (kind: AgentKind): SelectableVendor => + kind === 'codex' ? 'codex' : kind === 'pi' ? 'pi' : kind === 'grok-build' ? 'grok-build' : 'cc'; if (pendingTarget) { return { vendorKey: toVendorKey(pendingTarget), @@ -633,7 +636,7 @@ interface ModelSelectorProps { /** 非选中模型行的 effort/fast 全局预设读写器(按本机 / 被控设备隔离)。 */ modelMemory?: ModelMemoryAccessors; /** When provided, only models with this vendorKey are shown in the dropdown. */ - vendorKey?: 'cc' | 'codex' | 'pi'; + vendorKey?: SelectableVendor; /** * 已创建会话的 trigger 同时展示 Agent 与模型,避免 Claude Code 使用 OpenAI 模型时 * 只看来源图标而误判成 Codex。必须由权威 session/runtime 身份或明确切换 intent 提供, @@ -746,7 +749,7 @@ interface ModelSelectorProps { * device-link / SSH 远程不传(v1 不支持切换)。 */ agentSwitch?: { - currentVendor: 'cc' | 'codex' | 'pi'; + currentVendor: SelectableVendor; /** * 进入非当前 Agent 浏览态前确认;false 时保持原分段,什么都不改。 * @@ -754,14 +757,14 @@ interface ModelSelectorProps { * 判据是「会话上已有**指向该目标**的切换意图」。不传目标,它只能判「有没有意图」, * 于是先切 Codex 再选 Pi 时确认框永久静默(见 agentSwitchConfirmation.hasSwitchIntent)。 */ - confirmBrowseSwitch?: (targetVendor: 'cc' | 'codex' | 'pi') => Promise; + confirmBrowseSwitch?: (targetVendor: SelectableVendor) => Promise; /** * 返回值(若有)= 切换事务**真的登记成功了没有**;本两步分段路径不消费它, * 声明成宽联合只是为了让同一个 `performAgentSwitch` 能同时喂给这里与统一面板的 * `onCrossEngineSelect`(后者按真实结果决定要不要做清理动作)。 */ onSwitch: ( - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: AgentKind, modelId: string, providerId: string | null, ) => void | boolean | Promise; @@ -785,7 +788,7 @@ interface ModelSelectorContentProps { thinkingEnabled?: boolean; onThinkingChange?: (enabled: boolean) => void | Promise; modelMemory?: ModelMemoryAccessors; - vendorKey?: 'cc' | 'codex' | 'pi'; + vendorKey?: SelectableVendor; /** device-link 远程会话所属被控端 id(列被控端模型)。 */ deviceId?: string; /** SSH 远程会话隐藏订阅直连模型(语义同 ModelSelectorProps 同名字段)。 */ @@ -871,7 +874,7 @@ interface ModelSelectorContentProps { anchor: { uid: string; wireModelId: string; - engine: 'cc' | 'codex' | 'pi'; + engine: SelectableVendor; /** 选中时的显式来源。来源也是锚点身份的一部分:同 wire id 同引擎、仅来源不同的 * 配置是两份配置,少了它,别的窗口把会话来源从 A 切到 B 后,面板仍在 A 的收藏上 * 打勾(2026-08-17 review)。 */ @@ -896,7 +899,7 @@ interface ModelSelectorContentProps { modelId: string; /** 该行生效档位;该 (模型, 引擎) 不可调档时为 undefined。 */ effort?: Effort; - engine: 'cc' | 'codex' | 'pi'; + engine: SelectableVendor; fast: boolean; favoriteUid: string | null; /** 配置浮层「恢复推荐」的应用动作;调用方应删除 override,不得重新记忆推荐值。 */ @@ -917,16 +920,16 @@ interface ModelSelectorContentProps { fluidWidth?: boolean; /** 语义同 ModelSelectorProps.agentSwitch(显式两步引擎切换)。 */ agentSwitch?: { - currentVendor: 'cc' | 'codex' | 'pi'; + currentVendor: SelectableVendor; /** 语义同 ModelSelectorProps.agentSwitch.confirmBrowseSwitch(带本次目标引擎)。 */ - confirmBrowseSwitch?: (targetVendor: 'cc' | 'codex' | 'pi') => Promise; + confirmBrowseSwitch?: (targetVendor: SelectableVendor) => Promise; /** * 返回值(若有)= 切换事务**真的登记成功了没有**;本两步分段路径不消费它, * 声明成宽联合只是为了让同一个 `performAgentSwitch` 能同时喂给这里与统一面板的 * `onCrossEngineSelect`(后者按真实结果决定要不要做清理动作)。 */ onSwitch: ( - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: AgentKind, modelId: string, providerId: string | null, ) => void | boolean | Promise; @@ -947,10 +950,11 @@ interface ModelSelectorContentProps { interactionDisabled?: boolean; } -function vendorKeyToAgentKind(v?: 'cc' | 'codex' | 'pi'): AgentKind | null { +function vendorKeyToAgentKind(v?: SelectableVendor): AgentKind | null { if (v === 'cc') return 'claude-code'; if (v === 'codex') return 'codex'; if (v === 'pi') return 'pi'; + if (v === 'grok-build') return 'grok-build'; return null; } @@ -1090,11 +1094,11 @@ function ModelSelectorContentView({ const modelTagDensity = modelTagDensityForWidth(paneWidth ?? (fluidWidth ? null : 320)); // session-agent-switch:两步式引擎切换的浏览态。browseVendor 初始 = 会话当前引擎; // 切到另一家 tab 只是「浏览目标引擎的模型」,选中模型行才真正触发切换事务。 - const [browseVendor, setBrowseVendor] = useState<'cc' | 'codex' | 'pi'>( + const [browseVendor, setBrowseVendor] = useState( agentSwitch?.currentVendor ?? vendorKey ?? 'cc', ); const browseSwitchPendingRef = useRef(false); - const handleBrowseVendorChange = async (next: 'cc' | 'codex' | 'pi') => { + const handleBrowseVendorChange = async (next: SelectableVendor) => { if (interactionDisabled || next === browseVendor || browseSwitchPendingRef.current) return; // 返回当前引擎(含已有意图时浏览原引擎准备撤销)不需要确认;只有从 // currentVendor 进入另一 Agent 浏览态才调用上层风险确认。确认前绝不翻分段。 @@ -1119,10 +1123,9 @@ function ModelSelectorContentView({ // A field that only persists a model must not offer another Harness. const unifiedAgents = requestedUnifiedAgents ?? (vendorKey && agentKind && !onUnifiedSelect && !sessionEngineFilter ? [agentKind] : undefined); - const browseTargetLabel = - browseVendor === 'codex' ? 'Codex' : browseVendor === 'pi' ? 'Pi' : 'Claude Code'; + const browseTargetLabel = agentOptionOf(browseVendor).label; const enqueueAgentSwitch = ( - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: AgentKind, targetModelId: string, targetProviderId: string | null, ) => { @@ -1385,7 +1388,13 @@ function ModelSelectorContentView({ ], ); - const currentModel = visibleModels.find((m) => m.id === modelId); + const currentModel = + visibleModels.find((m) => m.id === modelId) ?? + visibleModels.find((m) => { + const selected = exclusiveXaiCatalogModelId(modelId); + const candidate = exclusiveXaiCatalogModelId(m.id); + return selected !== null && selected === candidate; + }); // 显式 vendor / 浏览分段已给出 agentKind 时直接采用;浏览目标引擎期间 modelId 仍是 // 旧引擎当前模型,通常不在目标 catalog,不能先因 currentModel 缺失判 null(否则目标 @@ -1675,7 +1684,7 @@ function ModelSelectorContentView({ ? remoteProviders.modelVisibilityOverrides === undefined ? null : new Set( - (agentKind ? [agentKind] : (['claude-code', 'codex', 'pi'] as const)).flatMap((agent) => + (agentKind ? [agentKind] : SELECTABLE_AGENT_KINDS).flatMap((agent) => visibleModelUnion(providers, agent, (providerId, model) => isDeviceModelVisible( remoteProviders.modelVisibilityOverrides, @@ -1687,7 +1696,7 @@ function ModelSelectorContentView({ ), ) : new Set( - (agentKind ? [agentKind] : (['claude-code', 'codex', 'pi'] as const)).flatMap((agent) => + (agentKind ? [agentKind] : SELECTABLE_AGENT_KINDS).flatMap((agent) => visibleModelUnion(providers, agent, (providerId, model) => isModelEnabled(agent, providerId, model), ).map((model) => model.id), @@ -1811,7 +1820,7 @@ function ModelSelectorContentView({ // trigger 来源 icon / 路由立即正确(null = flat 退化行,交给默认路由)。 if (browsing && agentSwitch) { enqueueAgentSwitch( - browseVendor === 'codex' ? 'codex' : browseVendor === 'pi' ? 'pi' : 'claude-code', + vendorKeyToAgentKind(browseVendor) ?? 'claude-code', id, providerId, ); @@ -2635,13 +2644,15 @@ function ModelSelectorContentView({ // 0 个可连来源:整张引导卡取代列表(仅 providers 加载完成后判,避免拉取期闪空态)。 // device-link 远程会话不显示该引导(控制端无法替被控端连来源)→ 退化为扁平兜底列表。 + // Grok Build 没有目录供应商声明 grok-build;SuperGrok / xAI 已连接时不能盖住统一列表。 const emptyState = !unifiedPanel && sourcesEnabled && !deviceId && currentAgentKind && !providersLoading && - connected.length === 0 ? ( + connected.length === 0 && + !hasUsableConnectedSource(providers, currentAgentKind, modelId) ? (
@@ -2681,6 +2692,7 @@ function ModelSelectorContentView({ // 渲染直接 "Rendered more hooks" 崩溃)。 const unifiedProviderLabel = useCallback( (providerId: string): string => { + if (providerId === GROK_BUILD_HARNESS_PROVIDER_ID) return 'Grok Build'; const provider = providers.find((entry) => entry.id === providerId); return provider ? providerDisplayName(provider, t) : providerId; }, @@ -3287,7 +3299,7 @@ export function ModelSelector({ if (!confirmBrowseSwitch) return agentSwitch; return { ...agentSwitch, - confirmBrowseSwitch: async (targetVendor: 'cc' | 'codex' | 'pi') => { + confirmBrowseSwitch: async (targetVendor: SelectableVendor) => { setKeepOpenForAgentConfirmation(true); try { return await confirmBrowseSwitch(targetVendor); @@ -3391,7 +3403,12 @@ export function ModelSelector({ const routeModel = routeProvider && agentKind ? getModel(routeProvider, modelId, agentKind) : undefined; const currentModel = routeModel ? { ...routeModel, displayName: routeModel.name, id: modelId } - : visibleModels.find((m) => m.id === modelId); + : visibleModels.find((m) => m.id === modelId) + ?? visibleModels.find((m) => { + const selected = exclusiveXaiCatalogModelId(modelId); + const candidate = exclusiveXaiCatalogModelId(m.id); + return selected !== null && selected === candidate; + }); // 已保存模型即使隐藏、断开或下架,实际任务仍保留模型 ID;偏好字段可通过 // unknownModelLabel 提供诊断文案。没有保存选择的入口才显示选择模型占位符。 // unknown label 空串/全空白按缺省处理(否则 ?? 不回落,trigger 渲染成空白)。 @@ -3483,7 +3500,8 @@ export function ModelSelector({ !deviceId && !!currentAgentKind && !providersLoading && - !hasConnectedSource; + !hasConnectedSource && + !hasUsableConnectedSource(providers, currentAgentKind, modelId); // trigger 上仍展示当前模型的 effort(模型支持时)。 const triggerProvider = actualRoute && routeProvider ? routeProvider diff --git a/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx b/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx index dd31095e750..272171f2741 100644 --- a/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx +++ b/apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx @@ -24,7 +24,7 @@ import type { PermissionMode } from '@/lib/userPreferences.types'; interface PermissionSelectorProps { permissionMode: PermissionMode; onPermissionModeChange: (mode: PermissionMode) => void; - vendorKey?: 'cc' | 'codex' | 'pi'; + vendorKey?: 'cc' | 'codex' | 'pi' | 'grok-build'; /** device-link 远程会话所属被控端 id;非空 = 权限档从被控端读(本地会话 undefined,行为不变)。 */ deviceId?: string; /** 禁用 trigger。用于断线远程会话等只读 composer 状态。 */ @@ -66,9 +66,10 @@ const PERMISSION_ICONS: Record = { bypassPermissions: TriangleAlert, }; -function vendorKeyToAgentKind(v: 'cc' | 'codex' | 'pi'): AgentKind { +function vendorKeyToAgentKind(v: 'cc' | 'codex' | 'pi' | 'grok-build'): AgentKind { if (v === 'codex') return 'codex'; if (v === 'pi') return 'pi'; + if (v === 'grok-build') return 'grok-build'; return 'claude-code'; } diff --git a/apps/desktop/src/renderer/components/new-chat/UnifiedFlyoutHost.tsx b/apps/desktop/src/renderer/components/new-chat/UnifiedFlyoutHost.tsx index 7969d61740e..ff6e73358fa 100644 --- a/apps/desktop/src/renderer/components/new-chat/UnifiedFlyoutHost.tsx +++ b/apps/desktop/src/renderer/components/new-chat/UnifiedFlyoutHost.tsx @@ -2,8 +2,9 @@ import { createPortal } from 'react-dom'; import { useEffect, useState, type ReactNode, type RefObject } from 'react'; import { DismissableLayer, DismissableLayerBranch } from '@radix-ui/react-dismissable-layer'; -import type { ProviderView } from '@cindy/model-providers'; +import { GROK_BUILD_HARNESS_PROVIDER_ID, type ProviderView } from '@cindy/model-providers'; +import { GrokBuildMark } from '@/components/icons/GrokBuildMark'; import { WINDOW_NO_DRAG_STYLE } from '@/components/layout/windowDrag'; import { cn } from '@/lib/utils'; @@ -24,6 +25,9 @@ export function ProviderRailMark({ providerId: string; providers: readonly ProviderView[]; }) { + if (providerId === GROK_BUILD_HARNESS_PROVIDER_ID) { + return ; + } const provider = providers.find((entry) => entry.id === providerId); return ( > cc: { label: 'Claude', Mark: ClaudeMark }, codex: { label: 'Codex', Mark: CodexMark }, pi: { label: 'Pi', Mark: PiMark }, + 'grok-build': { label: 'Grok Build', Mark: GrokBuildMark }, }; export const AGENT_OPTIONS: readonly AgentOption[] = SELECTABLE_VENDORS.map((vendor) => ({ diff --git a/apps/desktop/src/renderer/components/new-chat/sourceSwitch.ts b/apps/desktop/src/renderer/components/new-chat/sourceSwitch.ts index b4bebefd212..9a37bfb553f 100644 --- a/apps/desktop/src/renderer/components/new-chat/sourceSwitch.ts +++ b/apps/desktop/src/renderer/components/new-chat/sourceSwitch.ts @@ -18,6 +18,7 @@ import { classifyModel, connectedProvidersForAgent, getModel, + hasUsableConnectedSource, groupModelsForDisplay, groupOf, isModelSelectableForNewRoute, @@ -195,6 +196,14 @@ export function isSelectedSourceDisconnected(args: { }): boolean { const { providers, agent, modelId, selectedProviderId, providersLoading } = args; if (providersLoading || !agent || !selectedProviderId) return false; + // Grok Build is not a catalog provider; persist providerId=grok-build + // (or any leftover catalog id) must not look "disconnected" after SuperGrok is connected. + if ( + agent === 'grok-build' && + hasUsableConnectedSource(providers, agent, modelId, { includeDisabled: true }) + ) { + return false; + } // chatEligibleSourcesForModel + includeDisabled:选中来源若还在但这个 id 在它上面 // 已经不是聊天模型了(mode 变化),也要判"断连"——否则这里说"没断连"、 // effectiveSourceIdForModel 却解析不出可用来源,界面显示能发、实际发不出去 diff --git a/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts b/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts index 9c8d04fd637..c9fa7f065b9 100644 --- a/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts +++ b/apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts @@ -30,12 +30,18 @@ export type UnifiedEngine = SelectableVendor; /** vendor → AgentKind(查目录 / 能力 / 记忆时用)。 */ export function agentKindOfEngine(engine: UnifiedEngine): AgentKind { - return engine === 'cc' ? 'claude-code' : engine === 'codex' ? 'codex' : 'pi'; + if (engine === 'cc') return 'claude-code'; + if (engine === 'codex') return 'codex'; + if (engine === 'grok-build') return 'grok-build'; + return 'pi'; } /** AgentKind → vendor(落 store / draft 时用)。未知值回落 cc,与既有 sanitize 方向一致。 */ export function engineOfAgentKind(agent: AgentKind): UnifiedEngine { - return agent === 'codex' ? 'codex' : agent === 'pi' ? 'pi' : 'cc'; + if (agent === 'codex') return 'codex'; + if (agent === 'pi') return 'pi'; + if (agent === 'grok-build') return 'grok-build'; + return 'cc'; } /** @@ -340,10 +346,16 @@ export function buildUnifiedRail( // 只在有收藏时出现会让功能不可发现(Chris 2026-08-13 实测:「分类栏直接砍了?」)。 items.push({ kind: 'favorites' }); if (sessionAgent) items.push({ kind: 'engine', agent: sessionAgent }); + // Grok Build 是 harness:目录行带 grok-build 候选时露出引擎格,不当成供应商分类。 + const hasGrokBuild = entries.some((entry) => entry.candidates.includes('grok-build')); + if (hasGrokBuild && sessionAgent !== 'grok-build') { + items.push({ kind: 'engine', agent: 'grok-build' }); + } items.push({ kind: 'all' }); const seen = new Set(); const firstSeen: string[] = []; for (const entry of entries) { + if (entry.providerId === 'grok-build') continue; if (seen.has(entry.providerId)) continue; seen.add(entry.providerId); firstSeen.push(entry.providerId); diff --git a/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx b/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx index dddf6172229..17f9b7998b3 100644 --- a/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx +++ b/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx @@ -90,6 +90,7 @@ const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + 'grok-build': 'Grok Build', }; function presetRuntimeBaseUrl( diff --git a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx index 7f358a2c1f4..833e324886f 100644 --- a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx @@ -125,6 +125,11 @@ type DialogAgentKind = Extract; const AGENTS: DialogAgentKind[] = ['claude-code', 'codex', 'pi']; +/** grok-build 不在本面板:它是本机 CLI,没有自定义 provider / baseUrl 可配。 */ +function isDialogAgentKind(value: string): value is DialogAgentKind { + return (AGENTS as string[]).includes(value); +} + const VISIBLE_AGENTS: DialogAgentKind[] = AGENTS; const DIALOG_FOCUSABLE_SELECTOR = [ @@ -1007,7 +1012,7 @@ export function CustomProviderDialog({ // 的 runtime 上,handleSave 的守卫拦不住"用户已经看不到"的这条草稿,表单 // 卡死报错却找不到对应输入框(review P1)。 setWindowDrafts({}); - const first = configuredPresetAgents(p)[0]; + const first = configuredPresetAgents(p).find(isDialogAgentKind); if (first) setActiveTab(first); }, [i18n.language, setRtSynced], @@ -1731,8 +1736,8 @@ export function CustomProviderDialog({ for (const [draftKey, draftText] of Object.entries(windowDrafts)) { if (isCommittableWindowText(draftText)) continue; const sep = draftKey.lastIndexOf(':'); - const draftAgent = draftKey.slice(0, sep) as AgentKind; - if (!VISIBLE_AGENTS.includes(draftAgent)) continue; + const draftAgent = draftKey.slice(0, sep); + if (!isDialogAgentKind(draftAgent) || !VISIBLE_AGENTS.includes(draftAgent)) continue; // 该 runtime 未配置 baseUrl、或该行 id/name 为空:两者都会在下面序列化时 // 被丢弃,不会写进最终配置,草稿再非法也不该挡住一个原本有效的保存 // (review P1)。 diff --git a/apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx b/apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx index f0a40ea38ac..82bb32a1019 100644 --- a/apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx +++ b/apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx @@ -491,19 +491,20 @@ function PrefsField({ } /** hook prefs 的 agentKind → 选择器的 vendor key。 */ -function toVendorKey(agentKind: string | null): 'cc' | 'codex' | 'pi' { - return agentKind === 'codex' || agentKind === 'pi' ? agentKind : 'cc'; +function toVendorKey(agentKind: string | null): 'cc' | 'codex' | 'pi' | 'grok-build' { + if (agentKind === 'codex' || agentKind === 'pi' || agentKind === 'grok-build') return agentKind; + return 'cc'; } /** * 选择器的 vendor key → hook prefs 的 agentKind。 - * MakerVendor 还含 'orca' 等本编辑器不支持的值 —— 分段只有 Claude/Codex 两项,该分支 - * 物理不可达;若未来有人把别的 vendor 接进来,fail-fast 好过静默写成 claude-code - * 偏好(Copilot review)。 + * MakerVendor 还含 'orca' 等本编辑器不支持的值 —— 若未来有人把别的 vendor 接进来, + * fail-fast 好过静默写成 claude-code 偏好。 */ function toAgentKind(vendor: MakerVendor): KnownAgent { if (vendor === 'codex') return 'codex'; if (vendor === 'pi') return 'pi'; + if (vendor === 'grok-build') return 'grok-build'; if (vendor === 'cc') return 'claude-code'; throw new Error(`WorkspacePrefsEditor: unsupported vendor '${vendor}' for hook prefs`); } @@ -522,14 +523,16 @@ export function WorkspacePrefsEditor({ const claudeCaps = useAgentCapabilities('claude-code'); const codexCaps = useAgentCapabilities('codex'); const piCaps = useAgentCapabilities('pi'); + const grokBuildCaps = useAgentCapabilities('grok-build'); const capsByAgent = useMemo( () => ({ 'claude-code': claudeCaps.capabilities, codex: codexCaps.capabilities, pi: piCaps.capabilities, + 'grok-build': grokBuildCaps.capabilities, }) as Record, - [claudeCaps.capabilities, codexCaps.capabilities, piCaps.capabilities], + [claudeCaps.capabilities, codexCaps.capabilities, piCaps.capabilities, grokBuildCaps.capabilities], ); const capsOf = useCallback( (agentKind: string): AgentCapabilities | null => diff --git a/apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx b/apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx index 8330a7a10ff..9eb196411ea 100644 --- a/apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx @@ -42,13 +42,16 @@ import { resolveAgentSwitchSettings, } from './imDefaultSettingsLogic'; -function vendorKeyFor(agentKind: ImDefaultAgentKind): 'cc' | 'codex' | 'pi' { +function vendorKeyFor(agentKind: ImDefaultAgentKind): 'cc' | 'codex' | 'pi' | 'grok-build' { return agentKind === 'claude-code' ? 'cc' : agentKind; } /** AgentSelect 的 vendor → IM 默认配置的 agentKind。 */ function agentKindOfVendor(vendor: string): ImDefaultAgentKind { - return vendor === 'cc' ? 'claude-code' : vendor === 'pi' ? 'pi' : 'codex'; + if (vendor === 'cc') return 'claude-code'; + if (vendor === 'pi') return 'pi'; + if (vendor === 'grok-build') return 'grok-build'; + return 'codex'; } export interface ImDefaultSettingsSummary { @@ -81,6 +84,7 @@ export function ImDefaultSettingsSection({ const cc = useAgentCapabilities('claude-code'); const codex = useAgentCapabilities('codex'); const pi = useAgentCapabilities('pi'); + const grokBuild = useAgentCapabilities('grok-build'); const [settings, setSettings] = useState(null); const [pending, setPending] = useState(false); @@ -120,6 +124,7 @@ export function ImDefaultSettingsSection({ }), codex: deriveModelsFromProviders(providers, 'codex', { admissionFiltered: true }), pi: deriveModelsFromProviders(providers, 'pi', { admissionFiltered: true }), + 'grok-build': deriveModelsFromProviders(providers, 'grok-build', { admissionFiltered: true }), }; return { 'claude-code': fromProviders['claude-code'].length @@ -131,8 +136,11 @@ export function ImDefaultSettingsSection({ pi: fromProviders.pi.length ? fromProviders.pi : (pi.capabilities?.availableModels ?? []), + 'grok-build': fromProviders['grok-build'].length + ? fromProviders['grok-build'] + : (grokBuild.capabilities?.availableModels ?? []), }; - }, [providers, cc.capabilities, codex.capabilities, pi.capabilities]); + }, [providers, cc.capabilities, codex.capabilities, pi.capabilities, grokBuild.capabilities]); const resolveProviderId = useCallback( (agentKind: ImDefaultAgentKind, modelId: string, providerId: string | null): string | null => { diff --git a/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx b/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx index 7b5a206549a..e4a42e3bd48 100644 --- a/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx +++ b/apps/desktop/src/renderer/components/settings/ModelAdvancedDrawer.tsx @@ -31,6 +31,7 @@ import { Tip } from '@/components/ui/tooltip'; import { Switch } from '@/components/ui/switch'; import { ClaudeMark } from '@/components/icons/ClaudeMark'; import { CodexMark } from '@/components/icons/CodexMark'; +import { GrokBuildMark } from '@/components/icons/GrokBuildMark'; import { PiMark } from '@/components/icons/PiMark'; import { useModelContextLimit } from '@/hooks/useModelContextLimit'; import { modelPriceDetailRows, type ModelPricePresentation } from '@/lib/modelPriceFormat'; @@ -74,6 +75,7 @@ const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + 'grok-build': 'Grok Build', }; // Editing uses whole decimal K, rounded down to avoid suggesting a value above the upstream @@ -86,6 +88,7 @@ const AGENT_MARK: Record ReactNode> = { 'claude-code': (size) => , codex: (size) => , pi: (size) => , + 'grok-build': (size) => , }; /** 抽屉里的档位顺序 = 目录枚举顺序(弱到强)。ultra 只在模型真的提供时出现。 */ diff --git a/apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx b/apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx index f4ec646fc43..cd1d9253b5e 100644 --- a/apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx @@ -18,6 +18,7 @@ const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + 'grok-build': 'Grok Build', }; interface Props { diff --git a/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx b/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx index 72ba1316b08..24ac1844422 100644 --- a/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx +++ b/apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx @@ -90,6 +90,7 @@ const AGENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + 'grok-build': 'Grok Build', }; /** diff --git a/apps/desktop/src/renderer/components/settings/__tests__/ImDefaultSettingsSection.test.tsx b/apps/desktop/src/renderer/components/settings/__tests__/ImDefaultSettingsSection.test.tsx index 52d467d9268..9ced6530856 100644 --- a/apps/desktop/src/renderer/components/settings/__tests__/ImDefaultSettingsSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/__tests__/ImDefaultSettingsSection.test.tsx @@ -88,6 +88,7 @@ function defaults(agentKind: ImDefaultSettingsState['agentKind']): ImDefaultSett 'claude-code': { providerId: null, model: 'claude-opus-4-8', effort: 'xhigh' }, codex: { providerId: null, model: 'codex/gpt-5.5', effort: 'high' }, pi: { providerId: null, model: 'claude-sonnet-5', effort: 'high' }, + 'grok-build': { providerId: null, model: 'grok-4.6', effort: 'high' }, }, isCustomized: false, customizedKeys: [], diff --git a/apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts b/apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts index 692c16aecbc..84ad23c49f6 100644 --- a/apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts +++ b/apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts @@ -24,7 +24,7 @@ import type { HookPrefsPatch, HookWorkspacePrefs } from '../../../shared/hookCon * renderer 侧合法 agentKind 的单一来源(编辑器 UI 与本文件的归一化共用;与 main 侧 * 派发 defaults.ts 的 AGENT_KINDS 同口径 —— 进程边界两侧各持一份,新增 agent 时同步)。 */ -export const AGENT_KINDS = ['claude-code', 'codex', 'pi'] as const; +export const AGENT_KINDS = ['claude-code', 'codex', 'pi', 'grok-build'] as const; export type KnownAgent = (typeof AGENT_KINDS)[number]; export function isKnownAgent(value: string | null): value is KnownAgent { diff --git a/apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx b/apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx index 951da9ac46a..fd27a11a975 100644 --- a/apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx +++ b/apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx @@ -25,6 +25,7 @@ const AGENT_RANK: Record = { 'claude-code': 0, codex: 1, pi: 2, + 'grok-build': 3, }; const TH_CLASS = diff --git a/apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx b/apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx index 2660bd7b478..0bade96470b 100644 --- a/apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx +++ b/apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx @@ -16,8 +16,9 @@ import { cn } from '@/lib/utils'; import { ClaudeMark } from '@/components/icons/ClaudeMark'; import { CodexMark } from '@/components/icons/CodexMark'; +import { GrokBuildMark } from '@/components/icons/GrokBuildMark'; -export type VendorIconKind = 'cc' | 'codex' | 'pi'; +export type VendorIconKind = 'cc' | 'codex' | 'pi' | 'grok-build'; /** * agentKind → VendorIcon vendor 的唯一映射。所有渲染 agent 身份图标的调用点 @@ -25,7 +26,10 @@ export type VendorIconKind = 'cc' | 'codex' | 'pi'; * 吞成 Claude 脸,2026-07-30 实测 bug)。兼容 'claude-code' 别名与 null。 */ export function agentKindToVendor(kind: string | null | undefined): VendorIconKind { - return kind === 'codex' ? 'codex' : kind === 'pi' ? 'pi' : 'cc'; + if (kind === 'codex') return 'codex'; + if (kind === 'pi') return 'pi'; + if (kind === 'grok-build') return 'grok-build'; + return 'cc'; } interface VendorIconProps { @@ -59,6 +63,8 @@ export function VendorIcon({ {vendor === 'codex' ? ( + ) : vendor === 'grok-build' ? ( + ) : vendor === 'pi' ? ( + onUnifiedSelect={(selection) => { + const harness = harnessFor(selection.engine); + if (!harness) return; replace(index, { - harness: harnessFor(selection.engine), + harness, providerId: selection.providerId, model: selection.modelId, effort: selection.effort ?? '', fastMode: selection.fast, - }) - } + }); + }} unknownModelLabel={(model) => t('bots.modelUnavailable', { model })} />
diff --git a/apps/desktop/src/renderer/features/bots/__tests__/botSessionRead.test.tsx b/apps/desktop/src/renderer/features/bots/__tests__/botSessionRead.test.tsx index 89a248161dc..fbc1691a534 100644 --- a/apps/desktop/src/renderer/features/bots/__tests__/botSessionRead.test.tsx +++ b/apps/desktop/src/renderer/features/bots/__tests__/botSessionRead.test.tsx @@ -94,7 +94,8 @@ describe('Bot conversation read position', () => { const view = render(); await waitFor(() => expect(view.getByTestId('chat').dataset.unreadBoundary).toBe('5000')); - expect(getBotLastReadAt('bot-1')).toBe(10_000); + // markBotRead runs after the ready gate paints; wait so Windows CI cannot lose the race. + await waitFor(() => expect(getBotLastReadAt('bot-1')).toBe(10_000)); }); it('keeps advancing the read position while the user is watching the chat', async () => { diff --git a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx index b0cdd2ebb2f..2897e8d45ce 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -5884,7 +5884,7 @@ function formatTokenCount(n: number): string { */ function getModelContextWindow( model: string, - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build', deviceId?: string, ): number | undefined { const found = getModelsForVendor(vendorKey, deviceId).find((m) => m.id === model); @@ -5908,7 +5908,7 @@ function ContextCapacityRing({ providerId?: string | null; contextTokens: number; model: string; - vendorKey: 'cc' | 'codex' | 'pi'; + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'; /** SDK-reported context window; 0 = not yet known → use hardcoded fallback. */ sdkContextWindow: number; verifiedContextWindow?: number | null; diff --git a/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx b/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx index df37e2954c1..230fdfeee0f 100644 --- a/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx @@ -69,6 +69,7 @@ import { remoteProjectsStore } from '@/features/device-link/remoteProjectsStore' import { dbToMakerAgentKind, normalizeDbAgentKind, + type DbAgentKind, type MakerAgentKindWire, } from '../../../shared/agentKindConversion'; import { getBranchName } from '../../../shared/managedWorktreeBranches'; @@ -752,8 +753,8 @@ export function NewMakerDraftRoute() { * 并清掉 —— 持久化之后那等于一切引擎只能记住最后一次选择。 */ const draftFavoriteAnchor = useDraftFavoriteAnchor(normalizeDbAgentKind(draft.vendor)); - const persistedAgentKind: 'cc' | 'codex' | 'pi' = normalizeDbAgentKind(draft.vendor); - const authVendor: 'cc' | 'codex' | 'pi' = persistedAgentKind; + const persistedAgentKind: DbAgentKind = normalizeDbAgentKind(draft.vendor); + const authVendor: DbAgentKind = persistedAgentKind; const capabilityAgentKind = dbToMakerAgentKind(persistedAgentKind); // 品牌区跟随当前主题;icon / logo 的固定布局统一由 ThemeBrandLockup 负责。 @@ -905,7 +906,7 @@ export function NewMakerDraftRoute() { ); const hiddenSwitcherVendors = useMemo(() => { if (!availableAgentsLoaded) return []; - return (['cc', 'codex', 'pi'] as const).filter((vendor) => !availableVendors.has(vendor)); + return (['cc', 'codex', 'pi', 'grok-build'] as const).filter((vendor) => !availableVendors.has(vendor)); }, [availableAgentsLoaded, availableVendors]); /** * 「这份草稿要建到对端设备上」—— 只看 deviceId,**不再要求 workingDir**(#807)。 @@ -2104,7 +2105,7 @@ export function NewMakerDraftRoute() { const carryDraftFavoriteAnchorToSession = useCallback( ( newSessionId: string, - engine: 'cc' | 'codex' | 'pi', + engine: 'cc' | 'codex' | 'pi' | 'grok-build', model: string, providerId: string | null, ): void => { @@ -2326,7 +2327,7 @@ export function NewMakerDraftRoute() { const handleRemoteProjectAdded = useCallback( async (target: RemoteProjectTarget) => { // vendor 由外层 VendorSegmentedSwitcher (draft.vendor) 单一决策 —— dialog 不再让用户选。 - const draftVendor: 'cc' | 'codex' | 'pi' = normalizeDbAgentKind(draft.vendor); + const draftVendor: DbAgentKind = normalizeDbAgentKind(draft.vendor); if (target.kind === 'device-link') { // device-link:**不**像 SSH 立即建会话(会在被控端留空会话)。改为把当前草稿指向该被控 @@ -3816,7 +3817,10 @@ export function NewMakerDraftRoute() { // agent 启动时看到的工作区已是迁移后的状态。fail-soft:检测错误只 warn,不阻塞 send。 try { const wd = effectiveWorkingDir; - if (wd && !isRemoteProjectDraft && persistedAgentKind !== 'pi') { + // 迁移只覆盖 CLAUDE.md ↔ AGENTS.md 两家;pi 与 grok-build 没有对应的约定文件。 + const crossAgentMigratable = + persistedAgentKind === 'cc' || persistedAgentKind === 'codex'; + if (wd && !isRemoteProjectDraft && crossAgentMigratable) { const r = await crossAgentConvertService.detect( wd, persistedAgentKind === 'cc' ? 'claude-code' : persistedAgentKind, diff --git a/apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts b/apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts index 51a97634883..c225f4c51d4 100644 --- a/apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts +++ b/apps/desktop/src/renderer/features/cc-agent/deviceLinkCreateArgs.ts @@ -23,8 +23,8 @@ import type { AgentKind } from '@/hooks/useAgentCapabilities'; import type { Effort, PermissionMode } from '@/lib/userPreferences.types'; export interface DeviceLinkCreateParams { - /** 草稿 vendor 形态:'cc' | 'codex' | 'pi'(persistedAgentKind)。 */ - agentKind: 'cc' | 'codex' | 'pi'; + /** 草稿 vendor 形态:'cc' | 'codex' | 'pi' | 'grok-build'(persistedAgentKind)。 */ + agentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; /** * 被控端上的项目目录。缺省 / 空白 = 在该设备上建**不绑项目的 standalone dialogue**, * workspaceKind 随之派生为 'dialogue',运行目录由被控端分配。 @@ -57,7 +57,7 @@ export interface DeviceLinkCreateParams { } export interface DeviceLinkCreateArgs { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** 仅远程 worktree 流程出现(与 worktree:create 登记的绑定同 id)。 */ id?: string; /** 仅项目会话出现;dialogue 不带此字段(被控端自行分配运行目录)。 */ @@ -114,7 +114,7 @@ export interface DeviceLinkSubmissionCandidate { } export interface DeviceLinkSubmissionParams { - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; workingDir?: string; id?: string; extraDirs?: string[]; @@ -222,7 +222,7 @@ export function buildProvisionalRemoteSession(p: ProvisionalRemoteSessionParams) // 侧边栏按这条时间轴排序,新会话该立刻浮到顶部。 userSendAt: p.nowIso, status: 'active', - // Session.agentKind 是本机形态('cc' | 'codex' | 'pi'),args 里是 maker-core 形态,这里转回来。 + // Session.agentKind 是本机形态('cc' | 'codex' | 'pi' | 'grok-build'),args 里是 maker-core 形态,这里转回来。 agentKind: p.args.agentKind === 'claude-code' ? 'cc' : p.args.agentKind, extraDirs: p.args.extraDirs ?? [], writableDirs: p.args.writableDirs ?? [], diff --git a/apps/desktop/src/renderer/features/cc-agent/sidebar/ConversationSearchBox.tsx b/apps/desktop/src/renderer/features/cc-agent/sidebar/ConversationSearchBox.tsx index 7156c6fddc1..779d6f242f7 100644 --- a/apps/desktop/src/renderer/features/cc-agent/sidebar/ConversationSearchBox.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/sidebar/ConversationSearchBox.tsx @@ -139,6 +139,7 @@ const AGENT_OPTIONS: ReadonlyArray> = [ { value: 'cc', labelKey: 'ccAgent.sidebar.filterVendor.cc' }, { value: 'codex', labelKey: 'ccAgent.sidebar.filterVendor.codex' }, { value: 'pi', labelKey: 'ccAgent.sidebar.filterVendor.pi' }, + { value: 'grok-build', labelKey: 'ccAgent.sidebar.filterVendor.grok-build' }, ]; const LAST_ACTIVITY_OPTIONS: ReadonlyArray> = [ diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts b/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts index 43c77eb495b..8cc5e54e487 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts @@ -34,7 +34,7 @@ export interface SessionTaskItem { /** 标题链取不到任何来源时为 ''(UI 层负责 i18n 兜底)。 */ title: string; status: AgentTaskStatus; - provider: 'claude-code' | 'codex' | 'pi'; + provider: 'claude-code' | 'codex' | 'pi' | 'grok-build'; update?: AgentTaskUpdate; toolCallClientId?: string; toolUseId?: string; diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx b/apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx index 04ba8f25f3b..c7c1afc1578 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx @@ -40,12 +40,14 @@ const KIND_ICON: Record = { utility: Cog, 'agent-claude': Bot, 'agent-codex': Bot, + 'agent-grok-build': Bot, 'agent-pi': Bot, }; const AGENT_NAME: Record = { 'agent-claude': 'Claude Code', 'agent-codex': 'Codex', + 'agent-grok-build': 'Grok Build', 'agent-pi': 'Pi', }; diff --git a/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx b/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx index edd0ca1dba2..bc34a6fd036 100644 --- a/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx +++ b/apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx @@ -51,7 +51,7 @@ import type { SessionReference } from '../../../../shared/sessionReference'; import { isReviewSessionSource } from '../../../../shared/sessionSource'; export type Destination = 'local' | 'worktree' | 'thread'; -export type AgentKind = 'claude-code' | 'codex' | 'pi'; +export type AgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; interface ChipButtonProps { icon?: React.ReactNode; diff --git a/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts b/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts index e536cb146cb..6a3681301bd 100644 --- a/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts +++ b/apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts @@ -85,6 +85,7 @@ function defaultScheduleFormPrefs(): ScheduleFormPrefs { 'claude-code': EMPTY_AGENT_PREFS, codex: EMPTY_AGENT_PREFS, pi: EMPTY_AGENT_PREFS, + 'grok-build': EMPTY_AGENT_PREFS, }, }; } @@ -95,7 +96,7 @@ function loadScheduleFormPrefs(): ScheduleFormPrefs { const raw = window.localStorage.getItem(SCHEDULE_FORM_PREFS_KEY); if (!raw) return defaultScheduleFormPrefs(); const parsed = JSON.parse(raw) as Partial; - const agentKind = parsed.agentKind === 'codex' ? 'codex' : parsed.agentKind === 'pi' ? 'pi' : 'claude-code'; + const agentKind = parsed.agentKind === 'codex' ? 'codex' : parsed.agentKind === 'pi' ? 'pi' : parsed.agentKind === 'grok-build' ? 'grok-build' : 'claude-code'; const workingDir = typeof parsed.workingDir === 'string' ? parsed.workingDir : ''; const workspaceKind = normalizePrefsWorkspaceKind(parsed.workspaceKind, workingDir); return { @@ -107,6 +108,7 @@ function loadScheduleFormPrefs(): ScheduleFormPrefs { 'claude-code': sanitizeAgentPrefs(parsed.lastByAgent?.['claude-code']), codex: sanitizeAgentPrefs(parsed.lastByAgent?.codex), pi: sanitizeAgentPrefs(parsed.lastByAgent?.pi), + 'grok-build': sanitizeAgentPrefs(parsed.lastByAgent?.['grok-build']), }, }; } catch { diff --git a/apps/desktop/src/renderer/features/scheduler/lib/projectAutomationConfig.ts b/apps/desktop/src/renderer/features/scheduler/lib/projectAutomationConfig.ts index 22249c265b3..c19e93b1e71 100644 --- a/apps/desktop/src/renderer/features/scheduler/lib/projectAutomationConfig.ts +++ b/apps/desktop/src/renderer/features/scheduler/lib/projectAutomationConfig.ts @@ -14,7 +14,7 @@ export interface ProjectScheduleConfig { recurring?: boolean; manual?: boolean; intervalMs?: number; - agentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind?: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model?: string; /** 显式来源(供应商)id;省略 = 使用该 Agent 的原生默认来源。 */ providerId?: string; diff --git a/apps/desktop/src/renderer/features/scheduler/lib/scheduleFormLogic.ts b/apps/desktop/src/renderer/features/scheduler/lib/scheduleFormLogic.ts index 188febd361e..e82eda0c4a5 100644 --- a/apps/desktop/src/renderer/features/scheduler/lib/scheduleFormLogic.ts +++ b/apps/desktop/src/renderer/features/scheduler/lib/scheduleFormLogic.ts @@ -119,7 +119,7 @@ export interface ScheduleFormState { recurring: boolean; /** 手动模式:true → 创建后永不自动 fire,只能 Run now。UI 上需要 recurring=false 才能勾。 */ manual: boolean; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; /** * 显式选定的来源(供应商)id。'' = 跟随该 agent 原生默认来源(no-break,与未升级 @@ -350,10 +350,11 @@ export function applyRunMode( /** renderer Session.agentKind('cc'|'codex')→ schedule agentKind 映射。 */ export function sessionAgentKindToScheduleAgentKind( - kind: 'cc' | 'codex' | 'pi', + kind: 'cc' | 'codex' | 'pi' | 'grok-build', ): ScheduleFormState['agentKind'] { if (kind === 'codex') return 'codex'; if (kind === 'pi') return 'pi'; + if (kind === 'grok-build') return 'grok-build'; return 'claude-code'; } diff --git a/apps/desktop/src/renderer/features/scheduler/lib/usageLimitScheduleCreateIntent.ts b/apps/desktop/src/renderer/features/scheduler/lib/usageLimitScheduleCreateIntent.ts index d586d888ece..ccbb0cfa7f0 100644 --- a/apps/desktop/src/renderer/features/scheduler/lib/usageLimitScheduleCreateIntent.ts +++ b/apps/desktop/src/renderer/features/scheduler/lib/usageLimitScheduleCreateIntent.ts @@ -4,7 +4,7 @@ export interface UsageLimitScheduleCreateIntent { kind: 'usage-limit-recovery'; requestId: string; sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; resetAtMs: number | null; } @@ -32,7 +32,10 @@ export function readUsageLimitScheduleCreateIntent( !value.requestId || typeof value.sessionId !== 'string' || !value.sessionId || - (value.agentKind !== 'claude-code' && value.agentKind !== 'codex' && value.agentKind !== 'pi') || + (value.agentKind !== 'claude-code' && + value.agentKind !== 'codex' && + value.agentKind !== 'pi' && + value.agentKind !== 'grok-build') || (value.resetAtMs !== null && (typeof value.resetAtMs !== 'number' || !Number.isFinite(value.resetAtMs))) ) { diff --git a/apps/desktop/src/renderer/features/skillhub/lib/localRoutes.ts b/apps/desktop/src/renderer/features/skillhub/lib/localRoutes.ts index 7386df62f41..8fd017e141b 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/localRoutes.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/localRoutes.ts @@ -1,6 +1,7 @@ interface LocalSkillRouteEntry { id: string; - engine: 'claude-code' | 'codex' | 'pi'; + // Track the scanner's engine union so a new agent runtime cannot break the constraint. + engine: SkillhubSkill['engine']; kind: SkillhubKind; scope: SkillhubScope; name: string; diff --git a/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts b/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts index f0850dae9d2..060b6b5d1c0 100644 --- a/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts +++ b/apps/desktop/src/renderer/hooks/useAgentCapabilities.ts @@ -17,12 +17,12 @@ import type { Effort, PermissionMode } from '@/lib/userPreferences.types'; const log = createLogger('useAgentCapabilities'); -export type AgentKind = 'claude-code' | 'codex' | 'pi'; +export type AgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; // capability 生命周期(预取 / 驱逐通知 / 本地快照刷新 / 启动预载)必须覆盖全部 agent, // 少一个就会让该 agent 的远程会话在断链或 provider revision 后收不到 loading 事件、 // 也不再被重新预取,界面永久停在旧模型/能力快照(codex review)。新增 agent 只改这里。 -const ALL_AGENT_KINDS = ['claude-code', 'codex', 'pi'] as const; +const ALL_AGENT_KINDS = ['claude-code', 'codex', 'pi', 'grok-build'] as const; // renderer 视角: id 全部是不透明 string, 渲染只读 displayName。 // effort 的合法 id 集合 = capabilities.effortLevels 上每个项的 id。 @@ -55,7 +55,7 @@ export interface ModelDescriptor { * 解耦;生产环境 XD 网关由服务端按区域下发)。消费点见 modelDefinitions.newSessionDefaultModelId * 与 draftModelCalibration:被标记且可用的模型优先作新对话默认。 */ - newSessionDefault?: ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: ('claude-code' | 'codex' | 'pi' | 'grok-build')[]; } export interface EffortDescriptor { diff --git a/apps/desktop/src/renderer/hooks/useAvailableAgents.ts b/apps/desktop/src/renderer/hooks/useAvailableAgents.ts index 84886d06538..a23eb983bad 100644 --- a/apps/desktop/src/renderer/hooks/useAvailableAgents.ts +++ b/apps/desktop/src/renderer/hooks/useAvailableAgents.ts @@ -47,7 +47,7 @@ function refreshRemoteCapabilitiesOnce(deviceId: string): void { remoteCapabilitiesRefreshInFlight.set(deviceId, pending); } -type RuntimeAgentKind = 'claude-code' | 'codex' | 'pi'; +type RuntimeAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** runtime agent id → NewMaker vendor(其余保持同名)。 */ function toVendor(agent: RuntimeAgentKind): MakerVendor { @@ -80,7 +80,7 @@ async function fetchAvailableAgents(deviceId?: string | null): Promise - v === 'claude-code' || v === 'codex' || v === 'pi') as RuntimeAgentKind[]) : []; + v === 'claude-code' || v === 'codex' || v === 'pi' || v === 'grok-build') as RuntimeAgentKind[]) : []; } const api = getMakerApi(); if (!api) throw new Error('maker IPC not available'); diff --git a/apps/desktop/src/renderer/hooks/useConnectedSource.ts b/apps/desktop/src/renderer/hooks/useConnectedSource.ts index 8c904d56d4f..7ddc659bc75 100644 --- a/apps/desktop/src/renderer/hooks/useConnectedSource.ts +++ b/apps/desktop/src/renderer/hooks/useConnectedSource.ts @@ -16,7 +16,7 @@ import { useMemo } from 'react'; -import { chatEligibleSourcesForModel, connectedProvidersForAgent, type AgentKind } from '@cindy/model-providers'; +import { hasUsableConnectedSource, type AgentKind } from '@cindy/model-providers'; import { useProviders } from './useProviders'; @@ -31,14 +31,9 @@ export function useConnectedSource(agent: AgentKind | null, modelId?: string): U const { providers, loading } = useProviders(); const hasConnectedSource = useMemo( () => { - if (!agent) return false; - // chatEligibleSourcesForModel(不是裸 sourcesForModel):非聊天模型不该被判定为 - // "有可用来源"(issue #882 第 3 点,2026-07 review)——本 hook 是整个产品"要不要 - // 进连接来源空态"的唯一真相,漏这个过滤会让 ModelSelector CTA 和 Send 按钮对 - // 一个非聊天模型显示"已连接、可以发"。 - return modelId - ? chatEligibleSourcesForModel(providers, modelId, agent).length > 0 - : connectedProvidersForAgent(providers, agent).length > 0; + // hasUsableConnectedSource 是 picker / Send 同源判定。Grok Build + 独占 Grok + // 不要求目录供应商声明 grok-build;其余 agent 仍走 chatEligibleSourcesForModel。 + return hasUsableConnectedSource(providers, agent, modelId); }, [providers, agent, modelId], ); diff --git a/apps/desktop/src/renderer/hooks/useMakerSession.ts b/apps/desktop/src/renderer/hooks/useMakerSession.ts index 2e66605b971..a1b257041cb 100644 --- a/apps/desktop/src/renderer/hooks/useMakerSession.ts +++ b/apps/desktop/src/renderer/hooks/useMakerSession.ts @@ -15,13 +15,13 @@ interface MakerEvent { interface SessionInfo { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workDir: string; capabilities: unknown; } interface CreateSessionParams { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; /** 与 maker-core/types/common.ts 的 Effort union 一致 */ diff --git a/apps/desktop/src/renderer/hooks/useUsageHistory.ts b/apps/desktop/src/renderer/hooks/useUsageHistory.ts index 85abf9953d6..8fcdf76294e 100644 --- a/apps/desktop/src/renderer/hooks/useUsageHistory.ts +++ b/apps/desktop/src/renderer/hooks/useUsageHistory.ts @@ -31,7 +31,7 @@ import { } from '../../shared/regionalMoney'; export interface UsageHistoryModel { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; money: RegionalMoney; estimatedMoney: RegionalMoney | null; @@ -44,7 +44,7 @@ export interface UsageHistoryModel { /** 每日 × 模型明细 — 右栏堆叠柱状图分段。 */ export interface UsageHistoryModelDay { day: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; money: RegionalMoney; apiMoney: RegionalMoney; diff --git a/apps/desktop/src/renderer/hooks/useVendorAuthGate.ts b/apps/desktop/src/renderer/hooks/useVendorAuthGate.ts index 174f31eaea8..fb07bafe074 100644 --- a/apps/desktop/src/renderer/hooks/useVendorAuthGate.ts +++ b/apps/desktop/src/renderer/hooks/useVendorAuthGate.ts @@ -24,7 +24,7 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { - connectedProvidersForAgent, + hasUsableConnectedSource, type AgentKind as ProviderAgentKind, } from '@cindy/model-providers'; @@ -54,7 +54,9 @@ type CopyKey = | 'voice-direct-api-key-unauth' | 'codex-voice-unauth' | 'codex-binary-missing' - | 'pi-binary-missing'; + | 'pi-binary-missing' + | 'grok-build-binary-missing' + | 'grok-build-unauth'; function buildCopy(t: (key: string) => string): Record { return { @@ -103,6 +105,20 @@ function buildCopy(t: (key: string) => string): Record { cancelText: t('logic.confirm.cancel'), settingsTab: 'providers', }, + 'grok-build-binary-missing': { + title: t('logic.confirm.grokBuildBinaryMissingTitle'), + description: t('logic.confirm.grokBuildBinaryMissingDescription'), + confirmText: t('logic.confirm.goToSettings'), + cancelText: t('logic.confirm.cancel'), + settingsTab: 'providers', + }, + 'grok-build-unauth': { + title: t('logic.confirm.grokBuildUnauthenticatedTitle'), + description: t('logic.confirm.grokBuildUnauthenticatedDescription'), + confirmText: t('logic.confirm.goToSettings'), + cancelText: t('logic.confirm.cancel'), + settingsTab: 'providers', + }, }; } @@ -122,8 +138,9 @@ function pickCopy( ): DialogCopy | null { if (readiness === 'binary-missing' && vendor === 'codex') return copy['codex-binary-missing']; if (readiness === 'binary-missing' && vendor === 'pi') return copy['pi-binary-missing']; + if (readiness === 'binary-missing' && vendor === 'grok-build') return copy['grok-build-binary-missing']; if (readiness !== 'unauthenticated') return null; - // 无可用来源:cc / codex 走同一条「连接来源」文案(send 门禁,与 agent 类型无关)。 + // 无可用来源:cc / codex / grok-build 同构,跳「设置 → 模型供应商」(SuperGrok / 网关)。 return copy['no-source']; } @@ -172,9 +189,9 @@ export function sourceReadyFromProviderList( ): boolean | null { try { const { providers } = parseDeviceProvidersPayload(value); - return connectedProvidersForAgent(providers, agent, { + return hasUsableConnectedSource(providers, agent, undefined, { includeSuspended: opts?.includeSuspended === true, - }).length > 0; + }); } catch { return null; } @@ -237,6 +254,7 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { const cc = useVendorReadiness('cc'); const codex = useVendorReadiness('codex'); const pi = useVendorReadiness('pi'); + const grokBuild = useVendorReadiness('grok-build'); const checkAndConfirm = useCallback( async ( @@ -271,7 +289,7 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { const deviceId = options?.deviceId; if (deviceId) { const providerAgent: ProviderAgentKind = - vendor === 'codex' ? 'codex' : vendor === 'pi' ? 'pi' : 'claude-code'; + vendor === 'codex' ? 'codex' : vendor === 'pi' ? 'pi' : vendor === 'grok-build' ? 'grok-build' : 'claude-code'; const [statusRes, providersRes] = await Promise.allSettled([ window.electronAPI.deviceLink.invoke(deviceId, 'maker:agent:status', [providerAgent]), window.electronAPI.deviceLink.invoke(deviceId, 'maker:provider:list', []), @@ -317,13 +335,20 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { let title: string; let description: string; if (remoteReadiness === 'binary-missing') { - title = t(vendor === 'pi' - ? 'logic.confirm.remotePiBinaryMissingTitle' - : 'logic.confirm.remoteCodexBinaryMissingTitle'); + title = t( + vendor === 'pi' + ? 'logic.confirm.remotePiBinaryMissingTitle' + : vendor === 'grok-build' + ? 'logic.confirm.remoteGrokBuildBinaryMissingTitle' + : 'logic.confirm.remoteCodexBinaryMissingTitle', + ); description = t('logic.confirm.remoteAuthDescription', { device }); } else if (vendor === 'codex') { title = t('logic.confirm.remoteCodexNoSourceTitle'); description = t('logic.confirm.remoteCodexNoSourceDescription', { device }); + } else if (vendor === 'grok-build') { + title = t('logic.confirm.remoteGrokBuildNoSourceTitle'); + description = t('logic.confirm.remoteGrokBuildNoSourceDescription', { device }); } else { title = t('logic.confirm.remoteCcNoSourceTitle'); description = t('logic.confirm.remoteCcNoSourceDescription', { device }); @@ -339,7 +364,7 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { } // 触发一次最新检查——避免 stale state 误放行。 - const target = vendor === 'codex' ? codex : vendor === 'pi' ? pi : cc; + const target = vendor === 'codex' ? codex : vendor === 'pi' ? pi : vendor === 'grok-build' ? grokBuild : cc; // 已建会话的发送门禁计入 suspended 来源(见 useVendorReadiness 注释);草稿不传。 const readiness = await target.revalidate({ includeSuspended: options?.existingSessionRoute === true, @@ -361,7 +386,7 @@ export function useVendorAuthGate(): UseVendorAuthGateReturn { } return { proceed: false }; }, - [cc, codex, pi, confirm, copy, navigate, t], + [cc, codex, pi, grokBuild, confirm, copy, navigate, t], ); return { checkAndConfirm }; diff --git a/apps/desktop/src/renderer/hooks/useVendorReadiness.ts b/apps/desktop/src/renderer/hooks/useVendorReadiness.ts index 6daac49112b..b29dde41f54 100644 --- a/apps/desktop/src/renderer/hooks/useVendorReadiness.ts +++ b/apps/desktop/src/renderer/hooks/useVendorReadiness.ts @@ -3,11 +3,10 @@ * --------------------------------------------------------------------------- * 输出统一的 Readiness 枚举。判定分两条**正交**的轴: * - * 1. 有没有可用来源(与 agent 类型无关,唯一真相): - * connectedProvidersForAgent(providers, agent).length > 0 → 'ready' | 'unauthenticated' - * cc 由 XD 网关 key / claude.ai 订阅满足;codex 由 codex OAuth / XD key 满足 —— 同一条规则, + * 1. 有没有可用来源(hasUsableConnectedSource): + * cc / codex / pi 看目录已连接供应商;grok-build 看 SuperGrok / xAI。 * 与 useConnectedSource(渲染期空態判定)同源,这里在 send 门禁时刻现拉一次避免 stale。 - * 2. 运行时前提(codex / pi):本地二进制缺失时先拦截。 + * 2. 运行时前提(codex / pi / grok-build):本地 hosted-loop 二进制缺失时先拦截。 * cc 二进制随包分发、永远在,无此轴。 * * revalidate() 供 send 门禁 / DropdownMenu onOpenChange(true) 时手动触发,不自动轮询。 @@ -15,7 +14,7 @@ import { useCallback, useEffect, useState } from 'react'; -import { connectedProvidersForAgent, type AgentKind, type ProviderView } from '@cindy/model-providers'; +import { hasUsableConnectedSource, type AgentKind, type ProviderView } from '@cindy/model-providers'; export type Readiness = 'ready' | 'unauthenticated' | 'binary-missing' | 'loading'; @@ -24,20 +23,20 @@ export type Readiness = 'ready' | 'unauthenticated' | 'binary-missing' | 'loadin * 两种不同的恢复路径,不能把 Pi 的缺包状态伪装成未授权。 */ export function readinessFromBinaryStatus( - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build', binaryReady: boolean, ): Readiness | null { return vendorKey !== 'cc' && !binaryReady ? 'binary-missing' : null; } -export function useVendorReadiness(vendorKey: 'cc' | 'codex' | 'pi'): { +export function useVendorReadiness(vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'): { readiness: Readiness; revalidate: (opts?: { includeSuspended?: boolean }) => Promise; } { const [readiness, setReadiness] = useState('loading'); const revalidate = useCallback(async (opts?: { includeSuspended?: boolean }): Promise => { - const agent: AgentKind = vendorKey === 'cc' ? 'claude-code' : vendorKey === 'pi' ? 'pi' : 'codex'; + const agent: AgentKind = vendorKey === 'cc' ? 'claude-code' : vendorKey === 'pi' ? 'pi' : vendorKey === 'grok-build' ? 'grok-build' : 'codex'; // 轴 2(codex / pi,正交于来源):本地二进制是运行时前提,缺了连发都发不了 → 优先返回 // binary-missing。binary 状态走 maker:agent:status(其 authReady 是 codex OAuth 专属,已被 @@ -61,8 +60,8 @@ export function useVendorReadiness(vendorKey: 'cc' | 'codex' | 'pi'): { } } - // 轴 1(与 agent 类型无关,唯一真相):该 agent 有没有「已连接的可选来源」。连接态走本地 IPC - // listProviders(极快),send 门禁时刻现拉避免 stale;失败按空列表处理(判未就绪,引导去连接)。 + // 轴 1:该 agent 有没有可用来源。Grok Build 看 SuperGrok / xAI,其余走目录 + // connectedProvidersForAgent。listProviders 极快;失败按空列表处理。 let providers: ProviderView[] = []; try { providers = (await window.electronAPI.maker.listProviders()).providers; @@ -72,12 +71,11 @@ export function useVendorReadiness(vendorKey: 'cc' | 'codex' | 'pi'): { // includeSuspended:已建会话的发送门禁传 true —— 供应商级停用是准入轴,不打断 // 运行中会话,门禁只回答「凭证还连着吗」;全停时把继续发送判成 unauthenticated // 会误堵旧会话(PR #744 review 第十七轮)。新路由(草稿)保持准入口径。 - const next: Readiness = - connectedProvidersForAgent(providers, agent, { - includeSuspended: opts?.includeSuspended === true, - }).length > 0 - ? 'ready' - : 'unauthenticated'; + const next: Readiness = hasUsableConnectedSource(providers, agent, undefined, { + includeSuspended: opts?.includeSuspended === true, + }) + ? 'ready' + : 'unauthenticated'; setReadiness(next); return next; }, [vendorKey]); diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index c8cc90a023b..ef73bb4a007 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -2735,6 +2735,10 @@ "pi": { "label": "Pi", "description": "Saves long-session compression summaries to Cindy memory for future Pi sessions." + }, + "grok-build": { + "label": "Grok Build", + "description": "Grok Build does not expose a Cindy-managed auto-memory channel in this version." } }, "agent": { @@ -3819,7 +3823,8 @@ "agents": { "claude-code": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "groupPermissionLabel": "Permission mode for new group tasks", "groupPermissionDescription": "Permission mode used for every task created in a group chat — @bot opening a topic, /new, and /ctr all use it; the permission mode above applies to direct messages only. Defaults to Auto approval. Group context can contain member-controlled content — choosing Full access executes actions without per-step confirmation, so only pick it for groups you trust. Changes apply to tasks created afterwards; switch an existing task with /permission in the group." @@ -7603,6 +7608,7 @@ "claudeCode": "Claude Code", "codex": "Codex", "pi": "Pi", + "grok-build": "Grok Build", "pending": "Next: {{agent}}" } }, @@ -7720,6 +7726,20 @@ "label": "Full access", "description": "Routine tools run without asking. Pi extension commands you send directly run as requested; agent-initiated changes still require tool approval. Highest risk; use only for trusted tasks." } + }, + "grok-build": { + "ask": { + "label": "Default permissions", + "description": "Read-only tools run directly; writing files, running commands, and MCP tools ask each time." + }, + "auto": { + "label": "Auto-review", + "description": "In-workspace writes and safe commands run automatically; out-of-workspace writes, risky commands, and MCP tools still ask." + }, + "bypassPermissions": { + "label": "Full access", + "description": "Routine tools run without asking. Agent-initiated extension changes still require tool approval. Highest risk; use only for trusted tasks." + } } } }, @@ -9242,7 +9262,8 @@ "all": "All", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "filterGroupBy": { "project": "Group by project", @@ -11368,14 +11389,22 @@ "codexBinaryMissingDescription": "The Codex component is not ready yet, so this feature cannot be used. Try again later or check the Codex connection in Settings → Providers.", "piBinaryMissingTitle": "Pi component not ready", "piBinaryMissingDescription": "The Pi component is not ready yet, so this feature cannot be used. Try again later or check the Pi connection in Settings → Providers.", + "grokBuildBinaryMissingTitle": "Grok Build is not ready", + "grokBuildBinaryMissingDescription": "The Cindy hosted loop for Grok Build is not ready yet, so this feature cannot be used. Try again later or check Providers in Settings. Claude Code, Codex, and Pi are unaffected.", + "grokBuildUnauthenticatedTitle": "SuperGrok is not connected", + "grokBuildUnauthenticatedDescription": "Grok Build uses SuperGrok (xAI) on Cindy's model plane. Open Settings → Providers to connect SuperGrok.", + "grokBuildLogin": "Open Settings", "goToSettings": "Open Settings", "remoteCodexBinaryMissingTitle": "Codex component not ready on the remote device", "remotePiBinaryMissingTitle": "Pi component not ready on the remote device", + "remoteGrokBuildBinaryMissingTitle": "Grok Build is not ready on the remote device", "remoteAuthDescription": "You need to finish setup on the controlled device \"{{device}}\" before you can start a session here. Configure it on that device and try again.", "remoteCodexNoSourceTitle": "No Codex source connected on the remote device", "remoteCodexNoSourceDescription": "\"{{device}}\" has no model source connected for Codex, so sessions can't be started from here. Open {{appName}} on that device, sign in to OpenAI or connect another source under Settings → Providers, then try again.", "remoteCcNoSourceTitle": "No Claude source connected on the remote device", "remoteCcNoSourceDescription": "\"{{device}}\" has no Claude model source connected, so sessions can't be started from here. Open {{appName}} on that device, connect a source under Settings → Providers, then try again.", + "remoteGrokBuildNoSourceTitle": "SuperGrok is not connected on the remote device", + "remoteGrokBuildNoSourceDescription": "\"{{device}}\" has no SuperGrok (xAI) connection, so Grok Build sessions can't be started from here. Open {{appName}} on that device, connect SuperGrok under Settings → Providers, then try again.", "gotIt": "Got it" }, "fileTypes": { diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index a5eddf4f40d..7f9e0662ac7 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -2734,6 +2734,10 @@ "pi": { "label": "Pi", "description": "長いセッションの圧縮要約を Cindy メモリに保存し、今後の Pi セッションで利用します" + }, + "grok-build": { + "label": "Grok Build", + "description": "このバージョンの Grok Build は Cindy 管理の自動メモリを提供しません。" } }, "agent": { @@ -3818,7 +3822,8 @@ "agents": { "claude-code": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "groupPermissionLabel": "グループで新規タスクを作成するときの権限モード", "groupPermissionDescription": "グループで新規タスクを作成するときに共通で使う権限モード(@bot でトピックを開く場合、/new、/ctr のいずれも対象)。初期値は「自動承認」で、上の権限モードは個人チャット専用です。グループの文脈にはメンバーが操作できる内容が含まれるため、「フルアクセス」を選ぶと確認なしで直接実行されます。信頼できるグループでのみ選択してください。変更は以後に作成するタスクにのみ適用され、既存のタスクはグループ内で /permission を使って個別に切り替えます。" @@ -7601,6 +7606,7 @@ "claudeCode": "Claude Code", "codex": "Codex", "pi": "Pi", + "grok-build": "Grok Build", "pending": "次回:{{agent}}" } }, @@ -7718,6 +7724,20 @@ "label": "フルアクセス", "description": "通常のツールは確認なしで実行しますが、Pi 拡張のインストール、更新、アンインストールには引き続き確認が必要です。最もリスクが高いため、信頼できるタスクでのみ使用してください。" } + }, + "grok-build": { + "ask": { + "label": "デフォルト権限", + "description": "読み取り専用ツールはそのまま実行し、ファイル書き込み・コマンド実行・MCP ツールは毎回確認します。" + }, + "auto": { + "label": "自動レビュー", + "description": "ワークスペース内の書き込みと安全なコマンドは自動実行し、境界外への書き込み・危険なコマンド・MCP ツールは確認します。" + }, + "bypassPermissions": { + "label": "フルアクセス", + "description": "通常のツールは確認なしで実行しますが、エージェント起点の拡張変更には引き続き確認が必要です。最もリスクが高いため、信頼できるタスクでのみ使用してください。" + } } } }, @@ -9227,7 +9247,8 @@ "all": "すべて", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "filterGroupBy": { "project": "プロジェクトでグループ化", @@ -11352,14 +11373,22 @@ "codexBinaryMissingDescription": "Codex コンポーネントの準備がまだできていないため、この機能は使用できません。しばらくしてから再試行するか、「設定 → プロバイダー」で Codex の接続状態を確認してください。", "piBinaryMissingTitle": "Pi コンポーネントの準備ができていません", "piBinaryMissingDescription": "Pi コンポーネントの準備がまだできていないため、この機能は使用できません。しばらくしてから再試行するか、「設定 → プロバイダー」で Pi の接続状態を確認してください。", + "grokBuildBinaryMissingTitle": "Grok Build の準備ができていません", + "grokBuildBinaryMissingDescription": "Cindy のホスト済みループがまだ準備できていないため、Grok Build は使えません。しばらくしてから再試行するか、「設定 → プロバイダー」を確認してください。Claude Code / Codex / Pi には影響しません。", + "grokBuildUnauthenticatedTitle": "SuperGrok が未接続です", + "grokBuildUnauthenticatedDescription": "Grok Build は Cindy のモデル面の SuperGrok(xAI)を使います。「設定 → プロバイダー」で SuperGrok を接続してください。", + "grokBuildLogin": "設定を開く", "goToSettings": "設定を開く", "remoteCodexBinaryMissingTitle": "リモートデバイスの Codex コンポーネントが未準備です", "remotePiBinaryMissingTitle": "リモートデバイスの Pi コンポーネントが未準備です", + "remoteGrokBuildBinaryMissingTitle": "リモートデバイスの Grok Build が未準備です", "remoteAuthDescription": "操作対象のデバイス「{{device}}」で設定を完了してから、ここでセッションを開始できます。そのデバイスで設定してから再試行してください。", "remoteCodexNoSourceTitle": "リモートデバイスに利用可能な Codex ソースがありません", "remoteCodexNoSourceDescription": "デバイス「{{device}}」には Codex で利用可能なモデルソースが接続されていないため、ここからセッションを開始できません。そのデバイスで {{appName}} を開き、「設定 → プロバイダー」で OpenAI にログインするか他のソースを接続してから再試行してください。", "remoteCcNoSourceTitle": "リモートデバイスに利用可能な Claude ソースがありません", "remoteCcNoSourceDescription": "デバイス「{{device}}」には利用可能な Claude モデルソースが接続されていないため、ここからセッションを開始できません。そのデバイスで {{appName}} を開き、「設定 → プロバイダー」でソースを接続してから再試行してください。", + "remoteGrokBuildNoSourceTitle": "リモートデバイスに SuperGrok が未接続です", + "remoteGrokBuildNoSourceDescription": "デバイス「{{device}}」には SuperGrok(xAI)が接続されていないため、ここから Grok Build セッションを開始できません。そのデバイスで {{appName}} を開き、「設定 → プロバイダー」で SuperGrok を接続してから再試行してください。", "gotIt": "OK" }, "fileTypes": { diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index d772821b6eb..a3faca762f9 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -2734,6 +2734,10 @@ "pi": { "label": "Pi", "description": "긴 세션의 압축 요약을 Cindy 메모리에 저장해 이후 Pi 세션에서 사용합니다" + }, + "grok-build": { + "label": "Grok Build", + "description": "이 버전의 Grok Build는 Cindy가 관리하는 자동 메모리를 제공하지 않습니다." } }, "agent": { @@ -3818,7 +3822,8 @@ "agents": { "claude-code": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "groupPermissionLabel": "그룹에서 새 작업을 만들 때의 권한 모드", "groupPermissionDescription": "그룹 채팅에서 새 작업을 만들 때 공통으로 사용하는 권한 모드입니다(@bot으로 스레드 열기, /new, /ctr 모두 해당). 기본값은 자동 승인이며, 위의 권한 모드는 개인 채팅에만 적용됩니다. 그룹 컨텍스트에는 멤버가 조작할 수 있는 내용이 포함되므로 전체 액세스를 선택하면 확인 없이 바로 실행됩니다. 신뢰할 수 있는 그룹에서만 선택하세요. 변경은 이후에 만드는 작업에만 적용되며, 기존 작업은 그룹에서 /permission으로 개별 전환합니다." @@ -7601,6 +7606,7 @@ "claudeCode": "Claude Code", "codex": "Codex", "pi": "Pi", + "grok-build": "Grok Build", "pending": "다음: {{agent}}" } }, @@ -7718,6 +7724,20 @@ "label": "전체 접근", "description": "일반 도구는 확인 없이 실행하지만 Pi 확장 설치, 업데이트 또는 제거에는 계속 확인이 필요합니다. 가장 위험하므로 신뢰할 수 있는 작업에서만 사용하세요." } + }, + "grok-build": { + "ask": { + "label": "기본 권한", + "description": "읽기 전용 도구는 바로 실행하고, 파일 쓰기·명령 실행·MCP 도구는 매번 확인합니다." + }, + "auto": { + "label": "자동 리뷰", + "description": "워크스페이스 안의 쓰기와 안전한 명령은 자동 실행하고, 경계 밖 쓰기·위험한 명령·MCP 도구는 확인합니다." + }, + "bypassPermissions": { + "label": "전체 접근", + "description": "일반 도구는 확인 없이 실행하지만, 에이전트가 시작한 확장 변경에는 계속 확인이 필요합니다. 가장 위험하므로 신뢰할 수 있는 작업에서만 사용하세요." + } } } }, @@ -9227,7 +9247,8 @@ "all": "전체", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "filterGroupBy": { "project": "프로젝트별 그룹", @@ -11352,14 +11373,22 @@ "codexBinaryMissingDescription": "Codex 구성 요소가 아직 준비되지 않아 이 기능을 사용할 수 없습니다. 잠시 후 다시 시도하거나 설정 → 제공자에서 Codex 연결 상태를 확인하세요.", "piBinaryMissingTitle": "Pi 구성 요소가 준비되지 않음", "piBinaryMissingDescription": "Pi 구성 요소가 아직 준비되지 않아 이 기능을 사용할 수 없습니다. 잠시 후 다시 시도하거나 설정 → 제공자에서 Pi 연결 상태를 확인하세요.", + "grokBuildBinaryMissingTitle": "Grok Build가 준비되지 않았습니다", + "grokBuildBinaryMissingDescription": "Cindy 호스티드 루프가 아직 준비되지 않아 Grok Build를 사용할 수 없습니다. 잠시 후 다시 시도하거나 설정 → 제공자에서 확인하세요. Claude Code / Codex / Pi는 영향을 받지 않습니다.", + "grokBuildUnauthenticatedTitle": "SuperGrok이 연결되지 않았습니다", + "grokBuildUnauthenticatedDescription": "Grok Build는 Cindy 모델 플레인의 SuperGrok(xAI)을 사용합니다. 설정 → 제공자에서 SuperGrok을 연결하세요.", + "grokBuildLogin": "설정 열기", "goToSettings": "설정 열기", "remoteCodexBinaryMissingTitle": "원격 기기의 Codex 구성 요소가 준비되지 않음", "remotePiBinaryMissingTitle": "원격 기기의 Pi 구성 요소가 준비되지 않음", + "remoteGrokBuildBinaryMissingTitle": "원격 기기의 Grok Build가 준비되지 않음", "remoteAuthDescription": "원격 기기 \"{{device}}\"에서 설정을 완료해야 여기에서 세션을 시작할 수 있습니다. 해당 기기에서 설정한 후 다시 시도하세요.", "remoteCodexNoSourceTitle": "원격 기기에 연결된 Codex 소스가 없습니다", "remoteCodexNoSourceDescription": "원격 기기 \"{{device}}\"에 Codex에서 사용할 수 있는 모델 소스가 연결되어 있지 않아 여기에서 세션을 시작할 수 없습니다. 해당 기기에서 {{appName}}를 열고 설정 → 제공자에서 OpenAI에 로그인하거나 다른 소스를 연결한 후 다시 시도하세요.", "remoteCcNoSourceTitle": "원격 기기에 연결된 Claude 소스가 없습니다", "remoteCcNoSourceDescription": "원격 기기 \"{{device}}\"에 연결된 Claude 모델 소스가 없어 여기에서 세션을 시작할 수 없습니다. 해당 기기에서 {{appName}}를 열고 설정 → 제공자에서 소스를 연결한 후 다시 시도하세요.", + "remoteGrokBuildNoSourceTitle": "원격 기기에 SuperGrok이 연결되지 않았습니다", + "remoteGrokBuildNoSourceDescription": "원격 기기 \"{{device}}\"에 SuperGrok(xAI)이 연결되어 있지 않아 여기에서 Grok Build 세션을 시작할 수 없습니다. 해당 기기에서 {{appName}}를 열고 설정 → 제공자에서 SuperGrok을 연결한 후 다시 시도하세요.", "gotIt": "확인" }, "fileTypes": { diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index a97c4af21d1..2edb43429e4 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -2734,6 +2734,10 @@ "pi": { "label": "Pi", "description": "将长任务的压缩摘要保存到 Cindy 记忆,供后续 Pi 任务继续使用" + }, + "grok-build": { + "label": "Grok Build", + "description": "当前版本 Grok Build 不提供 Cindy 托管的自动记忆通道。" } }, "agent": { @@ -3818,7 +3822,8 @@ "agents": { "claude-code": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "groupPermissionLabel": "群聊新建任务权限档", "groupPermissionDescription": "群里新建任务时统一使用的权限档(@bot 开话题、/new、/ctr 都算),默认「自动审批」;上面那个权限档只管私聊。群上下文里有成员可控的内容,设成「完全访问」后将直接执行、不再逐条确认,请确认群里的人都可信。改动只影响之后新建的任务,已有任务在群里用 /permission 单独切换。" @@ -7601,6 +7606,7 @@ "claudeCode": "Claude Code", "codex": "Codex", "pi": "Pi", + "grok-build": "Grok Build", "pending": "下条:{{agent}}" } }, @@ -7718,6 +7724,20 @@ "label": "完全访问", "description": "常规工具无需询问直接执行;安装、更新或卸载 Pi 扩展仍需确认。风险最高,只适合可信任务。" } + }, + "grok-build": { + "ask": { + "label": "默认权限", + "description": "只读工具直接执行;写文件、跑命令和 MCP 工具每次都询问。" + }, + "auto": { + "label": "自动审批", + "description": "工作区内写文件与安全命令自动执行;越界写、危险命令和 MCP 工具仍会询问。" + }, + "bypassPermissions": { + "label": "完全访问", + "description": "常规工具无需询问直接执行;Agent 发起的扩展变更仍需确认。风险最高,只适合可信任务。" + } } } }, @@ -9227,7 +9247,8 @@ "all": "全部", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "filterGroupBy": { "project": "按项目分组", @@ -11352,14 +11373,22 @@ "codexBinaryMissingDescription": "Codex 组件尚未准备好,暂时无法使用这个功能。请稍后重试,或前往「模型供应商」检查 Codex 连接状态。", "piBinaryMissingTitle": "Pi 组件未就绪", "piBinaryMissingDescription": "Pi 组件尚未准备好,暂时无法使用这个功能。请稍后重试,或前往「模型供应商」检查 Pi 连接状态。", + "grokBuildBinaryMissingTitle": "Grok Build 尚未就绪", + "grokBuildBinaryMissingDescription": "Cindy 托管循环还没准备好,暂时无法使用 Grok Build。请稍后重试,或前往「设置 → 模型供应商」检查。Claude Code / Codex / Pi 不受影响。", + "grokBuildUnauthenticatedTitle": "尚未连接 SuperGrok", + "grokBuildUnauthenticatedDescription": "Grok Build 走 Cindy 模型面的 SuperGrok(xAI)。请前往「设置 → 模型供应商」连接 SuperGrok。", + "grokBuildLogin": "前往设置", "goToSettings": "前往设置", "remoteCodexBinaryMissingTitle": "被控端 Codex 组件未就绪", "remotePiBinaryMissingTitle": "被控端 Pi 组件未就绪", + "remoteGrokBuildBinaryMissingTitle": "被控端 Grok Build 尚未就绪", "remoteAuthDescription": "需要在被控设备「{{device}}」上完成配置后,才能在这里新建并运行任务。请在该设备上配置后重试。", "remoteCodexNoSourceTitle": "被控端 Codex 暂无可用来源", "remoteCodexNoSourceDescription": "被控设备「{{device}}」尚未连接 Codex 可用的模型来源,无法在这里新建并运行任务。请在该设备上打开 {{appName}},在「设置 → 模型供应商」登录 OpenAI 或连接其他来源后重试。", "remoteCcNoSourceTitle": "被控端 Claude 暂无可用来源", "remoteCcNoSourceDescription": "被控设备「{{device}}」尚未连接可用的 Claude 模型来源,无法在这里新建并运行任务。请在该设备上打开 {{appName}},在「设置 → 模型供应商」连接来源后重试。", + "remoteGrokBuildNoSourceTitle": "被控端尚未连接 SuperGrok", + "remoteGrokBuildNoSourceDescription": "被控设备「{{device}}」尚未连接 SuperGrok(xAI),无法在这里新建 Grok Build 任务。请在该设备上打开 {{appName}},在「设置 → 模型供应商」连接 SuperGrok 后重试。", "gotIt": "知道了" }, "fileTypes": { diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index c86f53f6fea..bf55477f158 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -2734,6 +2734,10 @@ "pi": { "label": "Pi", "description": "將長任務的壓縮摘要儲存到 Cindy 記憶,供後續 Pi 任務繼續使用" + }, + "grok-build": { + "label": "Grok Build", + "description": "目前版本 Grok Build 不提供 Cindy 託管的自動記憶通道。" } }, "agent": { @@ -3818,7 +3822,8 @@ "agents": { "claude-code": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "groupPermissionLabel": "群聊新建任務權限檔", "groupPermissionDescription": "群組裡新建任務時統一使用的權限檔(@bot 開話題、/new、/ctr 都算),預設「自動審批」;上面那個權限檔只管私聊。群組上下文裡有成員可控制的內容,設成「完全存取」後將直接執行、不再逐項確認,請確認群組成員都可信。變更只影響之後新建的任務,已有任務在群組裡用 /permission 單獨切換。" @@ -7600,6 +7605,7 @@ "claudeCode": "Claude Code", "codex": "Codex", "pi": "Pi", + "grok-build": "Grok Build", "pending": "下條:{{agent}}" } }, @@ -7718,6 +7724,20 @@ "label": "完全訪問", "description": "常規工具無需詢問直接執行;安裝、更新或解除安裝 Pi 擴展仍需確認。風險最高,只適合可信任務。" } + }, + "grok-build": { + "ask": { + "label": "預設權限", + "description": "只讀工具直接執行;寫檔案、跑命令和 MCP 工具每次都詢問。" + }, + "auto": { + "label": "自動審批", + "description": "工作區內寫檔案與安全命令自動執行;越界寫、危險命令和 MCP 工具仍會詢問。" + }, + "bypassPermissions": { + "label": "完全訪問", + "description": "常規工具無需詢問直接執行;Agent 發起的擴展變更仍需確認。風險最高,只適合可信任務。" + } } } }, @@ -9227,7 +9247,8 @@ "all": "全部", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "filterGroupBy": { "project": "按專案分組", @@ -11352,14 +11373,22 @@ "codexBinaryMissingDescription": "Codex 元件尚未準備好,暫時無法使用這個功能。請稍後重試,或前往「模型供應商」檢查 Codex 連線狀態。", "piBinaryMissingTitle": "Pi 元件未就緒", "piBinaryMissingDescription": "Pi 元件尚未準備好,暫時無法使用這個功能。請稍後重試,或前往「模型供應商」檢查 Pi 連線狀態。", + "grokBuildBinaryMissingTitle": "Grok Build 尚未就緒", + "grokBuildBinaryMissingDescription": "Cindy 託管循環還沒準備好,暫時無法使用 Grok Build。請稍後重試,或前往「設定 → 模型供應商」檢查。Claude Code / Codex / Pi 不受影響。", + "grokBuildUnauthenticatedTitle": "尚未連線 SuperGrok", + "grokBuildUnauthenticatedDescription": "Grok Build 走 Cindy 模型面的 SuperGrok(xAI)。請前往「設定 → 模型供應商」連線 SuperGrok。", + "grokBuildLogin": "前往設定", "goToSettings": "前往設定", "remoteCodexBinaryMissingTitle": "被控端 Codex 元件未就緒", "remotePiBinaryMissingTitle": "被控端 Pi 元件未就緒", + "remoteGrokBuildBinaryMissingTitle": "被控端 Grok Build 尚未就緒", "remoteAuthDescription": "需要在被控裝置「{{device}}」上完成配置後,才能在這裡新建並執行任務。請在該裝置上配置後重試。", "remoteCodexNoSourceTitle": "被控端 Codex 暫無可用來源", "remoteCodexNoSourceDescription": "被控裝置「{{device}}」尚未連線 Codex 可用的模型來源,無法在這裡新建並執行任務。請在該裝置上開啟 {{appName}},在「設定 → 模型供應商」登入 OpenAI 或連線其他來源後重試。", "remoteCcNoSourceTitle": "被控端 Claude 暫無可用來源", "remoteCcNoSourceDescription": "被控裝置「{{device}}」尚未連線可用的 Claude 模型來源,無法在這裡新建並執行任務。請在該裝置上開啟 {{appName}},在「設定 → 模型供應商」連線來源後重試。", + "remoteGrokBuildNoSourceTitle": "被控端尚未連線 SuperGrok", + "remoteGrokBuildNoSourceDescription": "被控裝置「{{device}}」尚未連線 SuperGrok(xAI),無法在這裡新建 Grok Build 任務。請在該裝置上開啟 {{appName}},在「設定 → 模型供應商」連線 SuperGrok 後重試。", "gotIt": "知道了" }, "fileTypes": { diff --git a/apps/desktop/src/renderer/lib/agentVendors.ts b/apps/desktop/src/renderer/lib/agentVendors.ts index 519d1fc0f58..eb57811a5af 100644 --- a/apps/desktop/src/renderer/lib/agentVendors.ts +++ b/apps/desktop/src/renderer/lib/agentVendors.ts @@ -14,10 +14,30 @@ import type { MakerVendor } from './ccAgent.types'; -export const SELECTABLE_VENDORS = ['cc', 'codex', 'pi'] as const satisfies readonly MakerVendor[]; +export const SELECTABLE_VENDORS = ['cc', 'codex', 'pi', 'grok-build'] as const satisfies readonly MakerVendor[]; export type SelectableVendor = (typeof SELECTABLE_VENDORS)[number]; +/** SELECTABLE_VENDORS 对应的 runtime AgentKind(统一选择器 / IPC 同一张表)。 */ +export type SelectableAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; + +export function agentKindOfVendor(vendor: SelectableVendor): SelectableAgentKind { + if (vendor === 'cc') return 'claude-code'; + if (vendor === 'codex') return 'codex'; + if (vendor === 'pi') return 'pi'; + return 'grok-build'; +} + +export function vendorOfAgentKind(kind: SelectableAgentKind): SelectableVendor { + if (kind === 'claude-code') return 'cc'; + if (kind === 'codex') return 'codex'; + if (kind === 'pi') return 'pi'; + return 'grok-build'; +} + +export const SELECTABLE_AGENT_KINDS: readonly SelectableAgentKind[] = + SELECTABLE_VENDORS.map(agentKindOfVendor); + /** localStorage / IPC 等外部输入的引擎值校验(不认识的一律交给调用方回退默认)。 */ export function isSelectableVendor(value: unknown): value is SelectableVendor { return typeof value === 'string' && (SELECTABLE_VENDORS as readonly string[]).includes(value); diff --git a/apps/desktop/src/renderer/lib/atResourceService.ts b/apps/desktop/src/renderer/lib/atResourceService.ts index e524f1843c4..1dccee06ee3 100644 --- a/apps/desktop/src/renderer/lib/atResourceService.ts +++ b/apps/desktop/src/renderer/lib/atResourceService.ts @@ -83,7 +83,7 @@ const EMPTY_QUERY_SECTIONS: ReadonlyArray> = [ new Set(['plugin-command']), ]; -export type PaletteAgentKind = 'claude-code' | 'codex' | 'pi'; +export type PaletteAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export interface AtResourceScanContext { /** Current local task. Its built-in browser tabs are the only tabs exposed. */ diff --git a/apps/desktop/src/renderer/lib/ccAgent.types.ts b/apps/desktop/src/renderer/lib/ccAgent.types.ts index 0ceefd1eb9d..f05cea01e4e 100644 --- a/apps/desktop/src/renderer/lib/ccAgent.types.ts +++ b/apps/desktop/src/renderer/lib/ccAgent.types.ts @@ -16,7 +16,7 @@ export type DeviceLinkConnectionStatus = 'connected' | 'disconnected'; * 暂时只有 'cc'(Claude Code)。未来扩展 'codex' 等时新增枚举值即可, * schema 不动;老 session DEFAULT 'cc' 兜底。 */ -export type AgentKind = 'cc' | 'codex' | 'pi'; +export type AgentKind = 'cc' | 'codex' | 'pi' | 'grok-build'; export type MakerVendor = AgentKind | 'orca'; export type OrcaRole = 'lead' | 'worker'; @@ -374,7 +374,7 @@ export type UsageHistorySession = Pick< >; export interface SessionRuntimeProfileProjection { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; effort: Effort | null; @@ -400,8 +400,8 @@ export type MessageRole = 'user' | 'assistant' | 'tool_use' | 'tool_result' | 'a * 不作为对话正文渲染,也绝不回发给 agent(注入走 main 的 wire 前缀通道)。 */ export interface AgentSwitchContent { - fromAgentKind: 'cc' | 'codex' | 'pi'; - toAgentKind: 'cc' | 'codex' | 'pi'; + fromAgentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; + toAgentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; fromModel: string | null; toModel: string | null; /** Agent 切换时的来源快照;缺失表示旧版边界数据。 */ @@ -434,7 +434,7 @@ export interface Message { * session-agent-switch 后 session.agentKind 只代表当前活跃引擎,历史行按本字段解析; * null = 切换功能上线前的老消息(回落 session.agentKind)。 */ - agentKind?: 'cc' | 'codex' | 'pi' | null; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build' | null; /** Structured guard details for a persisted tool-loop terminal error. */ toolLoop?: ToolLoopErrorDetails; createdAt: string; // ISO 8601 diff --git a/apps/desktop/src/renderer/lib/makerChatStore.ts b/apps/desktop/src/renderer/lib/makerChatStore.ts index 659a22d89e6..a6195479ada 100644 --- a/apps/desktop/src/renderer/lib/makerChatStore.ts +++ b/apps/desktop/src/renderer/lib/makerChatStore.ts @@ -2327,7 +2327,7 @@ export type MessageDeliveryMode = 'queue' | 'steer'; /** 仅影响 selector/chip 的乐观展示;agentKind 始终保留真实 reducer 路由。 */ export interface AgentSwitchIntentRecord { - target: 'claude-code' | 'codex' | 'pi'; + target: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; effort?: string; @@ -2343,7 +2343,7 @@ export interface SessionChatState { * Codex reducer。ensureInitialMessages 从 DB sessions.agent_kind 读出来灌进。 * 默认 'claude-code' 兼容老路径(老 session row 没有此字段时按 Claude 处理)。 */ - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** 下一条消息发送时才由 main 应用的跨引擎切换意图。 */ agentSwitchIntent: AgentSwitchIntentRecord | null; /** @@ -9459,7 +9459,7 @@ setRemoteTerminalErrorProbe(hasSessionTerminalError); interface ActiveSessionSnapshot { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; isTurnRunning: boolean; } @@ -9468,7 +9468,8 @@ function isActiveSessionSnapshot(value: unknown): value is ActiveSessionSnapshot const item = value as Record; return ( typeof item.sessionId === 'string' && - (item.agentKind === 'claude-code' || item.agentKind === 'codex' || item.agentKind === 'pi') && + (item.agentKind === 'claude-code' || item.agentKind === 'codex' || item.agentKind === 'pi' + || item.agentKind === 'grok-build') && typeof item.isTurnRunning === 'boolean' ); } @@ -10499,18 +10500,19 @@ function retryInvalidatedInitialHistoryFetchIfNeeded( } /** - * DB sessions.agent_kind('cc' / 'codex' / 'pi')→ maker-core AgentKind 的唯一映射点。 + * DB sessions.agent_kind('cc' / 'codex' / 'pi' / 'grok-build')→ maker-core AgentKind 的唯一映射点。 * 缺失 / 异常值走 fallback(默认 'claude-code',老 row 兼容)。所有从 session * row 派生 agentKind 的地方必须走这里,不要在调用点手写三元(历史上多处各写 * 一份,遗漏 fallback 语义差异被 review 逐个揪出)。 */ function dbAgentKindToMakerKind( dbKind: string | null | undefined, - fallback: 'claude-code' | 'codex' | 'pi' = 'claude-code', -): 'claude-code' | 'codex' | 'pi' { + fallback: 'claude-code' | 'codex' | 'pi' | 'grok-build' = 'claude-code', +): 'claude-code' | 'codex' | 'pi' | 'grok-build' { if (dbKind === 'codex') return 'codex'; if (dbKind === 'cc') return 'claude-code'; if (dbKind === 'pi') return 'pi'; + if (dbKind === 'grok-build') return 'grok-build'; return fallback; } @@ -12780,7 +12782,7 @@ function autoTitleFallbackLabels(): AutoTitleFallbackLabels { function scheduleAutoName( sessionId: string, text: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', isUserText = true, ): void { // 与 main 共用 normalizeAutoTitle,两端算出的占位串逐字一致,回流时不跳变。 @@ -12896,7 +12898,7 @@ function clearAutoTitlePreviewSafely(sessionId: string): void { function maybeAutoNameUnnamedSession( sessionId: string, seed: AutoTitleSeed | null, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', ): void { if (!seed?.isUserText) return; scheduleAutoName(sessionId, seed.text, agentKind, true); @@ -13235,7 +13237,7 @@ async function sendMessageCore( // 用会话真实 agentKind 起名 — 之前写死 'claude-code',导致 Codex 会话也 // 用 Claude haiku 起标题:纯 Codex 用户(无 Claude 鉴权)会 oneShot 失败 → // fallback 原话,表现为"Codex 会话标题没有智能总结"。current.agentKind 已是 - // maker 格式('claude-code' | 'codex' | 'pi'),直接透传。起名走立即占位 + 后台覆盖。 + // maker 格式('claude-code' | 'codex' | 'pi' | 'grok-build'),直接透传。起名走立即占位 + 后台覆盖。 if (autoTitleSeed) { scheduleAutoName( sessionId, @@ -15456,7 +15458,10 @@ function sendUiTrigger(sessionId: string, prompt: string): Promise { * sdkSessionId——否则 buildCreateOpts 会把旧引擎的原生会话 id 当 resume 目标 * (main 侧 reconcileCreateOptsWithDb 是兜底,这里是第一现场收敛)。 */ -function noteAgentSwitched(sessionId: string, agentKind: 'claude-code' | 'codex' | 'pi'): void { +function noteAgentSwitched( + sessionId: string, + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', +): void { if (!sessionId) return; setState(sessionId, (s) => { const nextProviderId = s.agentSwitchIntent ? s.agentSwitchIntent.providerId : s.sessionProviderId; @@ -15488,7 +15493,7 @@ function noteAgentSwitched(sessionId: string, agentKind: 'claude-code' | 'codex' */ function noteAgentSwitchIntent( sessionId: string, - target: 'claude-code' | 'codex' | 'pi', + target: 'claude-code' | 'codex' | 'pi' | 'grok-build', opts: { model: string; providerId: string | null; effort?: string; fastMode?: boolean }, ): void { if (!sessionId) return; @@ -15598,7 +15603,7 @@ function mirrorAgentSwitchIntent(sessionId: string, value: unknown): void { function setSessionRuntime( sessionId: string, opts: { - agentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind?: 'claude-code' | 'codex' | 'pi' | 'grok-build'; fastMode?: boolean; planModeEnabled?: boolean; /** Seed before SessionView hydrates the DB row; sendMessage reads this for SSH routing. */ diff --git a/apps/desktop/src/renderer/lib/makerTransport.ts b/apps/desktop/src/renderer/lib/makerTransport.ts index 4d3a8f77ee5..f5fa94eed5d 100644 --- a/apps/desktop/src/renderer/lib/makerTransport.ts +++ b/apps/desktop/src/renderer/lib/makerTransport.ts @@ -223,7 +223,7 @@ export function makerApiForDevice(deviceId: string): RoutableMaker { /** Mutation 前按明确 deviceId 重新读取被控端能力,避免复用可能过期的 renderer cache。 */ export function agentCapabilitiesForDevice( deviceId: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', ): Promise<{ supportsOrcaWorkerPermissionMode?: boolean; supportsDeferredOrcaUiAssignment?: boolean; diff --git a/apps/desktop/src/renderer/lib/modelDefinitions.ts b/apps/desktop/src/renderer/lib/modelDefinitions.ts index c2ee6ae8f12..00360370069 100644 --- a/apps/desktop/src/renderer/lib/modelDefinitions.ts +++ b/apps/desktop/src/renderer/lib/modelDefinitions.ts @@ -17,7 +17,7 @@ export interface ModelDefinition { description: string; efforts: readonly Effort[]; defaultEffort: Effort | null; - vendorKey: 'cc' | 'codex' | 'pi'; + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'; contextWindow?: number; supportsFastMode?: boolean; /** 目录展示排序;缺省排末尾(见 getDefaultModelForVendor)。 */ @@ -29,10 +29,10 @@ export interface ModelDefinition { * 解耦)。缺省 = 不作为默认。getDefaultModelForVendor / newSessionDefaultModelId 据它选默认; * Pi 只接受自己的 v3 标记,不借用其它 Agent 的默认策略。 */ - newSessionDefault?: ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: ('claude-code' | 'codex' | 'pi' | 'grok-build')[]; } -function toLegacy(m: ModelDescriptor, vendorKey: 'cc' | 'codex' | 'pi'): ModelDefinition { +function toLegacy(m: ModelDescriptor, vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'): ModelDefinition { return { id: m.id, label: m.displayName, @@ -104,12 +104,12 @@ export function getModelById(modelId: string, deviceId?: string): ModelDefinitio } /** vendor → capabilities 缓存的 agent 键(pi 有自己的能力清单)。 */ -function agentKindForVendor(vendorKey: 'cc' | 'codex' | 'pi'): 'claude-code' | 'codex' | 'pi' { - return vendorKey === 'codex' ? 'codex' : vendorKey === 'pi' ? 'pi' : 'claude-code'; +function agentKindForVendor(vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'): 'claude-code' | 'codex' | 'pi' | 'grok-build' { + return vendorKey === 'codex' ? 'codex' : vendorKey === 'pi' ? 'pi' : vendorKey === 'grok-build' ? 'grok-build' : 'claude-code'; } export function getModelsForVendor( - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build', deviceId?: string, ): readonly ModelDefinition[] { // 直接读该 vendor 对应 agent 的能力缓存 —— 不经 allCachedModels(后者只聚合 cc/codex 供 @@ -138,8 +138,8 @@ function firstByCatalogOrder(models: readonly ModelDefinition[]): ModelDefinitio /** vendor → 新对话默认所依据的目录 Agent 标记。 */ function defaultMarkerAgentForVendor( - vendorKey: 'cc' | 'codex' | 'pi', -): 'claude-code' | 'codex' | 'pi' { + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build', +): 'claude-code' | 'codex' | 'pi' | 'grok-build' { if (vendorKey === 'cc') return 'claude-code'; return vendorKey; } @@ -155,7 +155,7 @@ function defaultMarkerAgentForVendor( * 本函数返回 null、默认行为不变。 */ export function newSessionDefaultModelId( - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build', deviceId?: string, ): string | null { const agent = defaultMarkerAgentForVendor(vendorKey); @@ -177,7 +177,7 @@ export function newSessionDefaultModelId( * useScheduleForm.ts getScheduleDefaultModel,不要把这里的默认接到 scheduler 上。 */ export function getDefaultModelForVendor( - vendorKey: 'cc' | 'codex' | 'pi', + vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build', deviceId?: string, ): ModelDefinition { const list = getModelsForVendor(vendorKey, deviceId); @@ -210,15 +210,17 @@ const COLD_START_CODEX_MODEL_ID = 'gpt-5.6-sol'; const COLD_START_PI_MODEL_ID = 'claude-sonnet-5'; /** 冷启动占位 id 的只读导出(newMakerDraft 的种子默认复用,避免另一处写死)。 */ -export function coldStartModelIdForVendor(vendorKey: 'cc' | 'codex' | 'pi'): string { - return vendorKey === 'codex' - ? COLD_START_CODEX_MODEL_ID - : vendorKey === 'pi' - ? COLD_START_PI_MODEL_ID - : COLD_START_CC_MODEL_ID; +export function coldStartModelIdForVendor(vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'): string { + if (vendorKey === 'codex') return COLD_START_CODEX_MODEL_ID; + if (vendorKey === 'pi') return COLD_START_PI_MODEL_ID; + if (vendorKey === 'grok-build') return 'grok-4.6'; + return COLD_START_CC_MODEL_ID; } /** 冷启动占位的展示名(与占位 id 同源,仅首帧短暂可见)。 */ -function coldStartLabelForVendor(vendorKey: 'cc' | 'codex' | 'pi'): string { - return vendorKey === 'codex' ? 'GPT-5.6-Sol' : vendorKey === 'pi' ? 'Sonnet 5' : 'Opus 5'; +function coldStartLabelForVendor(vendorKey: 'cc' | 'codex' | 'pi' | 'grok-build'): string { + if (vendorKey === 'codex') return 'GPT-5.6-Sol'; + if (vendorKey === 'pi') return 'Sonnet 5'; + if (vendorKey === 'grok-build') return 'Grok 4.6'; + return 'Opus 5'; } diff --git a/apps/desktop/src/renderer/lib/modelHarnessPresentation.ts b/apps/desktop/src/renderer/lib/modelHarnessPresentation.ts index 5bd131c3490..833bb74e282 100644 --- a/apps/desktop/src/renderer/lib/modelHarnessPresentation.ts +++ b/apps/desktop/src/renderer/lib/modelHarnessPresentation.ts @@ -5,4 +5,5 @@ export const MODEL_HARNESS_COLOR: Readonly> = { 'claude-code': 'var(--engine-badge-cc)', codex: 'var(--engine-badge-codex)', pi: 'var(--engine-badge-pi)', + 'grok-build': 'var(--engine-badge-grok-build)', }; diff --git a/apps/desktop/src/renderer/lib/newMakerDefaultTuple.ts b/apps/desktop/src/renderer/lib/newMakerDefaultTuple.ts index be180e9efb7..5da56fbaafc 100644 --- a/apps/desktop/src/renderer/lib/newMakerDefaultTuple.ts +++ b/apps/desktop/src/renderer/lib/newMakerDefaultTuple.ts @@ -9,6 +9,12 @@ import { import type { MakerVendor } from '@/lib/ccAgent.types'; +/** + * 产品默认 tuple 只覆盖走 provider 路由的三个 harness。grok-build 自带唯一内置 + * 模型、不参与来源/模型默认下放,所以不进这张种子表。 + */ +type NewMakerDefaultAgent = Exclude; + export interface NewMakerDefaultTuple { vendor: Extract; providerId: string; @@ -19,7 +25,7 @@ export interface NewMakerDefaultTuple { interface ProviderDefaultPolicy { providerId: 'openai' | 'anthropic' | 'xai' | 'xd'; accessKind: 'subscription' | 'managed'; - agents: readonly AgentKind[]; + agents: readonly NewMakerDefaultAgent[]; modelIds: readonly string[]; requireNewSessionDefault?: boolean; requireImageInput?: boolean; @@ -62,7 +68,7 @@ const DEFAULT_POLICIES: readonly ProviderDefaultPolicy[] = [ }, ]; -function vendorForAgent(agent: AgentKind): NewMakerDefaultTuple['vendor'] { +function vendorForAgent(agent: NewMakerDefaultAgent): NewMakerDefaultTuple['vendor'] { return agent === 'claude-code' ? 'cc' : agent; } diff --git a/apps/desktop/src/renderer/lib/providerSubtitle.ts b/apps/desktop/src/renderer/lib/providerSubtitle.ts index fe183a4215b..245bea71def 100644 --- a/apps/desktop/src/renderer/lib/providerSubtitle.ts +++ b/apps/desktop/src/renderer/lib/providerSubtitle.ts @@ -4,6 +4,7 @@ const AGENT_DISPLAY_LABELS: Record = { 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + 'grok-build': 'Grok Build', }; export function providerAgentSupportLabel(provider?: Pick | null): string { diff --git a/apps/desktop/src/renderer/lib/providerUpstreamErrorToast.ts b/apps/desktop/src/renderer/lib/providerUpstreamErrorToast.ts index d72c1102fa8..7c8a12add83 100644 --- a/apps/desktop/src/renderer/lib/providerUpstreamErrorToast.ts +++ b/apps/desktop/src/renderer/lib/providerUpstreamErrorToast.ts @@ -16,7 +16,7 @@ import { toast } from './toast'; import type { ProviderErrorCode } from '../../shared/providerErrors'; interface ProviderUpstreamErrorPayload { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; providerId: string; providerName?: string; code: ProviderErrorCode; diff --git a/apps/desktop/src/renderer/lib/sessionService.ts b/apps/desktop/src/renderer/lib/sessionService.ts index 8d7093f9cb1..9c8b3a399ea 100644 --- a/apps/desktop/src/renderer/lib/sessionService.ts +++ b/apps/desktop/src/renderer/lib/sessionService.ts @@ -102,7 +102,7 @@ export async function create(body?: { fastMode?: boolean; /** 计划模式一级开关(与 permissionMode 正交); 草稿开着计划模式时随建会话落库。 */ planModeEnabled?: boolean; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; orcaRole?: OrcaRole | null; /** 附加只读引用目录列表 (绝对路径); main 端 mapper 会 JSON.stringify 后写库。 */ extraDirs?: string[]; diff --git a/apps/desktop/src/renderer/state/newMakerDraft.ts b/apps/desktop/src/renderer/state/newMakerDraft.ts index 17da766fe34..3dc571ad4f4 100644 --- a/apps/desktop/src/renderer/state/newMakerDraft.ts +++ b/apps/desktop/src/renderer/state/newMakerDraft.ts @@ -23,7 +23,7 @@ import { useSyncExternalStore } from 'react'; import type { MakerVendor } from '@/lib/ccAgent.types'; import { isSelectableVendor } from '@/lib/agentVendors'; import type { Effort, PermissionMode } from '@/lib/userPreferences.types'; -import { getDefaultModelForVendor } from '@/lib/modelDefinitions'; +import { coldStartModelIdForVendor, getDefaultModelForVendor } from '@/lib/modelDefinitions'; import { isKnownProductDefaultTupleIdentity } from '@/lib/newMakerDefaultTuple'; import type { OrcaWorkerPermissionMode } from '../../shared/orca-worker-permission-mode'; import { normalizeWorkingDirForStorage } from '../../shared/workingDir'; @@ -175,6 +175,15 @@ export interface NewMakerDraft { * 在目录里都是默认隐藏的模型 —— 种子默认模型压根不在用户看到的清单里。 */ function defaultVendorPrefs(vendor: MakerVendor): VendorPrefs { + if (vendor === 'grok-build') { + return { + model: getDefaultModelForVendor('grok-build').id, + effort: 'high', + permissionMode: 'auto', + planMode: false, + providerId: null, + }; + } if (vendor === 'pi') { return { // pi 走 XD 网关(anthropic-messages 可达面),默认给网关中档模型; @@ -232,6 +241,7 @@ function makeDefault(): NewMakerDraft { pi: defaultVendorPrefs('pi'), orca: defaultVendorPrefs('orca'), codex: defaultVendorPrefs('codex'), + 'grok-build': defaultVendorPrefs('grok-build'), }, modelChosenByVendor: {}, defaultTupleCustomized: false, @@ -344,8 +354,11 @@ function sanitize(raw: unknown): NewMakerDraft { // 计划模式独立成一级开关后, 历史草稿里 permissionMode='plan' 迁移为 // planMode=true + 该 vendor 默认权限档(与 DB 迁移同语义)。 const legacyPlanPermission = p.permissionMode === 'plan'; + const rawModel = typeof p.model === 'string' && p.model.length > 0 ? p.model : fallback.model; + // Grok Build is a harness, not a model. Old drafts used the sentinel as a model id. + const model = rawModel === 'grok-build' ? coldStartModelIdForVendor('grok-build') : rawModel; return { - model: typeof p.model === 'string' && p.model.length > 0 ? p.model : fallback.model, + model, effort: typeof p.effort === 'string' ? (p.effort as Effort) : fallback.effort, permissionMode: typeof p.permissionMode === 'string' && !legacyPlanPermission @@ -353,8 +366,11 @@ function sanitize(raw: unknown): NewMakerDraft { : fallback.permissionMode, planMode: p.planMode === true || legacyPlanPermission, // providerId: 接受非空 string 或 null;脏数据 / 缺字段一律落 null(跟随默认路由)。 + // grok-build 不是目录供应商。 providerId: - typeof p.providerId === 'string' && p.providerId.length > 0 ? p.providerId : null, + typeof p.providerId === 'string' && p.providerId.length > 0 && p.providerId !== 'grok-build' + ? p.providerId + : null, }; }; // modelChosenByVendor: 老版本 localStorage 没有这个字段 → 空对象兜底 @@ -458,6 +474,7 @@ function sanitize(raw: unknown): NewMakerDraft { pi: sanitizeVendorPrefs(lastByVendorRaw.pi, 'pi'), orca: sanitizeVendorPrefs(lastByVendorRaw.orca, 'orca'), codex: sanitizeVendorPrefs(lastByVendorRaw.codex, 'codex'), + 'grok-build': sanitizeVendorPrefs(lastByVendorRaw['grok-build'], 'grok-build'), }, modelChosenByVendor, defaultTupleCustomized, diff --git a/apps/desktop/src/renderer/themes/__tests__/fixtures/desktop-color-defaults.json b/apps/desktop/src/renderer/themes/__tests__/fixtures/desktop-color-defaults.json index da58088ee04..a3611eba016 100644 --- a/apps/desktop/src/renderer/themes/__tests__/fixtures/desktop-color-defaults.json +++ b/apps/desktop/src/renderer/themes/__tests__/fixtures/desktop-color-defaults.json @@ -1,6 +1,6 @@ { "source": "apps/desktop/src/renderer/themes/colors.ts", - "count": 541, + "count": 542, "colors": [ { "id": "surface", @@ -1322,6 +1322,11 @@ "light": "#a78bfa", "dark": "#a78bfa" }, + { + "id": "engine-badge-grok-build", + "light": "#6b7280", + "dark": "#6b7280" + }, { "id": "perm-item-selected-bg", "light": "#f8f8f6", diff --git a/apps/desktop/src/renderer/themes/__tests__/tokenRegistry.test.ts b/apps/desktop/src/renderer/themes/__tests__/tokenRegistry.test.ts index 0cf2186e59a..8d20d3faae8 100644 --- a/apps/desktop/src/renderer/themes/__tests__/tokenRegistry.test.ts +++ b/apps/desktop/src/renderer/themes/__tests__/tokenRegistry.test.ts @@ -116,6 +116,7 @@ describe('主题注册表 · 引擎徽标标识色', () => { 'engine-badge-cc': '#d97757', 'engine-badge-codex': '#7a9dff', 'engine-badge-pi': '#a78bfa', + 'engine-badge-grok-build': '#6b7280', } as const; it.each(Object.entries(ENGINE_BADGE_TOKENS))( diff --git a/apps/desktop/src/renderer/themes/colors.ts b/apps/desktop/src/renderer/themes/colors.ts index 7f65f1714b2..1ed4ae2bd54 100644 --- a/apps/desktop/src/renderer/themes/colors.ts +++ b/apps/desktop/src/renderer/themes/colors.ts @@ -1166,7 +1166,8 @@ registerColor('fast-accent', { // 各自来源: // · cc = Anthropic 陶土橙,与 ClaudeMark 的 brand variant 同一支色; // · codex = Codex 官方渐变的中段蓝(CodexMark brand 的 0.5 stop); -// · pi = 上游无官方品牌色,取一支与前两者可区分的紫(统一选择器设计稿 v7)。 +// · pi = 上游无官方品牌色,取一支与前两者可区分的紫(统一选择器设计稿 v7); +// · grok-build = 上游品牌是黑白单色,取中性石墨灰,避免与前三支撞色。 // 徽标底色(14%)与描边(30%)由组件用 color-mix 从**同一个 var** 派生,PiMark 的 // currentColor 也接同一个 var —— TS 侧不再持有这三个 hex,不会出现「组件拿常量、 // 主题拿 token」两条路各画各的。 @@ -1182,6 +1183,10 @@ registerColor('engine-badge-pi', { light: '#a78bfa', dark: '#a78bfa', }, 'Pi 引擎徽标色 — 自选紫,上游无官方品牌色(light/dark 同值)'); +registerColor('engine-badge-grok-build', { + light: '#6b7280', + dark: '#6b7280', +}, 'Grok Build 引擎徽标色 — 上游品牌为黑白单色,取一支与前三支可区分的中性石墨灰(light/dark 同值)'); // Permission selector registerColor('perm-item-selected-bg', { light: '#f8f8f6', diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index 4622d62ce0a..596f5edb440 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -188,7 +188,7 @@ interface DeviceLinkPresenceSnapshot { /** .cshare 导入向导的预览数据(main 侧 SharePreview 的镜像)。 */ interface SessionSharePreview { title: string; - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; workspaceKind: 'project' | 'dialogue'; originalWorkingDir: string | null; exportedAt: string; @@ -552,7 +552,7 @@ interface WechatChannelSettingsState { type DiscordBotSessionAuthCheckResult = { ok: boolean; missing: 'gateway-key' | 'agent-oauth' | 'provider-key' | 'provider-disconnected' | null; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; providerLabel: string | null; @@ -651,7 +651,7 @@ interface OrcaWorkerRecord { session: { id: string; title: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; effort: string; @@ -736,7 +736,7 @@ interface CCAgentStreamEvent { | 'thinking' | 'compact_boundary'; data: unknown; - source?: 'claude-code' | 'codex' | 'pi' | 'vision-bridge'; + source?: 'claude-code' | 'codex' | 'pi' | 'grok-build' | 'vision-bridge'; /** * agent-meta: SDK 元信息(按 session.agentKind 解析)。当事件来自一条 SDK * message(assistant / tool_use / thinking final / done 等)时由 main 透传过来。 @@ -2398,12 +2398,12 @@ interface ElectronAPI { syncNewMakerDraft: (snapshot: { lastByVendor: Partial< Record< - 'cc' | 'codex' | 'pi', + 'cc' | 'codex' | 'pi' | 'grok-build', { model?: string; effort?: string; permissionMode?: string; providerId?: string | null } > >; /** 每个 vendor 是否由用户在 New Maker 中明确选过模型;device-link 默认校准据此保护显式选择。 */ - modelChosenByVendor: Partial>; + modelChosenByVendor: Partial>; fastModeByModel: Record; effortByModel: Record; /** 「新建会话默认启用 worktree」勾选记忆(vendor 无关根字段,远程草稿播种用)。 */ @@ -2426,7 +2426,7 @@ interface ElectronAPI { /** 被控端 renderer → 自身 main:会话「非选中模型」effort/fast 变化镜像(转发给控制端)。 */ syncSessionModelPref: (pref: { sessionId: string; - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; providerId: string; model: string; effort?: string; @@ -2436,7 +2436,7 @@ interface ElectronAPI { /** 被控端本地 main → 自身 renderer:控制端写穿的草稿「模型 effort/fast」pref(调本地 setter)。 */ onMakerDraftPrefApply: ( cb: (payload: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; providerId: string; modelId: string; active: boolean; @@ -2475,7 +2475,7 @@ interface ElectronAPI { onMakerSessionPrefApply: ( cb: (payload: { sessionId: string; - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; providerId: string; model: string; effort?: string; @@ -3127,7 +3127,7 @@ interface ElectronAPI { workingDir: string; cap?: number; query?: string; - agentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind?: 'claude-code' | 'codex' | 'pi' | 'grok-build'; }) => Promise<{ success: boolean; error?: string; @@ -4469,7 +4469,7 @@ interface ElectronAPI { permissionMode?: string; fastMode?: boolean; planModeEnabled?: boolean; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: 'cc' | 'codex' | 'pi' | 'grok-build'; orcaRole?: import('@/lib/ccAgent.types').OrcaRole | null; /** 附加只读引用目录列表 (绝对路径); main 端 mapper 会 JSON.stringify 后写库。 */ extraDirs?: string[]; @@ -4984,9 +4984,9 @@ interface ElectronAPI { * apps/desktop/src/main/maker-ipc/ 的 handlers + apps/desktop/src/main/maker-host/。 */ maker: { - listAvailableAgents: () => Promise>; + listAvailableAgents: () => Promise>; onAgentsChanged: (cb: () => void) => () => void; - getCapabilities: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise; + getCapabilities: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise; listBotDelegations: ( parentSessionId: string, ) => Promise; @@ -5050,12 +5050,12 @@ interface ElectronAPI { // 自定义供应商配置 CRUD(配置与 runtime 密钥均由 main 原子排队)。 createCustomProvider: ( config: import('@cindy/model-providers').CustomProviderConfig, - keys: Partial>, + keys: Partial>, options?: CustomProviderUpdateOptions, ) => Promise; updateCustomProvider: ( config: import('@cindy/model-providers').CustomProviderConfig, - keys: Partial>, + keys: Partial>, options?: CustomProviderUpdateOptions, ) => Promise; deleteCustomProvider: (providerId: string) => Promise<{ ok: true }>; @@ -5110,11 +5110,11 @@ interface ElectronAPI { /** 供应商「测试连接」—— 与真实会话同路由口径的最小探测请求(结构化结果,code 走 providerError.* i18n)。 */ testProviderConnection: ( input: - | { kind: 'saved'; providerId: string; agent: 'claude-code' | 'codex' | 'pi' } + | { kind: 'saved'; providerId: string; agent: 'claude-code' | 'codex' | 'pi' | 'grok-build' } | { kind: 'adhoc'; spec: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; baseUrl: string; modelId: string; authMethod: 'apiKey' | 'oauth' | 'none'; @@ -5133,7 +5133,7 @@ interface ElectronAPI { }>; /** 供应商「获取模型列表」—— 表单值透传,结构化结果(code 走 providerError.* i18n)。 */ fetchProviderModels: (input: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; baseUrl: string; authMethod: 'apiKey' | 'oauth' | 'none'; wireProtocol?: import('@cindy/model-providers').ProviderWireProtocol; @@ -5205,7 +5205,7 @@ interface ElectronAPI { /** 自定义供应商上游错误订阅(返回 off);code 走 providerError.* i18n。 */ onProviderUpstreamError: ( cb: (event: { - agent: 'claude-code' | 'codex' | 'pi'; + agent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; providerId: string; providerName?: string; code: import('../shared/providerErrors').ProviderErrorCode; @@ -5355,7 +5355,7 @@ interface ElectronAPI { attachments?: import('./lib/fileTypes').SerializedAttachedFile[]; }) => Promise<{ ok: true; runId: string; reviewerSessionId: string }>; listAgentCommands: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', params?: { sessionId?: string; allowManagedPiPackagePreview?: boolean }, ) => Promise<{ success: boolean; @@ -5365,7 +5365,7 @@ interface ElectronAPI { }>; listAgentSkills: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', params: { workingDir?: string; remoteHostId?: string; @@ -5451,7 +5451,7 @@ interface ElectronAPI { ) => () => void; scanAtResources: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', params: { workingDir: string; cap?: number; query?: string }, ) => Promise<{ success: boolean; @@ -5483,7 +5483,7 @@ interface ElectronAPI { createSession: (opts: { /** 可选: 复用外部 sessionId(本端 chat 用 local-db:sessions:create 拿到的 id) */ id?: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; title?: string; @@ -5528,7 +5528,7 @@ interface ElectronAPI { enableOrca: ( leadSessionId: string, opts: { - workerAgent: 'claude-code' | 'codex' | 'pi'; + workerAgent: 'claude-code' | 'codex' | 'pi' | 'grok-build'; delegateTask?: string; role?: string; label?: string; @@ -5576,7 +5576,7 @@ interface ElectronAPI { message: string | { type: 'user'; content: string | Array<{ type: string; [k: string]: unknown }> }, createOpts?: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; orcaRole?: import('@/lib/ccAgent.types').OrcaRole | null; @@ -5623,7 +5623,7 @@ interface ElectronAPI { getContextUsage: ( sessionId: string, createOpts?: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; orcaRole?: import('@/lib/ccAgent.types').OrcaRole | null; @@ -5653,7 +5653,7 @@ interface ElectronAPI { listActive: () => Promise< Array<{ sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workDir: string; capabilities: unknown; isTurnRunning: boolean; @@ -5793,14 +5793,14 @@ interface ElectronAPI { */ switchSessionAgent: ( sessionId: string, - targetAgentKind: 'claude-code' | 'codex' | 'pi', + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', model: string, providerId?: string | null, effort?: string, fastMode?: boolean, ) => Promise<{ switched: boolean; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; engineReady: boolean; deferred?: boolean; @@ -5812,7 +5812,7 @@ interface ElectronAPI { * 重开视图 / device-link 远程会话重连后恢复乐观显示用。 */ getSessionAgentSwitchIntent: (sessionId: string) => Promise<{ - targetAgentKind: 'claude-code' | 'codex' | 'pi'; + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; effort?: string; @@ -5850,13 +5850,13 @@ interface ElectronAPI { setWritableDirs: (sessionId: string, dirs: string[]) => Promise; // Memory 控制 (Settings → Personalization → Memory section) - memoryGet: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ + memoryGet: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise<{ enabled: boolean; source: 'agent-default' | 'host-runtime' | 'user-config'; stats?: { entryCount?: number; sizeBytes?: number; storagePath?: string }; }>; memorySet: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', enabled: boolean, ) => Promise<{ effective: 'immediate' | 'next-session'; @@ -5864,7 +5864,7 @@ interface ElectronAPI { customizedKeys: string[]; defaults: { maker: boolean; claudeCode: boolean; codex: boolean; pi: boolean }; }>; - memoryReset: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ + memoryReset: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise<{ removedEntries?: number; removedBytes?: number; }>; @@ -6163,7 +6163,7 @@ interface ElectronAPI { // Stage 2 C1: chat utility (前身 cc-agent:generate-title / cc-agent:plan-file-write) generateTitle: ( message: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', sessionId?: string, ) => Promise<{ title: string | null }>; /** 重命名输入框 Magic 按钮:按会话最新对话内容重新生成标题(失败返 title: null)。 */ @@ -6176,13 +6176,13 @@ interface ElectronAPI { autoTitle: (request: { sessionId: string; text: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; isUserText?: boolean; }) => Promise<{ applied: boolean; done: boolean }>; /** 输入框推荐提示词:turn 结束后预测用户下一步输入。 */ predictNextPrompt: (request: { sessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; messages: Array<{ role: string; content: string }>; workingDir?: string; turnGen: number; @@ -6253,22 +6253,22 @@ interface ElectronAPI { /* ── Agent 鉴权 (取代老 codex.auth.*) ── */ auth: { - getState: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise; + getState: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise; triggerLogin: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', options?: { mode?: 'browser' | 'device-code'; ownerId?: string }, ) => Promise; cancelLogin: ( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', options?: { releaseOwner?: boolean; ownerId?: string }, ) => Promise; - logout: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise; + logout: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise; onStateChanged: ( - cb: (s: { agentKind: 'claude-code' | 'codex' | 'pi' } & CodexAuthState) => void, + cb: (s: { agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build' } & CodexAuthState) => void, ) => () => void; onLoginProgress: ( cb: (p: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; phase: string; mode?: 'browser' | 'device-code'; detail?: string; @@ -6280,15 +6280,15 @@ interface ElectronAPI { /* ── Agent 联合状态 (binary + auth, 取代老 codex.binary.getStatus) ── */ agent: { - getStatus: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ + getStatus: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise<{ binaryReady: boolean; binaryPath: string; authReady: boolean; identity?: string; }>; /** spawn 当前应用使用的 binary `--version`, 进程内缓存。About 面板用。 */ - getBinaryVersion: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ - kind: 'claude-code' | 'codex' | 'pi'; + getBinaryVersion: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise<{ + kind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; binaryPath: string | null; version: string | null; error?: string; @@ -6297,7 +6297,7 @@ interface ElectronAPI { /* ── Agent 今日累计 (取代老 codex.usage.* + onUsageTodaySpendChanged) ── */ usage: { - getToday: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise<{ + getToday: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise<{ day: string; money?: import('../shared/regionalMoney').RegionalMoney; costUsd?: number; @@ -6307,7 +6307,7 @@ interface ElectronAPI { reasoningTokens?: number; cachedTokens?: number; }>; - getAccount: (agentKind: 'claude-code' | 'codex' | 'pi') => Promise; + getAccount: (agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build') => Promise; /** Codex app-server authoritative windows and banked reset-credit metadata. */ getCodexRateLimits: () => Promise< import('@cindy/maker-shared/device-link-contract').MobileCodexRateLimitsResult @@ -6395,7 +6395,7 @@ interface ElectronAPI { crossAgent: { detect: ( workingDir: string, - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build', ) => Promise<{ items: CrossAgentMigrationItem[] }>; convert: (items: CrossAgentMigrationItem[]) => Promise<{ total: number; @@ -6462,7 +6462,7 @@ interface ElectronAPI { scheduleName?: string; workingDir?: string; providerId?: string; - agentKind?: 'claude-code' | 'codex' | 'pi'; + agentKind?: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model?: string; /** 绑定会话任务:workingDir 空时 main 按会话 meta.workDir 解析落盘/自测目录。 */ targetSessionId?: string; @@ -6660,10 +6660,10 @@ interface SkillhubSkill { /** 同一 URL 基键存在多个来源时,详情路由必须携带 sourceKey。 */ requiresSourceKey?: boolean; /** 来自哪个 agent 引擎。 */ - engine: 'claude-code' | 'codex' | 'pi'; + engine: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** 发现该 skill 的所有引擎专属路径(去重后)。 */ linkedEngines: Array<{ - engine: 'claude-code' | 'codex' | 'pi'; + engine: 'claude-code' | 'codex' | 'pi' | 'grok-build'; label: string; runtimeStatus?: 'discovered' | 'approved' | 'loaded' | 'failed' | 'unknown'; }>; @@ -6788,7 +6788,7 @@ interface SkillUsageEvidenceIndex { rawLineNo: number; sessionId: string; sdkSessionId: string; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; skillName: string; skillPath: string | null; skillDocumentHash: string | null; diff --git a/apps/desktop/src/shared/agentInputQueue.ts b/apps/desktop/src/shared/agentInputQueue.ts index a9d1315f71e..70c9fa2e20e 100644 --- a/apps/desktop/src/shared/agentInputQueue.ts +++ b/apps/desktop/src/shared/agentInputQueue.ts @@ -144,7 +144,7 @@ export interface AgentInputChatMessage { } export interface AgentInputCreateOpts { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; providerId?: string | null; diff --git a/apps/desktop/src/shared/agentKindConversion.ts b/apps/desktop/src/shared/agentKindConversion.ts index e66b72f0e17..d5b66473d00 100644 --- a/apps/desktop/src/shared/agentKindConversion.ts +++ b/apps/desktop/src/shared/agentKindConversion.ts @@ -1,6 +1,6 @@ /** - * agentKindConversion —— DB/renderer 形态('cc' | 'codex' | 'pi')与 maker-core - * 形态('claude-code' | 'codex' | 'pi')的唯一双向映射。 + * agentKindConversion —— DB/renderer 形态('cc' | 'codex' | 'pi' | 'grok-build')与 + * maker-core 形态('claude-code' | 'codex' | 'pi' | 'grok-build')的唯一双向映射。 * * 背景:sessions.agent_kind 历史上存 renderer 形态('cc' 起家,default 'cc'), * maker-core 用 'claude-code'。三值化前全仓散落 `x === 'cc' ? 'claude-code' : @@ -9,23 +9,25 @@ */ /** DB(sessions.agent_kind)与 renderer 侧的 agent 形态。 */ -export type DbAgentKind = 'cc' | 'codex' | 'pi'; +export type DbAgentKind = 'cc' | 'codex' | 'pi' | 'grok-build'; /** maker-core / IPC 契约侧的 agent 形态。 */ -export type MakerAgentKindWire = 'claude-code' | 'codex' | 'pi'; +export type MakerAgentKindWire = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export function dbToMakerAgentKind(db: string | null | undefined): MakerAgentKindWire { if (db === 'codex') return 'codex'; if (db === 'pi') return 'pi'; + if (db === 'grok-build') return 'grok-build'; return 'claude-code'; // 'cc' 与历史缺省 } export function makerToDbAgentKind(maker: string | null | undefined): DbAgentKind { if (maker === 'codex') return 'codex'; if (maker === 'pi') return 'pi'; + if (maker === 'grok-build') return 'grok-build'; return 'cc'; // 'claude-code' 与历史缺省 } /** 宽输入归一成 DbAgentKind;非法值回落 'cc'(与 sessions 表 default 同语义)。 */ export function normalizeDbAgentKind(value: string | null | undefined): DbAgentKind { - return value === 'codex' || value === 'pi' ? value : 'cc'; + return value === 'codex' || value === 'pi' || value === 'grok-build' ? value : 'cc'; } diff --git a/apps/desktop/src/shared/conversationSearch.ts b/apps/desktop/src/shared/conversationSearch.ts index c540cd2c510..7858687abda 100644 --- a/apps/desktop/src/shared/conversationSearch.ts +++ b/apps/desktop/src/shared/conversationSearch.ts @@ -2,7 +2,7 @@ import { projectDraftSessionTitle } from '@cindy/maker-shared/session-title'; import type { SessionSource } from './sessionSource'; -export type ConversationSearchAgentKind = 'cc' | 'codex' | 'pi'; +export type ConversationSearchAgentKind = 'cc' | 'codex' | 'pi' | 'grok-build'; export type ConversationSearchWorkspaceKind = 'project' | 'dialogue'; export type ConversationSearchSessionStatus = 'active' | 'archived' | 'deleted'; export type ConversationSearchOrcaRole = 'lead' | 'worker'; diff --git a/apps/desktop/src/shared/imDefaultSettings.ts b/apps/desktop/src/shared/imDefaultSettings.ts index 0b16fe5b1fa..ec99a2981d6 100644 --- a/apps/desktop/src/shared/imDefaultSettings.ts +++ b/apps/desktop/src/shared/imDefaultSettings.ts @@ -1,4 +1,10 @@ -export type ImDefaultAgentKind = 'claude-code' | 'codex' | 'pi'; +export type ImDefaultAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; +export const IM_DEFAULT_AGENT_KINDS = [ + 'claude-code', + 'codex', + 'pi', + 'grok-build', +] as const satisfies readonly ImDefaultAgentKind[]; export type ImDefaultPermissionMode = 'ask' | 'default' | 'acceptEdits' | 'plan' | 'auto' | 'bypassPermissions'; export type ImDefaultEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'; @@ -71,6 +77,11 @@ export const IM_DEFAULT_SETTINGS: ImDefaultSettings = { model: 'claude-sonnet-5', effort: 'high', }, + 'grok-build': { + providerId: null, + model: 'grok-4.6', + effort: 'high', + }, }, }; @@ -89,7 +100,7 @@ export const IM_DEFAULT_EFFORT_OVERRIDES: Readonly(['claude-code', 'codex', 'pi']); +const AGENT_KINDS = new Set(IM_DEFAULT_AGENT_KINDS); const EFFORTS = new Set([ 'minimal', 'low', diff --git a/apps/desktop/src/shared/modelPriceQuote.ts b/apps/desktop/src/shared/modelPriceQuote.ts index ee4eaab9eaa..245b40a72ff 100644 --- a/apps/desktop/src/shared/modelPriceQuote.ts +++ b/apps/desktop/src/shared/modelPriceQuote.ts @@ -283,10 +283,11 @@ function referencePriceQuoteForVariant( options: ReferencePriceOptions = {}, ): ModelPriceQuote | undefined { // 参考价 registry 的 agent 维度只有 claude-code / codex;Pi(动态 BYOM,按 provider/模型 - // 路由)在此按 agent 无关的参考价解析 —— pi 一律降级为 undefined 传给协议函数。 + // 路由)与 Grok Build(本机 CLI 自带单一模型条目,不进参考价表)在此按 agent 无关的 + // 参考价解析 —— 这两个 kind 一律降级为 undefined 传给协议函数。 const resolved = resolveModelReferencePrice(registry, providerId, modelId, { ...options, - agent: options.agent === 'pi' ? undefined : options.agent, + agent: options.agent === 'pi' || options.agent === 'grok-build' ? undefined : options.agent, }); if (!resolved) return undefined; const day = referencePriceCalendarDate(options.at); diff --git a/apps/desktop/src/shared/piPackages.ts b/apps/desktop/src/shared/piPackages.ts index 2530a47679e..34911d467a7 100644 --- a/apps/desktop/src/shared/piPackages.ts +++ b/apps/desktop/src/shared/piPackages.ts @@ -1,3 +1,5 @@ +import type { MakerAgentKindWire } from './agentKindConversion.js'; + export type PiPackageResourceKind = 'extension' | 'skill' | 'prompt' | 'theme'; export type PiPackageCompatibility = 'supported' | 'partial' | 'unsupported' | 'unknown'; @@ -163,7 +165,7 @@ export type PiPackageCommandRuntimeStatus = /** Runtime-confirmed Pi package commands belong only to the Pi command palette. */ export function mergePiPackageCommands( - agentKind: 'claude-code' | 'codex' | 'pi', + agentKind: MakerAgentKindWire, builtins: PiPackageSlashCommand[], packageCommands: Array<{ name: string; description: string }>, ): PiPackageSlashCommand[] { @@ -181,7 +183,7 @@ export function mergePiPackageCommands( } export function shouldListPiPackageCommands( - requestedAgentKind: 'claude-code' | 'codex' | 'pi', + requestedAgentKind: MakerAgentKindWire, sessionIdProvided: boolean, session: { agentKind: 'claude-code' | 'codex' | 'pi'; diff --git a/apps/desktop/src/shared/processMonitor.ts b/apps/desktop/src/shared/processMonitor.ts index 8a01f133128..55527082e8e 100644 --- a/apps/desktop/src/shared/processMonitor.ts +++ b/apps/desktop/src/shared/processMonitor.ts @@ -23,6 +23,7 @@ export type ProcessUsageKind = | 'utility' | 'agent-claude' | 'agent-codex' + | 'agent-grok-build' | 'agent-pi'; /** Codex 本地 app-server 的产品职责;仅传枚举,不暴露 host key / 凭据 / 命令行。 */ @@ -72,5 +73,5 @@ export interface TerminateAgentProcessRequest { /** terminate 成功返回(失败一律走 IPC 错误协议 throwIpcError)。 */ export interface TerminateAgentProcessResult { pid: number; - kind: 'claude' | 'codex' | 'pi'; + kind: 'claude' | 'codex' | 'pi' | 'grok-build'; } diff --git a/apps/desktop/src/shared/sessionReference.ts b/apps/desktop/src/shared/sessionReference.ts index f700cd51d82..0fd0d9243ab 100644 --- a/apps/desktop/src/shared/sessionReference.ts +++ b/apps/desktop/src/shared/sessionReference.ts @@ -1,3 +1,5 @@ +import type { DbAgentKind } from './agentKindConversion'; + /** * A scheduler-facing snapshot of a session reference. * @@ -10,5 +12,5 @@ export interface SessionReference { state: 'available' | 'deleted' | 'missing'; status?: 'active' | 'archived' | 'deleted'; title?: string; - agentKind?: 'cc' | 'codex' | 'pi'; + agentKind?: DbAgentKind; } diff --git a/apps/desktop/src/shared/turnChangeSet.ts b/apps/desktop/src/shared/turnChangeSet.ts index 1f9f7f96f14..e3eb57e1b32 100644 --- a/apps/desktop/src/shared/turnChangeSet.ts +++ b/apps/desktop/src/shared/turnChangeSet.ts @@ -2,7 +2,7 @@ import type { DiffChangeKind, FileDiff } from './gitReviewWire'; export const TURN_CHANGE_SET_MAX_DIFF_BYTES = 12 * 1024 * 1024; -export type TurnChangeProvider = 'codex' | 'claude-code' | 'pi'; +export type TurnChangeProvider = 'codex' | 'claude-code' | 'pi' | 'grok-build'; export type TurnChangeSetState = 'complete' | 'partial'; export type TurnChangeWorkspaceState = 'applied' | 'undone'; export type TurnChangeAction = 'undo' | 'reapply'; diff --git a/apps/mobile/src/__tests__/agentAuthGate.test.ts b/apps/mobile/src/__tests__/agentAuthGate.test.ts index 13a67a4352c..dc3eaaa87a0 100644 --- a/apps/mobile/src/__tests__/agentAuthGate.test.ts +++ b/apps/mobile/src/__tests__/agentAuthGate.test.ts @@ -52,6 +52,18 @@ describe('agentAuthGateVerdict', () => { expect(agentAuthGateVerdict({ ...base, providers, agentKind: 'claude-code' })).toBe('unauthenticated'); expect(agentAuthGateVerdict({ ...base, providers, agentKind: 'codex' })).toBe('ready'); }); + + it('treats grok-build as ready when SuperGrok/xAI is connected', () => { + const providers = [ + provider({ id: 'xai', agents: ['claude-code', 'pi'], connected: true }), + ]; + expect(agentAuthGateVerdict({ ...base, providers, agentKind: 'grok-build' })).toBe('ready'); + expect(agentAuthGateVerdict({ + ...base, + providers: [provider({ id: 'xai', agents: ['pi'], connected: false })], + agentKind: 'grok-build', + })).toBe('unauthenticated'); + }); }); describe('agentAuthGateHint', () => { @@ -59,5 +71,6 @@ describe('agentAuthGateHint', () => { expect(agentAuthGateHint('claude-code')).toContain('Claude'); expect(agentAuthGateHint('claude-code')).toContain('设置 → 模型供应商'); expect(agentAuthGateHint('codex')).toContain('Codex'); + expect(agentAuthGateHint('grok-build')).toContain('Grok Build'); }); }); diff --git a/apps/mobile/src/__tests__/conversationSearchFilterMenu.test.ts b/apps/mobile/src/__tests__/conversationSearchFilterMenu.test.ts index 5b23b102814..c5b22ab7dff 100644 --- a/apps/mobile/src/__tests__/conversationSearchFilterMenu.test.ts +++ b/apps/mobile/src/__tests__/conversationSearchFilterMenu.test.ts @@ -26,6 +26,7 @@ const labels = { cc: "Claude Code", codex: "Codex", pi: "Pi", + "grok-build": "Grok Build", }, allProjectsLabel: "所有项目", lastActivityHeading: "最近活动", diff --git a/apps/mobile/src/__tests__/homeDesktopFirst.test.ts b/apps/mobile/src/__tests__/homeDesktopFirst.test.ts index 4466d7c95fb..77c3ee0615a 100644 --- a/apps/mobile/src/__tests__/homeDesktopFirst.test.ts +++ b/apps/mobile/src/__tests__/homeDesktopFirst.test.ts @@ -211,7 +211,7 @@ describe('mobile home desktop-first surface', () => { expect(filterSheet).toContain('devices.list.search.filter.agentHeading'); expect(filterSheet).toContain('devices.list.search.filter.lastActivityHeading'); expect(filterSheet).toContain('devices.list.search.filter.label'); - expect(filterSheet).toContain("'all', 'cc', 'codex', 'pi'"); + expect(filterSheet).toContain("'all', 'cc', 'codex', 'pi', 'grok-build'"); expect(source).toContain('conversationSearchOriginsFromDeviceModels'); expect(source).toContain('setConversationSearchDeviceModels'); expect(source).not.toContain(': deviceModels.filter((item) => item.canOpen);'); @@ -277,7 +277,9 @@ describe('mobile home desktop-first surface', () => { expect(providerMarkSource).not.toContain('CLAUDE_AGENT_PATH'); expect(providerMarkSource).not.toContain('CODEX_AGENT_FLOWER_PATH'); expect(vendorIconSource).toContain("import { MobileAgentMark } from './MobileAgentMark';"); - expect(vendorIconSource).toContain("agentKind={vendor === 'codex' || vendor === 'pi' ? vendor : 'claude-code'}"); + expect(vendorIconSource).toContain( + "agentKind={vendor === 'codex' || vendor === 'pi' || vendor === 'grok-build' ? vendor : 'claude-code'}", + ); expect(vendorIconSource).not.toContain('viewBox="136 137 282 158"'); expect(vendorIconSource).not.toContain('transform="translate('); expect(vendorIconSource).toContain('Easing.inOut(Easing.ease)'); diff --git a/apps/mobile/src/__tests__/newSession.test.ts b/apps/mobile/src/__tests__/newSession.test.ts index 68b5fddfd72..8a9df5eb145 100644 --- a/apps/mobile/src/__tests__/newSession.test.ts +++ b/apps/mobile/src/__tests__/newSession.test.ts @@ -1140,7 +1140,7 @@ describe('new session model', () => { it('exposes Pi as a first-class agent and preserves Fast for Pi sessions', () => { expect(NEW_SESSION_AGENT_OPTIONS.map((option) => option.kind)).toEqual([ - 'claude-code', 'codex', 'pi', + 'claude-code', 'codex', 'pi', 'grok-build', ]); const pi = withAgentDefaults({ ...DEFAULT_NEW_SESSION_DRAFT, fastMode: true }, 'pi'); expect(pi).toMatchObject({ agentKind: 'pi', model: 'gpt-5.4', fastMode: true }); @@ -1152,10 +1152,18 @@ describe('new session model', () => { }); it('filters the new-session agent options by the controlled device runtime-registered set', () => { - // null(未拉到)→ fail-open,全部保留。 + // null(未拉到)→ fail-open 保留随桌面端分发的 runtime;grok-build 跟 Cindy hosted + // loop,未确认注册前不露出,避免建出 requireAgent 报 not-registered 的会话。 expect(availableNewSessionAgentOptions(null).map((o) => o.kind)).toEqual([ 'claude-code', 'codex', 'pi', ]); + // 被控端确认注册了 grok-build → 才出现在入口里。 + expect( + availableNewSessionAgentOptions(new Set(['claude-code', 'codex', 'pi', 'grok-build'])) + .map((o) => o.kind), + ).toEqual(['claude-code', 'codex', 'pi', 'grok-build']); + expect(availableNewSessionAgentOptions(new Set(['grok-build'])).map((o) => o.kind)) + .toEqual(['grok-build']); // 被控端无 Pi(二进制缺失)→ 隐藏 Pi,避免建出 requireAgent 报 not-registered 的会话。 expect( availableNewSessionAgentOptions(new Set(['claude-code', 'codex'])).map((o) => o.kind), diff --git a/apps/mobile/src/__tests__/newSessionPreferenceStore.test.ts b/apps/mobile/src/__tests__/newSessionPreferenceStore.test.ts index 67a4b60a9df..2533c7e79b1 100644 --- a/apps/mobile/src/__tests__/newSessionPreferenceStore.test.ts +++ b/apps/mobile/src/__tests__/newSessionPreferenceStore.test.ts @@ -103,6 +103,21 @@ describe('newSessionPreferenceStore', () => { expect(source.match(/const worktreeAccountId = authOwnerAtCreate.accountId;/g)).toHaveLength(2); }); + it('persists grok-build as the last selected new-session harness', async () => { + const { readNewSessionPreferences, saveNewSessionPreferences } = await import( + '@/session/newSessionPreferenceStore' + ); + + await saveNewSessionPreferences({ agentKind: 'grok-build' }); + await expect(readNewSessionPreferences()).resolves.toEqual({ + agentKind: 'grok-build', + device: null, + workspaceKind: null, + permissionModeByAgent: {}, + workingDirByDevice: {}, + }); + }); + it('stores the last selected device and agent for new sessions', async () => { const { __testing, diff --git a/apps/mobile/src/components/MobileAgentMark.tsx b/apps/mobile/src/components/MobileAgentMark.tsx index 565592aba94..64d00fc06bd 100644 --- a/apps/mobile/src/components/MobileAgentMark.tsx +++ b/apps/mobile/src/components/MobileAgentMark.tsx @@ -14,7 +14,7 @@ import { } from './vendorIconPaths'; export interface MobileAgentMarkProps { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; color: string; size?: number; } @@ -26,6 +26,8 @@ export function MobileAgentMark({ agentKind, color, size = iconSize.sm }: Mobile {agentKind === 'pi' ? ( π + ) : agentKind === 'grok-build' ? ( + G ) : agentKind === 'codex' ? ( diff --git a/apps/mobile/src/device-link/DeviceLinkContext.tsx b/apps/mobile/src/device-link/DeviceLinkContext.tsx index b8df60a8d26..01309ee24bd 100644 --- a/apps/mobile/src/device-link/DeviceLinkContext.tsx +++ b/apps/mobile/src/device-link/DeviceLinkContext.tsx @@ -1393,7 +1393,7 @@ async function refreshDeviceCapabilities( ): Promise { const generation = getAgentCapabilitiesGeneration(deviceId); await Promise.allSettled( - (['claude-code', 'codex', 'pi'] as const).map(async (agentKind) => { + (['claude-code', 'codex', 'pi', 'grok-build'] as const).map(async (agentKind) => { const raw = await sendInvokeWithAccessHandling( client, deviceId, diff --git a/apps/mobile/src/device-link/mobileMakerTransport.ts b/apps/mobile/src/device-link/mobileMakerTransport.ts index 5668ed6e9fe..ea7d5f531b1 100644 --- a/apps/mobile/src/device-link/mobileMakerTransport.ts +++ b/apps/mobile/src/device-link/mobileMakerTransport.ts @@ -62,7 +62,7 @@ export interface SendOptions { } export interface CreateSessionOptions { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** * 控制端预生成的 sessionId(新建会话乐观管线用):被控端 readCreateSessionOpts * 自手机远控首版(2026-06-21)起透传 body.id,maker-core createSession 对 @@ -94,7 +94,7 @@ export interface CreateSessionResult { usedProjectContext?: boolean; } -export type MobileAgentKind = 'claude-code' | 'codex' | 'pi'; +export type MobileAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export type MobileSlashCommand = | { kind: 'agent-builtin'; name: string; description: string } diff --git a/apps/mobile/src/i18n/locales/en/devices.json b/apps/mobile/src/i18n/locales/en/devices.json index 4eebefa885b..16510cb5d09 100644 --- a/apps/mobile/src/i18n/locales/en/devices.json +++ b/apps/mobile/src/i18n/locales/en/devices.json @@ -289,7 +289,8 @@ "all": "All", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "lastActivity": { "1d": "1d", diff --git a/apps/mobile/src/i18n/locales/ja/devices.json b/apps/mobile/src/i18n/locales/ja/devices.json index e0a8d96353b..de4c752f390 100644 --- a/apps/mobile/src/i18n/locales/ja/devices.json +++ b/apps/mobile/src/i18n/locales/ja/devices.json @@ -289,7 +289,8 @@ "all": "すべて", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "lastActivity": { "1d": "1日", diff --git a/apps/mobile/src/i18n/locales/ko/devices.json b/apps/mobile/src/i18n/locales/ko/devices.json index 57b71688734..0b8b16f9d86 100644 --- a/apps/mobile/src/i18n/locales/ko/devices.json +++ b/apps/mobile/src/i18n/locales/ko/devices.json @@ -290,7 +290,8 @@ "all": "전체", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "lastActivity": { "1d": "1일", diff --git a/apps/mobile/src/i18n/locales/zh-CN/devices.json b/apps/mobile/src/i18n/locales/zh-CN/devices.json index a2e86d4ae7d..4108ff4933b 100644 --- a/apps/mobile/src/i18n/locales/zh-CN/devices.json +++ b/apps/mobile/src/i18n/locales/zh-CN/devices.json @@ -289,7 +289,8 @@ "all": "全部", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "lastActivity": { "1d": "1 天", diff --git a/apps/mobile/src/i18n/locales/zh-TW/devices.json b/apps/mobile/src/i18n/locales/zh-TW/devices.json index 4f53d49a2ba..2608e1eb062 100644 --- a/apps/mobile/src/i18n/locales/zh-TW/devices.json +++ b/apps/mobile/src/i18n/locales/zh-TW/devices.json @@ -289,7 +289,8 @@ "all": "全部", "cc": "Claude Code", "codex": "Codex", - "pi": "Pi" + "pi": "Pi", + "grok-build": "Grok Build" }, "lastActivity": { "1d": "1 天", diff --git a/apps/mobile/src/session/ConversationSearchFilterSheet.tsx b/apps/mobile/src/session/ConversationSearchFilterSheet.tsx index 3171172fdcd..951e5ffb8c8 100644 --- a/apps/mobile/src/session/ConversationSearchFilterSheet.tsx +++ b/apps/mobile/src/session/ConversationSearchFilterSheet.tsx @@ -25,7 +25,7 @@ import type { const SORT_OPTIONS: ConversationSearchSortBy[] = ['relevance', 'activityDesc', 'activityAsc']; const STATUS_OPTIONS: ConversationSearchStatusFilter[] = ['active', 'archived', 'all']; -const AGENT_OPTIONS: ConversationSearchAgentFilter[] = ['all', 'cc', 'codex', 'pi']; +const AGENT_OPTIONS: ConversationSearchAgentFilter[] = ['all', 'cc', 'codex', 'pi', 'grok-build']; const LAST_ACTIVITY_OPTIONS: ConversationSearchLastActivityFilter[] = ['1d', '3d', '7d', '30d', 'all']; export function ConversationSearchFilterSheet({ diff --git a/apps/mobile/src/session/MessageRenderer.tsx b/apps/mobile/src/session/MessageRenderer.tsx index 8bf841d5590..ec691dbf136 100644 --- a/apps/mobile/src/session/MessageRenderer.tsx +++ b/apps/mobile/src/session/MessageRenderer.tsx @@ -3987,6 +3987,7 @@ const AGENT_TASK_PROVIDER_LABEL: Record 'claude-code': 'Claude Code', codex: 'Codex', pi: 'Pi', + 'grok-build': 'Grok Build', }; function AgentTaskStatusIcon({ status, size = iconSize.md }: { status: AgentTaskStatus; size?: number }) { diff --git a/apps/mobile/src/session/SessionUsageSummary.tsx b/apps/mobile/src/session/SessionUsageSummary.tsx index 4cb8ae8b9ce..c1d6bb4d51f 100644 --- a/apps/mobile/src/session/SessionUsageSummary.tsx +++ b/apps/mobile/src/session/SessionUsageSummary.tsx @@ -16,6 +16,7 @@ import { spacing, typeScale, } from "@/theme/tokens"; +import { mobileAgentLabelFromUnknown } from "./sessionAgentSwitch"; import type { RemoteSession } from "./types"; import type { useSessionMenuUsage } from "./useSessionMenuUsage"; import { @@ -49,8 +50,7 @@ export function SessionUsageSummary({ source !== "api" && source !== "unavailable" ? t(`session.menu.usage.source.${source}`) - : (session.providerId ?? - { cc: "Claude Code", codex: "Codex", pi: "Pi" }[session.agentKind]); + : (session.providerId ?? mobileAgentLabelFromUnknown(session.agentKind)); // Overall and model-specific limits both constrain the task; never hide an exhausted one. const rows = accountUsageRows(account, t, i18n.language); const rawContext = diff --git a/apps/mobile/src/session/agentAuthGate.ts b/apps/mobile/src/session/agentAuthGate.ts index 1426e2dc363..62b039b5a50 100644 --- a/apps/mobile/src/session/agentAuthGate.ts +++ b/apps/mobile/src/session/agentAuthGate.ts @@ -10,7 +10,7 @@ * 或回了空目录时判 'unknown',调用方不拦截 —— 拦错了会把可用的发送路径堵死,而放过 * 去顶多撞上层一的友好错误提示(describeAgentAuthError)兜底。 */ -import { connectedProvidersForAgent } from '@cindy/model-providers/registry'; +import { hasUsableConnectedSource } from '@cindy/model-providers/registry'; import type { ProviderView } from '@cindy/model-providers/registry'; import { i18n } from '@/i18n'; @@ -24,7 +24,7 @@ export interface AgentAuthGateInput { loading: boolean; /** 目录拉取失败(典型:旧被控端不识别通道)。 */ error: string | null; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** * true = 已建会话的发送门禁:计入 suspended 来源(停用是准入轴,不打断运行中 * 会话,门禁只回答「凭证还连着吗」)。缺省 false = 新建草稿:suspended 不算可 @@ -37,15 +37,22 @@ export interface AgentAuthGateInput { /** 判定某 agent 在被控端是否有已连接来源;不确定时回 'unknown'(调用方不拦截)。 */ export function agentAuthGateVerdict(input: AgentAuthGateInput): AgentAuthGateVerdict { if (input.loading || input.error !== null || input.providers.length === 0) return 'unknown'; - return connectedProvidersForAgent(input.providers, input.agentKind, { + return hasUsableConnectedSource(input.providers, input.agentKind, undefined, { includeSuspended: input.existingSessionRoute === true, - }).length > 0 + }) ? 'ready' : 'unauthenticated'; } /** 未鉴权时的提示文案(与 describeAgentAuthError 的引导口径一致)。 */ -export function agentAuthGateHint(agentKind: 'claude-code' | 'codex' | 'pi'): string { - const label = agentKind === 'claude-code' ? 'Claude' : agentKind === 'pi' ? 'Pi' : 'Codex'; +export function agentAuthGateHint(agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'): string { + const label = + agentKind === 'claude-code' + ? 'Claude' + : agentKind === 'pi' + ? 'Pi' + : agentKind === 'grok-build' + ? 'Grok Build' + : 'Codex'; return i18n.t('session.row.authGateHint', { agent: label }); } diff --git a/apps/mobile/src/session/composerPalette.ts b/apps/mobile/src/session/composerPalette.ts index f45f62d5954..2fe9255805d 100644 --- a/apps/mobile/src/session/composerPalette.ts +++ b/apps/mobile/src/session/composerPalette.ts @@ -2,6 +2,8 @@ import type { RemoteSession } from './types'; export * from '@cindy/maker-shared/composer-palette'; -export function agentKindForSession(session: Pick): 'claude-code' | 'codex' | 'pi' { - return session.agentKind === 'codex' || session.agentKind === 'pi' ? session.agentKind : 'claude-code'; +export function agentKindForSession(session: Pick): 'claude-code' | 'codex' | 'pi' | 'grok-build' { + return session.agentKind === 'codex' || session.agentKind === 'pi' || session.agentKind === 'grok-build' + ? session.agentKind + : 'claude-code'; } diff --git a/apps/mobile/src/session/conversationSearchFilterMenu.ts b/apps/mobile/src/session/conversationSearchFilterMenu.ts index cd1d8537278..893e315d477 100644 --- a/apps/mobile/src/session/conversationSearchFilterMenu.ts +++ b/apps/mobile/src/session/conversationSearchFilterMenu.ts @@ -28,6 +28,7 @@ const AGENT_OPTIONS: ConversationSearchAgentFilter[] = [ "cc", "codex", "pi", + "grok-build", ]; const LAST_ACTIVITY_OPTIONS: ConversationSearchLastActivityFilter[] = [ "1d", diff --git a/apps/mobile/src/session/newSession.ts b/apps/mobile/src/session/newSession.ts index c0612a3454c..59dd6cf08c7 100644 --- a/apps/mobile/src/session/newSession.ts +++ b/apps/mobile/src/session/newSession.ts @@ -12,25 +12,37 @@ import { effectiveSourceIdForModel } from '@cindy/model-providers/registry'; import { reconcileEffortForModel, type ProviderModelRow } from './providerModelSections'; import type { RemoteSession } from './types'; -export type NewSessionAgentKind = 'claude-code' | 'codex' | 'pi'; +export type NewSessionAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export type NewSessionWorkspaceKind = 'project' | 'dialogue'; export const NEW_SESSION_AGENT_OPTIONS: readonly { kind: NewSessionAgentKind; label: string }[] = [ { kind: 'claude-code', label: 'Claude' }, { kind: 'codex', label: 'Codex' }, { kind: 'pi', label: 'Pi' }, + { kind: 'grok-build', label: 'Grok Build' }, ]; +/** + * 需要被控端 Cindy hosted loop 才会注册的 runtime。Claude Code / Codex / Pi 的 + * 二进制随桌面端分发,几乎总是注册;grok-build 跟 Pi runtime,因此不进 + * fail-open 名单——否则拉取注册集合的那段时间里,多数用户会看到一个建了就报 + * not-registered 的入口。 + */ +const OPT_IN_NEW_SESSION_AGENT_KINDS: ReadonlySet = new Set(['grok-build']); + /** * 按被控端 runtime 已注册的 agent 集合过滤新建入口(maker:list-available-agents)。 - * `available === null` = 尚未拉到 → fail-open 返回全部(避免异步期间误隐藏合法 agent); + * `available === null` = 尚未拉到 → fail-open 返回随桌面端分发的那几个(避免异步期间 + * 误隐藏合法 agent,同时不提前露出需自行安装的 runtime); * 拉到后只保留已注册的 kind —— Pi 二进制缺失时被控端无 pi,过滤掉可防用户建出最终 * requireAgent 报 not-registered 的会话(codex review P2)。 */ export function availableNewSessionAgentOptions( available: ReadonlySet | null, ): readonly { kind: NewSessionAgentKind; label: string }[] { - if (!available) return NEW_SESSION_AGENT_OPTIONS; + if (!available) { + return NEW_SESSION_AGENT_OPTIONS.filter((option) => !OPT_IN_NEW_SESSION_AGENT_KINDS.has(option.kind)); + } const filtered = NEW_SESSION_AGENT_OPTIONS.filter((option) => available.has(option.kind)); // 防御:被控端异常返回空集时不至于把入口清空到无法创建(至少保留 Claude)。 return filtered.length > 0 ? filtered : NEW_SESSION_AGENT_OPTIONS.filter((o) => o.kind === 'claude-code'); @@ -127,7 +139,9 @@ export function parseNewSessionDeviceOptions( } export function normalizeNewSessionAgentKind(value: unknown): NewSessionAgentKind | null { - return value === 'claude-code' || value === 'codex' || value === 'pi' ? value : null; + return NEW_SESSION_AGENT_OPTIONS.some((option) => option.kind === value) + ? (value as NewSessionAgentKind) + : null; } export function pickNewSessionDefaultDevice(input: { @@ -162,6 +176,8 @@ const DEFAULT_MODELS: Record = { 'claude-code': 'claude-sonnet-4-6', codex: 'gpt-5.4', pi: 'gpt-5.4', + // grok-build 只有内置的单一模型条目(GrokBuildAgent.capabilities.availableModels)。 + 'grok-build': 'grok-build', }; /** 新建交互式会话的权限种子默认;三个 agent 都保留 Auto-review。 */ @@ -333,7 +349,7 @@ type NewSessionDefaultModel = { id: string; efforts: readonly string[]; defaultEffort: string | null; - newSessionDefault?: readonly ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: readonly ('claude-code' | 'codex' | 'pi' | 'grok-build')[]; }; function isNewSessionDefaultForAgent( diff --git a/apps/mobile/src/session/newSessionPreferenceStore.ts b/apps/mobile/src/session/newSessionPreferenceStore.ts index 17ccc470aac..093aec44719 100644 --- a/apps/mobile/src/session/newSessionPreferenceStore.ts +++ b/apps/mobile/src/session/newSessionPreferenceStore.ts @@ -138,7 +138,7 @@ function normalizePermissionModeByAgent(value: unknown): Partial> = {}; - for (const agent of ['claude-code', 'codex', 'pi'] as const) { + for (const agent of ['claude-code', 'codex', 'pi', 'grok-build'] as const) { const mode = readString(record[agent]); if (mode && isRememberablePermissionMode(mode)) { out[agent] = mode; diff --git a/apps/mobile/src/session/sessionAgentSwitch.ts b/apps/mobile/src/session/sessionAgentSwitch.ts index 6dc7c08a851..8870b548736 100644 --- a/apps/mobile/src/session/sessionAgentSwitch.ts +++ b/apps/mobile/src/session/sessionAgentSwitch.ts @@ -9,7 +9,7 @@ import type { import type { MobileAgentCapabilities } from './agentCapabilities'; import type { RemoteSession } from './types'; -export type MobileSessionAgentKind = 'claude-code' | 'codex' | 'pi'; +export type MobileSessionAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** 将不可信 device-link payload 收窄为公开 intent;非法值按“无意图”处理。 */ export function normalizeSessionAgentSwitchIntent( @@ -21,6 +21,7 @@ export function normalizeSessionAgentSwitchIntent( item.targetAgentKind !== 'claude-code' && item.targetAgentKind !== 'codex' && item.targetAgentKind !== 'pi' + && item.targetAgentKind !== 'grok-build' ) return null; if (typeof item.model !== 'string' || item.model.length === 0) return null; // providerId 缺失(undefined)按 null 处理,与桌面 projectPendingAgentSwitchIntent 的 @@ -42,7 +43,7 @@ export function normalizeSessionAgentSwitchIntent( /** DB 会话行的 cc/codex 映射到 maker agent kind。 */ export function sessionAgentKind(session: Pick): MobileSessionAgentKind { - return session.agentKind === 'codex' || session.agentKind === 'pi' + return session.agentKind === 'codex' || session.agentKind === 'pi' || session.agentKind === 'grok-build' ? session.agentKind : 'claude-code'; } @@ -58,13 +59,16 @@ export function supportsMobileSessionAgentSwitch( } export function mobileAgentLabel(agentKind: MobileSessionAgentKind): string { - return agentKind === 'codex' ? 'Codex' : agentKind === 'pi' ? 'Pi' : 'Claude Code'; + return mobileAgentLabelFromUnknown(agentKind); } export function mobileAgentLabelFromUnknown(agentKind: unknown): string { - return agentKind === 'codex' ? 'Codex' : agentKind === 'pi' ? 'Pi' : 'Claude Code'; + if (agentKind === 'codex') return 'Codex'; + if (agentKind === 'pi') return 'Pi'; + if (agentKind === 'grok-build') return 'Grok Build'; + return 'Claude Code'; } -export function mobileAgentVendor(agentKind: MobileSessionAgentKind): 'cc' | 'codex' | 'pi' { +export function mobileAgentVendor(agentKind: MobileSessionAgentKind): 'cc' | 'codex' | 'pi' | 'grok-build' { return agentKind === 'claude-code' ? 'cc' : agentKind; } diff --git a/apps/mobile/src/session/types.ts b/apps/mobile/src/session/types.ts index c6146939d02..1942cd5ffbb 100644 --- a/apps/mobile/src/session/types.ts +++ b/apps/mobile/src/session/types.ts @@ -61,7 +61,7 @@ export interface RemoteSession { activeTurnStartedAt?: number | null; lastTurnEndedAt?: number | null; status: RemoteSessionStatus; - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; /** main 进程内的下一条消息跨 Agent 切换意图;null = 已确认没有。 */ agentSwitchIntent?: MobileSessionAgentSwitchIntent | null; source?: string; @@ -91,7 +91,7 @@ export interface RemoteSession { } export interface RemoteSessionRuntimeProfile { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; effort: string | null; @@ -197,7 +197,7 @@ export interface QueuedRemoteMessage { sessionReferencesRequireTrustedSnapshot?: boolean; userName?: string; createOpts: { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; workingDir: string; model: string; effort?: string; diff --git a/apps/mobile/src/session/useConversationSearchFilterMenu.ts b/apps/mobile/src/session/useConversationSearchFilterMenu.ts index 434c442731e..15631ac9f9e 100644 --- a/apps/mobile/src/session/useConversationSearchFilterMenu.ts +++ b/apps/mobile/src/session/useConversationSearchFilterMenu.ts @@ -43,6 +43,7 @@ export function useConversationSearchFilterMenu({ cc: t("devices.list.search.filter.agent.cc"), codex: t("devices.list.search.filter.agent.codex"), pi: t("devices.list.search.filter.agent.pi"), + "grok-build": t("devices.list.search.filter.agent.grok-build"), }, allProjectsLabel: t("devices.list.search.filter.allProjects"), lastActivity, diff --git a/docs/dev-rules/grok-build-harness.md b/docs/dev-rules/grok-build-harness.md new file mode 100644 index 00000000000..01c2ed253ad --- /dev/null +++ b/docs/dev-rules/grok-build-harness.md @@ -0,0 +1,48 @@ +# Grok harness 集成规则 + +> 修改 `packages/maker-core/src/agents/grok-build/**`、`apps/desktop/src/main/maker-host/grok-build-host.ts`, +> 或任何 Grok 会话行为、权限、探测之前必读本文件。 + +## 0. 产品方向(2026-09 调整) + +Cindy 要的 harness 是和 Claude Code 同级的一等公民,不是一条独立 ACP 对话通道。 +换到 Grok 必须仍能用 Cindy 的模型面 / 账号与计费、MCP / Orca / Ghost、以及 +fork / rewind / steer / plan。纯 `grok agent stdio` 做不到这些,本 PR 按这个方向改。 + +Grok 是 **harness**(和 Claude Code / Codex / Pi 一样),不是模型分类、也不是目录里的一行「Grok Build」。 + +## 1. 架构总览 + +目标形态对齐 Claude Code:Cindy 持有会话与工具面,模型走 Cindy 已连接的 xAI / SuperGrok +(或网关)路由,而不是再走一份 grok CLI 自己的登录态。 + +`GrokBuildAgent` 是 Cindy hosted loop(与 Pi 同一执行面)上的第四个 harness: +`kind = 'grok-build'`,模型只投影独占 Grok catalog slug,MCP / Orca / Ghost / +rewind / fork / plan 与 Pi 同源。仓库里仍保留 ACP client 文件,但 **host 不再 +spawn `grok agent stdio`**,也不再把 PATH 上的 `grok` 当作注册条件。 + +关键装配点: + +- **探测 / 注册**:Cindy Pi runtime 可用才注册。`grok` CLI 不在 PATH 不影响。 +- **Auth**:Cindy `desktopPiAuthAdapter`(SuperGrok OAuth / 网关 key)。禁止 + `grok login`,禁止读 `~/.grok/auth.json`。 +- **模型**:`deriveGrokBuildAvailableModels` 从目录投影独占 Grok slug。 +- **会话家目录**:`grok-build-agent-home`,与 `pi-agent-home` 分开。 + +## 2. 维护不变量 + +1. **权限档从严到宽**:`capabilities.permissionModes` 必须 + `[ask, auto, bypassPermissions]`,`[0]` 是最严档。由 + `grok-build-capabilities.test.ts` 守。 +2. **缺失 Cindy hosted loop 不得影响其它 harness**:CC / Codex / Pi 的注册保持原样。 +3. **UI vendor 是 `'grok-build'`**,不要用 `'grok'`(与 xAI catalog provider 撞名)。 +4. **模型面**:独占 Grok catalog slug(`grok-*` / `xai/grok-*`)。不要再注入 + `id: grok-build` 这种假模型行。 +5. **就绪态**:Cindy SuperGrok / 网关已连接即可。不要再 spawn `grok login`。 + +## 3. 非目标(本阶段不做) + +- CDN 钉死独立 grok CLI +- 移动端完整会话(只加类型与 `listAvailableAgents` 过滤) +- 嵌入 Grok TUI;one-shot `grok -p` +- 继续加深 ACP client(文件可留,host 不再调用) diff --git a/packages/design-tokens/src/classification.json b/packages/design-tokens/src/classification.json index c73cbef5e56..41da89e69d3 100644 --- a/packages/design-tokens/src/classification.json +++ b/packages/design-tokens/src/classification.json @@ -1,9 +1,9 @@ { "source": "apps/desktop/src/renderer/themes/colors.ts", "generatedBy": "packages/design-tokens/src/classify.ts", - "snapshotCount": 541, + "snapshotCount": 542, "categories": { - "literal": 265, + "literal": 266, "alias": 240, "hsl-triplet": 6, "runtime-derived-or-protected": 30 @@ -2438,6 +2438,14 @@ "darkKind": "hex", "modeledAsSemantic": false }, + { + "id": "engine-badge-grok-build", + "category": "literal", + "destination": "reference-candidate", + "lightKind": "hex", + "darkKind": "hex", + "modeledAsSemantic": false + }, { "id": "perm-item-selected-bg", "category": "literal", diff --git a/packages/lizi-mcps/src/scheduler/setPreRunHook.ts b/packages/lizi-mcps/src/scheduler/setPreRunHook.ts index 63923171985..ffa22b50a5e 100644 --- a/packages/lizi-mcps/src/scheduler/setPreRunHook.ts +++ b/packages/lizi-mcps/src/scheduler/setPreRunHook.ts @@ -19,6 +19,8 @@ import { z } from 'zod'; +import type { AgentKind } from '@cindy/maker-core'; + import { withScheduler } from './_shared.js'; import type { SchedulerMcpDeps } from '../types.js'; import type { SchedulerToolRegistry } from '../cindy_schedulerToolRegistry.js'; @@ -88,7 +90,9 @@ export function registerScheduleSetPreRunHookTool( let currentCommand: string | undefined; let currentTimeoutMs: number | undefined; let providerId: string | undefined; - let agentKind: 'codex' | 'claude-code' | 'pi' | undefined; + // schedule.agentKind 由宿主写入,这里只透传;类型跟 maker-core 的 AgentKind 走, + // 不再自己抄一份三档字面量(抄漏一档就是本行原来的编译错误)。 + let agentKind: AgentKind | undefined; let model: string | undefined; if (scheduleId) { const schedule = await scheduler.get(scheduleId); diff --git a/packages/lizi-mcps/src/types.ts b/packages/lizi-mcps/src/types.ts index dc42cc6ee4f..e5ac7ac6ad0 100644 --- a/packages/lizi-mcps/src/types.ts +++ b/packages/lizi-mcps/src/types.ts @@ -522,7 +522,7 @@ export type ControlResult = * adding a new vendor (e.g. 'gemini') without updating this union will cause * LLM tool calls to fail zod enum validation. */ -export type ControlWorkerAgent = 'claude-code' | 'codex' | 'pi'; +export type ControlWorkerAgent = 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** Browser automation MCP host deps. Core browser execution is injected by host. */ export interface BrowserMcpDeps { diff --git a/packages/lizi-mcps/src/xdt-helper/send_to_worker.ts b/packages/lizi-mcps/src/xdt-helper/send_to_worker.ts index ee54a65e1e3..f5fa140727c 100644 --- a/packages/lizi-mcps/src/xdt-helper/send_to_worker.ts +++ b/packages/lizi-mcps/src/xdt-helper/send_to_worker.ts @@ -8,7 +8,7 @@ import { BRAND_NAME } from '@cindy/maker-shared/branding'; import { z } from 'zod'; import type { XdtHelperToolRegistry } from '../lizi_xdtHelperToolRegistry.js'; -import type { ControlResult } from '../lizi_xdtHelperMcpServer.js'; +import type { ControlResult, ControlWorkerAgent } from '../lizi_xdtHelperMcpServer.js'; import { errorPayload, okPayload } from './_payload.js'; export interface SendToWorkerDeps { @@ -22,7 +22,7 @@ export interface SendToWorkerDeps { }) => Promise< ControlResult< { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: ControlWorkerAgent; wakeKind: 'resumed' | 'already-active' | 'queued'; targetTitle: string | null; targetLastUserSendAt: string | null; @@ -38,7 +38,7 @@ export interface SendToWorkerDeps { }) => Promise< ControlResult< { - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: ControlWorkerAgent; queuedMessageId: string; stopOutcome: | 'requested' diff --git a/packages/maker-core/package.json b/packages/maker-core/package.json index 262d353ed2f..dce4209fd07 100644 --- a/packages/maker-core/package.json +++ b/packages/maker-core/package.json @@ -9,7 +9,8 @@ "exports": { ".": "./src/index.ts", "./contacts-sync-worker": "./src/contacts/sync/worker-api.ts", - "./pi-subagent-runs": "./src/agents/pi/pi-subagent-runs.ts" + "./pi-subagent-runs": "./src/agents/pi/pi-subagent-runs.ts", + "./grok-build": "./src/agents/grok-build/index.ts" }, "scripts": { "build": "tsc --noEmit", diff --git a/packages/maker-core/src/agents/base-agent.ts b/packages/maker-core/src/agents/base-agent.ts index 9c16e54db07..499dbc7305b 100644 --- a/packages/maker-core/src/agents/base-agent.ts +++ b/packages/maker-core/src/agents/base-agent.ts @@ -431,7 +431,7 @@ export interface CodexAppServerProcessRegistration { export interface LocalAgentProcessRegistration { pid: number; - kind: 'claude' | 'pi'; + kind: 'claude' | 'pi' | 'grok-build'; role: 'task-host' | 'control-plane-service'; } diff --git a/packages/maker-core/src/agents/grok-build/__tests__/fake-grok-acp.mjs b/packages/maker-core/src/agents/grok-build/__tests__/fake-grok-acp.mjs new file mode 100755 index 00000000000..ae548ea76fe --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/fake-grok-acp.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import readline from 'node:readline'; + +const failInit = process.env.FAKE_GROK_FAIL_INIT === '1'; +const failPrompt = process.env.FAKE_GROK_FAIL_PROMPT === '1'; +const updateBeforeError = process.env.FAKE_GROK_UPDATE_BEFORE_ERROR === '1'; +const promptDelayMs = Number(process.env.FAKE_GROK_PROMPT_DELAY_MS ?? '0'); + +function reply(obj) { + process.stdout.write(`${JSON.stringify(obj)}\n`); +} + +const rl = readline.createInterface({ input: process.stdin }); +rl.on('line', (line) => { + if (!line.trim()) return; + let msg; + try { + msg = JSON.parse(line); + } catch { + return; + } + if (msg.method === 'initialize') { + if (failInit) { + reply({ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: 'initialize failed' } }); + return; + } + reply({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, authMethods: [] } }); + return; + } + if (msg.method === 'session/new') { + reply({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'sess-1' } }); + return; + } + if (msg.method === 'session/prompt') { + const sessionId = msg.params?.sessionId ?? 'sess-1'; + if (failPrompt && !updateBeforeError) { + reply({ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: 'prompt rejected' } }); + return; + } + reply({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }, + }, + }); + if (failPrompt && updateBeforeError) { + reply({ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: 'late failure' } }); + return; + } + const finish = () => { + reply({ jsonrpc: '2.0', id: msg.id, result: { stopReason: 'end_turn' } }); + }; + if (promptDelayMs > 0) setTimeout(finish, promptDelayMs); + else finish(); + } +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/fake-transport.ts b/packages/maker-core/src/agents/grok-build/__tests__/fake-transport.ts new file mode 100644 index 00000000000..e2b62e7c4a0 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/fake-transport.ts @@ -0,0 +1,48 @@ +import type { AcpTransport, AcpCloseHandler, AcpLineHandler, AcpStderrHandler } from '../stdio-transport.js'; + +/** In-memory ACP transport for tests. */ +export class FakeAcpTransport implements AcpTransport { + readonly written: string[] = []; + private lineHandlers = new Set(); + private stderrHandlers = new Set(); + private closeHandlers = new Set(); + private closed = false; + readonly pid = 4242; + + async writeLine(line: string): Promise { + if (this.closed) throw new Error('closed'); + this.written.push(line); + } + + onLine(handler: AcpLineHandler): () => void { + this.lineHandlers.add(handler); + return () => { this.lineHandlers.delete(handler); }; + } + + onStderr(handler: AcpStderrHandler): () => void { + this.stderrHandlers.add(handler); + return () => { this.stderrHandlers.delete(handler); }; + } + + onClose(handler: AcpCloseHandler): () => void { + this.closeHandlers.add(handler); + return () => { this.closeHandlers.delete(handler); }; + } + + async close(reason = 'test close'): Promise { + if (this.closed) return; + this.closed = true; + for (const handler of this.closeHandlers) handler({ reason }); + } + + pushLine(obj: unknown): void { + const line = typeof obj === 'string' ? obj : JSON.stringify(obj); + for (const handler of this.lineHandlers) handler(line); + } + + lastRequest(): { id: number; method: string; params?: unknown; jsonrpc: string } { + const raw = this.written.at(-1); + if (!raw) throw new Error('no request written'); + return JSON.parse(raw) as { id: number; method: string; params?: unknown; jsonrpc: string }; + } +} diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-acp-client.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-acp-client.test.ts new file mode 100644 index 00000000000..52e7b4682ea --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-acp-client.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { AcpClient, AcpRequestTimeoutError } from '../acp-client.js'; +import { ACP_JSONRPC_VERSION } from '../types.js'; +import { FakeAcpTransport } from './fake-transport.js'; + +describe('AcpClient JSON-RPC 2.0', () => { + it('sends initialize with jsonrpc 2.0 and resolves the result', async () => { + const transport = new FakeAcpTransport(); + const client = new AcpClient({ transport }); + client.start(); + const pending = client.initialize({ + protocolVersion: 1, + clientInfo: { name: 'cindy', version: '0' }, + }); + await Promise.resolve(); + const req = transport.lastRequest(); + expect(req.jsonrpc).toBe(ACP_JSONRPC_VERSION); + expect(req.method).toBe('initialize'); + transport.pushLine({ + jsonrpc: '2.0', + id: req.id, + result: { protocolVersion: 1, authMethods: [] }, + }); + await expect(pending).resolves.toMatchObject({ protocolVersion: 1, authMethods: [] }); + await client.close(); + }); + + it('routes session/update notifications', async () => { + const transport = new FakeAcpTransport(); + const client = new AcpClient({ transport }); + const updates: unknown[] = []; + client.onNotification((method, params) => { + updates.push({ method, params }); + }); + client.start(); + transport.pushLine({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId: 's1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }, + }, + }); + expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ method: 'session/update' }); + await client.close(); + }); + + it('answers session/request_permission from the request handler', async () => { + const transport = new FakeAcpTransport(); + const client = new AcpClient({ transport }); + client.setRequestHandler(async (method, params) => { + expect(method).toBe('session/request_permission'); + expect(params).toMatchObject({ toolCall: { toolCallId: 'tc-1' } }); + return { outcome: { outcome: 'selected', optionId: 'allow-once' } }; + }); + client.start(); + transport.pushLine({ + jsonrpc: '2.0', + id: 99, + method: 'session/request_permission', + params: { + sessionId: 's1', + toolCall: { toolCallId: 'tc-1', kind: 'execute', title: 'bash' }, + options: [{ optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }], + }, + }); + await Promise.resolve(); + await Promise.resolve(); + const response = JSON.parse(transport.written.at(-1)!) as { + jsonrpc: string; + id: number; + result: { outcome: { outcome: string; optionId: string } }; + }; + expect(response.jsonrpc).toBe('2.0'); + expect(response.id).toBe(99); + expect(response.result.outcome).toEqual({ outcome: 'selected', optionId: 'allow-once' }); + await client.close(); + }); + + it('times out initialize when the agent never replies', async () => { + const transport = new FakeAcpTransport(); + const client = new AcpClient({ transport, defaultTimeoutMs: 20 }); + client.start(); + await expect(client.initialize({ protocolVersion: 1 }, 20)).rejects.toBeInstanceOf(AcpRequestTimeoutError); + await client.close(); + }); + + it('sends session/cancel as a notification (no id)', async () => { + const transport = new FakeAcpTransport(); + const client = new AcpClient({ transport }); + client.start(); + await client.sessionCancel('s-9'); + const payload = JSON.parse(transport.written.at(-1)!) as Record; + expect(payload).toEqual({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId: 's-9' } }); + expect(payload).not.toHaveProperty('id'); + await client.close(); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-auto-review-policy.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-auto-review-policy.test.ts new file mode 100644 index 00000000000..e2e8083ada8 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-auto-review-policy.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { grokBuildToolToReviewableAction, pickPermissionOptionId } from '../auto-review-policy.js'; + +describe('grok-build auto-review policy', () => { + it('maps ACP tool kinds onto ReviewableAction', () => { + expect(grokBuildToolToReviewableAction({ + toolCallId: '1', kind: 'execute', rawInput: { command: 'ls' }, + })).toEqual({ kind: 'exec', command: 'ls', cwd: undefined, cwdUnknown: false }); + expect(grokBuildToolToReviewableAction({ + toolCallId: '2', kind: 'edit', locations: [{ path: '/repo/a.ts' }], + })).toEqual({ kind: 'file-write', path: '/repo/a.ts' }); + expect(grokBuildToolToReviewableAction({ + toolCallId: '3', kind: 'read', rawInput: { path: '/repo/a.ts' }, + })).toMatchObject({ kind: 'read', path: '/repo/a.ts' }); + expect(grokBuildToolToReviewableAction({ + toolCallId: '4', kind: 'fetch', rawInput: { url: 'https://example.com' }, + })).toMatchObject({ kind: 'network', target: 'https://example.com' }); + expect(grokBuildToolToReviewableAction({ + toolCallId: '5', kind: 'think', + })).toEqual({ kind: 'session-state' }); + expect(grokBuildToolToReviewableAction({ + toolCallId: '6', kind: 'other', title: 'mcp', + })).toMatchObject({ kind: 'other', description: 'mcp' }); + }); + + it('picks ACP permission option ids for allow/deny', () => { + const options = [ + { optionId: 'a1', kind: 'allow_once' }, + { optionId: 'a2', kind: 'allow_always' }, + { optionId: 'r1', kind: 'reject_once' }, + ]; + expect(pickPermissionOptionId(options, 'allow')).toBe('a1'); + expect(pickPermissionOptionId(options, 'allow', true)).toBe('a2'); + expect(pickPermissionOptionId(options, 'deny')).toBe('r1'); + }); + + it('fail-closes deny when only allow_* options exist', () => { + const options = [{ optionId: 'a1', kind: 'allow_once' }]; + expect(pickPermissionOptionId(options, 'deny')).toBeNull(); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-capabilities.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-capabilities.test.ts new file mode 100644 index 00000000000..f59ad124397 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-capabilities.test.ts @@ -0,0 +1,93 @@ +/** + * GrokBuildAgent capabilities contract — permissionModes must be strict→wide, + * `[0]` is the strictest mode (hook-control/defaults.ts falls back to it). + */ +import { describe, expect, it } from 'vitest'; + +import { GrokBuildAgent } from '../index.js'; +import type { AgentDeps } from '../../base-agent.js'; +import type { Logger } from '../../../interfaces/logger.js'; + +const noopLogger: Logger = { + trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {}, + child: () => noopLogger, +}; + +function buildAgent(): GrokBuildAgent { + const deps: AgentDeps = { + auth: { + getState: async () => ({ authenticated: true, identity: 't', authSource: 'api-key' as const }), + triggerLogin: async () => ({ authenticated: true }), + logout: async () => {}, + getAuthEnv: async () => ({}), + }, + runtimeConfig: {}, + binaryPath: '/nonexistent/grok', + logger: noopLogger, + }; + return new GrokBuildAgent(deps); +} + +describe('GrokBuildAgent capabilities contract', () => { + it('declares permission modes strict→wide with ask first (unattended clamp safety)', () => { + const ids = buildAgent().capabilities.permissionModes.map((m) => m.id); + expect(ids).toEqual(['ask', 'auto', 'bypassPermissions']); + expect(ids[0]).toBe('ask'); + expect(ids[ids.length - 1]).toBe('bypassPermissions'); + }); + + it('every permission mode ships an English fallback label + description', () => { + for (const m of buildAgent().capabilities.permissionModes) { + expect(m.displayName && m.displayName.length > 0).toBe(true); + expect(m.description && m.description.length > 0).toBe(true); + expect(/[一-鿿]/.test(`${m.displayName}${m.description}`)).toBe(false); + } + }); + + it('does not expose Fast mode', () => { + expect(buildAgent().capabilities.hasFastMode).toBe(false); + }); + + it('supports host turn policies in ask/auto but rejects Full Access', () => { + expect(buildAgent().capabilities.turnPermissionPolicy).toEqual({ + supported: { supported: true }, + unsupportedPermissionModes: ['bypassPermissions'], + }); + }); + + it('is a Cindy-hosted harness: rewind/fork/plan, no Grok Build model category', () => { + const capabilities = buildAgent().capabilities; + expect(buildAgent().kind).toBe('grok-build'); + expect(capabilities.availableModels.some((model) => model.id === 'grok-build')).toBe(false); + expect(capabilities.rewind).toEqual({ supported: true }); + expect(capabilities.fork).toEqual({ supported: true }); + expect(capabilities.planMode).toEqual({ supported: true }); + expect(capabilities.sameTurnSteer).toEqual({ supported: true }); + expect(capabilities.abort).toEqual({ supported: true }); + }); + + it('accepts exclusive Grok catalog slugs from the host model plane', () => { + const agent = new GrokBuildAgent({ + auth: { + getState: async () => ({ authenticated: true, identity: 't', authSource: 'api-key' as const }), + triggerLogin: async () => ({ authenticated: true }), + logout: async () => {}, + getAuthEnv: async () => ({}), + }, + runtimeConfig: {}, + binaryPath: '/nonexistent/pi', + logger: noopLogger, + capabilityAdditions: { + availableModels: [ + { id: 'grok-4.6', displayName: 'Grok 4.6', contextWindow: 500_000, efforts: [], defaultEffort: null }, + { id: 'xai/grok-4.5', displayName: 'Grok 4.5', contextWindow: 500_000, efforts: [], defaultEffort: null }, + ], + }, + }); + expect(agent.capabilities.availableModels.map((model) => model.id)).toEqual([ + 'grok-4.6', + 'xai/grok-4.5', + ]); + expect(agent.capabilities.availableModels.some((model) => model.id === 'grok-build')).toBe(false); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-detect.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-detect.test.ts new file mode 100644 index 00000000000..6323bb6a58f --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-detect.test.ts @@ -0,0 +1,187 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; + +import { + detectGrokBuildOnPath, + grokAcpInitializeLooksAuthenticated, + probeGrokBuildAcp, + resolveGrokBinaryFromPath, +} from '../detect.js'; +import type { GrokSpawnFn } from '../stdio-transport.js'; + +function fakeSpawn(handler: (stdin: PassThrough, stdout: PassThrough) => void): GrokSpawnFn { + return () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new EventEmitter() as ReturnType; + Object.assign(child, { + stdin, + stdout, + stderr, + pid: 99, + killed: false, + kill: () => { + // killed 在 ChildProcess 类型上是只读的,伪造对象用赋值语法会过不了 tsc。 + Object.assign(child, { killed: true }); + child.emit('exit', 0, null); + return true; + }, + }); + stdin.on('data', (buf: Buffer) => { + handler(stdin, stdout); + void buf; + }); + // Also handle line-oriented writes: listen after each write by wrapping. + const originalWrite = stdin.write.bind(stdin); + stdin.write = ((chunk: unknown, encoding?: unknown, cb?: unknown) => { + const result = originalWrite(chunk as never, encoding as never, cb as never); + queueMicrotask(() => handler(stdin, stdout)); + return result; + }) as typeof stdin.write; + return child; + }; +} + +describe('grok-build detection', () => { + it('reports uninstalled when grok is not on PATH', () => { + const result = detectGrokBuildOnPath({ + pathEnv: '/tmp/empty-bin', + existsSyncImpl: () => false, + platform: 'linux', + }); + expect(result).toEqual({ + status: 'uninstalled', + binaryPath: null, + errorReason: 'uninstalled', + }); + expect(resolveGrokBinaryFromPath({ pathEnv: '', existsSyncImpl: () => false })).toBeNull(); + }); + + it('resolves grok on PATH without reading auth.json', () => { + const found = resolveGrokBinaryFromPath({ + pathEnv: '/opt/xai/bin:/usr/bin', + platform: 'linux', + existsSyncImpl: (candidate) => candidate === '/opt/xai/bin/grok', + }); + expect(found).toBe('/opt/xai/bin/grok'); + }); + + // Windows 上 grok 由 npm 装成 .cmd shim;分隔符是 ;、扩展名要逐个试。 + // 注入 platform 的用例在任何宿主上都要给出同一答案,所以拼接也得跟着 platform 走。 + it('resolves the Windows .cmd shim from a win32 PATH', () => { + const found = resolveGrokBinaryFromPath({ + pathEnv: 'C:\\Users\\u\\AppData\\Roaming\\npm;C:\\Windows\\system32', + platform: 'win32', + existsSyncImpl: (candidate) => candidate === 'C:\\Users\\u\\AppData\\Roaming\\npm\\grok.cmd', + }); + expect(found).toBe('C:\\Users\\u\\AppData\\Roaming\\npm\\grok.cmd'); + }); + + it('treats sign-in-only authMethods as logged-out', async () => { + const spawnImpl = fakeSpawn((_stdin, stdout) => { + stdout.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { + protocolVersion: 1, + authMethods: [{ id: 'oauth', name: 'Sign in' }], + }, + })}\n`); + }); + const result = await probeGrokBuildAcp({ + binaryPath: '/opt/xai/bin/grok', + spawnImpl, + timeoutMs: 1_000, + }); + expect(result.status).toBe('logged-out'); + expect(result.binaryPath).toBe('/opt/xai/bin/grok'); + }); + + it('treats grok cached_token default as ready even when grok.com is also listed', async () => { + const spawnImpl = fakeSpawn((_stdin, stdout) => { + stdout.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { + protocolVersion: 1, + authMethods: [ + { id: 'cached_token', name: 'cached_token', description: 'Cached token from ~/.grok/auth.json' }, + { id: 'grok.com', name: 'Grok', description: 'Sign in with Grok' }, + ], + _meta: { defaultAuthMethodId: 'cached_token' }, + }, + })}\n`); + }); + const result = await probeGrokBuildAcp({ + binaryPath: '/opt/xai/bin/grok', + spawnImpl, + timeoutMs: 1_000, + }); + expect(result.status).toBe('ready'); + expect(result.identity).toBe('grok'); + }); + + it('recognizes cached_token without reading auth.json', () => { + expect(grokAcpInitializeLooksAuthenticated({ authMethods: [] })).toBe(true); + expect( + grokAcpInitializeLooksAuthenticated({ + authMethods: [{ id: 'oauth' }], + }), + ).toBe(false); + expect( + grokAcpInitializeLooksAuthenticated({ + authMethods: [{ id: 'cached_token' }, { id: 'grok.com' }], + _meta: { defaultAuthMethodId: 'cached_token' }, + }), + ).toBe(true); + expect( + grokAcpInitializeLooksAuthenticated({ + authMethods: [{ id: 'cached_token' }, { id: 'grok.com' }], + }), + ).toBe(true); + }); + + it('treats empty authMethods as ready', async () => { + const spawnImpl = fakeSpawn((_stdin, stdout) => { + stdout.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { protocolVersion: 1, authMethods: [] }, + })}\n`); + }); + const result = await probeGrokBuildAcp({ + binaryPath: '/opt/xai/bin/grok', + spawnImpl, + timeoutMs: 1_000, + }); + expect(result.status).toBe('ready'); + }); + + it('reports acp-fail when initialize times out', async () => { + const spawnImpl = fakeSpawn(() => { + // never replies + }); + const result = await probeGrokBuildAcp({ + binaryPath: '/opt/xai/bin/grok', + spawnImpl, + timeoutMs: 30, + }); + expect(result.status).toBe('acp-fail'); + expect(result.errorReason).toMatch(/timed out/i); + }); +}); + +describe('optional grok-build registration', () => { + it('omits grok-build from the Maker agents map when detection returns null', () => { + const grokBuildAgent = null; + const agents = { + 'claude-code': { kind: 'claude-code' }, + codex: { kind: 'codex' }, + pi: { kind: 'pi' }, + ...(grokBuildAgent ? { 'grok-build': grokBuildAgent } : {}), + }; + expect(Object.keys(agents)).toEqual(['claude-code', 'codex', 'pi']); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-session.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-session.test.ts new file mode 100644 index 00000000000..5a746aab916 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-session.test.ts @@ -0,0 +1,39 @@ +/** + * GrokBuildAgent is a Cindy-hosted Pi loop with kind grok-build. + * ACP stdio session tests no longer apply to this class. + */ +import { describe, expect, it } from 'vitest'; + +import { GrokBuildAgent } from '../index.js'; +import { PiAgent } from '../../pi/index.js'; +import type { AgentDeps } from '../../base-agent.js'; +import type { Logger } from '../../../interfaces/logger.js'; + +const noopLogger: Logger = { + trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {}, + child: () => noopLogger, +}; + +function buildAgent(): GrokBuildAgent { + const deps: AgentDeps = { + auth: { + getState: async () => ({ authenticated: true, identity: 't', authSource: 'api-key' as const }), + triggerLogin: async () => ({ authenticated: true }), + logout: async () => {}, + getAuthEnv: async () => ({}), + }, + runtimeConfig: {}, + binaryPath: '/nonexistent/pi', + logger: noopLogger, + }; + return new GrokBuildAgent(deps); +} + +describe('GrokBuildAgent hosted-loop identity', () => { + it('is a Pi-hosted Cindy harness with grok-build kind', () => { + const agent = buildAgent(); + expect(agent).toBeInstanceOf(PiAgent); + expect(agent.kind).toBe('grok-build'); + expect(agent.kind).not.toBe('pi'); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-stdio-transport.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-stdio-transport.test.ts new file mode 100644 index 00000000000..eef65f6f09b --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-stdio-transport.test.ts @@ -0,0 +1,83 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; + +import { createGrokStdioTransport, type GrokSpawnFn } from '../stdio-transport.js'; + +type FakeChild = EventEmitter & { + pid: number; + killed: boolean; + exitCode: number | null; + signalCode: NodeJS.Signals | null; + stdout: PassThrough; + stderr: PassThrough; + stdin: PassThrough; + kill: (signal?: NodeJS.Signals) => boolean; + signals: NodeJS.Signals[]; +}; + +function makeChild(opts?: { ignoreTerm?: boolean }): FakeChild { + const child = new EventEmitter() as FakeChild; + child.pid = 4242; + child.killed = false; + child.exitCode = null; + child.signalCode = null; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.stdin = new PassThrough(); + child.signals = []; + child.kill = (signal: NodeJS.Signals = 'SIGTERM') => { + child.signals.push(signal); + // Node sets killed=true after SIGTERM even when the process is still alive. + child.killed = true; + if (signal === 'SIGTERM' && opts?.ignoreTerm) { + return true; + } + child.exitCode = signal === 'SIGKILL' ? null : 0; + child.signalCode = signal === 'SIGKILL' ? 'SIGKILL' : null; + child.emit('exit', child.exitCode, child.signalCode); + return true; + }; + return child; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('createGrokStdioTransport close()', () => { + it('sends SIGKILL if the child ignores SIGTERM (does not use child.killed)', async () => { + const child = makeChild({ ignoreTerm: true }); + const transport = createGrokStdioTransport({ + binaryPath: '/grok', + args: ['agent', 'stdio'], + spawnImpl: (() => child) as unknown as GrokSpawnFn, + }); + + const closing = transport.close('test'); + expect(child.signals).toEqual(['SIGTERM']); + expect(child.killed).toBe(true); + expect(child.exitCode).toBeNull(); + + await delay(1_500); + expect(child.signals).toEqual(['SIGTERM']); + + await delay(800); + expect(child.signals).toEqual(['SIGTERM', 'SIGKILL']); + await closing; + }); + + it('does not send SIGKILL when the child exits after SIGTERM', async () => { + const child = makeChild({ ignoreTerm: false }); + const transport = createGrokStdioTransport({ + binaryPath: '/grok', + args: ['agent', 'stdio'], + spawnImpl: (() => child) as unknown as GrokSpawnFn, + }); + + await transport.close('test'); + expect(child.signals).toEqual(['SIGTERM']); + await delay(2_100); + expect(child.signals).toEqual(['SIGTERM']); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/__tests__/grok-build-translator.test.ts b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-translator.test.ts new file mode 100644 index 00000000000..abeef53f104 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/__tests__/grok-build-translator.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + translatePromptResult, + translateSessionUpdate, +} from '../translator.js'; +import type { AcpSessionUpdate } from '../types.js'; + +describe('grok-build ACP translator', () => { + it('maps agent_message_chunk to streaming text', () => { + const update: AcpSessionUpdate = { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + }; + expect(translateSessionUpdate(update, {})).toEqual([ + { type: 'text', data: { text: 'hello', isFinal: false }, source: 'grok-build' }, + ]); + }); + + it('maps agent_thought_chunk to thinking', () => { + const update: AcpSessionUpdate = { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'hmm' }, + }; + expect(translateSessionUpdate(update, { thoughtBlockId: 't1' })).toEqual([ + { + type: 'thinking', + data: { stage: 'delta', blockId: 't1', text: 'hmm' }, + source: 'grok-build', + }, + ]); + }); + + it('maps tool_call then completed tool_call_update', () => { + const start: AcpSessionUpdate = { + sessionUpdate: 'tool_call', + toolCallId: 'tc-1', + title: 'bash', + kind: 'execute', + rawInput: { command: 'ls' }, + }; + expect(translateSessionUpdate(start, {})).toEqual([ + { + type: 'tool_use', + data: { toolUseId: 'tc-1', toolName: 'bash', input: { command: 'ls' } }, + source: 'grok-build', + }, + ]); + const done: AcpSessionUpdate = { + sessionUpdate: 'tool_call_update', + toolCallId: 'tc-1', + title: 'bash', + status: 'completed', + rawOutput: 'ok', + }; + const events = translateSessionUpdate(done, {}); + expect(events.map((e) => e.type)).toEqual(['tool_result_full', 'tool_result']); + expect(events[0]?.data).toMatchObject({ toolUseId: 'tc-1', content: 'ok', isError: false }); + }); + + it('maps usage_update to status', () => { + const update: AcpSessionUpdate = { + sessionUpdate: 'usage_update', + used: 12, + size: 128000, + inputTokens: 8, + outputTokens: 4, + }; + const events = translateSessionUpdate(update, {}); + expect(events[0]?.type).toBe('status'); + expect(events[0]?.data).toMatchObject({ + status: 'running', + tokenUsage: 12, + contextWindow: 128000, + outputTokens: 4, + }); + }); + + it('maps session/prompt result to done', () => { + expect(translatePromptResult({ stopReason: 'end_turn' })).toEqual({ + type: 'done', + data: { stopReason: 'end_turn' }, + source: 'grok-build', + }); + expect(translatePromptResult({ stopReason: 'cancelled' }).data).toEqual({ + stopReason: 'cancelled', + }); + }); +}); diff --git a/packages/maker-core/src/agents/grok-build/acp-client.ts b/packages/maker-core/src/agents/grok-build/acp-client.ts new file mode 100644 index 00000000000..cf8438159e9 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/acp-client.ts @@ -0,0 +1,279 @@ +/** + * ACP JSON-RPC 2.0 NDJSON client for Grok Build. + * + * Modeled on Codex app-server/client.ts but **includes** `jsonrpc: "2.0"`. + * Incoming: + * - id + method → agent→client request (`session/request_permission`) + * - method only → notification (`session/update`) + * - id + result/error → response to our request + */ + +import type { Logger } from '../../interfaces/logger.js'; +import type { AcpTransport } from './stdio-transport.js'; +import { + ACP_JSONRPC_VERSION, + ACP_PROTOCOL_VERSION, + parseIncomingMessage, + type AcpInitializeParams, + type AcpInitializeResult, + type AcpJsonRpcId, + type AcpPermissionRequest, + type AcpPermissionResponse, + type AcpSessionNewParams, + type AcpSessionNewResult, + type AcpSessionPromptParams, + type AcpSessionPromptResult, + type AcpSessionUpdateNotification, +} from './types.js'; + +const DEFAULT_MAX_LINE_BYTES = 16 * 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 30_000; +const INITIALIZE_TIMEOUT_MS = 15_000; + +export class AcpRequestTimeoutError extends Error { + constructor( + public readonly method: string, + public readonly timeoutMs: number, + ) { + super(`grok ACP ${method} timed out after ${timeoutMs}ms`); + this.name = 'AcpRequestTimeoutError'; + } +} + +export class AcpRpcError extends Error { + constructor( + public readonly method: string, + public readonly code: number, + message: string, + ) { + super(`grok ACP ${method} failed (${code}): ${message}`); + this.name = 'AcpRpcError'; + } +} + +type Pending = { + method: string; + resolve: (value: unknown) => void; + reject: (err: Error) => void; + timeoutId: ReturnType | null; +}; + +export type AcpRequestHandler = ( + method: string, + params: unknown, + id: AcpJsonRpcId, +) => Promise; + +export type AcpNotificationHandler = (method: string, params: unknown) => void; + +export interface AcpClientOptions { + transport: AcpTransport; + logger?: Logger; + maxLineBytes?: number; + defaultTimeoutMs?: number; +} + +export class AcpClient { + private nextId = 1; + private readonly pending = new Map(); + private requestHandler: AcpRequestHandler | undefined; + private notificationHandler: AcpNotificationHandler | undefined; + private started = false; + private closed = false; + private readonly maxLineBytes: number; + private readonly defaultTimeoutMs: number; + private readonly logger?: Logger; + private readonly transport: AcpTransport; + + constructor(opts: AcpClientOptions) { + this.transport = opts.transport; + this.logger = opts.logger; + this.maxLineBytes = opts.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES; + this.defaultTimeoutMs = opts.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + start(): void { + if (this.started) throw new Error('AcpClient: already started'); + if (this.closed) throw new Error('AcpClient: cannot start after close()'); + this.started = true; + this.transport.onLine((line) => this.handleLine(line)); + this.transport.onStderr((line) => { + this.logger?.debug('grok ACP stderr', { line: line.slice(0, 2_000) }); + }); + this.transport.onClose((info) => { + void this.failAll(new Error(`grok ACP transport closed: ${info.reason}`)); + }); + } + + setRequestHandler(handler: AcpRequestHandler): void { + this.requestHandler = handler; + } + + onNotification(handler: AcpNotificationHandler): void { + this.notificationHandler = handler; + } + + async initialize( + params: AcpInitializeParams = { + protocolVersion: ACP_PROTOCOL_VERSION, + clientInfo: { name: 'cindy', version: '0.0.0' }, + }, + timeoutMs = INITIALIZE_TIMEOUT_MS, + ): Promise { + return this.request('initialize', params, timeoutMs) as Promise; + } + + async sessionNew(params: AcpSessionNewParams, timeoutMs?: number): Promise { + return this.request('session/new', params, timeoutMs) as Promise; + } + + async sessionPrompt(params: AcpSessionPromptParams, timeoutMs?: number): Promise { + return this.request('session/prompt', params, timeoutMs ?? 10 * 60_000) as Promise; + } + + async sessionCancel(sessionId: string): Promise { + await this.notify('session/cancel', { sessionId }); + } + + async request(method: string, params?: unknown, timeoutMs?: number): Promise { + if (this.closed) throw new Error(`AcpClient closed; cannot ${method}`); + const id = this.nextId++; + const wait = timeoutMs ?? this.defaultTimeoutMs; + const payload = { + jsonrpc: ACP_JSONRPC_VERSION, + id, + method, + ...(params === undefined ? {} : { params }), + }; + const result = new Promise((resolve, reject) => { + const timeoutId = wait > 0 + ? setTimeout(() => { + this.pending.delete(id); + reject(new AcpRequestTimeoutError(method, wait)); + }, wait) + : null; + this.pending.set(id, { method, resolve, reject, timeoutId }); + }); + try { + await this.transport.writeLine(JSON.stringify(payload)); + } catch (err) { + this.takePending(id); + throw err; + } + return result; + } + + async notify(method: string, params?: unknown): Promise { + if (this.closed) return; + const payload = { + jsonrpc: ACP_JSONRPC_VERSION, + method, + ...(params === undefined ? {} : { params }), + }; + await this.transport.writeLine(JSON.stringify(payload)); + } + + async respond(id: AcpJsonRpcId, result: unknown): Promise { + if (this.closed) return; + await this.transport.writeLine(JSON.stringify({ + jsonrpc: ACP_JSONRPC_VERSION, + id, + result, + })); + } + + async respondError(id: AcpJsonRpcId, code: number, message: string): Promise { + if (this.closed) return; + await this.transport.writeLine(JSON.stringify({ + jsonrpc: ACP_JSONRPC_VERSION, + id, + error: { code, message }, + })); + } + + async close(reason = 'client close'): Promise { + if (this.closed) return; + this.closed = true; + this.failAll(new Error(`grok ACP closed: ${reason}`)); + await this.transport.close(reason); + } + + private failAll(err: Error): void { + for (const [, pending] of this.pending) { + if (pending.timeoutId) clearTimeout(pending.timeoutId); + pending.reject(err); + } + this.pending.clear(); + } + + private handleLine(line: string): void { + if (this.closed) return; + if (line.length > this.maxLineBytes) { + this.logger?.error('grok ACP line exceeded max size; closing', { bytes: line.length }); + void this.close('max line size exceeded'); + return; + } + const trimmed = line.trim(); + if (!trimmed) return; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch (err) { + this.logger?.warn('grok ACP ignored non-JSON line', { + line: trimmed.slice(0, 200), + error: err instanceof Error ? err.message : String(err), + }); + return; + } + const message = parseIncomingMessage(parsed); + if (!message) { + this.logger?.warn('grok ACP ignored malformed message', { line: trimmed.slice(0, 200) }); + return; + } + if ('method' in message && 'id' in message) { + void this.dispatchRequest(message.method, message.params, message.id); + return; + } + if ('method' in message) { + this.notificationHandler?.(message.method, message.params); + return; + } + if ('error' in message) { + const pending = this.takePending(message.id); + if (!pending) return; + pending.reject(new AcpRpcError(pending.method, message.error.code, message.error.message)); + return; + } + const pending = this.takePending(message.id); + pending?.resolve(message.result); + } + + private takePending(id: AcpJsonRpcId): Pending | undefined { + const pending = this.pending.get(id); + if (!pending) return undefined; + this.pending.delete(id); + if (pending.timeoutId) clearTimeout(pending.timeoutId); + return pending; + } + + private async dispatchRequest(method: string, params: unknown, id: AcpJsonRpcId): Promise { + const handler = this.requestHandler; + if (!handler) { + await this.respondError(id, -32601, `method not found: ${method}`); + return; + } + try { + const result = await handler(method, params, id); + await this.respond(id, result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await this.respondError(id, -32000, message); + } + } +} + +export type { + AcpPermissionRequest, + AcpPermissionResponse, + AcpSessionUpdateNotification, +}; diff --git a/packages/maker-core/src/agents/grok-build/auto-review-policy.ts b/packages/maker-core/src/agents/grok-build/auto-review-policy.ts new file mode 100644 index 00000000000..8a78905138d --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/auto-review-policy.ts @@ -0,0 +1,95 @@ +/** + * Grok Build Auto-review adapter — ACP tool_call.kind → ReviewableAction. + * + * Mapping (ACP kind → Cindy review kind): + * execute → exec + * edit / delete / move → file-write + * read / search → read + * fetch → network + * think → session-state + * other / unknown → other + */ + +import type { ReviewableAction } from '../shared/auto-review.js'; +import type { AcpToolCall, AcpToolKind } from './types.js'; +import { isRecord } from './types.js'; + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function firstPath(toolCall: AcpToolCall, input: Record): string | undefined { + const loc = toolCall.locations?.[0]?.path; + if (typeof loc === 'string' && loc.length > 0) return loc; + return ( + readString(input.path) + ?? readString(input.file) + ?? readString(input.file_path) + ?? readString(input.filename) + ?? readString(input.target) + ?? readString(input.dest) + ?? readString(input.destination) + ); +} + +function firstCommand(input: Record): string { + return ( + readString(input.command) + ?? readString(input.cmd) + ?? readString(input.shell) + ?? JSON.stringify(input) + ); +} + +export function grokBuildToolToReviewableAction(toolCall: AcpToolCall): ReviewableAction { + const input = isRecord(toolCall.rawInput) ? toolCall.rawInput : {}; + const kind: AcpToolKind | undefined = toolCall.kind; + switch (kind) { + case 'execute': + return { + kind: 'exec', + command: firstCommand(input), + cwd: readString(input.cwd), + cwdUnknown: 'cwd' in input && !readString(input.cwd), + }; + case 'edit': + case 'delete': + case 'move': + return { kind: 'file-write', path: firstPath(toolCall, input) }; + case 'read': + case 'search': + return { + kind: 'read', + path: firstPath(toolCall, input), + scope: kind === 'search' ? 'tree' : 'file', + }; + case 'fetch': + return { + kind: 'network', + target: readString(input.url) ?? readString(input.uri) ?? firstPath(toolCall, input), + operation: readString(input.method) ?? toolCall.title, + }; + case 'think': + return { kind: 'session-state' }; + default: + return { + kind: 'other', + description: toolCall.title ?? kind ?? 'tool', + }; + } +} + +export function pickPermissionOptionId( + options: ReadonlyArray<{ optionId: string; kind: string }>, + behavior: 'allow' | 'deny', + always = false, +): string | null { + const preferred = behavior === 'allow' + ? (always ? ['allow_always', 'allow_once'] : ['allow_once', 'allow_always']) + : (always ? ['reject_always', 'reject_once'] : ['reject_once', 'reject_always']); + for (const kind of preferred) { + const match = options.find((option) => option.kind === kind); + if (match) return match.optionId; + } + return behavior === 'allow' ? options[0]?.optionId ?? null : null; +} diff --git a/packages/maker-core/src/agents/grok-build/detect.ts b/packages/maker-core/src/agents/grok-build/detect.ts new file mode 100644 index 00000000000..6b208bed010 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/detect.ts @@ -0,0 +1,148 @@ +/** + * Grok Build binary + ACP probe. + * + * PATH walk only — never reads ~/.grok/auth.json. Auth is inferred from ACP + * initialize: empty authMethods, or grok's cached_token default, means logged in. + * Live grok always lists authMethods (cached_token + grok.com) even when already + * signed in; treating any non-empty list as logged-out is wrong. + * + * `buildGrokBuildAgent` must stay PATH-only so a missing/slow grok cannot delay + * Cindy startup. ACP initialize lives in AuthAdapter.getState. + */ + +import path from 'node:path'; +import { existsSync } from 'node:fs'; + +import type { Logger } from '../../interfaces/logger.js'; +import { ACP_PROTOCOL_VERSION } from './types.js'; +import { AcpClient, AcpRequestTimeoutError } from './acp-client.js'; +import { createGrokStdioTransport, type GrokSpawnFn } from './stdio-transport.js'; + +export type GrokBuildDetectStatus = + | 'uninstalled' + | 'logged-out' + | 'unsupported-version' + | 'acp-fail' + | 'ready'; + +export interface GrokBuildProbeResult { + status: GrokBuildDetectStatus; + binaryPath: string | null; + identity?: string; + agentVersion?: string; + errorReason?: string; +} + +export interface ResolveGrokBinaryOptions { + pathEnv?: string; + platform?: NodeJS.Platform; + existsSyncImpl?: (candidate: string) => boolean; + pathSep?: string; +} + +export function resolveGrokBinaryFromPath(options: ResolveGrokBinaryOptions = {}): string | null { + const pathEnv = options.pathEnv ?? process.env.PATH ?? ''; + const platform = options.platform ?? process.platform; + const exists = options.existsSyncImpl ?? existsSync; + // 分隔符与拼接必须同属一个 platform:注入 platform 做跨平台用例时,若这里还用宿主的 + // path.join,在 Windows 上跑 posix 用例会拼出 `\opt\xai\bin\grok`,反之亦然。 + const pathApi = platform === 'win32' ? path.win32 : path.posix; + const delim = options.pathSep ?? pathApi.delimiter; + const exts = platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']; + for (const dir of pathEnv.split(delim)) { + if (!dir) continue; + for (const ext of exts) { + const candidate = pathApi.join(dir, `grok${ext}`); + if (exists(candidate)) return candidate; + } + } + return null; +} + +export interface ProbeGrokBuildOptions { + binaryPath: string; + timeoutMs?: number; + env?: NodeJS.ProcessEnv; + spawnImpl?: GrokSpawnFn; + logger?: Logger; +} + +const DEFAULT_PROBE_TIMEOUT_MS = 5_000; + +/** Live grok lists sign-in methods even when a cached token is already the default. */ +export function grokAcpInitializeLooksAuthenticated(init: { + authMethods?: ReadonlyArray<{ id: string }>; + _meta?: { defaultAuthMethodId?: string }; +}): boolean { + const methods = init.authMethods ?? []; + if (methods.length === 0) return true; + if (init._meta?.defaultAuthMethodId === 'cached_token') return true; + return methods.some((method) => method.id === 'cached_token'); +} + +export async function probeGrokBuildAcp(options: ProbeGrokBuildOptions): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + const logger = options.logger; + let transport: ReturnType | undefined; + let client: AcpClient | undefined; + try { + transport = createGrokStdioTransport({ + binaryPath: options.binaryPath, + args: ['agent', 'stdio'], + env: options.env, + spawnImpl: options.spawnImpl, + }); + client = new AcpClient({ + transport, + logger, + defaultTimeoutMs: timeoutMs, + }); + client.start(); + const init = await client.initialize({ + protocolVersion: ACP_PROTOCOL_VERSION, + clientInfo: { name: 'cindy', version: '0.0.0' }, + }, timeoutMs); + if (typeof init.protocolVersion === 'number' && init.protocolVersion > ACP_PROTOCOL_VERSION) { + return { + status: 'unsupported-version', + binaryPath: options.binaryPath, + agentVersion: init.agentInfo?.version, + errorReason: `unsupported ACP protocolVersion ${init.protocolVersion}`, + }; + } + if (!grokAcpInitializeLooksAuthenticated(init)) { + return { + status: 'logged-out', + binaryPath: options.binaryPath, + agentVersion: init.agentInfo?.version, + errorReason: 'logged-out', + }; + } + return { + status: 'ready', + binaryPath: options.binaryPath, + identity: init.agentInfo?.name ?? 'grok', + agentVersion: init.agentInfo?.version, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const status: GrokBuildDetectStatus = + err instanceof AcpRequestTimeoutError ? 'acp-fail' : 'acp-fail'; + logger?.warn('grok-build ACP probe failed', { message }); + return { + status, + binaryPath: options.binaryPath, + errorReason: message, + }; + } finally { + await client?.close('probe complete').catch(() => undefined); + } +} + +export function detectGrokBuildOnPath(options: ResolveGrokBinaryOptions = {}): GrokBuildProbeResult { + const binaryPath = resolveGrokBinaryFromPath(options); + if (!binaryPath) { + return { status: 'uninstalled', binaryPath: null, errorReason: 'uninstalled' }; + } + return { status: 'ready', binaryPath }; +} diff --git a/packages/maker-core/src/agents/grok-build/index.ts b/packages/maker-core/src/agents/grok-build/index.ts new file mode 100644 index 00000000000..1552b93f989 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/index.ts @@ -0,0 +1,26 @@ +/** + * GrokBuildAgent — Grok Build as a first-class Cindy harness (same bar as Claude Code). + * + * Runtime is the Cindy-hosted Pi loop: MCP / Orca / Ghost, rewind / fork / plan / + * steer, and Cindy xAI · SuperGrok model routing. It is not `grok agent stdio` ACP + * and does not use a separate grok CLI login. + * + * `kind` stays `grok-build` so picker, persistence, and IPC keep a fourth harness. + */ + +import type { AgentKind } from '../../types/common.js'; +import type { AgentDeps } from '../base-agent.js'; +import { PiAgent } from '../pi/index.js'; + +export class GrokBuildAgent extends PiAgent { + override readonly kind: AgentKind = 'grok-build'; + + constructor(deps: AgentDeps) { + super(deps); + // Exclusive Grok chips do not advertise Fast; keep the harness toggle off. + this.capabilities.hasFastMode = false; + } +} + +export { detectGrokBuildOnPath, probeGrokBuildAcp, resolveGrokBinaryFromPath } from './detect.js'; +export type { GrokBuildDetectStatus, GrokBuildProbeResult } from './detect.js'; diff --git a/packages/maker-core/src/agents/grok-build/stdio-transport.ts b/packages/maker-core/src/agents/grok-build/stdio-transport.ts new file mode 100644 index 00000000000..668f687861f --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/stdio-transport.ts @@ -0,0 +1,168 @@ +/** + * Stdio transport for Grok Build ACP (`grok agent [flags] stdio`). + * + * Byte-stream only: NDJSON framing is handled by AcpClient. Spawn is injectable + * so detection tests can fake a child without a real grok binary. + */ + +import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptions } from 'node:child_process'; +import { createInterface, type Interface } from 'node:readline'; + +export type AcpLineHandler = (line: string) => void; +export type AcpStderrHandler = (line: string) => void; +export type AcpCloseHandler = (info: { reason: string }) => void; + +export interface AcpTransport { + writeLine(line: string): Promise; + onLine(handler: AcpLineHandler): () => void; + onStderr(handler: AcpStderrHandler): () => void; + onClose(handler: AcpCloseHandler): () => void; + close(reason?: string): Promise; + readonly pid: number | undefined; +} + +export type GrokSpawnFn = ( + command: string, + args: readonly string[], + options: SpawnOptions, +) => ChildProcessWithoutNullStreams; + +export interface GrokStdioTransportOptions { + binaryPath: string; + args: readonly string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + spawnImpl?: GrokSpawnFn; + onProcessSpawned?: (pid: number) => void | (() => void); +} + +export function createGrokStdioTransport(opts: GrokStdioTransportOptions): AcpTransport { + if (!opts.binaryPath) { + throw new Error('createGrokStdioTransport: binaryPath is required'); + } + + const spawnImpl = opts.spawnImpl ?? (spawn as GrokSpawnFn); + const lineHandlers = new Set(); + const stderrHandlers = new Set(); + const closeHandlers = new Set(); + const pendingLines: string[] = []; + let closed = false; + let exited = false; + let closeReason = 'transport closed'; + let stdoutRl: Interface | undefined; + let stderrRl: Interface | undefined; + let disposeProcess: (() => void) | undefined; + + const child = spawnImpl(opts.binaryPath, [...opts.args], { + cwd: opts.cwd, + env: opts.env, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + + if (typeof child.pid === 'number' && opts.onProcessSpawned) { + const disposer = opts.onProcessSpawned(child.pid); + if (typeof disposer === 'function') disposeProcess = disposer; + } + + stdoutRl = createInterface({ input: child.stdout }); + stderrRl = createInterface({ input: child.stderr }); + + stdoutRl.on('line', (line: string) => { + if (closed) return; + if (lineHandlers.size === 0) { + pendingLines.push(line); + return; + } + for (const handler of lineHandlers) handler(line); + }); + + stderrRl.on('line', (line: string) => { + if (closed) return; + for (const handler of stderrHandlers) handler(line); + }); + + const finish = (reason: string) => { + if (closed) return; + closed = true; + closeReason = reason; + disposeProcess?.(); + stdoutRl?.close(); + stderrRl?.close(); + for (const handler of closeHandlers) handler({ reason }); + }; + + child.on('error', (err) => { + finish(`grok spawn error: ${err.message}`); + }); + child.on('exit', (code, signal) => { + exited = true; + finish(signal ? `grok exited signal ${signal}` : `grok exited code ${code ?? 'unknown'}`); + }); + + return { + get pid() { + return child.pid; + }, + async writeLine(line: string): Promise { + if (closed || !child.stdin.writable) { + throw new Error(`grok ACP transport closed (${closeReason})`); + } + await new Promise((resolve, reject) => { + child.stdin.write(`${line}\n`, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + }, + onLine(handler: AcpLineHandler): () => void { + lineHandlers.add(handler); + if (pendingLines.length > 0) { + const queued = pendingLines.splice(0); + for (const line of queued) handler(line); + } + return () => { + lineHandlers.delete(handler); + }; + }, + onStderr(handler: AcpStderrHandler): () => void { + stderrHandlers.add(handler); + return () => { + stderrHandlers.delete(handler); + }; + }, + onClose(handler: AcpCloseHandler): () => void { + closeHandlers.add(handler); + if (closed) handler({ reason: closeReason }); + return () => { + closeHandlers.delete(handler); + }; + }, + async close(reason = 'client close'): Promise { + if (closed) return; + finish(reason); + if (exited || child.exitCode != null) return; + await new Promise((resolve) => { + if (exited) { + resolve(); + return; + } + const timer = setTimeout(() => { + if (!exited) { + try { child.kill('SIGKILL'); } catch { /* already gone */ } + } + resolve(); + }, 2_000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + try { child.kill('SIGTERM'); } catch { /* already gone */ } + if (exited) { + clearTimeout(timer); + resolve(); + } + }); + }, + }; +} diff --git a/packages/maker-core/src/agents/grok-build/translator.ts b/packages/maker-core/src/agents/grok-build/translator.ts new file mode 100644 index 00000000000..7d1210daec6 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/translator.ts @@ -0,0 +1,169 @@ +/** + * Grok Build ACP `session/update` → Cindy AgentEvent. + * + * agent_message_chunk → text { text, isFinal } + * agent_thought_chunk → thinking { stage, blockId, text, ... } + * tool_call → tool_use { toolUseId, toolName, input } + * tool_call_update completed/failed → tool_result_full + tool_result + * usage_update → status with UsageSnapshot + * session/prompt result → done + */ + +import type { AgentEvent, UsageSnapshot } from '../../types/events.js'; +import type { AcpContentBlock, AcpSessionPromptResult, AcpSessionUpdate, AcpToolCall } from './types.js'; +import { isRecord } from './types.js'; + +export const GROK_BUILD_SOURCE = 'grok-build' as const; + +function textOf(content: AcpContentBlock | undefined): string { + if (!content) return ''; + if (content.type === 'text' && typeof content.text === 'string') return content.text; + return ''; +} + +/** + * AcpSessionUpdate 末尾有 `{ sessionUpdate: string; [key: string]: unknown }` 兜底成员, + * 判别式是 string,所以 switch 收窄后 content 仍是 unknown。内容来自外部进程,这里按 + * 结构校验再收窄,而不是硬转。 + */ +function contentOf(content: unknown): AcpContentBlock | undefined { + if (!isRecord(content) || typeof content.type !== 'string') return undefined; + return content as AcpContentBlock; +} + +function toolNameOf(call: Partial): string { + return call.title || call.kind || 'tool'; +} + +function stringifyOutput(value: unknown): string { + if (value == null) return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function translateSessionUpdate( + update: AcpSessionUpdate, + ctx: { thoughtBlockId?: string }, +): AgentEvent[] { + const events: AgentEvent[] = []; + switch (update.sessionUpdate) { + case 'agent_message_chunk': { + const text = textOf(contentOf(update.content)); + if (!text) break; + events.push({ + type: 'text', + data: { text, isFinal: false }, + source: GROK_BUILD_SOURCE, + }); + break; + } + case 'agent_thought_chunk': { + const text = textOf(contentOf(update.content)); + if (!text) break; + events.push({ + type: 'thinking', + data: { + stage: 'delta', + blockId: ctx.thoughtBlockId ?? 'grok-thought', + text, + }, + source: GROK_BUILD_SOURCE, + }); + break; + } + case 'tool_call': { + const call = update as AcpToolCall & { sessionUpdate: 'tool_call' }; + events.push({ + type: 'tool_use', + data: { + toolUseId: call.toolCallId, + toolName: toolNameOf(call), + input: isRecord(call.rawInput) ? call.rawInput : {}, + }, + source: GROK_BUILD_SOURCE, + }); + break; + } + case 'tool_call_update': { + const call = update as Partial & { + sessionUpdate: 'tool_call_update'; + toolCallId: string; + }; + if (call.status !== 'completed' && call.status !== 'failed') break; + const content = stringifyOutput(call.rawOutput ?? call.content); + const isError = call.status === 'failed'; + events.push({ + type: 'tool_result_full', + data: { + toolUseId: call.toolCallId, + toolName: toolNameOf(call), + content, + isError, + }, + source: GROK_BUILD_SOURCE, + }); + events.push({ + type: 'tool_result', + data: { + toolUseId: call.toolCallId, + toolName: toolNameOf(call), + content, + isError, + }, + source: GROK_BUILD_SOURCE, + }); + break; + } + case 'usage_update': { + const snapshot = usageFromUpdate(update); + events.push({ + type: 'status', + data: { + status: 'running', + ...snapshot, + }, + source: GROK_BUILD_SOURCE, + }); + break; + } + default: + break; + } + return events; +} + +export function usageFromUpdate(update: Extract | Record): UsageSnapshot { + const rec = update as Record; + const input = typeof rec.inputTokens === 'number' ? rec.inputTokens : 0; + const output = typeof rec.outputTokens === 'number' ? rec.outputTokens : 0; + const used = typeof rec.used === 'number' ? rec.used : input + output; + const size = typeof rec.size === 'number' ? rec.size : 0; + const cost = isRecord(rec.cost) && typeof rec.cost.amount === 'number' ? rec.cost.amount : 0; + return { + tokenUsage: used, + contextTokens: used, + contextWindow: size, + costUsd: cost, + outputTokens: output || undefined, + }; +} + +export function translatePromptResult(result: AcpSessionPromptResult): AgentEvent { + return { + type: 'done', + data: { stopReason: result.stopReason }, + source: GROK_BUILD_SOURCE, + }; +} + +export function translateError(message: string, isTerminal = true): AgentEvent { + return { + type: 'error', + data: { message, isTerminal }, + source: GROK_BUILD_SOURCE, + }; +} diff --git a/packages/maker-core/src/agents/grok-build/types.ts b/packages/maker-core/src/agents/grok-build/types.ts new file mode 100644 index 00000000000..4aa66d54114 --- /dev/null +++ b/packages/maker-core/src/agents/grok-build/types.ts @@ -0,0 +1,248 @@ +/** + * Agent Client Protocol (ACP) types used by Grok Build (`grok agent stdio`). + * + * JSON-RPC 2.0 **with** `jsonrpc: "2.0"` (unlike Codex app-server, which omits it). + * Spec: https://agentclientprotocol.com — grok-build session `_meta` is a vendor + * extension (`yoloMode` / `autoMode` / `rules` / `systemPromptOverride`). + */ + +export const ACP_PROTOCOL_VERSION = 1; +export const ACP_JSONRPC_VERSION = '2.0' as const; + +export type AcpJsonRpcId = number | string; + +export interface AcpJsonRpcRequest { + jsonrpc: typeof ACP_JSONRPC_VERSION; + id: AcpJsonRpcId; + method: string; + params?: unknown; +} + +export interface AcpJsonRpcNotification { + jsonrpc: typeof ACP_JSONRPC_VERSION; + method: string; + params?: unknown; +} + +export interface AcpJsonRpcSuccess { + jsonrpc: typeof ACP_JSONRPC_VERSION; + id: AcpJsonRpcId; + result: unknown; +} + +export interface AcpJsonRpcErrorObject { + code: number; + message: string; + data?: unknown; +} + +export interface AcpJsonRpcFailure { + jsonrpc: typeof ACP_JSONRPC_VERSION; + id: AcpJsonRpcId; + error: AcpJsonRpcErrorObject; +} + +export type AcpIncomingMessage = + | AcpJsonRpcRequest + | AcpJsonRpcNotification + | AcpJsonRpcSuccess + | AcpJsonRpcFailure; + +export interface AcpClientInfo { + name: string; + version: string; +} + +export interface AcpInitializeParams { + protocolVersion: number; + clientInfo?: AcpClientInfo; + clientCapabilities?: { + fs?: { readTextFile?: boolean; writeTextFile?: boolean }; + terminal?: boolean; + }; +} + +export interface AcpAuthMethod { + id: string; + name: string; + description?: string; +} + +export interface AcpInitializeResult { + protocolVersion: number; + agentInfo?: { name?: string; version?: string; title?: string }; + agentCapabilities?: { + loadSession?: boolean; + promptCapabilities?: { + image?: boolean; + audio?: boolean; + embeddedContext?: boolean; + }; + }; + authMethods?: AcpAuthMethod[]; + /** + * grok 1.0.13 always advertises authMethods even when ~/.grok/auth.json already + * has a token. `defaultAuthMethodId: "cached_token"` means that cached login + * is the active method — not "please sign in". + */ + _meta?: { + defaultAuthMethodId?: string; + }; +} + +export interface AcpSessionNewMeta { + yoloMode?: boolean; + autoMode?: boolean; + rules?: string; + systemPromptOverride?: string; + agentProfile?: string | Record; +} + +export interface AcpSessionNewParams { + cwd: string; + mcpServers: unknown[]; + _meta?: AcpSessionNewMeta; +} + +export interface AcpSessionNewResult { + sessionId: string; +} + +export type AcpContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + | { type: 'audio'; data: string; mimeType: string } + | { type: 'resource'; resource: unknown } + | { type: 'resource_link'; uri: string; name?: string }; + +export interface AcpSessionPromptParams { + sessionId: string; + prompt: AcpContentBlock[]; +} + +export type AcpStopReason = + | 'end_turn' + | 'max_tokens' + | 'max_turn_requests' + | 'refusal' + | 'cancelled'; + +export interface AcpSessionPromptResult { + stopReason: AcpStopReason; +} + +export type AcpToolKind = + | 'read' + | 'edit' + | 'delete' + | 'move' + | 'search' + | 'execute' + | 'think' + | 'fetch' + | 'other'; + +export type AcpToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; + +export interface AcpToolCall { + toolCallId: string; + title?: string; + kind?: AcpToolKind; + status?: AcpToolCallStatus; + locations?: Array<{ path: string }>; + content?: unknown[]; + rawInput?: Record; + rawOutput?: unknown; +} + +export type AcpSessionUpdate = + | { + sessionUpdate: 'agent_message_chunk'; + content: AcpContentBlock; + } + | { + sessionUpdate: 'agent_thought_chunk'; + content: AcpContentBlock; + } + | { + sessionUpdate: 'user_message_chunk'; + content: AcpContentBlock; + } + | ({ + sessionUpdate: 'tool_call'; + } & AcpToolCall) + | ({ + sessionUpdate: 'tool_call_update'; + } & Partial & { toolCallId: string }) + | { + sessionUpdate: 'plan'; + entries?: unknown[]; + } + | { + sessionUpdate: 'usage_update'; + used?: number; + size?: number; + cost?: { amount?: number; currency?: string }; + inputTokens?: number; + outputTokens?: number; + thoughtTokens?: number; + cachedTokens?: number; + } + | { + sessionUpdate: string; + [key: string]: unknown; + }; + +export interface AcpSessionUpdateNotification { + sessionId: string; + update: AcpSessionUpdate; +} + +export type AcpPermissionOptionKind = + | 'allow_once' + | 'allow_always' + | 'reject_once' + | 'reject_always'; + +export interface AcpPermissionOption { + optionId: string; + name: string; + kind: AcpPermissionOptionKind; +} + +export interface AcpPermissionRequest { + sessionId: string; + toolCall: AcpToolCall; + options: AcpPermissionOption[]; +} + +export type AcpPermissionOutcome = + | { outcome: 'selected'; optionId: string } + | { outcome: 'cancelled' }; + +export interface AcpPermissionResponse { + outcome: AcpPermissionOutcome; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function parseIncomingMessage(value: unknown): AcpIncomingMessage | null { + if (!isRecord(value) || value.jsonrpc !== ACP_JSONRPC_VERSION) return null; + const hasId = 'id' in value; + const hasMethod = typeof value.method === 'string'; + if (hasMethod && hasId) { + return value as unknown as AcpJsonRpcRequest; + } + if (hasMethod && !hasId) { + return value as unknown as AcpJsonRpcNotification; + } + if (hasId && 'result' in value) { + return value as unknown as AcpJsonRpcSuccess; + } + if (hasId && isRecord(value.error)) { + return value as unknown as AcpJsonRpcFailure; + } + return null; +} diff --git a/packages/maker-core/src/agents/index.ts b/packages/maker-core/src/agents/index.ts index 74385778ca0..82d19bc9480 100644 --- a/packages/maker-core/src/agents/index.ts +++ b/packages/maker-core/src/agents/index.ts @@ -19,6 +19,10 @@ export { // finalizeCodexCitationText = 剥截断残尾 + 归一化(与流式 completed 完全同口径)。 export { finalizeCodexCitationText, normalizeCodexFileCitations } from './codex/translator.js'; export { PiAgent } from './pi/index.js'; +// grok-build is optional and still has leftover ACP modules. Do not re-export +// it from this barrel: every `@cindy/maker-core` consumer (Codex proxy host +// included) would then load the ACP client + stdio transport. Host imports +// `@cindy/maker-core/grok-build`. export { canReuseCodexHostForCredentialMode, canReuseHostForCredentialMode, diff --git a/packages/maker-core/src/memory/manager.scope.test.ts b/packages/maker-core/src/memory/manager.scope.test.ts index b4d0c9c5fea..ee84bbbf04d 100644 --- a/packages/maker-core/src/memory/manager.scope.test.ts +++ b/packages/maker-core/src/memory/manager.scope.test.ts @@ -60,6 +60,8 @@ function trackingSqlite() { } describe('MakerMemoryManager · owner scope guard (#2341)', () => { + // Windows runner: dispose + reopen SQLite then copy legacy Bot Home files can + // exceed the default 5s when Defender holds the just-closed db. it('keeps an independent Bot store available in Bot Home and copies legacy data', async () => { const botScope = buildBotMemoryScopeKey('bot-a'); const legacyManager = new MakerMemoryManager({ @@ -104,7 +106,7 @@ describe('MakerMemoryManager · owner scope guard (#2341)', () => { expect(existsSync(path.join(rootA, 'maker-memory', memoryScopeDirName(botScope)))).toBe(true); expect(manager.getState()).toEqual({ enabled: false, activeWorkdirs: [] }); manager.dispose(); - }); + }, 20_000); it('global reset leaves an open Bot store and its Home memory intact', async () => { const botScope = buildBotMemoryScopeKey('bot-a'); diff --git a/packages/maker-core/src/types/common.ts b/packages/maker-core/src/types/common.ts index 46dd432444b..4394d87c5d8 100644 --- a/packages/maker-core/src/types/common.ts +++ b/packages/maker-core/src/types/common.ts @@ -5,7 +5,7 @@ * 故意保持兼容以便 desktop adapter 层零代码翻译。 */ -export type AgentKind = 'claude-code' | 'codex' | 'pi'; +export type AgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export type WorkspaceKind = 'project' | 'dialogue'; /** diff --git a/packages/maker-core/src/types/events.ts b/packages/maker-core/src/types/events.ts index ad3320929e5..1ba01c7fc4b 100644 --- a/packages/maker-core/src/types/events.ts +++ b/packages/maker-core/src/types/events.ts @@ -84,7 +84,7 @@ export interface AgentTaskUsage { } export interface AgentTaskUpdateEventData { - provider: 'claude-code' | 'codex' | 'pi'; + provider: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** Provider task id when available; falls back to the parent tool call id. */ taskId: string; /** The tool_use id that launched or controls this subagent task. */ @@ -156,7 +156,7 @@ export interface AgentEvent { type: AgentEventType; data: unknown; /** 事件来源标识,便于调试 */ - source?: 'claude-code' | 'codex' | 'pi'; + source?: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** * Events that finish work owned by a completed turn can still arrive after a * later turn has started (for example, a V1 collab child). These are still diff --git a/packages/maker-scheduler/src/types.ts b/packages/maker-scheduler/src/types.ts index f2968213633..db45ec77e6b 100644 --- a/packages/maker-scheduler/src/types.ts +++ b/packages/maker-scheduler/src/types.ts @@ -1,5 +1,5 @@ export type ScheduleKind = 'cron'; -export type AgentKind = 'claude-code' | 'codex' | 'pi'; +export type AgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export type ScheduleStatus = 'active' | 'paused' | 'expired'; export type ScheduleWorkspaceKind = 'project' | 'dialogue'; export type ScheduleExecutionMode = 'agent' | 'script'; diff --git a/packages/maker-shared/src/agentCapabilities.ts b/packages/maker-shared/src/agentCapabilities.ts index c62ed3d6241..15f9a407751 100644 --- a/packages/maker-shared/src/agentCapabilities.ts +++ b/packages/maker-shared/src/agentCapabilities.ts @@ -9,7 +9,7 @@ export interface MobileModelOption { /** Host-advertised catalog window when available; omitted by older Desktop versions. */ contextWindow?: number; /** 区域门控后的新任务默认标记。 */ - newSessionDefault?: ('claude-code' | 'codex' | 'pi')[]; + newSessionDefault?: ('claude-code' | 'codex' | 'pi' | 'grok-build')[]; } export interface MobileChoiceOption { @@ -347,8 +347,8 @@ function normalizeModelOption(value: unknown): MobileModelOption | null { : undefined; const newSessionDefault = Array.isArray(value.newSessionDefault) ? [...new Set(value.newSessionDefault.filter( - (item): item is 'claude-code' | 'codex' | 'pi' => - item === 'claude-code' || item === 'codex' || item === 'pi', + (item): item is 'claude-code' | 'codex' | 'pi' | 'grok-build' => + item === 'claude-code' || item === 'codex' || item === 'pi' || item === 'grok-build', ))] : []; return { diff --git a/packages/maker-shared/src/agentTask.ts b/packages/maker-shared/src/agentTask.ts index 2906af84c2d..ec70df62b4a 100644 --- a/packages/maker-shared/src/agentTask.ts +++ b/packages/maker-shared/src/agentTask.ts @@ -138,7 +138,7 @@ export function normalizeWorkflowProgressEntries( } export interface AgentTaskUpdate { - provider: 'claude-code' | 'codex' | 'pi'; + provider: 'claude-code' | 'codex' | 'pi' | 'grok-build'; taskId: string; parentToolUseId?: string; status: AgentTaskStatus; @@ -210,7 +210,7 @@ export const PI_SUBAGENT_TOOL_NAME = 'subagent'; */ export function normalizeAgentTaskUpdate( data: unknown, - source?: 'claude-code' | 'codex' | 'pi', + source?: 'claude-code' | 'codex' | 'pi' | 'grok-build', ): AgentTaskUpdate | null { if (!data || typeof data !== 'object') return null; const raw = data as Record; @@ -225,9 +225,9 @@ export function normalizeAgentTaskUpdate( rawStatus === 'completed' || rawStatus === 'failed' || rawStatus === 'stopped' ? rawStatus : 'running'; - const provider = raw.provider === 'codex' || raw.provider === 'claude-code' || raw.provider === 'pi' + const provider = raw.provider === 'codex' || raw.provider === 'claude-code' || raw.provider === 'pi' || raw.provider === 'grok-build' ? raw.provider - : source === 'codex' || source === 'pi' + : source === 'codex' || source === 'pi' || source === 'grok-build' ? source : 'claude-code'; const usageRaw = raw.usage && typeof raw.usage === 'object' ? raw.usage as Record : null; @@ -307,7 +307,7 @@ export function isSameAgentTaskAlias(left: AgentTaskUpdate, right: AgentTaskUpda export function applyAgentTaskUpdateEvent( prevMap: ReadonlyMap | undefined, data: unknown, - source: 'claude-code' | 'codex' | 'pi' | undefined, + source: 'claude-code' | 'codex' | 'pi' | 'grok-build' | undefined, nowIso: string, ): Map | null { const update = normalizeAgentTaskUpdate(data, source); @@ -362,7 +362,7 @@ export function findAgentTaskUpdate( */ export interface AgentTaskCardModel { status: AgentTaskStatus; - provider: 'claude-code' | 'codex' | 'pi'; + provider: 'claude-code' | 'codex' | 'pi' | 'grok-build'; /** Best title, or null when nothing usable was found (caller supplies its own fallback). */ title: string | null; description?: string; @@ -459,7 +459,7 @@ export function buildAgentTaskCardModel(input: { subagentSpawnReceiptName(toolName, toolInput, result) !== undefined || subagentSpawnResultIndicatesRunning(toolName, result), }); - const provider: 'claude-code' | 'codex' | 'pi' = + const provider: 'claude-code' | 'codex' | 'pi' | 'grok-build' = update?.provider ?? (toolName?.startsWith('collab:') ? 'codex' diff --git a/packages/maker-shared/src/conversationSearch.ts b/packages/maker-shared/src/conversationSearch.ts index c1125dfcf05..37b74ddf439 100644 --- a/packages/maker-shared/src/conversationSearch.ts +++ b/packages/maker-shared/src/conversationSearch.ts @@ -11,7 +11,7 @@ import { collapseWorktreeDirForGrouping } from './worktreePaths.js'; * 不包含桌面本机 SQLite、hybrid / 向量搜索、机器切换栏 origin 解析。 */ -export type ConversationSearchAgentKind = 'cc' | 'codex' | 'pi'; +export type ConversationSearchAgentKind = 'cc' | 'codex' | 'pi' | 'grok-build'; export type ConversationSearchWorkspaceKind = 'project' | 'dialogue'; export type ConversationSearchSessionStatus = 'active' | 'archived' | 'deleted'; export type ConversationSearchOrcaRole = 'lead' | 'worker'; diff --git a/packages/maker-shared/src/deviceLinkContract.ts b/packages/maker-shared/src/deviceLinkContract.ts index 3865bee7008..8ecab5466be 100644 --- a/packages/maker-shared/src/deviceLinkContract.ts +++ b/packages/maker-shared/src/deviceLinkContract.ts @@ -243,7 +243,7 @@ export interface MobileCodexRateLimitResetResult { /** 下一条消息发送时才会应用的跨 Agent 切换意图。 */ export interface MobileSessionAgentSwitchIntent { - targetAgentKind: 'claude-code' | 'codex' | 'pi'; + targetAgentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; providerId: string | null; effort?: string; @@ -253,7 +253,7 @@ export interface MobileSessionAgentSwitchIntent { /** desktop 登记 / 取消跨 Agent 意图后的稳定结果。 */ export interface MobileSessionAgentSwitchResult { switched: boolean; - agentKind: 'claude-code' | 'codex' | 'pi'; + agentKind: 'claude-code' | 'codex' | 'pi' | 'grok-build'; model: string; engineReady: boolean; deferred?: boolean; diff --git a/packages/maker-shared/src/fixtures.ts b/packages/maker-shared/src/fixtures.ts index 32c7b8e8a89..02c52791478 100644 --- a/packages/maker-shared/src/fixtures.ts +++ b/packages/maker-shared/src/fixtures.ts @@ -18,7 +18,7 @@ export interface RemoteControlSessionFixture { workingDir: string | null; workspaceKind: 'project' | 'dialogue'; status: 'active' | 'archived' | 'deleted'; - agentKind: 'cc' | 'codex' | 'pi'; + agentKind: 'cc' | 'codex' | 'pi' | 'grok-build'; model: string; effort: string; permissionMode: string; diff --git a/packages/maker-shared/src/scheduleForm.ts b/packages/maker-shared/src/scheduleForm.ts index 0232b5ebffa..4fc7bca780e 100644 --- a/packages/maker-shared/src/scheduleForm.ts +++ b/packages/maker-shared/src/scheduleForm.ts @@ -617,7 +617,7 @@ function defaultModelFor(agentKind: RemoteScheduleAgentKind): string { if (agentKind === 'codex') return DEFAULT_CODEX_MODEL; // Pi 模型来自动态 BYOM 供应商目录,没有固定默认 id;留空 → 序列化时省略 → host 解析 // 该 Pi agent 的当前默认模型(用户仍可在自由文本模型框里显式指定)。 - if (agentKind === 'pi') return ''; + if (agentKind === 'pi' || agentKind === 'grok-build') return ''; return DEFAULT_CLAUDE_MODEL; } diff --git a/packages/maker-shared/src/scheduleModel.ts b/packages/maker-shared/src/scheduleModel.ts index ecd6f152e0c..3026a3ccc91 100644 --- a/packages/maker-shared/src/scheduleModel.ts +++ b/packages/maker-shared/src/scheduleModel.ts @@ -466,6 +466,7 @@ function formatRunDuration(ms: number, localizer?: PresentationLocalizer): strin function humanizeAgentKind(agentKind: RemoteSchedule['agentKind']): string { if (agentKind === 'codex') return 'Codex'; if (agentKind === 'pi') return 'Pi'; + if (agentKind === 'grok-build') return 'Grok Build'; return 'Claude'; } diff --git a/packages/maker-shared/src/scheduleTypes.ts b/packages/maker-shared/src/scheduleTypes.ts index 3d33a927394..fb46c246d7e 100644 --- a/packages/maker-shared/src/scheduleTypes.ts +++ b/packages/maker-shared/src/scheduleTypes.ts @@ -1,5 +1,5 @@ export type RemoteScheduleStatus = 'active' | 'paused' | 'expired'; -export type RemoteScheduleAgentKind = 'claude-code' | 'codex' | 'pi'; +export type RemoteScheduleAgentKind = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export type RemoteScheduleWorkspaceKind = 'project' | 'dialogue'; export type RemoteScheduleRunStatus = 'running' | 'success' | 'failed' | 'aborted' | 'interrupted' | 'skipped'; export type RemoteScheduleExecutionMode = 'agent' | 'script'; diff --git a/packages/maker-shared/src/subagentWorkspace.ts b/packages/maker-shared/src/subagentWorkspace.ts index 3fd144f701f..64f9439477d 100644 --- a/packages/maker-shared/src/subagentWorkspace.ts +++ b/packages/maker-shared/src/subagentWorkspace.ts @@ -8,7 +8,7 @@ * may add opaque `providerRunIds` without changing the product model. */ -export type SubagentProvider = 'claude-code' | 'codex' | 'pi'; +export type SubagentProvider = 'claude-code' | 'codex' | 'pi' | 'grok-build'; export type SubagentRunStatus = 'running' | 'completed' | 'failed' | 'stopped'; diff --git a/packages/model-providers/src/__tests__/source-registry.test.ts b/packages/model-providers/src/__tests__/source-registry.test.ts index 2a2c5d2f5bf..69b2b99c980 100644 --- a/packages/model-providers/src/__tests__/source-registry.test.ts +++ b/packages/model-providers/src/__tests__/source-registry.test.ts @@ -27,6 +27,7 @@ import { getModel, sourcesForModel, chatEligibleSourcesForModel, + hasUsableConnectedSource, effectiveSourceIdForModel, resolveRoute, } from "../registry.js"; @@ -1554,6 +1555,56 @@ describe("registry visibility & sources(运行时注入 fixture)", () => { }); }); +describe("hasUsableConnectedSource (picker / send-ready)", () => { + it("treats grok-build as ready when SuperGrok/xAI is connected, without a catalog grok-build provider", () => { + const views = buildRegistry( + { + version: "test", + providers: [ + { + id: "xai", + name: "xAI", + source: "builtin", + agents: ["claude-code", "codex", "pi"], + auth: { method: "oauth" }, + routing: { + "claude-code": { upstream: "https://api.x.ai", authStrategy: "oauth-passthrough" }, + codex: { upstream: "https://api.x.ai", authStrategy: "oauth-passthrough" }, + pi: { upstream: "https://api.x.ai", authStrategy: "oauth-passthrough" }, + }, + models: { + "claude-code": [ + { + id: "xai/grok-4.6", + name: "Grok 4.6", + contextWindow: 500_000, + efforts: ["high"], + defaultEffort: "high", + }, + ], + }, + }, + ], + }, + { xai: true }, + ); + expect(views.some((provider) => provider.agents.includes("grok-build"))).toBe(false); + expect(hasUsableConnectedSource(views, "grok-build", "grok-4.6")).toBe(true); + expect(hasUsableConnectedSource(views, "grok-build", "xai/grok-4.6")).toBe(true); + expect(hasUsableConnectedSource(views, "grok-build", "xai/grok-4.5")).toBe(false); + expect(hasUsableConnectedSource(views, "grok-build")).toBe(true); + expect(hasUsableConnectedSource([], "grok-build", "grok-4.3")).toBe(false); + expect(hasUsableConnectedSource([], "grok-build", "grok-build")).toBe(false); + }); + + it("still counts Claude / Codex / Pi with zero connected catalog providers as no source", () => { + expect(hasUsableConnectedSource([], "claude-code", "claude-opus-4-8")).toBe(false); + expect(hasUsableConnectedSource([], "codex", "gpt-5.5")).toBe(false); + expect(hasUsableConnectedSource([], "pi", "grok-4.6")).toBe(false); + expect(hasUsableConnectedSource([], "claude-code")).toBe(false); + }); +}); + describe("resolveRoute(运行时注入 fixture)", () => { const views = buildRegistry(runtimeCatalog(), { xd: true, diff --git a/packages/model-providers/src/__tests__/unifiedSelection.test.ts b/packages/model-providers/src/__tests__/unifiedSelection.test.ts index e56a4d2c09a..24f8fd3dae0 100644 --- a/packages/model-providers/src/__tests__/unifiedSelection.test.ts +++ b/packages/model-providers/src/__tests__/unifiedSelection.test.ts @@ -14,6 +14,7 @@ import { describe, expect, it } from 'vitest'; import { UNIFIED_AGENT_PRIORITY, + GROK_BUILD_HARNESS_MODEL_ID, candidateAgentsForModel, catalogModelIdCandidates, findCatalogModel, @@ -1267,3 +1268,63 @@ describe('sortEntriesForAgent(原生底座优先,无主场不降级)', () => { expect(codexOnly.every((e) => e.candidates.includes('codex'))).toBe(true); }); }); + +describe('Grok Build harness in unifiedModelEntries', () => { + it('does not inject grok-build as a model row — it is only a harness chip', () => { + const catalogOnly = unifiedModelEntries({ providers: [xai], isVisible: alwaysVisible }); + expect(catalogOnly.some((entry) => entry.candidates.includes('grok-build'))).toBe(false); + expect(catalogOnly.some((entry) => entry.modelId === GROK_BUILD_HARNESS_MODEL_ID)).toBe(false); + + const withHarness = unifiedModelEntries({ + providers: [xai], + agents: ['claude-code', 'codex', 'pi', 'grok-build'], + isVisible: alwaysVisible, + }); + expect(withHarness.some((entry) => entry.modelId === GROK_BUILD_HARNESS_MODEL_ID)).toBe(false); + expect(withHarness.some((entry) => entry.providerId === 'grok-build')).toBe(false); + expect(withHarness.some((entry) => entry.displayName === 'Grok Build')).toBe(false); + expect(xai.models?.['grok-build']).toBeUndefined(); + }); + + it('puts a Grok Build chip on every exclusive Grok catalog row with the CLI slug', () => { + const grokCatalog = view({ + id: 'xai', + models: { + 'claude-code': [ + m('xai/grok-4.6', { group: 'grok' }), + m('xai/grok-4.5', { group: 'grok' }), + m('grok-4.3', { group: 'grok' }), + m('claude-opus-5'), + ], + codex: [ + m('xai/grok-4.6', { group: 'grok' }), + m('xai/grok-4.5', { group: 'grok' }), + m('grok-4.3', { group: 'grok' }), + m('claude-opus-5'), + ], + pi: [ + m('xai/grok-4.6', { group: 'grok' }), + m('xai/grok-4.5', { group: 'grok' }), + m('grok-4.3', { group: 'grok' }), + m('claude-opus-5'), + ], + }, + routingOverride: { codex: { modelIdRewrite: { stripPrefix: 'xai/' } } }, + }); + const entries = unifiedModelEntries({ + providers: [grokCatalog], + agents: ['claude-code', 'codex', 'pi', 'grok-build'], + isVisible: alwaysVisible, + }); + for (const slug of ['grok-4.6', 'grok-4.5', 'grok-4.3'] as const) { + const row = entries.find((entry) => entry.modelId === slug); + expect(row, slug).toBeDefined(); + expect(row?.candidates).toContain('grok-build'); + expect(row?.capabilities['grok-build']?.wireModelId).toBe(slug); + expect(row?.recommended).not.toBe('grok-build'); + } + const claude = entries.find((entry) => entry.modelId === 'claude-opus-5'); + expect(claude).toBeDefined(); + expect(claude?.candidates).not.toContain('grok-build'); + }); +}); diff --git a/packages/model-providers/src/catalog.ts b/packages/model-providers/src/catalog.ts index f236b7dc5a2..1e75a7f5366 100644 --- a/packages/model-providers/src/catalog.ts +++ b/packages/model-providers/src/catalog.ts @@ -29,7 +29,7 @@ import { isProviderRequestPath } from './provider-url.js'; export { BUNDLED_CATALOG, BUILTIN_PROVIDERS } from './builtin.js'; -const AGENT_KINDS: readonly AgentKind[] = ['claude-code', 'codex', 'pi']; +const AGENT_KINDS: readonly AgentKind[] = ['claude-code', 'codex', 'pi', 'grok-build']; const EFFORTS: readonly Effort[] = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']; const WIRE_PROTOCOLS = ['anthropic-messages', 'openai-responses', 'openai-chat'] as const; diff --git a/packages/model-providers/src/index.ts b/packages/model-providers/src/index.ts index af4a0a82f93..c0612e83203 100644 --- a/packages/model-providers/src/index.ts +++ b/packages/model-providers/src/index.ts @@ -127,6 +127,8 @@ export { getModel, sourcesForModel, chatEligibleSourcesForModel, + hasUsableConnectedSource, + isGrokBuildSourceReady, resolveRoute, modelSupportsFastMode, sessionModelSupportsFastMode, @@ -220,6 +222,10 @@ export type { // 规格 docs/product-rules/model-selector-unified.md §2.1 / §2.2 / §4。 export { UNIFIED_AGENT_PRIORITY, + GROK_BUILD_HARNESS_PROVIDER_ID, + GROK_BUILD_HARNESS_MODEL_ID, + grokBuildCliModelId, + attachGrokBuildHarnessToGrokEntries, unifiedModelKeyId, normalizeModelIdForClassification, catalogModelIdCandidates, diff --git a/packages/model-providers/src/registry.ts b/packages/model-providers/src/registry.ts index 1d29bea61e2..bf13e729407 100644 --- a/packages/model-providers/src/registry.ts +++ b/packages/model-providers/src/registry.ts @@ -14,7 +14,9 @@ import type { Catalog, Provider, CatalogModel, AgentKind, RoutingDescriptor } from './types.js'; import { + exclusiveXaiCatalogModelId, isAgentSelectableModel, + isExclusiveXaiModelId, isModelSelectableForNewRoute, } from './classification.js'; import type { ProviderLogoKind } from './providerBranding.js'; @@ -290,6 +292,87 @@ export function chatEligibleSourcesForModel( }); } +/** + * Grok Build 是 Cindy hosted harness,不是目录供应商。独占 Grok 走 SuperGrok + * (xAI);目录不会声明 `grok-build` runtime,所以不能用 connectedProvidersForAgent。 + */ +export function isGrokBuildSourceReady( + views: readonly ProviderView[], + opts: { includeSuspended?: boolean } = {}, +): boolean { + return views.some( + (provider) => + provider.id === 'xai' && + provider.connected && + (opts.includeSuspended === true || provider.suspended !== true), + ); +} + +function grokBuildModelCopy( + views: readonly ProviderView[], + modelId: string, + opts: { includeSuspended?: boolean } = {}, +): CatalogModel | undefined { + const xai = views.find( + (provider) => + provider.id === 'xai' && + provider.connected && + (opts.includeSuspended === true || provider.suspended !== true), + ); + if (!xai) return undefined; + const ids = [modelId, exclusiveXaiCatalogModelId(modelId)].filter( + (id, index, list): id is string => Boolean(id) && list.indexOf(id) === index, + ); + for (const plane of ['pi', 'claude-code'] as const) { + for (const id of ids) { + const copy = getModel(xai, id, plane); + if (copy) return copy; + } + } + return undefined; +} + +/** + * Picker trigger / Send 门禁的「有没有可用来源」。 + * + * Grok Build 复用 Cindy hosted loop + SuperGrok / xAI,不要求目录供应商声明 + * `grok-build`。Claude / Codex / Pi 仍走 chatEligibleSourcesForModel / + * connectedProvidersForAgent。 + */ +export function hasUsableConnectedSource( + views: ProviderView[], + agent: AgentKind | null, + modelId?: string, + opts: { onlyConnected?: boolean; includeDisabled?: boolean; includeSuspended?: boolean } = {}, +): boolean { + if (!agent) return false; + if (agent === 'grok-build') { + const ready = isGrokBuildSourceReady(views, { + ...(opts.includeSuspended === true ? { includeSuspended: true } : {}), + }); + if (!ready) return false; + if (!modelId) return true; + if (!isExclusiveXaiModelId(modelId)) return false; + const copy = grokBuildModelCopy(views, modelId, { + ...(opts.includeSuspended === true ? { includeSuspended: true } : {}), + }); + return !!copy && isModelSelectableForNewRoute(copy, { userProvider: false }); + } + if (modelId) { + return ( + chatEligibleSourcesForModel(views, modelId, agent, { + onlyConnected: opts.onlyConnected ?? true, + ...(opts.includeDisabled === true ? { includeDisabled: true } : {}), + }).length > 0 + ); + } + return ( + connectedProvidersForAgent(views, agent, { + ...(opts.includeSuspended === true ? { includeSuspended: true } : {}), + }).length > 0 + ); +} + /** * 某 agent 在已连接来源列表(rail)里的「原生默认来源 id」。 * 与模型选择器 activeSourceId 的 nativeDefault 口径一致: diff --git a/packages/model-providers/src/types.ts b/packages/model-providers/src/types.ts index 9c302316ad7..ddbbb4fd548 100644 --- a/packages/model-providers/src/types.ts +++ b/packages/model-providers/src/types.ts @@ -21,7 +21,7 @@ import type { ModelMetadata } from "./modelMetadataLayers.js"; import type { ModelRegistry } from "./modelAccessBean.js"; /** 承载模型的 agent runtime —— 与 maker-core AgentKind 对齐。 */ -export type AgentKind = "claude-code" | "codex" | "pi"; +export type AgentKind = "claude-code" | "codex" | "pi" | "grok-build"; /** 推理强度档位 —— 与 maker-core Effort 对齐。 */ export type Effort = diff --git a/packages/model-providers/src/unifiedSelection.ts b/packages/model-providers/src/unifiedSelection.ts index 4e503a9b1d4..60f3169d7e5 100644 --- a/packages/model-providers/src/unifiedSelection.ts +++ b/packages/model-providers/src/unifiedSelection.ts @@ -68,6 +68,7 @@ import { XAI_MODEL_PREFIX, groupOf, isBudgetModel, + isExclusiveXaiModelId, } from './classification.js'; import type { AgentKind, CatalogModel, Effort, PiModelApi, Provider } from './types.js'; @@ -80,6 +81,39 @@ import type { AgentKind, CatalogModel, Effort, PiModelApi, Provider } from './ty */ export const UNIFIED_AGENT_PRIORITY: readonly AgentKind[] = ['claude-code', 'codex', 'pi']; +/** Grok Build 是 harness,不是目录供应商 / 模型行。legacy favorite 的 providerId 仍可能是这个值。 */ +export const GROK_BUILD_HARNESS_PROVIDER_ID = 'grok-build'; +/** @deprecated sentinel only — not a catalog model. startSession still accepts it as "no -m". */ +export const GROK_BUILD_HARNESS_MODEL_ID = 'grok-build'; + +/** Grok Build hosted loop 用裸 slug(`grok-4.6`),不是 SuperGrok 的 `xai/grok-4.6`。 */ +export function grokBuildCliModelId(modelId: string): string { + return modelId.startsWith(XAI_MODEL_PREFIX) ? modelId.slice(XAI_MODEL_PREFIX.length) : modelId; +} + +/** + * 把 Grok Build 接到已有的 Grok 目录行上:同一行多出第四个引擎芯片, + * wire id 是 hosted loop 能吃的裸 slug。推荐引擎不改(仍是目录里原来的 cc/codex/pi)。 + */ +export function attachGrokBuildHarnessToGrokEntries(entries: UnifiedModelEntry[]): void { + for (const entry of entries) { + if (!isExclusiveXaiModelId(entry.modelId)) continue; + if (entry.candidates.includes('grok-build')) continue; + const baseline = entry.capabilities[entry.recommended]; + entry.candidates.push('grok-build'); + entry.capabilities['grok-build'] = { + agent: 'grok-build', + wireModelId: grokBuildCliModelId(entry.modelId), + efforts: [], + defaultEffort: null, + defaultEffortSource: 'none', + supportsFastMode: false, + contextWindow: baseline?.contextWindow ?? 0, + contextWindowVerified: baseline?.contextWindowVerified ?? false, + }; + } +} + /** * 未声明专用原生协议时保留 cc / codex 的历史回落序。Google 原生 API 的 Pi * 路由在 pickRecommendedAgent 中优先处理,不能再把 Pi 一律当成最后兜底。 @@ -822,6 +856,10 @@ export function unifiedModelEntries(opts: UnifiedModelEntriesOptions): UnifiedMo capabilities, }); } + // Grok Build 是第四个 harness 芯片,不是目录里的一种模型。只把芯片挂到独占 Grok 行上。 + if (agents?.includes('grok-build')) { + attachGrokBuildHarnessToGrokEntries(out); + } return out; } diff --git a/packages/model-providers/src/user-provider.ts b/packages/model-providers/src/user-provider.ts index 3abb624f462..b0daa6aebb0 100644 --- a/packages/model-providers/src/user-provider.ts +++ b/packages/model-providers/src/user-provider.ts @@ -214,7 +214,9 @@ function registryEffortMetadata( modelId: string, agent: AgentKind, ): RegistryEffortMetadata | undefined { - if (agent === "pi" || !registry) return undefined; + // pi 与 grok-build 不在参考价 registry 的 agent 维度里(前者动态 BYOM,后者本机 + // CLI 自带单一模型条目),直接判无 effort 元数据。 + if (agent === "pi" || agent === "grok-build" || !registry) return undefined; // Stage 1 — exact lookup: only the original modelId. const exactMatches = expandedRegistryEntries(registry).filter((entry) =>