Enable coordinator swarm features
This commit is contained in:
parent
916d84d295
commit
042cf907c3
25
build.ts
25
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_<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_'))
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const AssistantSessionChooser: (props: Record<string, unknown>) => null = () => null;
|
||||
54
src/assistant/AssistantSessionChooser.tsx
Normal file
54
src/assistant/AssistantSessionChooser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? '',
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(''));
|
||||
175
src/commands/assistant/assistant.tsx
Normal file
175
src/commands/assistant/assistant.tsx
Normal 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;
|
||||
}
|
||||
21
src/commands/assistant/gate.ts
Normal file
21
src/commands/assistant/gate.ts
Normal 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
|
||||
}
|
||||
16
src/commands/assistant/index.ts
Normal file
16
src/commands/assistant/index.ts
Normal 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
|
||||
63
src/commands/coordinator.ts
Normal file
63
src/commands/coordinator.ts
Normal 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
56
src/commands/proactive.ts
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
102
src/proactive/useProactive.ts
Normal file
102
src/proactive/useProactive.ts
Normal 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(),
|
||||
])
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
87
src/tools/PushNotificationTool/PushNotificationTool.ts
Normal file
87
src/tools/PushNotificationTool/PushNotificationTool.ts
Normal 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.',
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
84
src/tools/SendUserFileTool/SendUserFileTool.ts
Normal file
84
src/tools/SendUserFileTool/SendUserFileTool.ts
Normal 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.',
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
134
src/tools/SleepTool/SleepTool.ts
Normal file
134
src/tools/SleepTool/SleepTool.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
15
src/tools/WorkflowTool/bundled/index.ts
Normal file
15
src/tools/WorkflowTool/bundled/index.ts
Normal 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.
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
415
src/utils/swarm/backends/WindowsTerminalBackend.ts
Normal file
415
src/utils/swarm/backends/WindowsTerminalBackend.ts
Normal 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 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<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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user