feat: context-exhausted new-session suggestion (方案3) + orchestration mode

方案3 (desktop, manual-guided): when compactions keep firing only a turn or two
apart — the same thrash the CLI circuit breaker trips on — chatStore now appends
a one-time visible system notice suggesting the user start a fresh session
(the prior summary stays in the current one). Detection lives in module-level
state (compactionThrashBySession) so PerSessionState is untouched; counts user
turns between compactions, suggests after 3 rapid ones, deduped per session and
reset on /clear. New i18n key chat.contextExhausted (en/zh/zh-TW/jp/kr).

Also includes in-progress orchestration/coordinator work on the same files
(orchestrationPrompt.ts, conversationService coordinatorMode --append-system-prompt,
ws handler/events, chatStore, ChatInput, sessionRuntimeStore, types/chat) —
committed together per request.

Tested:
- bunx vitest run desktop/src/stores/chatStore.test.ts (102 pass, incl. new
  方案3 rapid-compaction suggestion + spread-out negative case)
- cd desktop && bun run lint (clean)
- get_diagnostics clean across changed server + desktop files
Not-tested: full bun run check:server / check:desktop gates; pre-existing WS
runtime-restart integration tests are flaky in this env (CLI code 143).
Confidence: medium-high
Scope-risk: moderate
This commit is contained in:
你的姓名 2026-06-09 15:17:52 +08:00
parent 2cf6040451
commit 7e55d6cee9
14 changed files with 402 additions and 17 deletions

View File

@ -143,6 +143,9 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const runtimeSelection = useSessionRuntimeStore((state) =>
activeTabId ? state.selections[activeTabId] : undefined,
)
const coordinatorMode = useSessionRuntimeStore((state) =>
activeTabId ? state.coordinatorModes[activeTabId] ?? false : false,
)
const currentModel = useSettingsStore((state) => state.currentModel)
const chatSendBehavior = useSettingsStore((state) => state.chatSendBehavior)
const runtimeSelectionKey = runtimeSelection
@ -994,6 +997,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const slashCommandsLabel = isHeroComposer ? t('empty.slashCommands') : t('chat.slashCommands')
const skillsLabel = t('chat.openSkills')
const pluginsLabel = t('chat.openPlugins')
const coordinatorModeLabel = t('chat.coordinatorMode')
return (
<div
@ -1357,6 +1361,21 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">extension</span>
<span className="text-sm text-[var(--color-text-primary)]">{pluginsLabel}</span>
</button>
{activeTabId && (
<button
onClick={() => {
useChatStore.getState().setSessionCoordinatorMode(activeTabId, !coordinatorMode)
setPlusMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className={`material-symbols-outlined text-[18px] ${coordinatorMode ? 'text-[var(--color-primary)]' : 'text-[var(--color-text-secondary)]'}`}>hub</span>
<span className="flex-1 text-sm text-[var(--color-text-primary)]">{coordinatorModeLabel}</span>
{coordinatorMode && (
<span className="material-symbols-outlined text-[18px] text-[var(--color-primary)]">check</span>
)}
</button>
)}
</div>
)}
</div>

View File

@ -1136,7 +1136,9 @@ export const en = {
'chat.slashCommands': 'Slash commands',
'chat.openSkills': 'Skills',
'chat.openPlugins': 'Plugins',
'chat.coordinatorMode': 'Orchestration mode',
'chat.questionDropped': 'Claude wanted to ask you a question, but the request was invalid or canceled before you could answer.',
'chat.contextExhausted': 'The context is full and repeated compaction can no longer free space. Consider starting a new session to continue (the earlier summary is preserved in this one).',
'chat.skillPicker.title': 'Pick a skill to insert',
'chat.skillPicker.empty': 'No invocable skills are available right now.',
'chat.pluginPicker.title': 'Pick a plugin to insert',

View File

@ -1138,7 +1138,9 @@ export const jp: Record<TranslationKey, string> = {
'chat.slashCommands': 'スラッシュコマンド',
'chat.openSkills': 'スキル',
'chat.openPlugins': 'プラグイン',
'chat.coordinatorMode': 'オーケストレーションモード',
'chat.questionDropped': 'AI が質問しようとしましたが、リクエストが無効か、回答する前にキャンセルされました。',
'chat.contextExhausted': 'コンテキストがいっぱいで、圧縮を繰り返しても空きを作れません。新しいセッションを開始して続けることをおすすめします(これまでの要約はこのセッションに残っています)。',
'chat.skillPicker.title': '挿入するスキルを選択',
'chat.skillPicker.empty': '現在呼び出せるスキルはありません。',
'chat.pluginPicker.title': '挿入するプラグインを選択',

View File

@ -1138,7 +1138,9 @@ export const kr: Record<TranslationKey, string> = {
'chat.slashCommands': '슬래시 명령',
'chat.openSkills': '스킬',
'chat.openPlugins': '플러그인',
'chat.coordinatorMode': '오케스트레이션 모드',
'chat.questionDropped': 'AI가 질문하려 했지만 요청이 유효하지 않거나 답변하기 전에 취소되었습니다.',
'chat.contextExhausted': '컨텍스트가 가득 찼고 여러 번 압축해도 공간을 확보할 수 없습니다. 새 세션을 시작해 계속하는 것을 권장합니다(이전 요약은 이 세션에 남아 있습니다).',
'chat.skillPicker.title': '삽입할 스킬 선택',
'chat.skillPicker.empty': '현재 호출 가능한 스킬이 없습니다.',
'chat.pluginPicker.title': '삽입할 플러그인 선택',

View File

@ -1138,7 +1138,9 @@ export const zh: Record<TranslationKey, string> = {
'chat.slashCommands': '斜槓命令',
'chat.openSkills': '技能',
'chat.openPlugins': '外掛',
'chat.coordinatorMode': '協調模式',
'chat.questionDropped': 'AI 想向你提問,但該請求無效或已取消(你還沒來得及作答)。',
'chat.contextExhausted': '上下文已滿,且多次壓縮仍無法騰出空間。建議新建會話繼續(先前的摘要已保留在本會話)。',
'chat.skillPicker.title': '選擇技能插入',
'chat.skillPicker.empty': '目前沒有可呼叫的技能。',
'chat.pluginPicker.title': '選擇外掛插入',

View File

@ -1138,7 +1138,9 @@ export const zh: Record<TranslationKey, string> = {
'chat.slashCommands': '斜杠命令',
'chat.openSkills': '技能',
'chat.openPlugins': '插件',
'chat.coordinatorMode': '协调模式',
'chat.questionDropped': 'AI 想向你提问,但该请求无效或已取消(你还没来得及作答)。',
'chat.contextExhausted': '上下文已满,且多次压缩仍无法腾出空间。建议新建会话继续(之前的摘要已保留在本会话)。',
'chat.skillPicker.title': '选择技能插入',
'chat.skillPicker.empty': '当前没有可调用的技能。',
'chat.pluginPicker.title': '选择插件插入',

View File

@ -2102,6 +2102,46 @@ describe('chatStore history mapping', () => {
expect(updateSessionPermissionModeMock).toHaveBeenCalledWith('session-1', 'acceptEdits')
})
it('persists coordinator mode and only sends it to a live session', () => {
useSessionRuntimeStore.setState({ coordinatorModes: {} })
// No live session yet: the preference is persisted (replayed on connect) but
// nothing is pushed over the wire.
useChatStore.getState().setSessionCoordinatorMode('coord-session', true)
expect(useSessionRuntimeStore.getState().coordinatorModes['coord-session']).toBe(true)
expect(sendMock).not.toHaveBeenCalled()
useChatStore.setState({
sessions: {
'coord-session': {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().setSessionCoordinatorMode('coord-session', false)
expect(useSessionRuntimeStore.getState().coordinatorModes['coord-session']).toBe(false)
expect(sendMock).toHaveBeenCalledWith('coord-session', {
type: 'set_coordinator_mode',
enabled: false,
})
})
it('mirrors CLI permission-mode broadcasts locally without echoing back to the server', () => {
sendMock.mockReset()
updateSessionPermissionModeMock.mockReset()
@ -3893,6 +3933,69 @@ describe('chatStore history mapping', () => {
})
})
describe('chatStore context-exhausted suggestion (方案3)', () => {
const SID = 'thrash-session'
beforeEach(() => {
sendMock.mockReset()
getMemberBySessionIdMock.mockReset()
getMemberBySessionIdMock.mockReturnValue(null)
useSessionRuntimeStore.setState({ selections: {} })
useChatStore.setState({ ...initialState, sessions: {} })
// Reset the module-level compaction-thrash tracking for this session id.
useChatStore.getState().clearMessages(SID)
})
function systemNoticeCount(sessionId: string): number {
const messages = useChatStore.getState().sessions[sessionId]?.messages ?? []
return messages.filter((m) => m.type === 'system').length
}
function emitCompactBoundary(sessionId: string) {
useChatStore.getState().handleServerMessage(sessionId, {
type: 'system_notification',
subtype: 'compact_boundary',
message: 'Context compacted',
data: {},
})
}
it('suggests starting a new session after repeated rapid compactions', () => {
useChatStore.setState({ sessions: { [SID]: makeSession({ chatState: 'thinking' }) } })
emitCompactBoundary(SID) // 1st compaction — not rapid (no prior turn baseline)
expect(systemNoticeCount(SID)).toBe(0)
useChatStore.getState().sendMessage(SID, 'turn a')
emitCompactBoundary(SID) // 2nd, 1 turn apart → rapid
expect(systemNoticeCount(SID)).toBe(0)
useChatStore.getState().sendMessage(SID, 'turn b')
emitCompactBoundary(SID) // 3rd, 1 turn apart → threshold reached → suggest once
expect(systemNoticeCount(SID)).toBe(1)
// Continued thrash must not spam additional suggestions.
useChatStore.getState().sendMessage(SID, 'turn c')
emitCompactBoundary(SID)
expect(systemNoticeCount(SID)).toBe(1)
})
it('does not suggest when compactions are spread across real work', () => {
useChatStore.setState({ sessions: { [SID]: makeSession({ chatState: 'thinking' }) } })
emitCompactBoundary(SID)
for (let round = 0; round < 4; round += 1) {
// 3 user turns between compactions > the rapid window, so this is normal use.
useChatStore.getState().sendMessage(SID, `r${round}-a`)
useChatStore.getState().sendMessage(SID, `r${round}-b`)
useChatStore.getState().sendMessage(SID, `r${round}-c`)
emitCompactBoundary(SID)
}
expect(systemNoticeCount(SID)).toBe(0)
})
})
describe('chatStore message queue', () => {
beforeEach(() => {
sendMock.mockReset()

View File

@ -168,6 +168,7 @@ type ChatStore = {
) => void
setSessionRuntime: (sessionId: string, selection: RuntimeSelection) => void
setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void
setSessionCoordinatorMode: (sessionId: string, enabled: boolean) => void
stopGeneration: (sessionId: string) => void
loadHistory: (sessionId: string) => Promise<void>
reloadHistory: (sessionId: string) => Promise<void>
@ -274,6 +275,73 @@ function makeDroppedQuestionMessage(): UIMessage {
}
}
// 方案3: detect context-compaction thrash on the desktop side. When compactions
// keep firing only a turn or two apart, the context is effectively full and
// compaction can no longer help — the same condition the CLI circuit breaker
// trips on. We mirror it client-side and, once, append a visible suggestion to
// start a fresh session. Kept as module-level state so PerSessionState (which
// has concurrent in-progress edits) is not widened.
const RAPID_COMPACTION_TURN_WINDOW = 2
const RAPID_COMPACTION_SUGGEST_AFTER = 3
type CompactionThrashState = {
userTurnsSinceCompact: number
rapidCount: number
suggested: boolean
}
const compactionThrashBySession = new Map<string, CompactionThrashState>()
function getCompactionThrash(sessionId: string): CompactionThrashState {
let state = compactionThrashBySession.get(sessionId)
if (!state) {
// Start "infinitely far" from the last compaction so the first one is
// never counted as rapid.
state = { userTurnsSinceCompact: Number.POSITIVE_INFINITY, rapidCount: 0, suggested: false }
compactionThrashBySession.set(sessionId, state)
}
return state
}
// Call on each user turn so the boundary handler can tell rapid re-compaction
// (thrash) from compactions spread across real work.
function noteUserTurnForCompactionThrash(sessionId: string): void {
const state = getCompactionThrash(sessionId)
if (state.userTurnsSinceCompact === Number.POSITIVE_INFINITY) {
state.userTurnsSinceCompact = 1
} else {
state.userTurnsSinceCompact += 1
}
}
// Record a completed compaction; returns true exactly once, when rapid
// re-compaction has crossed the threshold and we should suggest a new session.
function registerCompactionAndShouldSuggest(sessionId: string): boolean {
const state = getCompactionThrash(sessionId)
if (state.userTurnsSinceCompact <= RAPID_COMPACTION_TURN_WINDOW) {
state.rapidCount += 1
} else {
state.rapidCount = 1
}
state.userTurnsSinceCompact = 0
if (state.rapidCount >= RAPID_COMPACTION_SUGGEST_AFTER && !state.suggested) {
state.suggested = true
return true
}
return false
}
function resetCompactionThrash(sessionId: string): void {
compactionThrashBySession.delete(sessionId)
}
function makeContextExhaustedMessage(): UIMessage {
return {
id: nextId(),
type: 'system',
content: t('chat.contextExhausted'),
timestamp: Date.now(),
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
@ -892,6 +960,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
if (runtimeSelection) {
wsManager.send(sessionId, { type: 'set_runtime_config', ...runtimeSelection })
}
// Replay the per-session orchestration toggle on (re)connect, before the
// first message, so the CLI launches with the right --append-system-prompt.
if (useSessionRuntimeStore.getState().coordinatorModes[sessionId]) {
wsManager.send(sessionId, { type: 'set_coordinator_mode', enabled: true })
}
if (!sessionId.startsWith('__') && !useTeamStore.getState().getMemberBySessionId(sessionId)) {
wsManager.send(sessionId, { type: 'prewarm_session' })
}
@ -1044,6 +1117,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
return
}
// 方案3: count this as a real user turn so rapid re-compaction (thrash)
// can be told apart from compactions spread across genuine work.
noteUserTurnForCompactionThrash(sessionId)
wsManager.send(sessionId, { type: 'user_message', content, attachments })
},
@ -1188,6 +1264,16 @@ export const useChatStore = create<ChatStore>((set, get) => ({
wsManager.send(sessionId, { type: 'set_permission_mode', mode })
},
setSessionCoordinatorMode: (sessionId, enabled) => {
// Persist the per-session preference (survives reconnect / app restart and
// is replayed on connect). Only push to a live session; otherwise the
// replay on next connect carries it.
useSessionRuntimeStore.getState().setCoordinatorMode(sessionId, enabled)
if (get().sessions[sessionId]) {
wsManager.send(sessionId, { type: 'set_coordinator_mode', enabled })
}
},
stopGeneration: (sessionId) => {
wsManager.send(sessionId, { type: 'stop_generation' })
// Pause queue draining until the user sends their next message. Stop emits
@ -1430,6 +1516,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
clearPendingTaskToolUseIds(sessionId)
clearPendingToolParentUseIds(sessionId)
clearPendingToolInputDelta(sessionId)
resetCompactionThrash(sessionId)
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({
messages: [],
activeGoal: null,
@ -1948,10 +2035,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}
if (msg.subtype === 'compact_boundary') {
const metadata = compactMetadataFromUnknown(msg.data)
update((session) => ({
chatState: session.chatState === 'compacting' ? 'thinking' : session.chatState,
statusVerb: session.chatState === 'compacting' ? '' : session.statusVerb,
messages: appendOrUpdateTailCompactSummary(
const shouldSuggestNewSession = registerCompactionAndShouldSuggest(sessionId)
update((session) => {
const compacted = appendOrUpdateTailCompactSummary(
session.messages,
{
title: typeof msg.message === 'string' && msg.message.trim()
@ -1961,8 +2048,15 @@ export const useChatStore = create<ChatStore>((set, get) => ({
...metadata,
},
Date.now(),
),
}))
)
return {
chatState: session.chatState === 'compacting' ? 'thinking' : session.chatState,
statusVerb: session.chatState === 'compacting' ? '' : session.statusVerb,
messages: shouldSuggestNewSession
? [...compacted, makeContextExhaustedMessage()]
: compacted,
}
})
}
if (msg.subtype === 'compact_summary') {
const summary = extractCompactSummaryContent(msg.message)

View File

@ -2,14 +2,18 @@ import { create } from 'zustand'
import type { RuntimeSelection } from '../types/runtime'
const STORAGE_KEY = 'cc-haha-session-runtime'
const COORDINATOR_STORAGE_KEY = 'cc-haha-session-coordinator'
export const DRAFT_RUNTIME_SELECTION_KEY = '__draft__'
type SessionRuntimeStore = {
selections: Record<string, RuntimeSelection>
/** Per-session orchestration ("协调") mode toggle. Absent/false = off. */
coordinatorModes: Record<string, boolean>
setSelection: (key: string, selection: RuntimeSelection) => void
clearSelection: (key: string) => void
moveSelection: (fromKey: string, toKey: string) => void
setCoordinatorMode: (key: string, enabled: boolean) => void
}
function loadSelections(): Record<string, RuntimeSelection> {
@ -33,8 +37,30 @@ function persistSelections(selections: Record<string, RuntimeSelection>) {
}
}
function loadCoordinatorModes(): Record<string, boolean> {
if (typeof localStorage === 'undefined') return {}
try {
const raw = localStorage.getItem(COORDINATOR_STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, boolean>
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
function persistCoordinatorModes(modes: Record<string, boolean>) {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(COORDINATOR_STORAGE_KEY, JSON.stringify(modes))
} catch {
// noop
}
}
export const useSessionRuntimeStore = create<SessionRuntimeStore>((set) => ({
selections: loadSelections(),
coordinatorModes: loadCoordinatorModes(),
setSelection: (key, selection) =>
set((state) => {
@ -48,22 +74,49 @@ export const useSessionRuntimeStore = create<SessionRuntimeStore>((set) => ({
clearSelection: (key) =>
set((state) => {
if (!(key in state.selections)) return state
const { [key]: _removed, ...rest } = state.selections
persistSelections(rest)
return { selections: rest }
const hadSelection = key in state.selections
const hadCoordinator = key in state.coordinatorModes
if (!hadSelection && !hadCoordinator) return state
const next: Partial<SessionRuntimeStore> = {}
if (hadSelection) {
const { [key]: _removed, ...rest } = state.selections
persistSelections(rest)
next.selections = rest
}
if (hadCoordinator) {
const { [key]: _removed, ...rest } = state.coordinatorModes
persistCoordinatorModes(rest)
next.coordinatorModes = rest
}
return next
}),
moveSelection: (fromKey, toKey) =>
set((state) => {
const selection = state.selections[fromKey]
if (!selection) return state
const { [fromKey]: _removed, ...rest } = state.selections
const selections = {
...rest,
[toKey]: selection,
const coordinator = state.coordinatorModes[fromKey]
if (!selection && coordinator === undefined) return state
const next: Partial<SessionRuntimeStore> = {}
if (selection) {
const { [fromKey]: _removed, ...rest } = state.selections
next.selections = { ...rest, [toKey]: selection }
persistSelections(next.selections)
}
persistSelections(selections)
return { selections }
if (coordinator !== undefined) {
const { [fromKey]: _removed, ...rest } = state.coordinatorModes
next.coordinatorModes = { ...rest, [toKey]: coordinator }
persistCoordinatorModes(next.coordinatorModes)
}
return next
}),
setCoordinatorMode: (key, enabled) =>
set((state) => {
if ((state.coordinatorModes[key] ?? false) === enabled) return state
const coordinatorModes = { ...state.coordinatorModes, [key]: enabled }
persistCoordinatorModes(coordinatorModes)
return { coordinatorModes }
}),
}))

View File

@ -22,6 +22,7 @@ export type ClientMessage =
}
| { type: 'set_permission_mode'; mode: PermissionMode }
| ({ type: 'set_runtime_config' } & RuntimeSelection)
| { type: 'set_coordinator_mode'; enabled: boolean }
| { type: 'stop_generation' }
| { type: 'ping' }

View File

@ -0,0 +1,47 @@
/**
* Orchestration ("协调") mode a session-level directive appended to the main
* agent's system prompt (via the CLI `--append-system-prompt` flag) when the
* user enables coordinator mode in the desktop composer.
*
* This is a PROMPT-LEVEL mode layered on the real, working AgentTool + the
* built-in agents. It deliberately does NOT use the ant-internal
* COORDINATOR_MODE machinery (worker agents / SendMessage tool), which is
* feature-gated off and stubbed out in external builds. The main agent keeps
* all its tools this strongly steers it to delegate substantive multi-step
* work to sub-agents and synthesize, without hard-locking it out of acting
* directly on trivial tasks.
*/
export const ORCHESTRATION_SYSTEM_PROMPT = `# Orchestration Mode (协调模式)
You are operating as an ORCHESTRATOR. The user has explicitly turned on coordinator mode for this session. Your default working style changes: prefer to delegate substantive work to sub-agents (via the Task/Agent tool) and act as the coordinator who plans, dispatches, and synthesizes rather than doing all the work inline yourself.
## When to delegate vs. act directly
Delegate to a sub-agent when the work is substantial or benefits from isolation:
- Multi-step investigation, codebase research, or "find where/how X works"
- Implementation that spans multiple files
- Writing or running tests, debugging a failure, security or performance review
- Anything that would otherwise flood your context with file reads and tool output
Act directly (do NOT delegate) when delegation would only add overhead:
- Answering a question you already know or can resolve in one or two reads
- A single trivial edit (a typo, a one-line change)
- Clarifying the user's intent
Delegation has real cost (spawn + run + summarize round-trips). Use it where parallelism or context isolation pays off not for everything.
## How to orchestrate
1. **Plan first.** Break the request into independent units of work. Decide which sub-agent type fits each (general-purpose for research/multi-step, explore for locating code, plan for design, debugger for root-causing a bug, test-author for tests, code-reviewer / security-reviewer for review, refactor / migration / performance / docs-writer / commit-pr for those specialties).
2. **Fan out.** Launch independent sub-agents in parallel (multiple Task tool calls in one turn) for work that can run simultaneously especially read-only research. Serialize only writes that touch the same files.
3. **Write self-contained prompts.** Sub-agents cannot see this conversation. Each task prompt must include the specific files, paths, intended behavior, and what "done" looks like. Never write "based on our discussion" or "fix the bug we found" restate the concrete details yourself.
4. **Synthesize, don't relay.** When a sub-agent reports back, read and understand the result before the next step. Turn findings into a precise follow-up spec yourself; do not hand undigested findings to another agent.
5. **Keep the user informed.** Briefly say what you dispatched and report results as they arrive. Don't fabricate or predict sub-agent results.
## Important
- You retain all your tools orchestration is a preference, not a hard restriction. If delegating a step would clearly be slower or pointless, just do it.
- Match rigor to stakes: large or risky changes (auth, payments, persistence, infra) benefit most from delegation plus a separate review/verification sub-agent.`
/** Marker substring used by tests to assert the flag carries the directive. */
export const ORCHESTRATION_PROMPT_MARKER = '# Orchestration Mode'

View File

@ -28,6 +28,7 @@ import {
} from '../../utils/desktopBundledCli.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { findCanonicalGitRoot } from '../../utils/git.js'
import { ORCHESTRATION_SYSTEM_PROMPT } from '../orchestrationPrompt.js'
import { sanitizePath } from '../../utils/path.js'
import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js'
import { attributionHeaderEnvForModel } from './attributionHeaderPolicy.js'
@ -125,6 +126,7 @@ type SessionStartOptions = {
effort?: string
thinking?: 'enabled' | 'adaptive' | 'disabled'
providerId?: string | null
coordinatorMode?: boolean
}
export class ConversationStartupError extends Error {
@ -1037,6 +1039,10 @@ export class ConversationService {
args.push('--thinking', options.thinking)
}
if (options?.coordinatorMode) {
args.push('--append-system-prompt', ORCHESTRATION_SYSTEM_PROMPT)
}
return args
}

View File

@ -25,6 +25,7 @@ export type ClientMessage =
}
| { type: 'set_permission_mode'; mode: string }
| { type: 'set_runtime_config'; providerId: string | null; modelId: string; effortLevel?: string; thinkingEnabled?: boolean }
| { type: 'set_coordinator_mode'; enabled: boolean }
| { type: 'stop_generation' }
| { type: 'ping' }

View File

@ -83,6 +83,11 @@ const runtimeOverrides = new Map<string, {
thinkingEnabled?: boolean
}>()
// Per-session orchestration ("协调") mode. In-memory only: a transient session
// preference, not persisted across app restart / resume (v1). Read by
// getRuntimeSettings and threaded into the CLI as --append-system-prompt.
const coordinatorModeSessions = new Set<string>()
const runtimeTransitionPromises = new Map<string, Promise<void>>()
const sessionStartupPromises = new Map<string, Promise<void>>()
const runtimeOverrideVersions = new Map<string, number>()
@ -222,6 +227,10 @@ export const handleWebSocket = {
void handleSetPermissionMode(ws, message)
break
case 'set_coordinator_mode':
void handleSetCoordinatorMode(ws, message)
break
case 'set_runtime_config':
void handleSetRuntimeConfig(ws, message)
break
@ -589,6 +598,42 @@ async function applyPermissionModeToActiveSession(
await persistSessionPermissionMode(sessionId, mode)
}
async function handleSetCoordinatorMode(
ws: ServerWebSocket<WebSocketData>,
message: Extract<ClientMessage, { type: 'set_coordinator_mode' }>,
): Promise<void> {
const { sessionId } = ws.data
const enabled = message.enabled === true
const was = coordinatorModeSessions.has(sessionId)
if (was === enabled) return
if (enabled) coordinatorModeSessions.add(sessionId)
else coordinatorModeSessions.delete(sessionId)
// Orchestration mode is applied via --append-system-prompt at CLI launch, so
// an active session must restart to pick up (or drop) the directive. Defer
// until idle so we never interrupt an in-progress turn; reuses the same
// restart path as runtime-config changes (which re-reads getRuntimeSettings).
const pendingStartup = sessionStartupPromises.get(sessionId)
if (pendingStartup) {
await enqueueRuntimeTransition(sessionId, async () => {
await pendingStartup.catch(() => undefined)
if (!conversationService.hasSession(sessionId)) return
await scheduleRestartSessionWithRuntimeConfig(ws, sessionId)
})
return
}
if (!conversationService.hasSession(sessionId)) {
// No live process yet — the flag is recorded and applied on next start.
return
}
await enqueueRuntimeTransition(sessionId, () =>
scheduleRestartSessionWithRuntimeConfig(ws, sessionId),
)
}
async function handleSetRuntimeConfig(
ws: ServerWebSocket<WebSocketData>,
message: Extract<ClientMessage, { type: 'set_runtime_config' }>
@ -1110,6 +1155,7 @@ function cleanupSessionRuntimeState(sessionId: string) {
sessionSlashCommands.delete(sessionId)
sessionTitleState.delete(sessionId)
runtimeOverrides.delete(sessionId)
coordinatorModeSessions.delete(sessionId)
runtimeTransitionPromises.delete(sessionId)
sessionStartupPromises.delete(sessionId)
lastResolvedStartupWorkDirs.delete(sessionId)
@ -2178,6 +2224,7 @@ type RuntimeSettings = {
effort?: string
thinking?: 'enabled' | 'disabled'
providerId?: string | null
coordinatorMode?: boolean
}
function isKnownRuntimeProviderId(
@ -2191,6 +2238,7 @@ function isKnownRuntimeProviderId(
}
async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings> {
const coordinatorMode = sessionId ? coordinatorModeSessions.has(sessionId) : false
const launchInfo = sessionId
? await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
: null
@ -2224,6 +2272,7 @@ async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings>
return {
...defaults,
permissionMode: sessionPermissionMode ?? defaults.permissionMode,
coordinatorMode,
}
}
}
@ -2237,6 +2286,7 @@ async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings>
effort: runtimeOverride.effort,
thinking,
providerId: runtimeOverride.providerId,
coordinatorMode,
}
}
@ -2245,6 +2295,7 @@ async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings>
...defaults,
permissionMode: sessionPermissionMode ?? defaults.permissionMode,
effort: launchInfo?.effortLevel ?? defaults.effort,
coordinatorMode,
}
}