Enable coordinator swarm features

This commit is contained in:
James Feng 2026-06-01 20:33:29 +08:00
parent 916d84d295
commit 042cf907c3
34 changed files with 2011 additions and 154 deletions

View File

@ -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_<NAME>=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_'))

View File

@ -1,3 +0,0 @@
// Auto-generated stub — replace with real implementation
export {};
export const AssistantSessionChooser: (props: Record<string, unknown>) => null = () => null;

View File

@ -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 (
<Dialog title="Select Assistant Session" onCancel={onCancel} hideInputGuide>
<Box flexDirection="column" gap={1}>
<Text>Multiple sessions found. Select one to attach:</Text>
<Box flexDirection="column">
{sessions.map((s, i) => (
<ListItem key={s.id} isFocused={focusIndex === i}>
<Box>
<Text>{s.title || s.id.slice(0, 20)}</Text>
<Text dimColor> [{s.status}]</Text>
</Box>
</ListItem>
))}
</Box>
<Text dimColor> navigate · Enter select · Esc cancel</Text>
</Box>
</Dialog>
);
}

View File

@ -1,3 +1,23 @@
// Auto-generated stub — replace with real implementation
export {};
export const isKairosEnabled: () => Promise<boolean> = () => 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<boolean> {
if (!feature('KAIROS')) {
return false
}
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)) {
return false
}
return true
}

View File

@ -1,8 +1,135 @@
// Auto-generated stub — replace with real implementation
export {};
export const isAssistantMode: () => boolean = () => false;
export const initializeAssistantTeam: () => Promise<void> = 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'
}

View File

@ -1,3 +1,51 @@
// Auto-generated stub — replace with real implementation
export type AssistantSession = { id: string; [key: string]: unknown };
export const discoverAssistantSessions: () => Promise<AssistantSession[]> = () => 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<AssistantSession[]> {
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 ?? '',
}))
}

View File

@ -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,

View File

@ -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<string> = (() => Promise.resolve(''));

View File

@ -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<string> {
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 (
<Dialog title="Assistant Setup" onCancel={onCancel} hideInputGuide>
<Text>Starting daemon in {defaultDir}...</Text>
</Dialog>
);
}
return (
<Dialog title="Assistant Setup" onCancel={onCancel} hideInputGuide>
<Box flexDirection="column" gap={1}>
<Text>No active assistant sessions found.</Text>
<Text>
Start a daemon in <Text bold>{defaultDir || '.'}</Text> to create a cloud session?
</Text>
<Box flexDirection="column">
<ListItem isFocused={focusIndex === 0}>
<Text>Start assistant daemon</Text>
</ListItem>
<ListItem isFocused={focusIndex === 1}>
<Text>Cancel</Text>
</ListItem>
</Box>
<Text dimColor>Enter to select · Esc to cancel</Text>
</Box>
</Dialog>
);
}
/**
* /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<React.ReactNode> {
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<string, unknown>).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;
}

View File

@ -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
}

View File

@ -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

View File

@ -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<React.ReactNode> {
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: [
'<system-reminder>\nCoordinator mode is now disabled. You have access to all standard tools again. Work directly instead of dispatching to workers.\n</system-reminder>',
],
})
} 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: [
'<system-reminder>\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</system-reminder>',
],
},
)
}
return null
},
}),
} satisfies Command
export default coordinator

56
src/commands/proactive.ts Normal file
View File

@ -0,0 +1,56 @@
/**
* /proactive Toggle proactive (autonomous tick-driven) mode.
*
* When enabled, the model receives periodic <tick> 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<React.ReactNode> {
// 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: [
'<system-reminder>\nProactive mode is now enabled. You will receive periodic <tick> 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</system-reminder>',
],
},
)
}
return null
},
}),
} satisfies Command
export default proactive

View File

@ -5663,9 +5663,9 @@ async function run(): Promise<CommanderCommand> {
program.addOption(
new Option(
"--teammate-mode <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,

View File

@ -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 <tick> 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
}

View File

@ -0,0 +1,102 @@
/**
* useProactive React hook that drives tick generation for proactive mode.
*
* Mounted inside REPL.tsx when feature('PROACTIVE') || feature('KAIROS').
* Generates <tick>HH:MM:SS</tick> 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<typeof setTimeout> | 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()}</${TICK_TAG}>`
// 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(),
])
}

View File

@ -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<string>
// Queue of user messages to deliver when viewing teammate transcript
pendingUserMessages: string[]
pendingUserMessages: Array<string | PendingTeammateUserMessage>
// UI: random spinner verbs (stable across re-renders, shared between components)
spinnerVerb?: string

View File

@ -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<typeof inputSchema>
type PushInput = z.infer<InputSchema>
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<PushInput>) {
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.',
},
}
},
})

View File

@ -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<typeof inputSchema>
type SendUserFileInput = z.infer<InputSchema>
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<SendUserFileInput>) {
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.',
},
}
},
})

View File

@ -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<typeof inputSchema>
type SleepInput = z.infer<InputSchema>
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<SleepInput>) {
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<void>((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<typeof setInterval>)
})
return {
data: {
slept_seconds: duration_seconds,
interrupted: false,
},
}
} catch {
const elapsed = Math.round((Date.now() - startTime) / 1000)
return {
data: {
slept_seconds: elapsed,
interrupted: true,
},
}
}
},
})

View File

@ -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.
}

View File

@ -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
}

View File

@ -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

View File

@ -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,
}
}

View File

@ -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(
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 =

View File

@ -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<CreatePaneResult & { windowName: string }> {
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.
*/

View File

@ -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<CommandResult>
type PaneStatus = 'registered' | 'spawning' | 'ready' | 'killing' | 'dead'
type WindowsTerminalPane = {
title: string
mode: 'pane' | 'window'
pidFile: string
status: PaneStatus
pid?: number
spawnPromise?: Promise<void>
}
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<number> {
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<PaneId, WindowsTerminalPane>()
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<boolean> {
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<boolean> {
return this.getPlatformValue() === 'windows' && isInWindowsTerminal()
}
async createTeammatePaneInSwarmView(
name: string,
_color: AgentColorName,
): Promise<CreatePaneResult> {
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<CreatePaneResult & { windowName: string }> {
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<void> {
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<void>((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<void> {
// Windows Terminal does not expose per-pane border colors through wt.exe.
}
async setPaneTitle(
_paneId: PaneId,
_name: string,
_color: AgentColorName,
_useExternalSession?: boolean,
): Promise<void> {
// Title is passed at launch in sendCommandToPane.
}
async enablePaneBorderStatus(
_windowTarget?: string,
_useExternalSession?: boolean,
): Promise<void> {
// Not supported by Windows Terminal's wt.exe surface.
}
async rebalancePanes(
_windowTarget: string,
_hasLeader: boolean,
): Promise<void> {
// Windows Terminal handles split layout itself.
}
async killPane(
paneId: PaneId,
_useExternalSession?: boolean,
): Promise<boolean> {
const pane = this.panes.get(paneId)
if (!pane) {
return false
}
// 1. 解 kill-while-spawn raceawait 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<boolean> {
return false
}
async showPane(
_paneId: PaneId,
_targetWindowOrPane: string,
_useExternalSession?: boolean,
): Promise<boolean> {
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)

View File

@ -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<boolean> {
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<boolean> {
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<boolean> {
export function resetDetectionCache(): void {
isInsideTmuxCached = null
isInITerm2Cached = null
isInWindowsTerminalCached = null
}

View File

@ -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<void> {
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<BackendDetectionResult> {
// 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<BackendDetectionResult> {
)
}
// 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<TeammateExecutor> {
if (preferInProcess && isInProcessEnabled()) {
logForDebugging('[BackendRegistry] Using in-process executor')
return getInProcessBackend()
}
// Return pane backend executor
try {
logForDebugging('[BackendRegistry] Using pane backend executor')
return getPaneBackendExecutor()
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<TeammateExecutor> {
async function getPaneBackendExecutor(options?: {
onNeedsIt2Setup?: (
tmuxAvailable: boolean,
) => Promise<'installed' | 'use-tmux' | 'cancelled'>
}): Promise<TeammateExecutor> {
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}`,

View File

@ -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

View File

@ -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<CreatePaneResult>
/**
* 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<CreatePaneResult & { windowName: string }>
/**
* 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<boolean>
@ -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'
}

View File

@ -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',
}
}

View File

@ -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)
}

View File

@ -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
}