fix(team): harden codex login and runtime previews
This commit is contained in:
parent
8d06ee81c2
commit
5730ddc7af
25 changed files with 735 additions and 103 deletions
13
README.md
13
README.md
|
|
@ -276,6 +276,19 @@ pnpm dev
|
|||
|
||||
The app auto-discovers Claude Code projects from `~/.claude/`.
|
||||
|
||||
### Debug teammate runtimes
|
||||
|
||||
Development launches use the app-managed process backend for teammates by default. To inspect
|
||||
teammates in `tmux` panes while debugging, start the desktop app with:
|
||||
|
||||
```bash
|
||||
CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
||||
```
|
||||
|
||||
The same override is available per launch from custom CLI args with
|
||||
`--teammate-mode tmux`. Use this as an operator/debug mode; the default process backend provides
|
||||
stronger app-owned lifecycle, diagnostics, and cleanup for normal team launches.
|
||||
|
||||
### Build for distribution
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
| [openclaw-agent-teams-integration.md](./openclaw-agent-teams-integration.md) | How to connect OpenClaw or another outside AI through Agent Teams MCP and REST control API |
|
||||
| [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) |
|
||||
| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync |
|
||||
| [debugging-agent-teams.md](./debugging-agent-teams.md) | Runtime debugging runbook, включая `CLAUDE_TEAM_TEAMMATE_MODE=tmux` для pane-backed teammate debug |
|
||||
|
||||
## Ключевые решения
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,34 @@ Primary launch and OpenCode secondary lanes are different paths.
|
|||
|
||||
When a launch hangs at `Prepared communication channels for X/Y members`, check whether `Y` incorrectly includes secondary OpenCode members. The filesystem monitor should wait for `effectiveMembers`, not every requested member.
|
||||
|
||||
## Teammate Runtime Debug Mode
|
||||
|
||||
Desktop launches use the app-managed process backend by default. That is the supported default for
|
||||
normal app launches because the app owns the process lifecycle, runtime logs, cleanup, and bootstrap
|
||||
evidence.
|
||||
|
||||
For local debugging, force pane-backed teammates through `tmux`:
|
||||
|
||||
```bash
|
||||
CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
||||
```
|
||||
|
||||
For a single launch from the UI, add this to custom CLI args:
|
||||
|
||||
```bash
|
||||
--teammate-mode tmux
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
- `tmux` mode should remove `CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES` from the launch env.
|
||||
- The desktop app should pass `--teammate-mode tmux` to the runtime CLI.
|
||||
- The orchestrator should report `backend_type: "tmux"` and `tmux_pane_id` like `%1`.
|
||||
- If `tmux` is unavailable, the launch dialog should block explicit tmux mode with a tmux readiness message.
|
||||
|
||||
Use this mode to inspect interactive CLI behavior, terminal prompts, and pane output. Do not treat it
|
||||
as equivalent to the process backend for recovery semantics; persisted pane IDs can help discovery,
|
||||
but app restart does not make old panes a fully app-owned runtime again.
|
||||
|
||||
## Member State Meanings
|
||||
|
||||
Common `launch-state.json` cases:
|
||||
|
|
|
|||
|
|
@ -382,7 +382,7 @@ export function useGraphMemberLogPreviews(input: {
|
|||
return;
|
||||
}
|
||||
if (event.type === 'task-log-change') {
|
||||
scheduleReload(false);
|
||||
scheduleReload(true);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,10 @@ function normalizeMemberName(value: string): string {
|
|||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function buildRenderedItemKey(memberName: string, itemId: string): string {
|
||||
return `${normalizeMemberName(memberName)}:${itemId}`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
const parsed = Date.parse(timestamp);
|
||||
if (!Number.isFinite(parsed)) return '';
|
||||
|
|
@ -145,7 +149,11 @@ function trimRepeatedTitlePrefix(preview: string, title: string): string {
|
|||
function compactPreviewText(item: MemberLogPreviewItem, displayTitle: string): string {
|
||||
const preview = item.preview?.trim();
|
||||
if (preview) {
|
||||
const compact = trimRepeatedTitlePrefix(preview, displayTitle);
|
||||
const rawTitle = item.title.trim();
|
||||
const compact = trimRepeatedTitlePrefix(
|
||||
trimRepeatedTitlePrefix(preview, rawTitle),
|
||||
displayTitle
|
||||
);
|
||||
return compact || preview;
|
||||
}
|
||||
if (item.kind === 'tool_result') {
|
||||
|
|
@ -244,45 +252,45 @@ export const GraphMemberLogPreviewHud = ({
|
|||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const newItemIds: string[] = [];
|
||||
const newItemKeys: string[] = [];
|
||||
for (const [memberKey, preview] of previewsByMember) {
|
||||
const currentIds = new Set(preview.items.map((item) => item.id));
|
||||
const knownIds = knownItemIdsByMemberRef.current.get(memberKey);
|
||||
if (knownIds) {
|
||||
for (const itemId of currentIds) {
|
||||
if (!knownIds.has(itemId)) {
|
||||
newItemIds.push(itemId);
|
||||
newItemKeys.push(buildRenderedItemKey(memberKey, itemId));
|
||||
}
|
||||
}
|
||||
}
|
||||
knownItemIdsByMemberRef.current.set(memberKey, currentIds);
|
||||
}
|
||||
|
||||
if (newItemIds.length === 0) return;
|
||||
if (newItemKeys.length === 0) return;
|
||||
|
||||
setHighlightedItemIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const itemId of newItemIds) {
|
||||
next.add(itemId);
|
||||
for (const itemKey of newItemKeys) {
|
||||
next.add(itemKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
for (const itemId of newItemIds) {
|
||||
const existingTimer = highlightTimersRef.current.get(itemId);
|
||||
for (const itemKey of newItemKeys) {
|
||||
const existingTimer = highlightTimersRef.current.get(itemKey);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
highlightTimersRef.current.delete(itemId);
|
||||
highlightTimersRef.current.delete(itemKey);
|
||||
setHighlightedItemIds((current) => {
|
||||
if (!current.has(itemId)) return current;
|
||||
if (!current.has(itemKey)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(itemId);
|
||||
next.delete(itemKey);
|
||||
return next;
|
||||
});
|
||||
}, NEW_LOG_HIGHLIGHT_MS);
|
||||
highlightTimersRef.current.set(itemId, timer);
|
||||
highlightTimersRef.current.set(itemKey, timer);
|
||||
}
|
||||
}, [enabled, previewsByMember]);
|
||||
|
||||
|
|
@ -424,7 +432,7 @@ export const GraphMemberLogPreviewHud = ({
|
|||
const titleText = relativeTime
|
||||
? `${displayTitle} ${relativeTime} ${fullPreviewText}`
|
||||
: `${displayTitle} ${fullPreviewText}`;
|
||||
const isHighlighted = highlightedItemIds.has(item.id);
|
||||
const isHighlighted = highlightedItemIds.has(buildRenderedItemKey(memberName, item.id));
|
||||
const isError = item.tone === 'error';
|
||||
const rowStateClassName = isHighlighted
|
||||
? isError
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export interface CodexLoginStateDto {
|
|||
error: string | null;
|
||||
startedAt: string | null;
|
||||
authUrl?: string | null;
|
||||
userCode?: string | null;
|
||||
}
|
||||
|
||||
export interface CodexRuntimeContextDto {
|
||||
|
|
|
|||
|
|
@ -693,6 +693,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
error: loginState.status === 'failed' ? loginState.error : null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export class CodexLoginSessionManager {
|
|||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
};
|
||||
private pendingStartToken: symbol | null = null;
|
||||
private activeSession: {
|
||||
|
|
@ -72,6 +73,7 @@ export class CodexLoginSessionManager {
|
|||
error: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -89,7 +91,7 @@ export class CodexLoginSessionManager {
|
|||
|
||||
const response = await session.request<CodexAppServerLoginAccountResponse>(
|
||||
'account/login/start',
|
||||
{ type: 'chatgpt' },
|
||||
{ type: 'chatgptDeviceCode' },
|
||||
LOGIN_REQUEST_TIMEOUT_MS
|
||||
);
|
||||
|
||||
|
|
@ -98,15 +100,19 @@ export class CodexLoginSessionManager {
|
|||
return;
|
||||
}
|
||||
|
||||
if (response.type !== 'chatgpt') {
|
||||
if (response.type !== 'chatgptDeviceCode') {
|
||||
throw new Error('Codex app-server returned an unexpected login response type');
|
||||
}
|
||||
|
||||
const authUrl = new URL(response.authUrl);
|
||||
const authUrl = new URL(response.verificationUrl);
|
||||
if (authUrl.protocol !== 'https:') {
|
||||
throw new Error('Codex app-server returned a non-https auth URL');
|
||||
}
|
||||
|
||||
if (!response.userCode.trim()) {
|
||||
throw new Error('Codex app-server returned an empty ChatGPT login code');
|
||||
}
|
||||
|
||||
const disposeNotificationListener = session.onNotification((method, params) => {
|
||||
if (method !== 'account/login/completed') {
|
||||
return;
|
||||
|
|
@ -137,6 +143,7 @@ export class CodexLoginSessionManager {
|
|||
error: null,
|
||||
startedAt: this.state.startedAt,
|
||||
authUrl: authUrl.toString(),
|
||||
userCode: response.userCode,
|
||||
});
|
||||
} catch (error) {
|
||||
const wasAbandonedDuringStart =
|
||||
|
|
@ -159,6 +166,7 @@ export class CodexLoginSessionManager {
|
|||
error: error instanceof Error ? error.message : String(error),
|
||||
startedAt: this.state.startedAt,
|
||||
authUrl: this.state.authUrl,
|
||||
userCode: this.state.userCode,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -172,6 +180,7 @@ export class CodexLoginSessionManager {
|
|||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
});
|
||||
this.emitSettled();
|
||||
return;
|
||||
|
|
@ -183,6 +192,7 @@ export class CodexLoginSessionManager {
|
|||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -211,6 +221,7 @@ export class CodexLoginSessionManager {
|
|||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
});
|
||||
this.emitSettled();
|
||||
}
|
||||
|
|
@ -226,6 +237,7 @@ export class CodexLoginSessionManager {
|
|||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -240,6 +252,7 @@ export class CodexLoginSessionManager {
|
|||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -262,6 +275,7 @@ export class CodexLoginSessionManager {
|
|||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
|
|
@ -269,6 +283,7 @@ export class CodexLoginSessionManager {
|
|||
error: notification.error ?? 'ChatGPT login failed.',
|
||||
startedAt: this.state.startedAt,
|
||||
authUrl: this.state.authUrl,
|
||||
userCode: this.state.userCode,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -290,6 +305,7 @@ export class CodexLoginSessionManager {
|
|||
error: errorMessage,
|
||||
startedAt: this.state.startedAt,
|
||||
authUrl: this.state.authUrl,
|
||||
userCode: this.state.userCode,
|
||||
});
|
||||
this.emitSettled();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ export type CodexAppServerLoginAccountParams =
|
|||
| {
|
||||
type: 'chatgpt';
|
||||
}
|
||||
| {
|
||||
type: 'chatgptDeviceCode';
|
||||
}
|
||||
| {
|
||||
type: 'chatgptAuthTokens';
|
||||
accessToken: string;
|
||||
|
|
@ -57,6 +60,12 @@ export type CodexAppServerLoginAccountResponse =
|
|||
loginId: string;
|
||||
authUrl: string;
|
||||
}
|
||||
| {
|
||||
type: 'chatgptDeviceCode';
|
||||
loginId: string;
|
||||
verificationUrl: string;
|
||||
userCode: string;
|
||||
}
|
||||
| { type: 'chatgptAuthTokens' };
|
||||
|
||||
export type CodexAppServerLogoutAccountResponse = Record<string, never>;
|
||||
|
|
|
|||
|
|
@ -16496,54 +16496,10 @@ export class TeamProvisioningService {
|
|||
stdoutLineBuf += text;
|
||||
const lines = stdoutLineBuf.split('\n');
|
||||
stdoutLineBuf = lines.pop() ?? '';
|
||||
run.stdoutParserCarry = stdoutLineBuf;
|
||||
const trimmedCarry = stdoutLineBuf.trim();
|
||||
if (!trimmedCarry) {
|
||||
run.stdoutParserCarryIsCompleteJson = false;
|
||||
run.stdoutParserCarryLooksLikeClaudeJson = false;
|
||||
} else {
|
||||
try {
|
||||
JSON.parse(trimmedCarry);
|
||||
run.stdoutParserCarryIsCompleteJson = true;
|
||||
} catch {
|
||||
run.stdoutParserCarryIsCompleteJson = false;
|
||||
}
|
||||
run.stdoutParserCarryLooksLikeClaudeJson = looksLikeClaudeStdoutJsonFragment(trimmedCarry);
|
||||
}
|
||||
this.updateStdoutParserCarry(run, stdoutLineBuf);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const msg = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
// Only reset stall timer on messages that represent actual API progress
|
||||
// (assistant response or result). System messages like retry attempts
|
||||
// (type=system, subtype=attempt) are informational — the CLI is still
|
||||
// waiting for the API and the user should see the stall warning.
|
||||
const msgType = msg.type;
|
||||
if (msgType === 'assistant' || msgType === 'result') {
|
||||
run.lastStdoutReceivedAt = Date.now();
|
||||
if (run.stallWarningIndex != null) {
|
||||
const removedIndex = run.stallWarningIndex;
|
||||
run.provisioningOutputParts.splice(removedIndex, 1);
|
||||
this.shiftProvisioningOutputIndexesAfterRemoval(run, removedIndex);
|
||||
run.stallWarningIndex = null;
|
||||
if (run.preStallMessage != null) {
|
||||
run.progress.message = run.preStallMessage;
|
||||
run.preStallMessage = null;
|
||||
delete run.progress.messageSeverity;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.handleStreamJsonMessage(run, msg);
|
||||
} catch {
|
||||
// Not valid JSON — check for auth failure in raw text output
|
||||
this.handleAuthFailureInOutput(run, trimmed, 'stdout');
|
||||
if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed, 'stdout')) {
|
||||
// Show warning but do NOT kill — the SDK may be retrying internally (e.g. 429 model_cooldown).
|
||||
// If all retries fail, result.subtype="error" will catch it and kill then.
|
||||
this.emitApiErrorWarning(run, trimmed);
|
||||
}
|
||||
}
|
||||
this.handleStdoutParserLine(run, trimmed);
|
||||
}
|
||||
|
||||
const currentTs = Date.now();
|
||||
|
|
@ -16554,6 +16510,76 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
|
||||
private updateStdoutParserCarry(run: ProvisioningRun, carry: string): void {
|
||||
run.stdoutParserCarry = carry;
|
||||
const trimmedCarry = carry.trim();
|
||||
if (!trimmedCarry) {
|
||||
run.stdoutParserCarryIsCompleteJson = false;
|
||||
run.stdoutParserCarryLooksLikeClaudeJson = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(trimmedCarry);
|
||||
run.stdoutParserCarryIsCompleteJson = true;
|
||||
} catch {
|
||||
run.stdoutParserCarryIsCompleteJson = false;
|
||||
}
|
||||
run.stdoutParserCarryLooksLikeClaudeJson = looksLikeClaudeStdoutJsonFragment(trimmedCarry);
|
||||
}
|
||||
|
||||
private flushStdoutParserCarry(run: ProvisioningRun): void {
|
||||
const trimmed = run.stdoutParserCarry.trim();
|
||||
if (!trimmed || !run.stdoutParserCarryIsCompleteJson) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleStdoutParserLine(run, trimmed);
|
||||
this.updateStdoutParserCarry(run, '');
|
||||
}
|
||||
|
||||
private handleStdoutParserLine(run: ProvisioningRun, trimmed: string): void {
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
this.handleParsedStdoutJsonMessage(run, msg);
|
||||
} catch {
|
||||
// Not valid JSON - check for auth failure in raw text output.
|
||||
this.handleAuthFailureInOutput(run, trimmed, 'stdout');
|
||||
if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed, 'stdout')) {
|
||||
// Show warning but do not kill - the SDK may be retrying internally (e.g. 429 model_cooldown).
|
||||
// If all retries fail, result.subtype="error" will catch it and kill then.
|
||||
this.emitApiErrorWarning(run, trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleParsedStdoutJsonMessage(run: ProvisioningRun, msg: Record<string, unknown>): void {
|
||||
// Only reset stall timer on messages that represent actual API progress
|
||||
// (assistant response or result). System messages like retry attempts
|
||||
// (type=system, subtype=attempt) are informational - the CLI is still
|
||||
// waiting for the API and the user should see the stall warning.
|
||||
const msgType = msg.type;
|
||||
if (msgType === 'assistant' || msgType === 'result') {
|
||||
run.lastStdoutReceivedAt = Date.now();
|
||||
if (run.stallWarningIndex != null) {
|
||||
const removedIndex = run.stallWarningIndex;
|
||||
run.provisioningOutputParts.splice(removedIndex, 1);
|
||||
this.shiftProvisioningOutputIndexesAfterRemoval(run, removedIndex);
|
||||
run.stallWarningIndex = null;
|
||||
if (run.preStallMessage != null) {
|
||||
run.progress.message = run.preStallMessage;
|
||||
run.preStallMessage = null;
|
||||
delete run.progress.messageSeverity;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.handleStreamJsonMessage(run, msg);
|
||||
}
|
||||
|
||||
/** Attaches the stderr handler with auth failure detection. */
|
||||
private attachStderrHandler(run: ProvisioningRun): void {
|
||||
const child = run.child;
|
||||
|
|
@ -20364,7 +20390,7 @@ export class TeamProvisioningService {
|
|||
private markUnconfirmedBootstrapMembersFailed(
|
||||
run: ProvisioningRun,
|
||||
reason: string,
|
||||
options?: { cleanupRequested?: boolean }
|
||||
options?: { cleanupRequested?: boolean; preserveExistingFailure?: boolean }
|
||||
): void {
|
||||
const failedAt = nowIso();
|
||||
const baseReason = reason.trim() || 'Deterministic bootstrap failed before teammate check-in.';
|
||||
|
|
@ -20373,6 +20399,15 @@ export class TeamProvisioningService {
|
|||
if (prev.bootstrapConfirmed || prev.skippedForLaunch) {
|
||||
continue;
|
||||
}
|
||||
const hasExistingFailure =
|
||||
prev.status === 'error' ||
|
||||
prev.launchState === 'failed_to_start' ||
|
||||
prev.hardFailure === true ||
|
||||
Boolean(prev.error) ||
|
||||
Boolean(prev.hardFailureReason);
|
||||
if (options?.preserveExistingFailure && hasExistingFailure) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const runtimeWasAlive = prev.runtimeAlive === true || prev.livenessSource === 'process';
|
||||
const hardFailureReason = runtimeWasAlive
|
||||
|
|
@ -28315,11 +28350,16 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete && !run.cancelRequested) {
|
||||
this.markUnconfirmedBootstrapMembersFailed(
|
||||
run,
|
||||
'Launch ended before teammate bootstrap completed.',
|
||||
{ cleanupRequested: true }
|
||||
);
|
||||
const cleanupReason =
|
||||
typeof run.progress.error === 'string' && run.progress.error.trim()
|
||||
? run.progress.error.trim()
|
||||
: run.progress.state === 'failed' && run.progress.message.trim()
|
||||
? run.progress.message.trim()
|
||||
: 'Launch ended before teammate bootstrap completed.';
|
||||
this.markUnconfirmedBootstrapMembersFailed(run, cleanupReason, {
|
||||
cleanupRequested: true,
|
||||
preserveExistingFailure: true,
|
||||
});
|
||||
void this.persistLaunchStateSnapshot(run, 'finished');
|
||||
}
|
||||
this.resetRuntimeToolActivity(run);
|
||||
|
|
@ -28615,6 +28655,8 @@ export class TeamProvisioningService {
|
|||
return;
|
||||
}
|
||||
|
||||
this.flushStdoutParserCarry(run);
|
||||
|
||||
// IMPORTANT: stopStallWatchdog MUST be AFTER authRetryInProgress guard above!
|
||||
// During respawn, the old process exit fires but run.stallCheckHandle already
|
||||
// points to the NEW process's watchdog. Stopping it here would kill the wrong timer.
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ interface DesktopTeammateModeDecision {
|
|||
forceProcessTeammates: boolean;
|
||||
}
|
||||
|
||||
type DesktopTeammateMode = 'auto' | 'tmux' | 'in-process';
|
||||
|
||||
const DESKTOP_TEAMMATE_MODE_ENV = 'CLAUDE_TEAM_TEAMMATE_MODE';
|
||||
|
||||
let tmuxAvailablePromise: Promise<boolean> | null = null;
|
||||
|
||||
function getExplicitTeammateMode(
|
||||
rawExtraCliArgs: string | undefined
|
||||
): 'auto' | 'tmux' | 'in-process' | null {
|
||||
function getExplicitTeammateMode(rawExtraCliArgs: string | undefined): DesktopTeammateMode | null {
|
||||
const tokens = parseCliArgs(rawExtraCliArgs);
|
||||
for (let i = 0; i < tokens.length; i += 1) {
|
||||
const token = tokens[i];
|
||||
|
|
@ -34,6 +36,17 @@ function getExplicitTeammateMode(
|
|||
return null;
|
||||
}
|
||||
|
||||
function normalizeDesktopTeammateMode(value: string | undefined): DesktopTeammateMode | null {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === 'auto' || normalized === 'tmux' || normalized === 'in-process'
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
|
||||
function getEnvTeammateMode(env: NodeJS.ProcessEnv): DesktopTeammateMode | null {
|
||||
return normalizeDesktopTeammateMode(env[DESKTOP_TEAMMATE_MODE_ENV]);
|
||||
}
|
||||
|
||||
async function isTmuxAvailable(): Promise<boolean> {
|
||||
if (!tmuxAvailablePromise) {
|
||||
tmuxAvailablePromise = isTmuxRuntimeReadyForCurrentPlatform()
|
||||
|
|
@ -48,24 +61,25 @@ async function isTmuxAvailable(): Promise<boolean> {
|
|||
}
|
||||
|
||||
export async function resolveDesktopTeammateModeDecision(
|
||||
rawExtraCliArgs: string | undefined
|
||||
rawExtraCliArgs: string | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): Promise<DesktopTeammateModeDecision> {
|
||||
const explicitMode = getExplicitTeammateMode(rawExtraCliArgs);
|
||||
if (explicitMode === 'tmux') {
|
||||
const requestedMode = getExplicitTeammateMode(rawExtraCliArgs) ?? getEnvTeammateMode(env);
|
||||
if (requestedMode === 'tmux') {
|
||||
return {
|
||||
injectedTeammateMode: 'tmux',
|
||||
forceProcessTeammates: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedMode === 'auto') {
|
||||
return {
|
||||
injectedTeammateMode: null,
|
||||
forceProcessTeammates: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (explicitMode === 'auto') {
|
||||
return {
|
||||
injectedTeammateMode: null,
|
||||
forceProcessTeammates: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (explicitMode === 'in-process') {
|
||||
if (requestedMode === 'in-process') {
|
||||
return {
|
||||
injectedTeammateMode: null,
|
||||
forceProcessTeammates: false,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ import {
|
|||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||
import {
|
||||
CodexLoginLinkCopyButton,
|
||||
CodexLoginUserCodeBadge,
|
||||
} from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||
import {
|
||||
formatProviderStatusText,
|
||||
getProviderConnectionModeSummary,
|
||||
|
|
@ -103,7 +106,9 @@ function getCodexDashboardHint(provider: CliProviderStatus): string | null {
|
|||
}
|
||||
|
||||
if (codex.login.status === 'starting' || codex.login.status === 'pending') {
|
||||
return codex.login.authUrl ? 'Finish ChatGPT login in the browser.' : null;
|
||||
return codex.login.authUrl
|
||||
? 'Finish ChatGPT login in the browser. Enter the shown code if prompted.'
|
||||
: null;
|
||||
}
|
||||
|
||||
const usageHint = codex.localActiveChatgptAccountPresent
|
||||
|
|
@ -718,6 +723,7 @@ const InstalledBanner = ({
|
|||
provider.connection?.codex?.login.status !== 'starting' &&
|
||||
provider.connection?.codex?.login.status !== 'pending';
|
||||
const codexLoginAuthUrl = provider.connection?.codex?.login.authUrl ?? null;
|
||||
const codexLoginUserCode = provider.connection?.codex?.login.userCode ?? null;
|
||||
const showCodexLoginActions = codexNeedsReconnect || Boolean(codexLoginAuthUrl);
|
||||
const disconnectAction = getProviderDisconnectAction(provider);
|
||||
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
||||
|
|
@ -888,9 +894,11 @@ const InstalledBanner = ({
|
|||
<>
|
||||
<CodexLoginLinkCopyButton
|
||||
authUrl={codexLoginAuthUrl}
|
||||
userCode={codexLoginUserCode}
|
||||
disabled={codexReconnectBusy || actionDisabled}
|
||||
size="xs"
|
||||
/>
|
||||
<CodexLoginUserCodeBadge userCode={codexLoginUserCode} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { TmuxInstallerBannerView } from '@features/tmux-installer/renderer';
|
||||
|
||||
import type { JSX } from 'react';
|
||||
|
||||
export const TmuxStatusBanner = (): JSX.Element => {
|
||||
return <TmuxInstallerBannerView />;
|
||||
export const TmuxStatusBanner = (): JSX.Element | null => {
|
||||
// tmux is now a debug/operator runtime mode, not a default production requirement.
|
||||
// return <TmuxInstallerBannerView />;
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import { Check, Copy } from 'lucide-react';
|
|||
|
||||
interface CodexLoginLinkCopyButtonProps {
|
||||
authUrl?: string | null;
|
||||
userCode?: string | null;
|
||||
disabled?: boolean;
|
||||
size?: 'xs' | 'sm';
|
||||
}
|
||||
|
||||
export const CodexLoginLinkCopyButton = ({
|
||||
authUrl,
|
||||
userCode,
|
||||
disabled = false,
|
||||
size = 'sm',
|
||||
}: CodexLoginLinkCopyButtonProps): React.JSX.Element | null => {
|
||||
|
|
@ -17,7 +19,7 @@ export const CodexLoginLinkCopyButton = ({
|
|||
|
||||
useEffect(() => {
|
||||
setCopyState('idle');
|
||||
}, [authUrl]);
|
||||
}, [authUrl, userCode]);
|
||||
|
||||
if (!authUrl) {
|
||||
return null;
|
||||
|
|
@ -29,7 +31,8 @@ export const CodexLoginLinkCopyButton = ({
|
|||
return;
|
||||
}
|
||||
|
||||
void navigator.clipboard.writeText(authUrl).then(
|
||||
const text = userCode ? `${authUrl}\nCode: ${userCode}` : authUrl;
|
||||
void navigator.clipboard.writeText(text).then(
|
||||
() => setCopyState('copied'),
|
||||
() => setCopyState('failed')
|
||||
);
|
||||
|
|
@ -47,10 +50,40 @@ export const CodexLoginLinkCopyButton = ({
|
|||
borderColor: 'rgba(245, 158, 11, 0.28)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.08)',
|
||||
}}
|
||||
title="Copy ChatGPT login link"
|
||||
title={userCode ? 'Copy ChatGPT login link and code' : 'Copy ChatGPT login link'}
|
||||
>
|
||||
{copyState === 'copied' ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copyState === 'copied' ? 'Copied' : copyState === 'failed' ? 'Copy failed' : 'Copy link'}
|
||||
{copyState === 'copied'
|
||||
? 'Copied'
|
||||
: copyState === 'failed'
|
||||
? 'Copy failed'
|
||||
: userCode
|
||||
? 'Copy link + code'
|
||||
: 'Copy link'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodexLoginUserCodeBadge = ({
|
||||
userCode,
|
||||
}: {
|
||||
userCode?: string | null;
|
||||
}): React.JSX.Element | null => {
|
||||
if (!userCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.22)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.06)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
title="Enter this code on the ChatGPT login page"
|
||||
>
|
||||
Code <span className="font-mono tracking-wide text-amber-100">{userCode}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ import {
|
|||
import { RuntimeProviderManagementPanel } from '@features/runtime-provider-management/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||
import {
|
||||
CodexLoginLinkCopyButton,
|
||||
CodexLoginUserCodeBadge,
|
||||
} from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -718,6 +721,7 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
const codexLoginPending =
|
||||
codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending';
|
||||
const codexLoginAuthUrl = codexConnection?.login.authUrl ?? null;
|
||||
const codexLoginUserCode = codexConnection?.login.userCode ?? null;
|
||||
const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? [];
|
||||
const configuredAuthMode: CliProviderAuthMode | undefined =
|
||||
selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined;
|
||||
|
|
@ -1395,8 +1399,10 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
<>
|
||||
<CodexLoginLinkCopyButton
|
||||
authUrl={codexLoginAuthUrl}
|
||||
userCode={codexLoginUserCode}
|
||||
disabled={codexActionBusy}
|
||||
/>
|
||||
<CodexLoginUserCodeBadge userCode={codexLoginUserCode} />
|
||||
{codexLoginAuthUrl ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -1430,8 +1436,10 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
<>
|
||||
<CodexLoginLinkCopyButton
|
||||
authUrl={codexLoginAuthUrl}
|
||||
userCode={codexLoginUserCode}
|
||||
disabled={codexActionBusy}
|
||||
/>
|
||||
<CodexLoginUserCodeBadge userCode={codexLoginUserCode} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||
import {
|
||||
CodexLoginLinkCopyButton,
|
||||
CodexLoginUserCodeBadge,
|
||||
} from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||
import { api } from '@renderer/api';
|
||||
import { LogIn } from 'lucide-react';
|
||||
|
||||
|
|
@ -62,10 +65,12 @@ export function shouldShowCodexReconnectPrompt({
|
|||
|
||||
export const CodexReconnectPrompt = ({
|
||||
authUrl,
|
||||
userCode,
|
||||
reconnectBusy,
|
||||
onReconnect,
|
||||
}: {
|
||||
authUrl: string | null;
|
||||
userCode: string | null;
|
||||
reconnectBusy: boolean;
|
||||
onReconnect: () => void;
|
||||
}): React.JSX.Element => {
|
||||
|
|
@ -80,9 +85,15 @@ export const CodexReconnectPrompt = ({
|
|||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="min-w-0 flex-1 text-[11px] text-amber-100/90">
|
||||
Codex found the local ChatGPT account, but this session is stale. Sign in with ChatGPT,
|
||||
then finish login in the browser and retry this dialog.
|
||||
enter the code if shown, then retry this dialog.
|
||||
</p>
|
||||
<CodexLoginLinkCopyButton authUrl={authUrl} disabled={reconnectBusy} size="xs" />
|
||||
<CodexLoginUserCodeBadge userCode={userCode} />
|
||||
<CodexLoginLinkCopyButton
|
||||
authUrl={authUrl}
|
||||
userCode={userCode}
|
||||
disabled={reconnectBusy}
|
||||
size="xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -2177,6 +2177,7 @@ export const CreateTeamDialog = ({
|
|||
<div className="pl-6">
|
||||
<CodexReconnectPrompt
|
||||
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
||||
userCode={codexAccount.snapshot?.login.userCode ?? null}
|
||||
reconnectBusy={codexAccount.loading}
|
||||
onReconnect={handleCodexReconnect}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2910,6 +2910,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
<div className="pl-6">
|
||||
<CodexReconnectPrompt
|
||||
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
||||
userCode={codexAccount.snapshot?.login.userCode ?? null}
|
||||
reconnectBusy={codexAccount.loading}
|
||||
onReconnect={handleCodexReconnect}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -31,9 +31,10 @@ function createSession(overrides?: {
|
|||
const request =
|
||||
overrides?.request ??
|
||||
vi.fn().mockResolvedValue({
|
||||
type: 'chatgpt',
|
||||
type: 'chatgptDeviceCode',
|
||||
loginId: 'login-1',
|
||||
authUrl: 'https://chatgpt.com/auth',
|
||||
verificationUrl: 'https://chatgpt.com/auth',
|
||||
userCode: 'ABCD-EFGH',
|
||||
});
|
||||
const close = overrides?.close ?? vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
|
|
@ -101,9 +102,10 @@ describe('CodexLoginSessionManager', () => {
|
|||
await Promise.all([firstStart, secondStart]);
|
||||
|
||||
expect(fakeSession.request).toHaveBeenCalledTimes(1);
|
||||
expect(openExternalMock).toHaveBeenCalledTimes(1);
|
||||
expect(openExternalMock).not.toHaveBeenCalled();
|
||||
expect(manager.getState().status).toBe('pending');
|
||||
expect(manager.getState().authUrl).toBe('https://chatgpt.com/auth');
|
||||
expect(manager.getState().userCode).toBe('ABCD-EFGH');
|
||||
});
|
||||
|
||||
it('cancels a login cleanly while the app-server session is still starting', async () => {
|
||||
|
|
@ -137,6 +139,7 @@ describe('CodexLoginSessionManager', () => {
|
|||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -173,6 +176,7 @@ describe('CodexLoginSessionManager', () => {
|
|||
error: null,
|
||||
startedAt: null,
|
||||
authUrl: null,
|
||||
userCode: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1294,6 +1294,70 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
expect(persist).toHaveBeenCalledWith(run, 'finished');
|
||||
});
|
||||
|
||||
it('preserves specific member launch failures when cleanup applies its fallback reason', () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const timeoutReason = 'Teammate was registered but did not bootstrap-confirm before timeout.';
|
||||
const specificReason = 'OpenCode bridge reported member launch failure';
|
||||
const run = createClaudeLogsRun({
|
||||
runId: 'run-cleanup-preserves-specific-launch-failure',
|
||||
teamName: 'cleanup-preserves-specific-launch-failure-team',
|
||||
isLaunch: true,
|
||||
provisioningComplete: false,
|
||||
cancelRequested: false,
|
||||
expectedMembers: ['bob', 'carol'],
|
||||
provisioningOutputParts: [],
|
||||
progress: {
|
||||
runId: 'run-cleanup-preserves-specific-launch-failure',
|
||||
teamName: 'cleanup-preserves-specific-launch-failure-team',
|
||||
state: 'failed',
|
||||
message: 'Deterministic bootstrap failed',
|
||||
startedAt: '2026-04-19T10:00:00.000Z',
|
||||
updatedAt: '2026-04-19T10:00:01.000Z',
|
||||
error: timeoutReason,
|
||||
},
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
error: specificReason,
|
||||
hardFailure: true,
|
||||
hardFailureReason: specificReason,
|
||||
bootstrapConfirmed: false,
|
||||
}),
|
||||
],
|
||||
[
|
||||
'carol',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null);
|
||||
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
(svc as any).provisioningRunByTeam.set(run.teamName, run.runId);
|
||||
|
||||
(svc as any).cleanupRun(run);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailureReason: specificReason,
|
||||
});
|
||||
expect(run.memberSpawnStatuses.get('carol')).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailureReason: timeoutReason,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('member spawn status launch reads', () => {
|
||||
|
|
@ -12406,6 +12470,70 @@ describe('TeamProvisioningService', () => {
|
|||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('flushes a final newline-less bootstrap completion event before handling launch close', async () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'launch-close-flushes-final-json-team';
|
||||
const leadSessionId = 'lead-session-final-json-flush';
|
||||
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']);
|
||||
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
const child = createRunningChild();
|
||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||
|
||||
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
|
||||
removeConfigFile: vi.fn(async () => {}),
|
||||
} as any);
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { ANTHROPIC_API_KEY: 'test' },
|
||||
authSource: 'anthropic_api_key',
|
||||
}));
|
||||
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||
members: [{ name: 'alice' }],
|
||||
source: 'members-meta',
|
||||
warning: undefined,
|
||||
}));
|
||||
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
||||
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
|
||||
(svc as any).startFilesystemMonitor = vi.fn();
|
||||
(svc as any).pathExists = vi.fn(async (targetPath: string) =>
|
||||
targetPath.endsWith(`${leadSessionId}.jsonl`)
|
||||
);
|
||||
const complete = vi
|
||||
.spyOn(svc as any, 'handleProvisioningTurnComplete')
|
||||
.mockImplementation(async (run: any) => {
|
||||
run.provisioningComplete = true;
|
||||
});
|
||||
|
||||
const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {});
|
||||
|
||||
child.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
type: 'system',
|
||||
subtype: 'team_bootstrap',
|
||||
event: 'completed',
|
||||
run_id: runId,
|
||||
team_name: teamName,
|
||||
seq: 1,
|
||||
failed_members: [],
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(complete).not.toHaveBeenCalled();
|
||||
|
||||
child.emit('close', 0);
|
||||
|
||||
await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('clears stale team-scoped transient state before starting a new launch run', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.useFakeTimers();
|
||||
|
|
|
|||
|
|
@ -7,9 +7,16 @@ vi.mock('@features/tmux-installer/main', () => ({
|
|||
}));
|
||||
|
||||
describe('runtimeTeammateMode', () => {
|
||||
const originalTeamMateModeEnv = process.env.CLAUDE_TEAM_TEAMMATE_MODE;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
if (originalTeamMateModeEnv === undefined) {
|
||||
delete process.env.CLAUDE_TEAM_TEAMMATE_MODE;
|
||||
} else {
|
||||
process.env.CLAUDE_TEAM_TEAMMATE_MODE = originalTeamMateModeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not inject tmux mode in default desktop launch when tmux runtime is ready', async () => {
|
||||
|
|
@ -34,6 +41,21 @@ describe('runtimeTeammateMode', () => {
|
|||
expect(decision.injectedTeammateMode).toBeNull();
|
||||
});
|
||||
|
||||
it('honors explicit tmux mode as a debug opt-out from process teammates', async () => {
|
||||
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
||||
const { resolveDesktopTeammateModeDecision } =
|
||||
await import('@main/services/team/runtimeTeammateMode');
|
||||
|
||||
const decision = await resolveDesktopTeammateModeDecision('--teammate-mode tmux');
|
||||
const equalsDecision = await resolveDesktopTeammateModeDecision('--teammate-mode=tmux');
|
||||
|
||||
expect(decision.forceProcessTeammates).toBe(false);
|
||||
expect(decision.injectedTeammateMode).toBe('tmux');
|
||||
expect(equalsDecision.forceProcessTeammates).toBe(false);
|
||||
expect(equalsDecision.injectedTeammateMode).toBe('tmux');
|
||||
expect(mockIsTmuxRuntimeReadyForCurrentPlatform).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats explicit auto mode as automatic process teammate selection without injection', async () => {
|
||||
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
||||
const { resolveDesktopTeammateModeDecision } =
|
||||
|
|
@ -49,6 +71,45 @@ describe('runtimeTeammateMode', () => {
|
|||
expect(mockIsTmuxRuntimeReadyForCurrentPlatform).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honors CLAUDE_TEAM_TEAMMATE_MODE=tmux for desktop debug launches', async () => {
|
||||
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
||||
process.env.CLAUDE_TEAM_TEAMMATE_MODE = 'tmux';
|
||||
const { resolveDesktopTeammateModeDecision } =
|
||||
await import('@main/services/team/runtimeTeammateMode');
|
||||
|
||||
const decision = await resolveDesktopTeammateModeDecision(undefined);
|
||||
|
||||
expect(decision.forceProcessTeammates).toBe(false);
|
||||
expect(decision.injectedTeammateMode).toBe('tmux');
|
||||
expect(mockIsTmuxRuntimeReadyForCurrentPlatform).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets explicit teammate mode args override CLAUDE_TEAM_TEAMMATE_MODE', async () => {
|
||||
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
||||
process.env.CLAUDE_TEAM_TEAMMATE_MODE = 'tmux';
|
||||
const { resolveDesktopTeammateModeDecision } =
|
||||
await import('@main/services/team/runtimeTeammateMode');
|
||||
|
||||
const decision = await resolveDesktopTeammateModeDecision('--teammate-mode=in-process');
|
||||
|
||||
expect(decision.forceProcessTeammates).toBe(false);
|
||||
expect(decision.injectedTeammateMode).toBeNull();
|
||||
expect(mockIsTmuxRuntimeReadyForCurrentPlatform).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores unsupported CLAUDE_TEAM_TEAMMATE_MODE values', async () => {
|
||||
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
||||
process.env.CLAUDE_TEAM_TEAMMATE_MODE = 'pane';
|
||||
const { resolveDesktopTeammateModeDecision } =
|
||||
await import('@main/services/team/runtimeTeammateMode');
|
||||
|
||||
const decision = await resolveDesktopTeammateModeDecision(undefined);
|
||||
|
||||
expect(decision.forceProcessTeammates).toBe(true);
|
||||
expect(decision.injectedTeammateMode).toBeNull();
|
||||
expect(mockIsTmuxRuntimeReadyForCurrentPlatform).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors explicit in-process mode as an opt-out from process teammates', async () => {
|
||||
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
||||
const { resolveDesktopTeammateModeDecision } =
|
||||
|
|
|
|||
|
|
@ -1779,7 +1779,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
'Usage limits appear only after Codex refreshes the currently selected ChatGPT session. Right now the local session needs reconnect. API key fallback is available if you switch auth mode.'
|
||||
);
|
||||
const reconnectButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Reconnect ChatGPT'
|
||||
(button) => button.textContent?.trim() === 'Generate link'
|
||||
);
|
||||
expect(reconnectButton).toBeTruthy();
|
||||
|
||||
|
|
|
|||
|
|
@ -968,7 +968,7 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
expect(host.textContent).toContain(
|
||||
'Codex has a locally selected ChatGPT account, but the current session needs reconnect before usage limits can load here. The detected API key is only used after you switch Codex to API key mode.'
|
||||
);
|
||||
expect(host.textContent).toContain('Reconnect ChatGPT');
|
||||
expect(host.textContent).toContain('Generate link');
|
||||
expect(host.textContent).not.toContain('Disconnect account');
|
||||
expect(host.textContent).toContain('Reconnect required');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -349,6 +349,221 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not highlight existing rows when logs are toggled off and on', async () => {
|
||||
const node: GraphNode = {
|
||||
id: 'member:alpha-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'active',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
|
||||
};
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const renderHud = (enabled: boolean): void => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[node]}
|
||||
getLogWorldRect={() => ({
|
||||
left: 40,
|
||||
top: 80,
|
||||
right: 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
enabled={enabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
renderHud(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const initialRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('pnpm test')
|
||||
);
|
||||
expect(initialRow?.className).not.toContain('border-sky-300/70');
|
||||
|
||||
await act(async () => {
|
||||
renderHud(false);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(host.querySelectorAll('button')).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
renderHud(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const restoredRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('pnpm test')
|
||||
);
|
||||
expect(restoredRow?.className).not.toContain('border-sky-300/70');
|
||||
|
||||
const alicePreview = basePreviewsByMember.get('alice')!;
|
||||
mockedPreviewsByMember = new Map(basePreviewsByMember);
|
||||
mockedPreviewsByMember.set('alice', {
|
||||
...alicePreview,
|
||||
items: [
|
||||
{
|
||||
id: 'preview-after-toggle',
|
||||
kind: 'text' as const,
|
||||
provider: 'claude_transcript' as const,
|
||||
timestamp: '2026-04-03T00:01:00.000Z',
|
||||
title: 'Assistant',
|
||||
preview: 'new log after toggle',
|
||||
tone: 'neutral' as const,
|
||||
},
|
||||
...alicePreview.items,
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderHud(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const newRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('new log after toggle')
|
||||
);
|
||||
expect(newRow?.className).toContain('border-sky-300/70');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes new-row highlights by member when preview ids collide', async () => {
|
||||
const aliceNode: GraphNode = {
|
||||
id: 'member:alpha-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'active',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
|
||||
};
|
||||
const bobNode: GraphNode = {
|
||||
id: 'member:alpha-team:bob',
|
||||
kind: 'member',
|
||||
label: 'bob',
|
||||
state: 'active',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'bob' },
|
||||
};
|
||||
const sharedId = 'preview-shared-id';
|
||||
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
memberName: 'alice',
|
||||
items: [
|
||||
{
|
||||
id: sharedId,
|
||||
kind: 'text',
|
||||
provider: 'claude_transcript',
|
||||
timestamp: '2026-04-03T00:00:00.000Z',
|
||||
title: 'Assistant',
|
||||
preview: 'alice already known',
|
||||
tone: 'neutral',
|
||||
},
|
||||
],
|
||||
coverage: [{ provider: 'claude_transcript', status: 'included' }],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
[
|
||||
'bob',
|
||||
{
|
||||
memberName: 'bob',
|
||||
items: [],
|
||||
coverage: [{ provider: 'claude_transcript', status: 'included' }],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const renderHud = (): void => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[aliceNode, bobNode]}
|
||||
getLogWorldRect={(ownerNodeId) => ({
|
||||
left: ownerNodeId.includes('bob') ? 360 : 40,
|
||||
top: 80,
|
||||
right: ownerNodeId.includes('bob') ? 620 : 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
renderHud();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
mockedPreviewsByMember = new Map(mockedPreviewsByMember);
|
||||
mockedPreviewsByMember.set('bob', {
|
||||
memberName: 'bob',
|
||||
items: [
|
||||
{
|
||||
id: sharedId,
|
||||
kind: 'text',
|
||||
provider: 'claude_transcript',
|
||||
timestamp: '2026-04-03T00:01:00.000Z',
|
||||
title: 'Assistant',
|
||||
preview: 'bob new shared id',
|
||||
tone: 'neutral',
|
||||
},
|
||||
],
|
||||
coverage: [{ provider: 'claude_transcript', status: 'included' }],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:01:00.000Z',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderHud();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const aliceRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('alice already known')
|
||||
);
|
||||
const bobRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('bob new shared id')
|
||||
);
|
||||
|
||||
expect(aliceRow?.className).not.toContain('border-sky-300/70');
|
||||
expect(bobRow?.className).toContain('border-sky-300/70');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders lead log previews and opens the lead profile logs tab', async () => {
|
||||
const leadNode: GraphNode = {
|
||||
id: 'lead:alpha-team',
|
||||
|
|
@ -427,6 +642,15 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
preview: 'stored',
|
||||
tone: 'success',
|
||||
},
|
||||
{
|
||||
id: 'bash-result-preview',
|
||||
kind: 'tool_result',
|
||||
provider: 'claude_transcript',
|
||||
timestamp: '2026-04-03T00:00:40.000Z',
|
||||
title: 'Bash result',
|
||||
preview: 'Bash result app/components/Calculator.tsx app/components/Display.tsx',
|
||||
tone: 'success',
|
||||
},
|
||||
],
|
||||
coverage: [{ provider: 'claude_transcript', status: 'included' }],
|
||||
warnings: [],
|
||||
|
|
@ -482,6 +706,13 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
);
|
||||
expect(genericResultRow?.textContent).toContain('Tool result');
|
||||
|
||||
const bashResultRow = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('app/components/Calculator.tsx')
|
||||
);
|
||||
expect(bashResultRow?.textContent).toContain('Bash');
|
||||
expect(bashResultRow?.textContent).not.toContain('Bash result');
|
||||
expect(bashResultRow?.textContent).not.toContain('result app/components');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -409,7 +409,7 @@ describe('useGraphMemberLogPreviews', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('reloads visible members on log-source events with force refresh', async () => {
|
||||
it('reloads visible members on log change events with force refresh', async () => {
|
||||
let teamChangeListener:
|
||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||
| null = null;
|
||||
|
|
@ -449,6 +449,19 @@ describe('useGraphMemberLogPreviews', () => {
|
|||
expect.objectContaining({ forceRefresh: true })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'task-log-change' });
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(3);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith(
|
||||
'alpha-team',
|
||||
['alice'],
|
||||
expect.objectContaining({ forceRefresh: true })
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue