diff --git a/build.ts b/build.ts index 4e8e0d260..cfc4aa9bb 100644 --- a/build.ts +++ b/build.ts @@ -1,6 +1,7 @@ import { readdir, readFile, writeFile, cp } from 'fs/promises' import { join } from 'path' import { getMacroDefines } from './scripts/defines.ts' +import { DEFAULT_BUILD_FEATURES } from './scripts/defines.ts' const outdir = 'dist' @@ -8,30 +9,6 @@ const outdir = 'dist' const { rmSync } = await import('fs') rmSync(outdir, { recursive: true, force: true }) -// Default features that match the official CLI build. -// Additional features can be enabled via FEATURE_=1 env vars. -const DEFAULT_BUILD_FEATURES = [ - 'AGENT_TRIGGERS_REMOTE', - 'CHICAGO_MCP', - 'VOICE_MODE', - 'SHOT_STATS', - 'PROMPT_CACHE_BREAK_DETECTION', - 'TOKEN_BUDGET', - // P0: local features - 'AGENT_TRIGGERS', - 'ULTRATHINK', - 'BUILTIN_EXPLORE_PLAN_AGENTS', - 'LODESTONE', - // P1: API-dependent features - 'EXTRACT_MEMORIES', - 'VERIFICATION_AGENT', - 'KAIROS_BRIEF', - 'AWAY_SUMMARY', - 'ULTRAPLAN', - // P2: daemon + remote control server - 'DAEMON', -] - // Collect FEATURE_* env vars → Bun.build features const envFeatures = Object.keys(process.env) .filter(k => k.startsWith('FEATURE_')) diff --git a/src/assistant/AssistantSessionChooser.ts b/src/assistant/AssistantSessionChooser.ts deleted file mode 100644 index e61ba6ced..000000000 --- a/src/assistant/AssistantSessionChooser.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Auto-generated stub — replace with real implementation -export {}; -export const AssistantSessionChooser: (props: Record) => null = () => null; diff --git a/src/assistant/AssistantSessionChooser.tsx b/src/assistant/AssistantSessionChooser.tsx new file mode 100644 index 000000000..5f004e840 --- /dev/null +++ b/src/assistant/AssistantSessionChooser.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { Dialog } from '../components/design-system/Dialog.js'; +import { ListItem } from '../components/design-system/ListItem.js'; +import { useRegisterOverlay } from '../context/overlayContext.js'; +import { useKeybindings } from '../keybindings/useKeybinding.js'; +import type { AssistantSession } from './sessionDiscovery.js'; + +interface Props { + sessions: AssistantSession[]; + onSelect: (id: string) => void; + onCancel: () => void; +} + +/** + * Interactive session chooser for `claude assistant` when multiple + * CCR sessions are discovered. Renders a Dialog with up/down navigation. + * + * Session IDs are in `session_*` compat format — passed directly to + * createRemoteSessionConfig() for viewer attach. + */ +export function AssistantSessionChooser({ sessions, onSelect, onCancel }: Props): React.ReactNode { + useRegisterOverlay('assistant-session-chooser'); + const [focusIndex, setFocusIndex] = useState(0); + + useKeybindings( + { + 'select:next': () => setFocusIndex(i => (i + 1) % sessions.length), + 'select:previous': () => setFocusIndex(i => (i - 1 + sessions.length) % sessions.length), + 'select:accept': () => onSelect(sessions[focusIndex]!.id), + }, + { context: 'Select' }, + ); + + return ( + + + Multiple sessions found. Select one to attach: + + {sessions.map((s, i) => ( + + + {s.title || s.id.slice(0, 20)} + [{s.status}] + + + ))} + + ↑↓ navigate · Enter select · Esc cancel + + + ); +} diff --git a/src/assistant/gate.ts b/src/assistant/gate.ts index c08265c2d..c6bc6ff7c 100644 --- a/src/assistant/gate.ts +++ b/src/assistant/gate.ts @@ -1,3 +1,23 @@ -// Auto-generated stub — replace with real implementation -export {}; -export const isKairosEnabled: () => Promise = () => Promise.resolve(false); +import { feature } from 'bun:bundle' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' + +/** + * Runtime gate for KAIROS features. + * + * Two-layer gate: + * 1. Build-time: feature('KAIROS') must be on + * 2. Runtime: tengu_kairos_assistant GrowthBook flag (remote kill switch) + * + * Called by main.tsx BEFORE setKairosActive(true) — must NOT check + * kairosActive (that would deadlock: gate needs active, active needs gate). + * The caller (main.tsx L1826-1832) sets kairosActive after this returns true. + */ +export async function isKairosEnabled(): Promise { + if (!feature('KAIROS')) { + return false + } + if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)) { + return false + } + return true +} diff --git a/src/assistant/index.ts b/src/assistant/index.ts index 3e23f69d9..3b5e4538c 100644 --- a/src/assistant/index.ts +++ b/src/assistant/index.ts @@ -1,8 +1,135 @@ -// Auto-generated stub — replace with real implementation -export {}; -export const isAssistantMode: () => boolean = () => false; -export const initializeAssistantTeam: () => Promise = async () => {}; -export const markAssistantForced: () => void = () => {}; -export const isAssistantForced: () => boolean = () => false; -export const getAssistantSystemPromptAddendum: () => string = () => ''; -export const getAssistantActivationPath: () => string | undefined = () => undefined; +import { readFileSync } from 'fs' +import { join } from 'path' +import { getKairosActive, getSessionId } from '../bootstrap/state.js' +import type { AppState } from '../state/AppState.js' +import { formatAgentId } from '../utils/agentId.js' +import { getCwd } from '../utils/cwd.js' +import { getClaudeConfigHomeDir } from '../utils/envUtils.js' +import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js' +import { + getTeamFilePath, + registerTeamForSessionCleanup, + sanitizeName, + writeTeamFileAsync, + type TeamFile, +} from '../utils/swarm/teamHelpers.js' +import { assignTeammateColor } from '../utils/swarm/teammateLayoutManager.js' +import { + ensureTasksDir, + resetTaskList, + setLeaderTeamName, +} from '../utils/tasks.js' + +let _assistantForced = false + +/** + * Whether the current session is in assistant (KAIROS) daemon mode. + * Wraps the bootstrap kairosActive state set by main.tsx after gate check. + */ +export function isAssistantMode(): boolean { + return getKairosActive() +} + +/** + * Mark this session as forced assistant mode (--assistant flag). + * Skips the GrowthBook gate check — daemon is pre-entitled. + */ +export function markAssistantForced(): void { + _assistantForced = true +} + +export function isAssistantForced(): boolean { + return _assistantForced +} + +/** + * Pre-create an in-process team so Agent(name) can spawn teammates + * without TeamCreate. + * + * Creates a session-scoped assistant team file and returns a full team + * context object matching AppState.teamContext. + */ +export async function initializeAssistantTeam(): Promise< + AppState['teamContext'] +> { + const sessionId = getSessionId() + const teamName = sanitizeName(`assistant-${sessionId.slice(0, 8)}`) + const leadAgentId = formatAgentId(TEAM_LEAD_NAME, teamName) + const teamFilePath = getTeamFilePath(teamName) + const now = Date.now() + const cwd = getCwd() + const color = assignTeammateColor(leadAgentId) + + const teamFile: TeamFile = { + name: teamName, + description: 'Assistant mode in-process team', + createdAt: now, + leadAgentId, + leadSessionId: sessionId, + members: [ + { + agentId: leadAgentId, + name: TEAM_LEAD_NAME, + agentType: 'assistant', + color, + joinedAt: now, + tmuxPaneId: '', + cwd, + subscriptions: [], + backendType: 'in-process', + }, + ], + } + + await writeTeamFileAsync(teamName, teamFile) + registerTeamForSessionCleanup(teamName) + await resetTaskList(teamName) + await ensureTasksDir(teamName) + setLeaderTeamName(teamName) + + return { + teamName, + teamFilePath, + leadAgentId, + selfAgentId: leadAgentId, + selfAgentName: TEAM_LEAD_NAME, + isLeader: true, + selfAgentColor: color, + teammates: { + [leadAgentId]: { + name: TEAM_LEAD_NAME, + agentType: 'assistant', + color, + tmuxSessionName: 'in-process', + tmuxPaneId: 'leader', + cwd, + spawnedAt: now, + }, + }, + } +} + +/** + * Assistant-specific system prompt addendum loaded from ~/.claude/agents/assistant.md. + * Returns empty string if the file doesn't exist. + */ +export function getAssistantSystemPromptAddendum(): string { + try { + return readFileSync( + join(getClaudeConfigHomeDir(), 'agents', 'assistant.md'), + 'utf-8', + ) + } catch { + return '' + } +} + +/** + * How assistant mode was activated. Used for diagnostics/analytics. + * - 'daemon': via --assistant flag (Agent SDK daemon) + * - 'gate': via GrowthBook gate check + */ +export function getAssistantActivationPath(): string | undefined { + if (!isAssistantMode()) return undefined + return _assistantForced ? 'daemon' : 'gate' +} diff --git a/src/assistant/sessionDiscovery.ts b/src/assistant/sessionDiscovery.ts index 424564c1c..d12e88ad6 100644 --- a/src/assistant/sessionDiscovery.ts +++ b/src/assistant/sessionDiscovery.ts @@ -1,3 +1,51 @@ -// Auto-generated stub — replace with real implementation -export type AssistantSession = { id: string; [key: string]: unknown }; -export const discoverAssistantSessions: () => Promise = () => Promise.resolve([]); +import { logForDebugging } from '../utils/debug.js' + +/** + * Minimal session type for assistant discovery. + * Only `id` is consumed by main.tsx (L4757); other fields are for chooser display. + * ID format is `session_*` (compat prefix) — viewer endpoints use /v1/sessions/*. + */ +export type AssistantSession = { + id: string + title: string + status: string + created_at: string +} + +/** + * Discover assistant sessions on Anthropic CCR. + * + * Reuses the existing fetchCodeSessionsFromSessionsAPI() which calls + * GET /v1/sessions with proper OAuth + anthropic-beta headers. + * + * Throws on failure — main.tsx L4720-4725 catch displays the error. + * Does NOT return [] on error (that would silently redirect to install wizard). + */ +export async function discoverAssistantSessions(): Promise { + const { fetchCodeSessionsFromSessionsAPI } = await import( + '../utils/teleport/api.js' + ) + + let allSessions + try { + allSessions = await fetchCodeSessionsFromSessionsAPI() + } catch (err) { + logForDebugging( + `[assistant:discovery] fetchCodeSessionsFromSessionsAPI failed: ${err}`, + ) + throw err + } + + // Filter to active/working sessions only — completed/archived are not attachable + return allSessions + .filter( + s => + s.status === 'idle' || s.status === 'working' || s.status === 'waiting', + ) + .map(s => ({ + id: s.id, + title: s.title || 'Untitled', + status: s.status, + created_at: s.created_at ?? '', + })) +} diff --git a/src/commands.ts b/src/commands.ts index 535d00fff..98c2a3681 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -80,6 +80,9 @@ const remoteControlServerCommand = const voiceCommand = feature('VOICE_MODE') ? require('./commands/voice/index.js').default : null +const coordinatorCmd = feature('COORDINATOR_MODE') + ? require('./commands/coordinator.js').default + : null const forceSnip = feature('HISTORY_SNIP') ? require('./commands/force-snip.js').default : null @@ -329,6 +332,7 @@ const COMMANDS = memoize((): Command[] => [ ...(bridge ? [bridge] : []), ...(remoteControlServerCommand ? [remoteControlServerCommand] : []), ...(voiceCommand ? [voiceCommand] : []), + ...(coordinatorCmd ? [coordinatorCmd] : []), thinkback, thinkbackPlay, permissions, diff --git a/src/commands/assistant/assistant.ts b/src/commands/assistant/assistant.ts deleted file mode 100644 index 80a04ca62..000000000 --- a/src/commands/assistant/assistant.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated stub — replace with real implementation -import type React from 'react'; - -export {}; -export const NewInstallWizard: React.FC<{ - defaultDir: string; - onInstalled: (dir: string) => void; - onCancel: () => void; - onError: (message: string) => void; -}> = (() => null); -export const computeDefaultInstallDir: () => Promise = (() => Promise.resolve('')); diff --git a/src/commands/assistant/assistant.tsx b/src/commands/assistant/assistant.tsx new file mode 100644 index 000000000..5deb30786 --- /dev/null +++ b/src/commands/assistant/assistant.tsx @@ -0,0 +1,175 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { resolve } from 'path'; +import { Box, Text } from '@anthropic/ink'; +import { Dialog } from '../../components/design-system/Dialog.js'; +import { ListItem } from '../../components/design-system/ListItem.js'; +import { useRegisterOverlay } from '../../context/overlayContext.js'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import { findGitRoot } from '../../utils/git.js'; +import { buildCliLaunch, spawnCli } from '../../utils/cliLaunch.js'; +import { getKairosActive, setKairosActive } from '../../bootstrap/state.js'; +import type { LocalJSXCommandContext } from '../../commands.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import type { AppState } from '../../state/AppState.js'; + +/** + * Compute the default directory for assistant daemon installation. + * Prefers git root of cwd; falls back to cwd itself. + */ +export async function computeDefaultInstallDir(): Promise { + const cwd = process.cwd(); + const gitRoot = findGitRoot(cwd); + return gitRoot || resolve(cwd); +} + +interface WizardProps { + defaultDir: string; + onInstalled: (dir: string) => void; + onCancel: () => void; + onError: (message: string) => void; +} + +/** + * Install wizard for assistant mode. Shown when `claude assistant` finds + * zero CCR sessions. Guides the user to start a daemon that registers + * a bridge → CCR cloud session. + * + * After installation, main.tsx tells the user to run `claude assistant` + * again in a few seconds (daemon needs time to register the bridge session). + */ +export function NewInstallWizard({ defaultDir, onInstalled, onCancel, onError }: WizardProps): React.ReactNode { + useRegisterOverlay('assistant-install-wizard'); + const [focusIndex, setFocusIndex] = useState(0); + const [starting, setStarting] = useState(false); + + useKeybindings( + { + 'select:next': () => setFocusIndex(i => (i + 1) % 2), + 'select:previous': () => setFocusIndex(i => (i - 1 + 2) % 2), + 'select:accept': () => { + if (focusIndex === 0) { + startDaemon(); + } else { + onCancel(); + } + }, + }, + { context: 'Select' }, + ); + + function startDaemon(): void { + if (starting) return; + setStarting(true); + + const dir = defaultDir || resolve('.'); + + try { + const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]); + + const child = spawnCli(launch, { + cwd: dir, + stdio: 'ignore', + detached: true, + }); + + child.unref(); + + child.on('error', err => { + onError(`Failed to start daemon: ${err.message}`); + }); + + // Give the daemon a moment to initialize, then report success. + // The daemon still needs several more seconds to register the bridge + // and create a CCR session — main.tsx will tell the user to reconnect. + setTimeout(() => { + onInstalled(dir); + }, 1500); + } catch (err) { + onError(`Failed to start daemon: ${err instanceof Error ? err.message : String(err)}`); + } + } + + if (starting) { + return ( + + Starting daemon in {defaultDir}... + + ); + } + + return ( + + + No active assistant sessions found. + + Start a daemon in {defaultDir || '.'} to create a cloud session? + + + + Start assistant daemon + + + Cancel + + + Enter to select · Esc to cancel + + + ); +} + +/** + * /assistant command implementation. + * + * First invocation activates KAIROS (sets kairosActive, enables brief + * and proactive tools). Subsequent invocations toggle the assistant panel. + */ +export async function call( + onDone: LocalJSXCommandOnDone, + context: LocalJSXCommandContext, + _args: string, +): Promise { + const { setAppState, getAppState } = context; + + // First invocation: activate KAIROS + if (!getKairosActive()) { + setKairosActive(true); + setAppState( + (prev: AppState) => + ({ + ...prev, + kairosEnabled: true, + assistantPanelVisible: true, + }) as AppState, + ); + onDone('KAIROS assistant mode activated.', { display: 'system' }); + return null; + } + + // Subsequent invocations: toggle panel visibility + const current = getAppState(); + const isVisible = (current as Record).assistantPanelVisible; + + if (isVisible) { + setAppState( + (prev: AppState) => + ({ + ...prev, + assistantPanelVisible: false, + }) as AppState, + ); + onDone('Assistant panel hidden.', { display: 'system' }); + } else { + setAppState( + (prev: AppState) => + ({ + ...prev, + assistantPanelVisible: true, + }) as AppState, + ); + onDone('Assistant panel opened.', { display: 'system' }); + } + + return null; +} diff --git a/src/commands/assistant/gate.ts b/src/commands/assistant/gate.ts new file mode 100644 index 000000000..148a556a8 --- /dev/null +++ b/src/commands/assistant/gate.ts @@ -0,0 +1,21 @@ +import { feature } from 'bun:bundle' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' + +/** + * Runtime gate for the /assistant command visibility. + * + * Build-time: feature('KAIROS') must be on. + * Runtime: tengu_kairos_assistant GrowthBook flag (remote kill switch). + * + * Does NOT require kairosActive — the /assistant command is visible + * before activation so users can invoke it to activate KAIROS. + */ +export function isAssistantEnabled(): boolean { + if (!feature('KAIROS')) { + return false + } + if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)) { + return false + } + return true +} diff --git a/src/commands/assistant/index.ts b/src/commands/assistant/index.ts new file mode 100644 index 000000000..18263be39 --- /dev/null +++ b/src/commands/assistant/index.ts @@ -0,0 +1,16 @@ +import type { Command } from '../../commands.js' +import { isAssistantEnabled } from './gate.js' + +const assistant = { + type: 'local-jsx', + name: 'assistant', + description: 'Open the Kairos assistant panel', + isEnabled: isAssistantEnabled, + get isHidden() { + return !isAssistantEnabled() + }, + immediate: true, + load: () => import('./assistant.js'), +} satisfies Command + +export default assistant diff --git a/src/commands/coordinator.ts b/src/commands/coordinator.ts new file mode 100644 index 000000000..fecce7ead --- /dev/null +++ b/src/commands/coordinator.ts @@ -0,0 +1,63 @@ +/** + * /coordinator — Toggle coordinator (multi-worker orchestration) mode. + * + * When enabled, the CLI becomes an orchestrator that dispatches tasks + * to worker agents via Agent({ subagent_type: "worker" }). + * The coordinator can only use Agent, SendMessage, and TaskStop. + */ +import { feature } from 'bun:bundle' +import type { ToolUseContext } from '../Tool.js' +import type { + Command, + LocalJSXCommandContext, + LocalJSXCommandOnDone, +} from '../types/command.js' + +const coordinator = { + type: 'local-jsx', + name: 'coordinator', + description: 'Toggle coordinator (multi-worker) mode', + isEnabled: () => { + if (feature('COORDINATOR_MODE')) { + return true + } + return false + }, + immediate: true, + load: () => + Promise.resolve({ + async call( + onDone: LocalJSXCommandOnDone, + _context: ToolUseContext & LocalJSXCommandContext, + ): Promise { + const mod = + require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js') + + if (mod.isCoordinatorMode()) { + // Disable: clear the env var + delete process.env.CLAUDE_CODE_COORDINATOR_MODE + onDone('Coordinator mode disabled — back to normal mode', { + display: 'system', + metaMessages: [ + '\nCoordinator mode is now disabled. You have access to all standard tools again. Work directly instead of dispatching to workers.\n', + ], + }) + } else { + // Enable: set the env var + process.env.CLAUDE_CODE_COORDINATOR_MODE = '1' + onDone( + 'Coordinator mode enabled — use Agent(subagent_type: "worker") to dispatch tasks', + { + display: 'system', + metaMessages: [ + '\nCoordinator mode is now enabled. You are an orchestrator. Use Agent({ subagent_type: "worker" }) to spawn workers, SendMessage to continue them, TaskStop to stop them. Do not use other tools directly.\n', + ], + }, + ) + } + return null + }, + }), +} satisfies Command + +export default coordinator diff --git a/src/commands/proactive.ts b/src/commands/proactive.ts new file mode 100644 index 000000000..3d63bb362 --- /dev/null +++ b/src/commands/proactive.ts @@ -0,0 +1,56 @@ +/** + * /proactive — Toggle proactive (autonomous tick-driven) mode. + * + * When enabled, the model receives periodic prompts and works + * autonomously between user inputs. SleepTool controls pacing. + */ +import { feature } from 'bun:bundle' +import type { ToolUseContext } from '../Tool.js' +import type { + Command, + LocalJSXCommandContext, + LocalJSXCommandOnDone, +} from '../types/command.js' + +const proactive = { + type: 'local-jsx', + name: 'proactive', + description: 'Toggle proactive (autonomous) mode', + isEnabled: () => { + if (feature('PROACTIVE') || feature('KAIROS')) { + return true + } + return false + }, + immediate: true, + load: () => + Promise.resolve({ + async call( + onDone: LocalJSXCommandOnDone, + _context: ToolUseContext & LocalJSXCommandContext, + ): Promise { + // Dynamic require to avoid pulling proactive into non-gated builds + const mod = + require('../proactive/index.js') as typeof import('../proactive/index.js') + + if (mod.isProactiveActive()) { + mod.deactivateProactive() + onDone('Proactive mode disabled', { display: 'system' }) + } else { + mod.activateProactive('slash_command') + onDone( + 'Proactive mode enabled — model will work autonomously between ticks', + { + display: 'system', + metaMessages: [ + '\nProactive mode is now enabled. You will receive periodic prompts. Do useful work on each tick, or call Sleep if there is nothing to do. Do not output "still waiting" — either act or sleep.\n', + ], + }, + ) + } + return null + }, + }), +} satisfies Command + +export default proactive diff --git a/src/main.tsx b/src/main.tsx index ace1daf5c..1c507cabf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5663,9 +5663,9 @@ async function run(): Promise { program.addOption( new Option( "--teammate-mode ", - 'How to spawn teammates: "tmux", "in-process", or "auto"', + 'How to spawn teammates: "tmux", "windows-terminal", "in-process", or "auto"', ) - .choices(["auto", "tmux", "in-process"]) + .choices(["auto", "tmux", "windows-terminal", "in-process"]) .hideHelp(), ); program.addOption( @@ -7003,7 +7003,7 @@ type TeammateOptions = { agentColor?: string; planModeRequired?: boolean; parentSessionId?: string; - teammateMode?: "auto" | "tmux" | "in-process"; + teammateMode?: "auto" | "tmux" | "windows-terminal" | "in-process"; agentType?: string; }; @@ -7031,6 +7031,7 @@ function extractTeammateOptions(options: unknown): TeammateOptions { teammateMode: teammateMode === "auto" || teammateMode === "tmux" || + teammateMode === "windows-terminal" || teammateMode === "in-process" ? teammateMode : undefined, diff --git a/src/proactive/index.ts b/src/proactive/index.ts index e4c87a4ed..0eda1b102 100644 --- a/src/proactive/index.ts +++ b/src/proactive/index.ts @@ -1,6 +1,135 @@ -// Auto-generated stub — replace with real implementation -export {}; -export const isProactiveActive: () => boolean = () => false; -export const activateProactive: (source?: string) => void = () => {}; -export const isProactivePaused: () => boolean = () => false; -export const deactivateProactive: () => void = () => {}; +/** + * Proactive mode — tick-driven autonomous agent. + * + * State machine: inactive → active (→ paused → active) → inactive + * + * When active, the REPL periodically injects prompts so the model + * keeps working even when the user is idle. SleepTool lets the model + * control its own wake-up cadence. + */ + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let active = false +let paused = false +let contextBlocked = false +let nextTickAt: number | null = null +let activationSource: string | undefined + +const listeners = new Set<() => void>() + +function notify(): void { + for (const cb of listeners) { + try { + cb() + } catch { + // subscriber errors must not break the notifier + } + } +} + +// --------------------------------------------------------------------------- +// Public API — consumed by REPL.tsx, PromptInputFooterLeftSide, prompts.ts +// --------------------------------------------------------------------------- + +export function isProactiveActive(): boolean { + return active +} + +export function activateProactive(source?: string): void { + if (active) return + active = true + paused = false + contextBlocked = false + activationSource = source + notify() +} + +export function deactivateProactive(): void { + if (!active) return + active = false + paused = false + contextBlocked = false + nextTickAt = null + activationSource = undefined + notify() +} + +export function isProactivePaused(): boolean { + return paused +} + +export function pauseProactive(): void { + if (!active || paused) return + paused = true + nextTickAt = null + notify() +} + +export function resumeProactive(): void { + if (!active || !paused) return + paused = false + notify() +} + +/** + * Block / unblock tick generation. + * + * Set to `true` on API errors to prevent tick → error → tick runaway loops. + * Cleared on successful response or after compaction. + */ +export function setContextBlocked(blocked: boolean): void { + if (contextBlocked === blocked) return + contextBlocked = blocked + if (blocked) { + nextTickAt = null + } + notify() +} + +export function isContextBlocked(): boolean { + return contextBlocked +} + +/** + * Schedule the next tick timestamp (epoch ms). + * Called by useProactive after submitting a tick. + */ +export function setNextTickAt(ts: number | null): void { + nextTickAt = ts + notify() +} + +/** + * Returns the epoch-ms timestamp of the next scheduled tick, or null. + * Used by PromptInputFooterLeftSide to render a countdown. + */ +export function getNextTickAt(): number | null { + if (!active || paused || contextBlocked) return null + return nextTickAt +} + +export function getActivationSource(): string | undefined { + return activationSource +} + +/** + * Subscribe to any proactive state change. + * Returns an unsubscribe function. + */ +export function subscribeToProactiveChanges(cb: () => void): () => void { + listeners.add(cb) + return () => { + listeners.delete(cb) + } +} + +/** + * Whether ticks should fire right now. + * Convenience predicate combining all blocking conditions. + */ +export function shouldTick(): boolean { + return active && !paused && !contextBlocked +} diff --git a/src/proactive/useProactive.ts b/src/proactive/useProactive.ts new file mode 100644 index 000000000..12e7721d1 --- /dev/null +++ b/src/proactive/useProactive.ts @@ -0,0 +1,102 @@ +/** + * useProactive — React hook that drives tick generation for proactive mode. + * + * Mounted inside REPL.tsx when feature('PROACTIVE') || feature('KAIROS'). + * Generates HH:MM:SS prompts at a fixed interval while + * proactive mode is active and not blocked. + */ +import { useEffect, useRef } from 'react' +import { TICK_TAG } from '../constants/xml.js' +import { + isProactiveActive, + isProactivePaused, + isContextBlocked, + setNextTickAt, + shouldTick, +} from './index.js' + +/** Default interval between ticks (ms). Prompt cache TTL is ~5 min so we + * stay well under that to keep the cache warm. */ +const TICK_INTERVAL_MS = 30_000 + +type UseProactiveOpts = { + isLoading: boolean + queuedCommandsLength: number + hasActiveLocalJsxUI: boolean + isInPlanMode: boolean + onSubmitTick: (prompt: string) => void + onQueueTick: (prompt: string) => void +} + +export function useProactive(opts: UseProactiveOpts): void { + const optsRef = useRef(opts) + optsRef.current = opts + + useEffect(() => { + if (!isProactiveActive()) return + + let timer: ReturnType | null = null + + function scheduleTick(): void { + const nextTs = Date.now() + TICK_INTERVAL_MS + setNextTickAt(nextTs) + + timer = setTimeout(() => { + timer = null + + // Guard: skip tick if any blocking condition is met + if (!shouldTick()) { + // Reschedule — conditions may clear later + scheduleTick() + return + } + + const { + isLoading, + queuedCommandsLength, + hasActiveLocalJsxUI, + isInPlanMode, + } = optsRef.current + + // Don't fire while a query is in-flight, plan mode is active, + // a local JSX UI is showing, or commands are queued + if ( + isLoading || + isInPlanMode || + hasActiveLocalJsxUI || + queuedCommandsLength > 0 + ) { + scheduleTick() + return + } + + const tickContent = `<${TICK_TAG}>${new Date().toLocaleTimeString()}` + + // If nothing is in the queue, submit directly; otherwise queue + if (queuedCommandsLength === 0) { + optsRef.current.onSubmitTick(tickContent) + } else { + optsRef.current.onQueueTick(tickContent) + } + + // Schedule next tick + scheduleTick() + }, TICK_INTERVAL_MS) + } + + scheduleTick() + + return () => { + if (timer !== null) { + clearTimeout(timer) + timer = null + } + setNextTickAt(null) + } + }, [ + // Re-mount when proactive state changes + isProactiveActive(), + isProactivePaused(), + isContextBlocked(), + ]) +} diff --git a/src/tasks/InProcessTeammateTask/types.ts b/src/tasks/InProcessTeammateTask/types.ts index d6e6d39af..60f4b155a 100644 --- a/src/tasks/InProcessTeammateTask/types.ts +++ b/src/tasks/InProcessTeammateTask/types.ts @@ -1,7 +1,7 @@ import type { TaskStateBase } from '../../Task.js' import type { AgentToolResult } from '../../tools/AgentTool/agentToolUtils.js' import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' -import type { Message } from '../../types/message.js' +import type { Message, MessageOrigin } from '../../types/message.js' import type { PermissionMode } from '../../utils/permissions/PermissionMode.js' import type { AgentProgress } from '../LocalAgentTask/LocalAgentTask.js' @@ -19,6 +19,13 @@ export type TeammateIdentity = { parentSessionId: string // Leader's session ID } +export type PendingTeammateUserMessage = { + message: string + autonomyRunId?: string + autonomyRootDir?: string + origin?: MessageOrigin +} + export type InProcessTeammateTaskState = TaskStateBase & { type: 'in_process_teammate' @@ -56,7 +63,7 @@ export type InProcessTeammateTaskState = TaskStateBase & { inProgressToolUseIDs?: Set // Queue of user messages to deliver when viewing teammate transcript - pendingUserMessages: string[] + pendingUserMessages: Array // UI: random spinner verbs (stable across re-renders, shared between components) spinnerVerb?: string diff --git a/src/tools/PushNotificationTool/PushNotificationTool.ts b/src/tools/PushNotificationTool/PushNotificationTool.ts new file mode 100644 index 000000000..63df5c61c --- /dev/null +++ b/src/tools/PushNotificationTool/PushNotificationTool.ts @@ -0,0 +1,87 @@ +import { z } from 'zod/v4' +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import { buildTool } from '../../Tool.js' +import { lazySchema } from '../../utils/lazySchema.js' + +const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification' + +const inputSchema = lazySchema(() => + z.strictObject({ + title: z + .string() + .describe('Title of the push notification.'), + body: z + .string() + .describe('Body text of the push notification.'), + priority: z + .enum(['normal', 'high']) + .optional() + .describe('Notification priority. Use "high" for blockers or permission prompts.'), + }), +) +type InputSchema = ReturnType +type PushInput = z.infer + +type PushOutput = { sent: boolean } + +export const PushNotificationTool = buildTool({ + name: PUSH_NOTIFICATION_TOOL_NAME, + searchHint: 'push notification mobile alert notify user', + maxResultSizeChars: 1_000, + strict: true, + + get inputSchema(): InputSchema { + return inputSchema() + }, + + async description() { + return 'Send a push notification to the user\'s mobile device' + }, + async prompt() { + return `Send a push notification to the user's mobile device via Remote Control. + +Use this when: +- A long-running task completes and the user may not be watching +- A permission prompt is waiting and you need user input +- Something urgent requires the user's attention + +Requires Remote Control to be configured. Respects user notification settings (taskCompleteNotifEnabled, inputNeededNotifEnabled, agentPushNotifEnabled).` + }, + + isConcurrencySafe() { + return true + }, + isReadOnly() { + return true + }, + + userFacingName() { + return 'Notify' + }, + + renderToolUseMessage(input: Partial) { + return `Push: ${input.title ?? '...'}` + }, + + mapToolResultToToolResultBlockParam( + content: PushOutput, + toolUseID: string, + ): ToolResultBlockParam { + return { + tool_use_id: toolUseID, + type: 'tool_result', + content: content.sent ? 'Notification sent.' : 'Failed to send notification.', + } + }, + + async call(_input: PushInput) { + // Push delivery is handled by the Remote Control / KAIROS transport layer. + // Without the KAIROS runtime, this tool is not available. + return { + data: { + sent: false, + error: 'PushNotification requires the KAIROS transport layer.', + }, + } + }, +}) diff --git a/src/tools/SendUserFileTool/SendUserFileTool.ts b/src/tools/SendUserFileTool/SendUserFileTool.ts new file mode 100644 index 000000000..2daa884a6 --- /dev/null +++ b/src/tools/SendUserFileTool/SendUserFileTool.ts @@ -0,0 +1,84 @@ +import { z } from 'zod/v4' +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import { buildTool } from '../../Tool.js' +import { lazySchema } from '../../utils/lazySchema.js' +import { SEND_USER_FILE_TOOL_NAME } from './prompt.js' + +const inputSchema = lazySchema(() => + z.strictObject({ + file_path: z + .string() + .describe('Absolute path to the file to send to the user.'), + description: z + .string() + .optional() + .describe('Optional description of the file being sent.'), + }), +) +type InputSchema = ReturnType +type SendUserFileInput = z.infer + +type SendUserFileOutput = { sent: boolean; file_path: string } + +export const SendUserFileTool = buildTool({ + name: SEND_USER_FILE_TOOL_NAME, + searchHint: 'send file to user mobile device upload share', + maxResultSizeChars: 5_000, + strict: true, + + get inputSchema(): InputSchema { + return inputSchema() + }, + + async description() { + return 'Send a file to the user (KAIROS assistant mode)' + }, + async prompt() { + return `Send a file to the user's device. Use this in assistant mode when the user requests a file or when a file is relevant to the conversation. + +Guidelines: +- Use absolute paths +- The file must exist and be readable +- Large files may take time to transfer` + }, + + isConcurrencySafe() { + return true + }, + isReadOnly() { + return true + }, + + userFacingName() { + return 'SendFile' + }, + + renderToolUseMessage(input: Partial) { + return `Send file: ${input.file_path ?? '...'}` + }, + + mapToolResultToToolResultBlockParam( + content: SendUserFileOutput, + toolUseID: string, + ): ToolResultBlockParam { + return { + tool_use_id: toolUseID, + type: 'tool_result', + content: content.sent + ? `File sent: ${content.file_path}` + : `Failed to send file: ${content.file_path}`, + } + }, + + async call(_input: SendUserFileInput) { + // File transfer is handled by the KAIROS assistant transport layer. + // Without the KAIROS runtime, this tool is not available. + return { + data: { + sent: false, + file_path: _input.file_path, + error: 'SendUserFile requires the KAIROS assistant transport layer.', + }, + } + }, +}) diff --git a/src/tools/SleepTool/SleepTool.ts b/src/tools/SleepTool/SleepTool.ts new file mode 100644 index 000000000..75ee0bf9d --- /dev/null +++ b/src/tools/SleepTool/SleepTool.ts @@ -0,0 +1,134 @@ +import { feature } from 'bun:bundle' +import { z } from 'zod/v4' +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import { buildTool } from '../../Tool.js' +import { lazySchema } from '../../utils/lazySchema.js' +import { SLEEP_TOOL_NAME, DESCRIPTION, SLEEP_TOOL_PROMPT } from './prompt.js' + +const inputSchema = lazySchema(() => + z.strictObject({ + duration_seconds: z + .number() + .describe( + 'How long to sleep in seconds. Can be interrupted by the user at any time.', + ), + }), +) +type InputSchema = ReturnType +type SleepInput = z.infer + +type SleepOutput = { slept_seconds: number; interrupted: boolean } + +export const SleepTool = buildTool({ + name: SLEEP_TOOL_NAME, + searchHint: 'wait pause sleep rest idle duration timer', + maxResultSizeChars: 1_000, + strict: true, + + get inputSchema(): InputSchema { + return inputSchema() + }, + + async description() { + return DESCRIPTION + }, + async prompt() { + return SLEEP_TOOL_PROMPT + }, + + isConcurrencySafe() { + return true + }, + isReadOnly() { + return true + }, + + userFacingName() { + return SLEEP_TOOL_NAME + }, + + renderToolUseMessage(input: Partial) { + const secs = input.duration_seconds ?? '?' + return `Sleep: ${secs}s` + }, + + mapToolResultToToolResultBlockParam( + content: SleepOutput, + toolUseID: string, + ): ToolResultBlockParam { + const msg = content.interrupted + ? `Sleep interrupted after ${content.slept_seconds}s` + : `Slept for ${content.slept_seconds}s` + return { + tool_use_id: toolUseID, + type: 'tool_result', + content: msg, + } + }, + + async call(input: SleepInput, context) { + // Refuse to sleep when proactive mode is off — prevents the model from + // re-issuing Sleep after an interruption caused by /proactive disable. + if (feature('PROACTIVE') || feature('KAIROS')) { + const mod = + require('../../proactive/index.js') as typeof import('../../proactive/index.js') + if (!mod.isProactiveActive()) { + return { + data: { + slept_seconds: 0, + interrupted: true, + }, + } + } + } + + const { duration_seconds } = input + const startTime = Date.now() + + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, duration_seconds * 1000) + + // Abort via user interrupt + context.abortController.signal.addEventListener( + 'abort', + () => { + clearTimeout(timer) + clearInterval(proactiveCheck) + reject(new Error('interrupted')) + }, + { once: true }, + ) + + // Poll proactive state — if deactivated mid-sleep, interrupt early + // so the user doesn't have to wait for the full duration. + const proactiveCheck = + feature('PROACTIVE') || feature('KAIROS') + ? setInterval(() => { + const mod = + require('../../proactive/index.js') as typeof import('../../proactive/index.js') + if (!mod.isProactiveActive()) { + clearTimeout(timer) + clearInterval(proactiveCheck) + reject(new Error('interrupted')) + } + }, 500) + : (null as unknown as ReturnType) + }) + return { + data: { + slept_seconds: duration_seconds, + interrupted: false, + }, + } + } catch { + const elapsed = Math.round((Date.now() - startTime) / 1000) + return { + data: { + slept_seconds: elapsed, + interrupted: true, + }, + } + } + }, +}) diff --git a/src/tools/WorkflowTool/bundled/index.ts b/src/tools/WorkflowTool/bundled/index.ts new file mode 100644 index 000000000..eb6620cd0 --- /dev/null +++ b/src/tools/WorkflowTool/bundled/index.ts @@ -0,0 +1,15 @@ +// Bundled workflow initialization. +// Called by tools.ts when WORKFLOW_SCRIPTS feature flag is enabled. +// Sets up any pre-bundled workflow scripts that ship with the CLI. + +/** + * Initialize bundled workflows. Called once at startup when the + * WORKFLOW_SCRIPTS feature flag is active. This is the hook point + * for registering any workflow scripts that are compiled into the + * binary (as opposed to user-authored ones in .claude/workflows/). + */ +export function initBundledWorkflows(): void { + // Bundled workflows are registered here at startup. + // Currently a no-op — all workflows are user-authored in .claude/workflows/. + // This function exists as the extension point for future built-in workflows. +} diff --git a/src/utils/agentSwarmsEnabled.ts b/src/utils/agentSwarmsEnabled.ts index fac5404c7..72c0b85ee 100644 --- a/src/utils/agentSwarmsEnabled.ts +++ b/src/utils/agentSwarmsEnabled.ts @@ -1,42 +1,11 @@ -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' import { isEnvTruthy } from './envUtils.js' /** - * Check if --agent-teams flag is provided via CLI. - * Checks process.argv directly to avoid import cycles with bootstrap/state. - * Note: The flag is only shown in help for ant users, but if external users - * pass it anyway, it will work (subject to the killswitch). - */ -function isAgentTeamsFlagSet(): boolean { - return process.argv.includes('--agent-teams') -} - -/** - * Centralized runtime check for agent teams/teammate features. - * This is the single gate that should be checked everywhere teammates - * are referenced (prompts, code, tools isEnabled, UI, etc.). - * - * Ant builds: always enabled. - * External builds require both: - * 1. Opt-in via CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS env var OR --agent-teams flag - * 2. GrowthBook gate 'tengu_amber_flint' enabled (killswitch) + * Fork build: enabled by default. Can be disabled via + * CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS_DISABLED=1 if needed. */ export function isAgentSwarmsEnabled(): boolean { - // Ant: always on - if (process.env.USER_TYPE === 'ant') { - return true - } - - // External: require opt-in via env var or --agent-teams flag - if ( - !isEnvTruthy(process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) && - !isAgentTeamsFlagSet() - ) { - return false - } - - // Killswitch — always respected for external users - if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_flint', true)) { + if (isEnvTruthy(process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS_DISABLED)) { return false } diff --git a/src/utils/config.ts b/src/utils/config.ts index 9dd690162..26163821d 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -527,8 +527,8 @@ export type GlobalConfig = { // Permission explainer configuration permissionExplainerEnabled?: boolean // Enable Haiku-generated explanations for permission requests (default: true) - // Teammate spawn mode: 'auto' | 'tmux' | 'in-process' - teammateMode?: 'auto' | 'tmux' | 'in-process' // How to spawn teammates (default: 'auto') + // Teammate spawn mode: 'auto' | 'tmux' | 'windows-terminal' | 'in-process' + teammateMode?: 'auto' | 'tmux' | 'windows-terminal' | 'in-process' // How to spawn teammates (default: 'auto') // Model for new teammates when the tool call doesn't pass one. // undefined = hardcoded Opus (backward-compat); null = leader's model; string = model alias/ID. teammateDefaultModel?: string | null diff --git a/src/utils/swarm/backends/InProcessBackend.ts b/src/utils/swarm/backends/InProcessBackend.ts index 0f43f81fe..6bd96133b 100644 --- a/src/utils/swarm/backends/InProcessBackend.ts +++ b/src/utils/swarm/backends/InProcessBackend.ts @@ -91,6 +91,7 @@ export class InProcessBackend implements TeammateExecutor { prompt: config.prompt, color: config.color, planModeRequired: config.planModeRequired ?? false, + model: config.model, }, this.context, ) @@ -115,6 +116,8 @@ export class InProcessBackend implements TeammateExecutor { }, taskId: result.taskId, prompt: config.prompt, + description: config.description, + agentDefinition: config.agentDefinition, teammateContext: result.teammateContext, // Strip messages: the teammate never reads toolUseContext.messages // (runAgent overrides it via createSubagentContext). Passing the @@ -126,6 +129,7 @@ export class InProcessBackend implements TeammateExecutor { systemPromptMode: config.systemPromptMode, allowedTools: config.permissions, allowPermissionPrompts: config.allowPermissionPrompts, + invokingRequestId: config.invokingRequestId, }) logForDebugging( @@ -138,6 +142,8 @@ export class InProcessBackend implements TeammateExecutor { agentId: result.agentId, taskId: result.taskId, abortController: result.abortController, + backendType: this.type, + color: config.color, error: result.error, } } diff --git a/src/utils/swarm/backends/PaneBackendExecutor.ts b/src/utils/swarm/backends/PaneBackendExecutor.ts index a978e032c..e1436a71d 100644 --- a/src/utils/swarm/backends/PaneBackendExecutor.ts +++ b/src/utils/swarm/backends/PaneBackendExecutor.ts @@ -2,13 +2,15 @@ import { getSessionId } from '../../../bootstrap/state.js' import type { ToolUseContext } from '../../../Tool.js' import { formatAgentId, parseAgentId } from '../../../utils/agentId.js' import { quote } from '../../../utils/bash/shellQuote.js' +import { isInBundledMode } from '../../../utils/bundledMode.js' import { registerCleanup } from '../../../utils/cleanupRegistry.js' import { logForDebugging } from '../../../utils/debug.js' import { jsonStringify } from '../../../utils/slowOperations.js' import { writeToMailbox } from '../../../utils/teammateMailbox.js' import { - buildInheritedCliFlags, + buildInheritedCliArgParts, buildInheritedEnvVars, + getInheritedEnvVarAssignments, getTeammateCommand, } from '../spawnUtils.js' import { assignTeammateColor } from '../teammateLayoutManager.js' @@ -22,6 +24,43 @@ import type { TeammateSpawnResult, } from './types.js' +function quotePowerShellString(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +function withoutModelArg(args: string[]): string[] { + const filtered: string[] = [] + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--model') { + i += 1 + continue + } + filtered.push(args[i]!) + } + return filtered +} + +function buildPowerShellSpawnCommand( + binaryPath: string, + args: string[], + cwd: string, +): string { + const envAssignments = getInheritedEnvVarAssignments().map( + ([key, value]) => `$env:${key} = ${quotePowerShellString(value)}`, + ) + // In dev mode (non-bundled), binaryPath is a .ts/.tsx file that PowerShell + // cannot execute directly. Prepend `bun run` so the teammate process starts + // through Bun's runtime, matching how `bun run dev` works. + const invocation = isInBundledMode() + ? `& ${quotePowerShellString(binaryPath)}` + : `& ${quotePowerShellString(process.execPath)} ${quotePowerShellString(binaryPath)}` + return [ + `Set-Location -LiteralPath ${quotePowerShellString(cwd)}`, + ...envAssignments, + `${invocation} ${args.map(quotePowerShellString).join(' ')}`, + ].join('; ') +} + /** * PaneBackendExecutor adapts a PaneBackend to the TeammateExecutor interface. * @@ -95,12 +134,18 @@ export class PaneBackendExecutor implements TeammateExecutor { // Assign a unique color to this teammate const teammateColor = config.color ?? assignTeammateColor(agentId) - // Create a pane in the swarm view - const { paneId, isFirstTeammate } = - await this.backend.createTeammatePaneInSwarmView( - config.name, - teammateColor, - ) + const paneResult = + config.useSplitPane === false && + this.backend.createTeammateWindowInSwarmView + ? await this.backend.createTeammateWindowInSwarmView( + config.name, + teammateColor, + ) + : await this.backend.createTeammatePaneInSwarmView( + config.name, + teammateColor, + ) + const { paneId, isFirstTeammate } = paneResult // Check if we're inside tmux to determine how to send commands const insideTmux = await isInsideTmux() @@ -115,43 +160,43 @@ export class PaneBackendExecutor implements TeammateExecutor { // Build teammate identity CLI args const teammateArgs = [ - `--agent-id ${quote([agentId])}`, - `--agent-name ${quote([config.name])}`, - `--team-name ${quote([config.teamName])}`, - `--agent-color ${quote([teammateColor])}`, - `--parent-session-id ${quote([config.parentSessionId || getSessionId()])}`, - config.planModeRequired ? '--plan-mode-required' : '', + '--agent-id', + agentId, + '--agent-name', + config.name, + '--team-name', + config.teamName, + '--agent-color', + teammateColor, + '--parent-session-id', + config.parentSessionId || getSessionId(), + ...(config.planModeRequired ? ['--plan-mode-required'] : []), + ...(config.agentType ? ['--agent-type', config.agentType] : []), ] - .filter(Boolean) - .join(' ') // Build CLI flags to propagate to teammate const appState = this.context.getAppState() - let inheritedFlags = buildInheritedCliFlags({ + let inheritedArgParts = buildInheritedCliArgParts({ planModeRequired: config.planModeRequired, permissionMode: appState.toolPermissionContext.mode, }) // If teammate has a custom model, add --model flag (or replace inherited one) if (config.model) { - inheritedFlags = inheritedFlags - .split(' ') - .filter( - (flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model', - ) - .join(' ') - inheritedFlags = inheritedFlags - ? `${inheritedFlags} --model ${quote([config.model])}` - : `--model ${quote([config.model])}` + inheritedArgParts = withoutModelArg(inheritedArgParts) + inheritedArgParts.push('--model', config.model) } - const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : '' const workingDir = config.cwd // Build environment variables to forward to teammate const envStr = buildInheritedEnvVars() - const spawnCommand = `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${teammateArgs}${flagsStr}` + const allArgs = [...teammateArgs, ...inheritedArgParts] + const spawnCommand = + this.type === 'windows-terminal' + ? buildPowerShellSpawnCommand(binaryPath, allArgs, workingDir) + : `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${quote(allArgs)}` // Send the command to the new pane // Use swarm socket when running outside tmux (external swarm session) @@ -193,6 +238,14 @@ export class PaneBackendExecutor implements TeammateExecutor { success: true, agentId, paneId, + backendType: this.type, + color: teammateColor, + insideTmux, + windowName: + 'windowName' in paneResult + ? (paneResult as { windowName: string }).windowName + : undefined, + isSplitPane: config.useSplitPane !== false, } } catch (error) { const errorMessage = diff --git a/src/utils/swarm/backends/TmuxBackend.ts b/src/utils/swarm/backends/TmuxBackend.ts index 402afd8dc..ae72c3b9c 100644 --- a/src/utils/swarm/backends/TmuxBackend.ts +++ b/src/utils/swarm/backends/TmuxBackend.ts @@ -145,6 +145,42 @@ export class TmuxBackend implements PaneBackend { } } + /** + * Creates a separate tmux window for a teammate in the swarm session. + * Used by the legacy `use_splitpane: false` path. + */ + async createTeammateWindowInSwarmView( + name: string, + color: AgentColorName, + ): Promise { + const windowName = `teammate-${name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}` + const { windowTarget } = await this.createExternalSwarmSession() + void windowTarget + + const result = await runTmuxInSwarm([ + 'new-window', + '-t', + SWARM_SESSION_NAME, + '-n', + windowName, + '-P', + '-F', + '#{pane_id}', + ]) + + if (result.code !== 0) { + throw new Error( + `Failed to create tmux window: ${result.stderr || 'Unknown error'}`, + ) + } + + const paneId = result.stdout.trim() + await this.setPaneTitle(paneId, name, color, true) + await this.setPaneBorderColor(paneId, color, true) + + return { paneId, isFirstTeammate: false, windowName } + } + /** * Sends a command to a specific pane. */ diff --git a/src/utils/swarm/backends/WindowsTerminalBackend.ts b/src/utils/swarm/backends/WindowsTerminalBackend.ts new file mode 100644 index 000000000..4e2a2b272 --- /dev/null +++ b/src/utils/swarm/backends/WindowsTerminalBackend.ts @@ -0,0 +1,415 @@ +import { randomUUID } from 'crypto' +import { readFile, unlink } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import type { AgentColorName } from '../../../tools/AgentTool/agentColorManager.js' +import { logForDebugging } from '../../../utils/debug.js' +import { execFileNoThrow } from '../../../utils/execFileNoThrow.js' +import { getPlatform, type Platform } from '../../../utils/platform.js' +import { isInWindowsTerminal } from './detection.js' +import { registerWindowsTerminalBackend } from './registry.js' +import type { CreatePaneResult, PaneBackend, PaneId } from './types.js' + +type CommandResult = { stdout: string; stderr: string; code: number } +type CommandRunner = (command: string, args: string[]) => Promise + +type PaneStatus = 'registered' | 'spawning' | 'ready' | 'killing' | 'dead' + +type WindowsTerminalPane = { + title: string + mode: 'pane' | 'window' + pidFile: string + status: PaneStatus + pid?: number + spawnPromise?: Promise +} + +function quotePowerShellString(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +function wrapPowerShellCommand(command: string, pidFile: string): string { + const quotedPidFile = quotePowerShellString(pidFile) + // PowerShell requires try/catch/finally to be a single compound statement — + // semicolons between the blocks cause "Try 语句缺少自己的 Catch 或 Finally 块". + // Use newlines (\n) so the parser treats it as one statement. + return [ + "$ErrorActionPreference = 'Stop'", + `Set-Content -LiteralPath ${quotedPidFile} -Value $PID`, + [ + `try { ${command}; if ($LASTEXITCODE -is [int]) { exit $LASTEXITCODE } }`, + `catch { Write-Error $_; exit 1 }`, + `finally { Remove-Item -LiteralPath ${quotedPidFile} -Force -ErrorAction SilentlyContinue }`, + ].join('\n'), + ].join('; ') +} + +const WT_PANE_TIMEOUT_DEFAULT_MS = 8000 +const WT_PANE_POLL_INTERVAL_MS = 200 + +function getWtPaneTimeoutMs(): number { + const raw = process.env.CLAUDE_WT_PANE_TIMEOUT_MS + if (!raw) return WT_PANE_TIMEOUT_DEFAULT_MS + const parsed = Number.parseInt(raw, 10) + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : WT_PANE_TIMEOUT_DEFAULT_MS +} + +async function waitForPidFile( + pidFile: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs + let lastErr: unknown + while (Date.now() < deadline) { + try { + const content = (await readFile(pidFile, 'utf-8')).trim() + if (!/^\d+$/.test(content)) { + lastErr = new Error( + `pidFile content not a valid pid: ${JSON.stringify(content)}`, + ) + } else { + const pid = Number.parseInt(content, 10) + if (Number.isFinite(pid) && pid > 0) return pid + lastErr = new Error(`pidFile content parsed to invalid pid: ${pid}`) + } + } catch (err) { + lastErr = err + } + await new Promise(r => setTimeout(r, WT_PANE_POLL_INTERVAL_MS)) + } + throw lastErr ?? new Error('pidFile never appeared') +} + +/** + * WindowsTerminalBackend uses wt.exe to create visible teammate panes/tabs. + * + * Windows Terminal's CLI starts commands directly in a new pane; it does not + * expose a stable pane id that can later receive arbitrary input. To fit the + * PaneBackend contract, createTeammatePaneInSwarmView allocates an internal id, + * and sendCommandToPane performs the actual `wt split-pane` launch. + */ +export class WindowsTerminalBackend implements PaneBackend { + readonly type = 'windows-terminal' as const + readonly displayName = 'Windows Terminal' + readonly supportsHideShow = false + + private panes = new Map() + + private readonly runCommand: CommandRunner + private readonly getPlatformValue: () => Platform + private readonly pidFileDir: string + + constructor( + runCommandOrOptions?: + | CommandRunner + | { + runCommand?: CommandRunner + getPlatform?: () => Platform + pidFileDir?: string + }, + getPlatformValue?: () => Platform, + ) { + if (runCommandOrOptions === undefined) { + this.runCommand = execFileNoThrow + this.getPlatformValue = getPlatformValue ?? getPlatform + this.pidFileDir = tmpdir() + } else if (typeof runCommandOrOptions === 'function') { + this.runCommand = runCommandOrOptions + this.getPlatformValue = getPlatformValue ?? getPlatform + this.pidFileDir = tmpdir() + } else { + this.runCommand = runCommandOrOptions.runCommand ?? execFileNoThrow + this.getPlatformValue = runCommandOrOptions.getPlatform ?? getPlatform + this.pidFileDir = runCommandOrOptions.pidFileDir ?? tmpdir() + } + } + + private makePidFile(paneId: string): string { + return join( + this.pidFileDir, + `${paneId.replace(/[^a-zA-Z0-9_-]/g, '-')}.pid`, + ) + } + + async isAvailable(): Promise { + if (this.getPlatformValue() !== 'windows') { + return false + } + // Do NOT run `wt.exe --version` — wt.exe is a UWP app bridge that opens + // the Windows Terminal app to render version info, producing a phantom + // "Windows 终端 1.24.x" window every time availability is checked. + // Instead, check the WT_SESSION env var (set inside WT) or verify the + // binary exists on PATH without executing it. + if (process.env.WT_SESSION) { + return true + } + const result = await this.runCommand('where.exe', ['wt.exe']) + return result.code === 0 + } + + async isRunningInside(): Promise { + return this.getPlatformValue() === 'windows' && isInWindowsTerminal() + } + + async createTeammatePaneInSwarmView( + name: string, + _color: AgentColorName, + ): Promise { + const paneId = `wt-${randomUUID()}` + const isFirstTeammate = this.panes.size === 0 + this.panes.set(paneId, { + title: name, + mode: 'pane', + pidFile: this.makePidFile(paneId), + status: 'registered', + }) + return { paneId, isFirstTeammate } + } + + async createTeammateWindowInSwarmView( + name: string, + _color: AgentColorName, + ): Promise { + const paneId = `wt-${randomUUID()}` + const windowName = `teammate-${name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}` + this.panes.set(paneId, { + title: name, + mode: 'window', + pidFile: this.makePidFile(paneId), + status: 'registered', + }) + return { paneId, isFirstTeammate: false, windowName } + } + + async sendCommandToPane( + paneId: PaneId, + command: string, + _useExternalSession?: boolean, + ): Promise { + const pane = this.panes.get(paneId) + if (!pane) { + throw new Error(`Unknown Windows Terminal pane id: ${paneId}`) + } + + // 拒绝 ready 态重 spawn(避免同 pidFile 双进程竞争) + if (pane.status === 'ready' || pane.status === 'killing') { + throw new Error( + `Pane ${paneId} already spawned (status=${pane.status}); create a new pane to re-launch`, + ) + } + if (pane.status === 'spawning') { + throw new Error( + `Pane ${paneId} is currently spawning; wait for the in-flight launch to complete`, + ) + } + if (pane.status === 'dead') { + throw new Error(`Pane ${paneId} is dead; create a new pane`) + } + // pane.status === 'registered' → 继续 + + // 提前赋值 spawnPromise 在任何 await 前(inner Promise 包装) + // Attach a no-op .catch() immediately to prevent unhandled rejection warnings + // in case killPane never awaits spawnPromise (e.g. sendCommandToPane fails + // before killPane is called). + let resolveSpawn!: () => void + let rejectSpawn!: (err: unknown) => void + const spawnPromise = new Promise((res, rej) => { + resolveSpawn = res + rejectSpawn = rej + }) + // Silence unhandled-rejection: killPane may .catch() this later, but if + // the pane dies before any kill is attempted, the rejection must not leak. + spawnPromise.catch(() => {}) + pane.status = 'spawning' + pane.spawnPromise = spawnPromise + + try { + const launcher = wrapPowerShellCommand(command, pane.pidFile) + // wt.exe treats ';' as its own command separator, which breaks + // multi-statement PowerShell commands passed via -Command. Encode the + // entire script as Base64 UTF-16LE and use -EncodedCommand instead. + const encoded = Buffer.from(launcher, 'utf16le').toString('base64') + const args = + pane.mode === 'window' + ? ['-w', '-1', 'new-tab', '--title', pane.title] + : ['-w', '0', 'split-pane', '--vertical', '--title', pane.title] + + await unlink(pane.pidFile).catch(() => {}) + + const result = await this.runCommand('wt.exe', [ + ...args, + 'powershell.exe', + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand', + encoded, + ]) + + if (result.code !== 0) { + throw new Error( + `Failed to launch Windows Terminal teammate ${paneId}: ${result.stderr}`, + ) + } + + const timeoutMs = getWtPaneTimeoutMs() + let pid: number + try { + pid = await waitForPidFile(pane.pidFile, timeoutMs) + } catch (err) { + throw new Error( + `Windows Terminal pane failed to launch within ${timeoutMs}ms\n` + + ` paneId: ${paneId}\n` + + ` pidFile: ${pane.pidFile}\n` + + ` wt.exe stdout: ${result.stdout || '(empty)'}\n` + + ` wt.exe stderr: ${result.stderr || '(empty)'}\n` + + ` underlying: ${err instanceof Error ? err.message : String(err)}\n` + + ` override timeout via env CLAUDE_WT_PANE_TIMEOUT_MS`, + ) + } + + pane.pid = pid + pane.status = 'ready' + resolveSpawn() + } catch (err) { + pane.status = 'dead' + pane.pid = undefined + rejectSpawn(err) + throw err + } finally { + pane.spawnPromise = undefined + } + } + + async setPaneBorderColor( + _paneId: PaneId, + _color: AgentColorName, + _useExternalSession?: boolean, + ): Promise { + // Windows Terminal does not expose per-pane border colors through wt.exe. + } + + async setPaneTitle( + _paneId: PaneId, + _name: string, + _color: AgentColorName, + _useExternalSession?: boolean, + ): Promise { + // Title is passed at launch in sendCommandToPane. + } + + async enablePaneBorderStatus( + _windowTarget?: string, + _useExternalSession?: boolean, + ): Promise { + // Not supported by Windows Terminal's wt.exe surface. + } + + async rebalancePanes( + _windowTarget: string, + _hasLeader: boolean, + ): Promise { + // Windows Terminal handles split layout itself. + } + + async killPane( + paneId: PaneId, + _useExternalSession?: boolean, + ): Promise { + const pane = this.panes.get(paneId) + if (!pane) { + return false + } + + // 1. 解 kill-while-spawn race:await spawn 完成(不论成功失败) + if (pane.status === 'spawning' && pane.spawnPromise) { + await pane.spawnPromise.catch(() => {}) + } + + // 2. TOCTOU 修正:重读 status/pid + if (pane.status === 'dead') { + this.panes.delete(paneId) + return false + } + if (pane.status !== 'ready') { + // 还在其它非终态(理论不可达,保险) + return false + } + + pane.status = 'killing' + + // 3. 优先用缓存 pid + let pid: number | undefined = pane.pid + + // 4. fallback:缓存没有则读盘(保留 retry 3×500ms) + if (pid === undefined) { + let pidContent: string | null = null + for (let attempt = 0; attempt < 3; attempt++) { + try { + pidContent = (await readFile(pane.pidFile, 'utf-8')).trim() + break + } catch { + if (attempt === 2) { + pane.status = 'dead' + this.panes.delete(paneId) + return false + } + await new Promise(r => setTimeout(r, 500)) + } + } + if (!pidContent || !/^\d+$/.test(pidContent)) { + pane.status = 'dead' + this.panes.delete(paneId) + return false + } + const parsed = Number.parseInt(pidContent, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + pane.status = 'dead' + this.panes.delete(paneId) + return false + } + pid = parsed + } + + // 5. 执行 Stop-Process + const result = await this.runCommand('powershell.exe', [ + '-NoLogo', + '-NoProfile', + '-Command', + `Stop-Process -Id ${pid} -Force -ErrorAction Stop`, + ]) + + // 6. 不管成功失败都清缓存 + 标 dead + 从 map 删(防 PID 复用误杀) + pane.pid = undefined + pane.status = 'dead' + this.panes.delete(paneId) + + logForDebugging( + `[WindowsTerminalBackend] killPane ${paneId} pid=${pid} code=${result.code}`, + ) + return result.code === 0 + } + + async hidePane( + _paneId: PaneId, + _useExternalSession?: boolean, + ): Promise { + return false + } + + async showPane( + _paneId: PaneId, + _targetWindowOrPane: string, + _useExternalSession?: boolean, + ): Promise { + return false + } +} + +// Register the backend with the registry when this module is imported. +// This side effect is intentional - the registry needs backends to self-register. +// eslint-disable-next-line custom-rules/no-top-level-side-effects +registerWindowsTerminalBackend(WindowsTerminalBackend) diff --git a/src/utils/swarm/backends/detection.ts b/src/utils/swarm/backends/detection.ts index 4812fcd58..e6cb21ca4 100644 --- a/src/utils/swarm/backends/detection.ts +++ b/src/utils/swarm/backends/detection.ts @@ -24,6 +24,9 @@ let isInsideTmuxCached: boolean | null = null /** Cached result for isInITerm2 */ let isInITerm2Cached: boolean | null = null +/** Cached result for isInWindowsTerminal */ +let isInWindowsTerminalCached: boolean | null = null + /** * Checks if we're currently running inside a tmux session (synchronous version). * Uses the original TMUX value captured at module load, not process.env.TMUX, @@ -75,6 +78,19 @@ export async function isTmuxAvailable(): Promise { return result.code === 0 } +/** + * Checks if wt.exe is available without executing it. + * Do NOT run `wt.exe --version` because it can open a visible Windows + * Terminal window just to render version info. + */ +export async function isWindowsTerminalAvailable(): Promise { + if (process.env.WT_SESSION) { + return true + } + const result = await execFileNoThrow('where.exe', ['wt.exe']) + return result.code === 0 +} + /** * Checks if we're currently running inside iTerm2. * Uses multiple detection methods: @@ -103,6 +119,18 @@ export function isInITerm2(): boolean { return isInITerm2Cached } +/** + * Checks if we're currently running inside Windows Terminal. + * Windows Terminal sets WT_SESSION for child processes. + */ +export function isInWindowsTerminal(): boolean { + if (isInWindowsTerminalCached !== null) { + return isInWindowsTerminalCached + } + isInWindowsTerminalCached = !!process.env.WT_SESSION + return isInWindowsTerminalCached +} + /** * The it2 CLI command name. */ @@ -125,4 +153,5 @@ export async function isIt2CliAvailable(): Promise { export function resetDetectionCache(): void { isInsideTmuxCached = null isInITerm2Cached = null + isInWindowsTerminalCached = null } diff --git a/src/utils/swarm/backends/registry.ts b/src/utils/swarm/backends/registry.ts index 4035a821d..b1e0b7d4b 100644 --- a/src/utils/swarm/backends/registry.ts +++ b/src/utils/swarm/backends/registry.ts @@ -1,12 +1,15 @@ import { getIsNonInteractiveSession } from '../../../bootstrap/state.js' import { logForDebugging } from '../../../utils/debug.js' +import { errorMessage } from '../../../utils/errors.js' import { getPlatform } from '../../../utils/platform.js' import { isInITerm2, + isInWindowsTerminal, isInsideTmux, isInsideTmuxSync, isIt2CliAvailable, isTmuxAvailable, + isWindowsTerminalAvailable, } from './detection.js' import { createInProcessBackend } from './InProcessBackend.js' import { getPreferTmuxOverIterm2 } from './it2Setup.js' @@ -65,6 +68,11 @@ let TmuxBackendClass: (new () => PaneBackend) | null = null */ let ITermBackendClass: (new () => PaneBackend) | null = null +/** + * Placeholder for WindowsTerminalBackend. + */ +let WindowsTerminalBackendClass: (new () => PaneBackend) | null = null + /** * Ensures backend classes are dynamically imported so getBackendByType() can * construct them. Unlike detectAndGetBackend(), this never spawns subprocesses @@ -75,6 +83,7 @@ export async function ensureBackendsRegistered(): Promise { if (backendsRegistered) return await import('./TmuxBackend.js') await import('./ITermBackend.js') + await import('./WindowsTerminalBackend.js') backendsRegistered = true } @@ -99,6 +108,12 @@ export function registerITermBackend( ITermBackendClass = backendClass } +export function registerWindowsTerminalBackend( + backendClass: new () => PaneBackend, +): void { + WindowsTerminalBackendClass = backendClass +} + /** * Creates a TmuxBackend instance. * Throws if TmuxBackend hasn't been registered. @@ -125,6 +140,15 @@ function createITermBackend(): PaneBackend { return new ITermBackendClass() } +function createWindowsTerminalBackend(): PaneBackend { + if (!WindowsTerminalBackendClass) { + throw new Error( + 'WindowsTerminalBackend not registered. Import WindowsTerminalBackend.ts before using the registry.', + ) + } + return new WindowsTerminalBackendClass() +} + /** * Detection priority flow: * 1. If inside tmux, always use tmux (even in iTerm2) @@ -150,11 +174,32 @@ export async function detectAndGetBackend(): Promise { // Check all environment conditions upfront for logging const insideTmux = await isInsideTmux() const inITerm2 = isInITerm2() + const inWindowsTerminal = isInWindowsTerminal() logForDebugging( - `[BackendRegistry] Environment: insideTmux=${insideTmux}, inITerm2=${inITerm2}`, + `[BackendRegistry] Environment: insideTmux=${insideTmux}, inITerm2=${inITerm2}, inWindowsTerminal=${inWindowsTerminal}`, ) + if (getTeammateMode() === 'windows-terminal') { + if (getPlatform() !== 'windows') { + throw new Error( + 'Windows Terminal teammate mode is only available on Windows', + ) + } + const wtAvailable = await isWindowsTerminalAvailable() + if (!wtAvailable) { + throw new Error('Windows Terminal teammate mode requires wt.exe in PATH') + } + const backend = createWindowsTerminalBackend() + cachedBackend = backend + cachedDetectionResult = { + backend, + isNative: inWindowsTerminal, + needsIt2Setup: false, + } + return cachedDetectionResult + } + // Priority 1: If inside tmux, always use tmux if (insideTmux) { logForDebugging( @@ -230,7 +275,30 @@ export async function detectAndGetBackend(): Promise { ) } - // Priority 3: Fall back to tmux external session + // Priority 3: Native Windows Terminal panes/tabs — only when actually + // running INSIDE Windows Terminal. If running in VS Code's integrated + // terminal or another non-WT environment, fall through to in-process + // mode instead of opening an external Windows Terminal window. + if (getPlatform() === 'windows' && inWindowsTerminal) { + const wtAvailable = await isWindowsTerminalAvailable() + logForDebugging( + `[BackendRegistry] Inside Windows Terminal, wt.exe available: ${wtAvailable}`, + ) + + if (wtAvailable) { + logForDebugging('[BackendRegistry] Selected: Windows Terminal (wt.exe)') + const backend = createWindowsTerminalBackend() + cachedBackend = backend + cachedDetectionResult = { + backend, + isNative: true, + needsIt2Setup: false, + } + return cachedDetectionResult + } + } + + // Priority 4: Fall back to tmux external session const tmuxAvailable = await isTmuxAvailable() logForDebugging( `[BackendRegistry] Not in tmux or iTerm2, tmux available: ${tmuxAvailable}`, @@ -298,6 +366,8 @@ export function getBackendByType(type: PaneBackendType): PaneBackend { return createTmuxBackend() case 'iterm2': return createITermBackend() + case 'windows-terminal': + return createWindowsTerminalBackend() } } @@ -332,7 +402,11 @@ export function markInProcessFallback(): void { * Gets the teammate mode for this session. * Returns the session snapshot captured at startup, ignoring runtime config changes. */ -function getTeammateMode(): 'auto' | 'tmux' | 'in-process' { +function getTeammateMode(): + | 'auto' + | 'tmux' + | 'windows-terminal' + | 'in-process' { return getTeammateModeFromSnapshot() } @@ -346,6 +420,7 @@ function getTeammateMode(): 'auto' | 'tmux' | 'in-process' { * - If inside tmux, use pane backend (return false) * - If inside iTerm2, use pane backend (return false) - detectAndGetBackend() * will pick ITermBackend if it2 is available, or fall back to tmux + * - If inside Windows Terminal, use pane backend (return false) * - Otherwise, use in-process (return true) */ export function isInProcessEnabled(): boolean { @@ -363,7 +438,7 @@ export function isInProcessEnabled(): boolean { let enabled: boolean if (mode === 'in-process') { enabled = true - } else if (mode === 'tmux') { + } else if (mode === 'tmux' || mode === 'windows-terminal') { enabled = false } else { // 'auto' mode - if a prior spawn fell back to in-process because no pane @@ -376,14 +451,26 @@ export function isInProcessEnabled(): boolean { return true } // Check if a pane backend environment is available - // If inside tmux or iTerm2, use pane backend; otherwise use in-process + // If inside tmux, iTerm2, or Windows Terminal, use pane backend; otherwise use in-process const insideTmux = isInsideTmuxSync() const inITerm2 = isInITerm2() - enabled = !insideTmux && !inITerm2 + const inWindowsTerminal = isInWindowsTerminal() + if ( + !insideTmux && + !inITerm2 && + !inWindowsTerminal && + getPlatform() === 'windows' + ) { + // On Windows, even outside Windows Terminal (e.g. VS Code terminal, cmd.exe), + // wt.exe may still be available. Let detectAndGetBackend() do the full async check. + enabled = false + } else { + enabled = !insideTmux && !inITerm2 && !inWindowsTerminal + } } logForDebugging( - `[BackendRegistry] isInProcessEnabled: ${enabled} (mode=${mode}, insideTmux=${isInsideTmuxSync()}, inITerm2=${isInITerm2()})`, + `[BackendRegistry] isInProcessEnabled: ${enabled} (mode=${mode})`, ) return enabled } @@ -393,8 +480,15 @@ export function isInProcessEnabled(): boolean { * Unlike getTeammateModeFromSnapshot which may return 'auto', this returns * what 'auto' actually resolves to given the current environment. */ -export function getResolvedTeammateMode(): 'in-process' | 'tmux' { - return isInProcessEnabled() ? 'in-process' : 'tmux' +export function getResolvedTeammateMode(): + | 'in-process' + | 'tmux' + | 'windows-terminal' { + if (isInProcessEnabled()) return 'in-process' + const mode = getTeammateMode() + if (mode === 'windows-terminal') return 'windows-terminal' + if (mode === 'auto' && getPlatform() === 'windows') return 'windows-terminal' + return 'tmux' } /** @@ -424,24 +518,51 @@ export function getInProcessBackend(): TeammateExecutor { */ export async function getTeammateExecutor( preferInProcess: boolean = false, + options?: { + onNeedsIt2Setup?: ( + tmuxAvailable: boolean, + ) => Promise<'installed' | 'use-tmux' | 'cancelled'> + }, ): Promise { if (preferInProcess && isInProcessEnabled()) { logForDebugging('[BackendRegistry] Using in-process executor') return getInProcessBackend() } - // Return pane backend executor - logForDebugging('[BackendRegistry] Using pane backend executor') - return getPaneBackendExecutor() + try { + logForDebugging('[BackendRegistry] Using pane backend executor') + return await getPaneBackendExecutor(options) + } catch (error) { + if (getTeammateModeFromSnapshot() !== 'auto') { + throw error + } + logForDebugging( + `[BackendRegistry] No pane backend available, falling back to in-process: ${errorMessage(error)}`, + ) + markInProcessFallback() + return getInProcessBackend() + } } /** * Gets the PaneBackendExecutor instance. * Creates and caches the instance on first call, detecting the appropriate pane backend. */ -async function getPaneBackendExecutor(): Promise { +async function getPaneBackendExecutor(options?: { + onNeedsIt2Setup?: ( + tmuxAvailable: boolean, + ) => Promise<'installed' | 'use-tmux' | 'cancelled'> +}): Promise { if (!cachedPaneBackendExecutor) { const detection = await detectAndGetBackend() + if (detection.needsIt2Setup && options?.onNeedsIt2Setup) { + const setupResult = await options.onNeedsIt2Setup(await isTmuxAvailable()) + if (setupResult === 'cancelled') { + throw new Error('Teammate spawn cancelled - iTerm2 setup required') + } + resetBackendDetection() + return getPaneBackendExecutor(options) + } cachedPaneBackendExecutor = createPaneBackendExecutor(detection.backend) logForDebugging( `[BackendRegistry] Created PaneBackendExecutor wrapping ${detection.backend.type}`, diff --git a/src/utils/swarm/backends/teammateModeSnapshot.ts b/src/utils/swarm/backends/teammateModeSnapshot.ts index e73f9d61d..535458605 100644 --- a/src/utils/swarm/backends/teammateModeSnapshot.ts +++ b/src/utils/swarm/backends/teammateModeSnapshot.ts @@ -10,7 +10,7 @@ import { getGlobalConfig } from '../../../utils/config.js' import { logForDebugging } from '../../../utils/debug.js' import { logError } from '../../../utils/log.js' -export type TeammateMode = 'auto' | 'tmux' | 'in-process' +export type TeammateMode = 'auto' | 'tmux' | 'windows-terminal' | 'in-process' // Module-level variable to hold the captured mode at startup let initialTeammateMode: TeammateMode | null = null diff --git a/src/utils/swarm/backends/types.ts b/src/utils/swarm/backends/types.ts index b57964c15..2e20894da 100644 --- a/src/utils/swarm/backends/types.ts +++ b/src/utils/swarm/backends/types.ts @@ -1,23 +1,27 @@ import type { AgentColorName } from '../../../tools/AgentTool/agentColorManager.js' +import type { CustomAgentDefinition } from '../../../tools/AgentTool/loadAgentsDir.js' +import type { ToolUseContext } from '../../../Tool.js' /** * Types of backends available for teammate execution. * - 'tmux': Uses tmux for pane management (works in tmux or standalone) * - 'iterm2': Uses iTerm2 native split panes via the it2 CLI + * - 'windows-terminal': Uses Windows Terminal panes/tabs via wt.exe * - 'in-process': Runs teammate in the same Node.js process with isolated context */ -export type BackendType = 'tmux' | 'iterm2' | 'in-process' +export type BackendType = 'tmux' | 'iterm2' | 'windows-terminal' | 'in-process' /** * Subset of BackendType for pane-based backends only. * Used in messages and types that specifically deal with terminal panes. */ -export type PaneBackendType = 'tmux' | 'iterm2' +export type PaneBackendType = 'tmux' | 'iterm2' | 'windows-terminal' /** * Opaque identifier for a pane managed by a backend. * For tmux, this is the tmux pane ID (e.g., "%1"). * For iTerm2, this is the session ID returned by it2. + * For Windows Terminal, this is an internal id mapped to the spawned shell PID. */ export type PaneId = string @@ -73,6 +77,15 @@ export type PaneBackend = { color: AgentColorName, ): Promise + /** + * Creates a separate terminal window/tab for a teammate when supported. + * This preserves the legacy `use_splitpane: false` behavior. + */ + createTeammateWindowInSwarmView?( + name: string, + color: AgentColorName, + ): Promise + /** * Sends a command to execute in a specific pane. * @@ -209,14 +222,24 @@ export type TeammateSpawnConfig = TeammateIdentity & { cwd: string /** Model to use for this teammate */ model?: string + /** Optional custom agent type for process-based teammates. */ + agentType?: string + /** Optional resolved custom agent definition for in-process teammates. */ + agentDefinition?: CustomAgentDefinition + /** Short description of the task, used for prompt display. */ + description?: string /** System prompt for this teammate (resolved from workflow config) */ systemPrompt?: string /** How to apply the system prompt: 'replace' or 'append' to default */ systemPromptMode?: 'default' | 'replace' | 'append' /** Optional git worktree path */ worktreePath?: string + /** false preserves legacy separate-window spawning for pane-capable backends. */ + useSplitPane?: boolean /** Parent session ID (for context linking) */ parentSessionId: string + /** request_id of the API call that spawned this teammate. */ + invokingRequestId?: string /** Tool permissions to grant this teammate */ permissions?: string[] /** Whether this teammate can show permission prompts for unlisted tools. @@ -251,6 +274,16 @@ export type TeammateSpawnResult = { /** Pane ID (pane-based only) */ paneId?: PaneId + /** Backend used for the spawned teammate. */ + backendType?: BackendType + /** Assigned color for display. */ + color?: AgentColorName + /** Whether the pane was spawned inside the user's current tmux session. */ + insideTmux?: boolean + /** Window/tab name when the backend created a separate window. */ + windowName?: string + /** Whether the backend used split panes. */ + isSplitPane?: boolean } /** @@ -280,6 +313,9 @@ export type TeammateExecutor = { /** Backend type identifier */ readonly type: BackendType + /** Provide AppState/tool context before lifecycle operations that need it. */ + setContext?(context: ToolUseContext): void + /** Check if this executor is available on the system */ isAvailable(): Promise @@ -306,6 +342,8 @@ export type TeammateExecutor = { /** * Type guard to check if a backend type uses terminal panes. */ -export function isPaneBackend(type: BackendType): type is 'tmux' | 'iterm2' { - return type === 'tmux' || type === 'iterm2' +export function isPaneBackend( + type: BackendType, +): type is 'tmux' | 'iterm2' | 'windows-terminal' { + return type === 'tmux' || type === 'iterm2' || type === 'windows-terminal' } diff --git a/src/utils/swarm/inProcessRunner.ts b/src/utils/swarm/inProcessRunner.ts index 245acf19f..6e37a5491 100644 --- a/src/utils/swarm/inProcessRunner.ts +++ b/src/utils/swarm/inProcessRunner.ts @@ -668,6 +668,8 @@ type WaitResult = | { type: 'new_message' message: string + autonomyRunId?: string + autonomyRootDir?: string from: string color?: string summary?: string @@ -710,7 +712,9 @@ async function waitForNextPromptOrShutdown( task.type === 'in_process_teammate' && task.pendingUserMessages.length > 0 ) { - const message = task.pendingUserMessages[0]! // Safe: checked length > 0 + const pending = task.pendingUserMessages[0]! // Safe: checked length > 0 + const message = + typeof pending === 'string' ? pending : pending.message // Pop the message from the queue setAppState(prev => { const prevTask = prev.tasks[taskId] @@ -734,6 +738,12 @@ async function waitForNextPromptOrShutdown( return { type: 'new_message', message, + ...(typeof pending !== 'string' && pending.autonomyRunId + ? { autonomyRunId: pending.autonomyRunId } + : {}), + ...(typeof pending !== 'string' && pending.autonomyRootDir + ? { autonomyRootDir: pending.autonomyRootDir } + : {}), from: 'user', } } diff --git a/src/utils/swarm/spawnInProcess.ts b/src/utils/swarm/spawnInProcess.ts index 7f548f28a..3132dcddb 100644 --- a/src/utils/swarm/spawnInProcess.ts +++ b/src/utils/swarm/spawnInProcess.ts @@ -24,6 +24,7 @@ import type { TeammateIdentity, } from '../../tasks/InProcessTeammateTask/types.js' import { createAbortController } from '../abortController.js' +import { markAutonomyRunFailed } from '../autonomyRuns.js' import { formatAgentId } from '../agentId.js' import { registerCleanup } from '../cleanupRegistry.js' import { logForDebugging } from '../debug.js' @@ -233,6 +234,7 @@ export function killInProcessTeammate( let agentId: string | null = null let toolUseId: string | undefined let description: string | undefined + let pendingAutonomyRuns: Array<{ runId: string; rootDir?: string }> = [] setAppState((prev: AppState) => { const task = prev.tasks[taskId] @@ -252,6 +254,20 @@ export function killInProcessTeammate( toolUseId = teammateTask.toolUseId description = teammateTask.description + // Capture pending autonomy run IDs before clearing them + pendingAutonomyRuns = teammateTask.pendingUserMessages.flatMap(message => + typeof message !== 'string' && message.autonomyRunId + ? [ + { + runId: message.autonomyRunId, + ...(message.autonomyRootDir + ? { rootDir: message.autonomyRootDir } + : {}), + }, + ] + : [], + ) + // Abort the controller to stop execution teammateTask.abortController?.abort() @@ -304,6 +320,13 @@ export function killInProcessTeammate( } if (killed) { + for (const run of pendingAutonomyRuns) { + void markAutonomyRunFailed( + run.runId, + `Teammate ${agentId ?? taskId} was stopped before it could consume the queued autonomy prompt.`, + run.rootDir, + ) + } void evictTaskOutput(taskId) // notified:true was pre-set so no XML notification fires; close the SDK // task_started bookend directly. The in-process runner's own @@ -326,3 +349,35 @@ export function killInProcessTeammate( return killed } + +/** + * Kills an in-process teammate by logical agent ID. + * Used by team-level UI/actions where the stable identifier is + * "name@team", not the AppState task id. + */ +export function killInProcessTeammateByAgentId( + agentIdToKill: string, + setAppState: SetAppStateFn, +): boolean { + let taskIdToKill: string | undefined + + setAppState((prev: AppState) => { + for (const [taskId, task] of Object.entries(prev.tasks)) { + if ( + task.type === 'in_process_teammate' && + task.identity.agentId === agentIdToKill && + task.status === 'running' + ) { + taskIdToKill = taskId + break + } + } + return prev + }) + + if (!taskIdToKill) { + return false + } + + return killInProcessTeammate(taskIdToKill, setAppState) +} diff --git a/src/utils/swarm/spawnUtils.ts b/src/utils/swarm/spawnUtils.ts index cfccdf5a2..5aaa9386b 100644 --- a/src/utils/swarm/spawnUtils.ts +++ b/src/utils/swarm/spawnUtils.ts @@ -39,6 +39,13 @@ export function buildInheritedCliFlags(options?: { planModeRequired?: boolean permissionMode?: PermissionMode }): string { + return quote(buildInheritedCliArgParts(options)) +} + +export function buildInheritedCliArgParts(options?: { + planModeRequired?: boolean + permissionMode?: PermissionMode +}): string[] { const flags: string[] = [] const { planModeRequired, permissionMode } = options || {} @@ -52,30 +59,33 @@ export function buildInheritedCliFlags(options?: { ) { flags.push('--dangerously-skip-permissions') } else if (permissionMode === 'acceptEdits') { - flags.push('--permission-mode acceptEdits') + flags.push('--permission-mode', 'acceptEdits') + } else if (permissionMode === 'auto') { + // Teammates inherit auto mode so the classifier evaluates their tool calls too. + flags.push('--permission-mode', 'auto') } // Propagate --model if explicitly set via CLI const modelOverride = getMainLoopModelOverride() if (modelOverride) { - flags.push(`--model ${quote([modelOverride])}`) + flags.push('--model', modelOverride) } // Propagate --settings if set via CLI const settingsPath = getFlagSettingsPath() if (settingsPath) { - flags.push(`--settings ${quote([settingsPath])}`) + flags.push('--settings', settingsPath) } // Propagate --plugin-dir for each inline plugin const inlinePlugins = getInlinePlugins() for (const pluginDir of inlinePlugins) { - flags.push(`--plugin-dir ${quote([pluginDir])}`) + flags.push('--plugin-dir', pluginDir) } // Propagate --teammate-mode so tmux teammates use the same mode as leader const sessionMode = getTeammateModeFromSnapshot() - flags.push(`--teammate-mode ${sessionMode}`) + flags.push('--teammate-mode', sessionMode) // Propagate --chrome / --no-chrome if explicitly set on the CLI const chromeFlagOverride = getChromeFlagOverride() @@ -85,7 +95,7 @@ export function buildInheritedCliFlags(options?: { flags.push('--no-chrome') } - return flags.join(' ') + return flags } /** @@ -133,14 +143,23 @@ const TEAMMATE_ENV_VARS = [ * plus any provider/config env vars that are set in the current process. */ export function buildInheritedEnvVars(): string { - const envVars = ['CLAUDECODE=1', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1'] + return getInheritedEnvVarAssignments() + .map(([key, value]) => `${key}=${quote([value])}`) + .join(' ') +} + +export function getInheritedEnvVarAssignments(): Array<[string, string]> { + const envVars: Array<[string, string]> = [ + ['CLAUDECODE', '1'], + ['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', '1'], + ] for (const key of TEAMMATE_ENV_VARS) { const value = process.env[key] if (value !== undefined && value !== '') { - envVars.push(`${key}=${quote([value])}`) + envVars.push([key, value]) } } - return envVars.join(' ') + return envVars }