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/`.
|
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
|
### Build for distribution
|
||||||
|
|
||||||
```bash
|
```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 |
|
| [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) |
|
| [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 |
|
| [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.
|
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
|
## Member State Meanings
|
||||||
|
|
||||||
Common `launch-state.json` cases:
|
Common `launch-state.json` cases:
|
||||||
|
|
|
||||||
|
|
@ -382,7 +382,7 @@ export function useGraphMemberLogPreviews(input: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.type === 'task-log-change') {
|
if (event.type === 'task-log-change') {
|
||||||
scheduleReload(false);
|
scheduleReload(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,10 @@ function normalizeMemberName(value: string): string {
|
||||||
return value.trim().toLowerCase();
|
return value.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRenderedItemKey(memberName: string, itemId: string): string {
|
||||||
|
return `${normalizeMemberName(memberName)}:${itemId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatRelativeTime(timestamp: string): string {
|
function formatRelativeTime(timestamp: string): string {
|
||||||
const parsed = Date.parse(timestamp);
|
const parsed = Date.parse(timestamp);
|
||||||
if (!Number.isFinite(parsed)) return '';
|
if (!Number.isFinite(parsed)) return '';
|
||||||
|
|
@ -145,7 +149,11 @@ function trimRepeatedTitlePrefix(preview: string, title: string): string {
|
||||||
function compactPreviewText(item: MemberLogPreviewItem, displayTitle: string): string {
|
function compactPreviewText(item: MemberLogPreviewItem, displayTitle: string): string {
|
||||||
const preview = item.preview?.trim();
|
const preview = item.preview?.trim();
|
||||||
if (preview) {
|
if (preview) {
|
||||||
const compact = trimRepeatedTitlePrefix(preview, displayTitle);
|
const rawTitle = item.title.trim();
|
||||||
|
const compact = trimRepeatedTitlePrefix(
|
||||||
|
trimRepeatedTitlePrefix(preview, rawTitle),
|
||||||
|
displayTitle
|
||||||
|
);
|
||||||
return compact || preview;
|
return compact || preview;
|
||||||
}
|
}
|
||||||
if (item.kind === 'tool_result') {
|
if (item.kind === 'tool_result') {
|
||||||
|
|
@ -244,45 +252,45 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
const newItemIds: string[] = [];
|
const newItemKeys: string[] = [];
|
||||||
for (const [memberKey, preview] of previewsByMember) {
|
for (const [memberKey, preview] of previewsByMember) {
|
||||||
const currentIds = new Set(preview.items.map((item) => item.id));
|
const currentIds = new Set(preview.items.map((item) => item.id));
|
||||||
const knownIds = knownItemIdsByMemberRef.current.get(memberKey);
|
const knownIds = knownItemIdsByMemberRef.current.get(memberKey);
|
||||||
if (knownIds) {
|
if (knownIds) {
|
||||||
for (const itemId of currentIds) {
|
for (const itemId of currentIds) {
|
||||||
if (!knownIds.has(itemId)) {
|
if (!knownIds.has(itemId)) {
|
||||||
newItemIds.push(itemId);
|
newItemKeys.push(buildRenderedItemKey(memberKey, itemId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
knownItemIdsByMemberRef.current.set(memberKey, currentIds);
|
knownItemIdsByMemberRef.current.set(memberKey, currentIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newItemIds.length === 0) return;
|
if (newItemKeys.length === 0) return;
|
||||||
|
|
||||||
setHighlightedItemIds((current) => {
|
setHighlightedItemIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
for (const itemId of newItemIds) {
|
for (const itemKey of newItemKeys) {
|
||||||
next.add(itemId);
|
next.add(itemKey);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const itemId of newItemIds) {
|
for (const itemKey of newItemKeys) {
|
||||||
const existingTimer = highlightTimersRef.current.get(itemId);
|
const existingTimer = highlightTimersRef.current.get(itemKey);
|
||||||
if (existingTimer) {
|
if (existingTimer) {
|
||||||
clearTimeout(existingTimer);
|
clearTimeout(existingTimer);
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
highlightTimersRef.current.delete(itemId);
|
highlightTimersRef.current.delete(itemKey);
|
||||||
setHighlightedItemIds((current) => {
|
setHighlightedItemIds((current) => {
|
||||||
if (!current.has(itemId)) return current;
|
if (!current.has(itemKey)) return current;
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
next.delete(itemId);
|
next.delete(itemKey);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, NEW_LOG_HIGHLIGHT_MS);
|
}, NEW_LOG_HIGHLIGHT_MS);
|
||||||
highlightTimersRef.current.set(itemId, timer);
|
highlightTimersRef.current.set(itemKey, timer);
|
||||||
}
|
}
|
||||||
}, [enabled, previewsByMember]);
|
}, [enabled, previewsByMember]);
|
||||||
|
|
||||||
|
|
@ -424,7 +432,7 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
const titleText = relativeTime
|
const titleText = relativeTime
|
||||||
? `${displayTitle} ${relativeTime} ${fullPreviewText}`
|
? `${displayTitle} ${relativeTime} ${fullPreviewText}`
|
||||||
: `${displayTitle} ${fullPreviewText}`;
|
: `${displayTitle} ${fullPreviewText}`;
|
||||||
const isHighlighted = highlightedItemIds.has(item.id);
|
const isHighlighted = highlightedItemIds.has(buildRenderedItemKey(memberName, item.id));
|
||||||
const isError = item.tone === 'error';
|
const isError = item.tone === 'error';
|
||||||
const rowStateClassName = isHighlighted
|
const rowStateClassName = isHighlighted
|
||||||
? isError
|
? isError
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ export interface CodexLoginStateDto {
|
||||||
error: string | null;
|
error: string | null;
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
authUrl?: string | null;
|
authUrl?: string | null;
|
||||||
|
userCode?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodexRuntimeContextDto {
|
export interface CodexRuntimeContextDto {
|
||||||
|
|
|
||||||
|
|
@ -693,6 +693,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
error: loginState.status === 'failed' ? loginState.error : null,
|
error: loginState.status === 'failed' ? loginState.error : null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
authUrl: null,
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export class CodexLoginSessionManager {
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
authUrl: null,
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
};
|
};
|
||||||
private pendingStartToken: symbol | null = null;
|
private pendingStartToken: symbol | null = null;
|
||||||
private activeSession: {
|
private activeSession: {
|
||||||
|
|
@ -72,6 +73,7 @@ export class CodexLoginSessionManager {
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
authUrl: null,
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -89,7 +91,7 @@ export class CodexLoginSessionManager {
|
||||||
|
|
||||||
const response = await session.request<CodexAppServerLoginAccountResponse>(
|
const response = await session.request<CodexAppServerLoginAccountResponse>(
|
||||||
'account/login/start',
|
'account/login/start',
|
||||||
{ type: 'chatgpt' },
|
{ type: 'chatgptDeviceCode' },
|
||||||
LOGIN_REQUEST_TIMEOUT_MS
|
LOGIN_REQUEST_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -98,15 +100,19 @@ export class CodexLoginSessionManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.type !== 'chatgpt') {
|
if (response.type !== 'chatgptDeviceCode') {
|
||||||
throw new Error('Codex app-server returned an unexpected login response type');
|
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:') {
|
if (authUrl.protocol !== 'https:') {
|
||||||
throw new Error('Codex app-server returned a non-https auth URL');
|
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) => {
|
const disposeNotificationListener = session.onNotification((method, params) => {
|
||||||
if (method !== 'account/login/completed') {
|
if (method !== 'account/login/completed') {
|
||||||
return;
|
return;
|
||||||
|
|
@ -137,6 +143,7 @@ export class CodexLoginSessionManager {
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: this.state.startedAt,
|
startedAt: this.state.startedAt,
|
||||||
authUrl: authUrl.toString(),
|
authUrl: authUrl.toString(),
|
||||||
|
userCode: response.userCode,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const wasAbandonedDuringStart =
|
const wasAbandonedDuringStart =
|
||||||
|
|
@ -159,6 +166,7 @@ export class CodexLoginSessionManager {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
startedAt: this.state.startedAt,
|
startedAt: this.state.startedAt,
|
||||||
authUrl: this.state.authUrl,
|
authUrl: this.state.authUrl,
|
||||||
|
userCode: this.state.userCode,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -172,6 +180,7 @@ export class CodexLoginSessionManager {
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
authUrl: null,
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
});
|
});
|
||||||
this.emitSettled();
|
this.emitSettled();
|
||||||
return;
|
return;
|
||||||
|
|
@ -183,6 +192,7 @@ export class CodexLoginSessionManager {
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
authUrl: null,
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -211,6 +221,7 @@ export class CodexLoginSessionManager {
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
authUrl: null,
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
});
|
});
|
||||||
this.emitSettled();
|
this.emitSettled();
|
||||||
}
|
}
|
||||||
|
|
@ -226,6 +237,7 @@ export class CodexLoginSessionManager {
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
authUrl: null,
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -240,6 +252,7 @@ export class CodexLoginSessionManager {
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
authUrl: null,
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -262,6 +275,7 @@ export class CodexLoginSessionManager {
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
authUrl: null,
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
|
|
@ -269,6 +283,7 @@ export class CodexLoginSessionManager {
|
||||||
error: notification.error ?? 'ChatGPT login failed.',
|
error: notification.error ?? 'ChatGPT login failed.',
|
||||||
startedAt: this.state.startedAt,
|
startedAt: this.state.startedAt,
|
||||||
authUrl: this.state.authUrl,
|
authUrl: this.state.authUrl,
|
||||||
|
userCode: this.state.userCode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,6 +305,7 @@ export class CodexLoginSessionManager {
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
startedAt: this.state.startedAt,
|
startedAt: this.state.startedAt,
|
||||||
authUrl: this.state.authUrl,
|
authUrl: this.state.authUrl,
|
||||||
|
userCode: this.state.userCode,
|
||||||
});
|
});
|
||||||
this.emitSettled();
|
this.emitSettled();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ export type CodexAppServerLoginAccountParams =
|
||||||
| {
|
| {
|
||||||
type: 'chatgpt';
|
type: 'chatgpt';
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'chatgptDeviceCode';
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'chatgptAuthTokens';
|
type: 'chatgptAuthTokens';
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
|
@ -57,6 +60,12 @@ export type CodexAppServerLoginAccountResponse =
|
||||||
loginId: string;
|
loginId: string;
|
||||||
authUrl: string;
|
authUrl: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'chatgptDeviceCode';
|
||||||
|
loginId: string;
|
||||||
|
verificationUrl: string;
|
||||||
|
userCode: string;
|
||||||
|
}
|
||||||
| { type: 'chatgptAuthTokens' };
|
| { type: 'chatgptAuthTokens' };
|
||||||
|
|
||||||
export type CodexAppServerLogoutAccountResponse = Record<string, never>;
|
export type CodexAppServerLogoutAccountResponse = Record<string, never>;
|
||||||
|
|
|
||||||
|
|
@ -16496,54 +16496,10 @@ export class TeamProvisioningService {
|
||||||
stdoutLineBuf += text;
|
stdoutLineBuf += text;
|
||||||
const lines = stdoutLineBuf.split('\n');
|
const lines = stdoutLineBuf.split('\n');
|
||||||
stdoutLineBuf = lines.pop() ?? '';
|
stdoutLineBuf = lines.pop() ?? '';
|
||||||
run.stdoutParserCarry = stdoutLineBuf;
|
this.updateStdoutParserCarry(run, 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);
|
|
||||||
}
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
this.handleStdoutParserLine(run, trimmed);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTs = Date.now();
|
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. */
|
/** Attaches the stderr handler with auth failure detection. */
|
||||||
private attachStderrHandler(run: ProvisioningRun): void {
|
private attachStderrHandler(run: ProvisioningRun): void {
|
||||||
const child = run.child;
|
const child = run.child;
|
||||||
|
|
@ -20364,7 +20390,7 @@ export class TeamProvisioningService {
|
||||||
private markUnconfirmedBootstrapMembersFailed(
|
private markUnconfirmedBootstrapMembersFailed(
|
||||||
run: ProvisioningRun,
|
run: ProvisioningRun,
|
||||||
reason: string,
|
reason: string,
|
||||||
options?: { cleanupRequested?: boolean }
|
options?: { cleanupRequested?: boolean; preserveExistingFailure?: boolean }
|
||||||
): void {
|
): void {
|
||||||
const failedAt = nowIso();
|
const failedAt = nowIso();
|
||||||
const baseReason = reason.trim() || 'Deterministic bootstrap failed before teammate check-in.';
|
const baseReason = reason.trim() || 'Deterministic bootstrap failed before teammate check-in.';
|
||||||
|
|
@ -20373,6 +20399,15 @@ export class TeamProvisioningService {
|
||||||
if (prev.bootstrapConfirmed || prev.skippedForLaunch) {
|
if (prev.bootstrapConfirmed || prev.skippedForLaunch) {
|
||||||
continue;
|
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 runtimeWasAlive = prev.runtimeAlive === true || prev.livenessSource === 'process';
|
||||||
const hardFailureReason = runtimeWasAlive
|
const hardFailureReason = runtimeWasAlive
|
||||||
|
|
@ -28315,11 +28350,16 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete && !run.cancelRequested) {
|
if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete && !run.cancelRequested) {
|
||||||
this.markUnconfirmedBootstrapMembersFailed(
|
const cleanupReason =
|
||||||
run,
|
typeof run.progress.error === 'string' && run.progress.error.trim()
|
||||||
'Launch ended before teammate bootstrap completed.',
|
? run.progress.error.trim()
|
||||||
{ cleanupRequested: true }
|
: 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');
|
void this.persistLaunchStateSnapshot(run, 'finished');
|
||||||
}
|
}
|
||||||
this.resetRuntimeToolActivity(run);
|
this.resetRuntimeToolActivity(run);
|
||||||
|
|
@ -28615,6 +28655,8 @@ export class TeamProvisioningService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.flushStdoutParserCarry(run);
|
||||||
|
|
||||||
// IMPORTANT: stopStallWatchdog MUST be AFTER authRetryInProgress guard above!
|
// IMPORTANT: stopStallWatchdog MUST be AFTER authRetryInProgress guard above!
|
||||||
// During respawn, the old process exit fires but run.stallCheckHandle already
|
// 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.
|
// points to the NEW process's watchdog. Stopping it here would kill the wrong timer.
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ interface DesktopTeammateModeDecision {
|
||||||
forceProcessTeammates: boolean;
|
forceProcessTeammates: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DesktopTeammateMode = 'auto' | 'tmux' | 'in-process';
|
||||||
|
|
||||||
|
const DESKTOP_TEAMMATE_MODE_ENV = 'CLAUDE_TEAM_TEAMMATE_MODE';
|
||||||
|
|
||||||
let tmuxAvailablePromise: Promise<boolean> | null = null;
|
let tmuxAvailablePromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
function getExplicitTeammateMode(
|
function getExplicitTeammateMode(rawExtraCliArgs: string | undefined): DesktopTeammateMode | null {
|
||||||
rawExtraCliArgs: string | undefined
|
|
||||||
): 'auto' | 'tmux' | 'in-process' | null {
|
|
||||||
const tokens = parseCliArgs(rawExtraCliArgs);
|
const tokens = parseCliArgs(rawExtraCliArgs);
|
||||||
for (let i = 0; i < tokens.length; i += 1) {
|
for (let i = 0; i < tokens.length; i += 1) {
|
||||||
const token = tokens[i];
|
const token = tokens[i];
|
||||||
|
|
@ -34,6 +36,17 @@ function getExplicitTeammateMode(
|
||||||
return null;
|
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> {
|
async function isTmuxAvailable(): Promise<boolean> {
|
||||||
if (!tmuxAvailablePromise) {
|
if (!tmuxAvailablePromise) {
|
||||||
tmuxAvailablePromise = isTmuxRuntimeReadyForCurrentPlatform()
|
tmuxAvailablePromise = isTmuxRuntimeReadyForCurrentPlatform()
|
||||||
|
|
@ -48,24 +61,25 @@ async function isTmuxAvailable(): Promise<boolean> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveDesktopTeammateModeDecision(
|
export async function resolveDesktopTeammateModeDecision(
|
||||||
rawExtraCliArgs: string | undefined
|
rawExtraCliArgs: string | undefined,
|
||||||
|
env: NodeJS.ProcessEnv = process.env
|
||||||
): Promise<DesktopTeammateModeDecision> {
|
): Promise<DesktopTeammateModeDecision> {
|
||||||
const explicitMode = getExplicitTeammateMode(rawExtraCliArgs);
|
const requestedMode = getExplicitTeammateMode(rawExtraCliArgs) ?? getEnvTeammateMode(env);
|
||||||
if (explicitMode === 'tmux') {
|
if (requestedMode === 'tmux') {
|
||||||
|
return {
|
||||||
|
injectedTeammateMode: 'tmux',
|
||||||
|
forceProcessTeammates: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedMode === 'auto') {
|
||||||
return {
|
return {
|
||||||
injectedTeammateMode: null,
|
injectedTeammateMode: null,
|
||||||
forceProcessTeammates: true,
|
forceProcessTeammates: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (explicitMode === 'auto') {
|
if (requestedMode === 'in-process') {
|
||||||
return {
|
|
||||||
injectedTeammateMode: null,
|
|
||||||
forceProcessTeammates: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (explicitMode === 'in-process') {
|
|
||||||
return {
|
return {
|
||||||
injectedTeammateMode: null,
|
injectedTeammateMode: null,
|
||||||
forceProcessTeammates: false,
|
forceProcessTeammates: false,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,10 @@ import {
|
||||||
import { api, isElectronMode } from '@renderer/api';
|
import { api, isElectronMode } from '@renderer/api';
|
||||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||||
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
import {
|
||||||
|
CodexLoginLinkCopyButton,
|
||||||
|
CodexLoginUserCodeBadge,
|
||||||
|
} from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||||
import {
|
import {
|
||||||
formatProviderStatusText,
|
formatProviderStatusText,
|
||||||
getProviderConnectionModeSummary,
|
getProviderConnectionModeSummary,
|
||||||
|
|
@ -103,7 +106,9 @@ function getCodexDashboardHint(provider: CliProviderStatus): string | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (codex.login.status === 'starting' || codex.login.status === 'pending') {
|
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
|
const usageHint = codex.localActiveChatgptAccountPresent
|
||||||
|
|
@ -718,6 +723,7 @@ const InstalledBanner = ({
|
||||||
provider.connection?.codex?.login.status !== 'starting' &&
|
provider.connection?.codex?.login.status !== 'starting' &&
|
||||||
provider.connection?.codex?.login.status !== 'pending';
|
provider.connection?.codex?.login.status !== 'pending';
|
||||||
const codexLoginAuthUrl = provider.connection?.codex?.login.authUrl ?? null;
|
const codexLoginAuthUrl = provider.connection?.codex?.login.authUrl ?? null;
|
||||||
|
const codexLoginUserCode = provider.connection?.codex?.login.userCode ?? null;
|
||||||
const showCodexLoginActions = codexNeedsReconnect || Boolean(codexLoginAuthUrl);
|
const showCodexLoginActions = codexNeedsReconnect || Boolean(codexLoginAuthUrl);
|
||||||
const disconnectAction = getProviderDisconnectAction(provider);
|
const disconnectAction = getProviderDisconnectAction(provider);
|
||||||
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
||||||
|
|
@ -888,9 +894,11 @@ const InstalledBanner = ({
|
||||||
<>
|
<>
|
||||||
<CodexLoginLinkCopyButton
|
<CodexLoginLinkCopyButton
|
||||||
authUrl={codexLoginAuthUrl}
|
authUrl={codexLoginAuthUrl}
|
||||||
|
userCode={codexLoginUserCode}
|
||||||
disabled={codexReconnectBusy || actionDisabled}
|
disabled={codexReconnectBusy || actionDisabled}
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
|
<CodexLoginUserCodeBadge userCode={codexLoginUserCode} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { TmuxInstallerBannerView } from '@features/tmux-installer/renderer';
|
|
||||||
|
|
||||||
import type { JSX } from 'react';
|
import type { JSX } from 'react';
|
||||||
|
|
||||||
export const TmuxStatusBanner = (): JSX.Element => {
|
export const TmuxStatusBanner = (): JSX.Element | null => {
|
||||||
return <TmuxInstallerBannerView />;
|
// 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 {
|
interface CodexLoginLinkCopyButtonProps {
|
||||||
authUrl?: string | null;
|
authUrl?: string | null;
|
||||||
|
userCode?: string | null;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'xs' | 'sm';
|
size?: 'xs' | 'sm';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CodexLoginLinkCopyButton = ({
|
export const CodexLoginLinkCopyButton = ({
|
||||||
authUrl,
|
authUrl,
|
||||||
|
userCode,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
}: CodexLoginLinkCopyButtonProps): React.JSX.Element | null => {
|
}: CodexLoginLinkCopyButtonProps): React.JSX.Element | null => {
|
||||||
|
|
@ -17,7 +19,7 @@ export const CodexLoginLinkCopyButton = ({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCopyState('idle');
|
setCopyState('idle');
|
||||||
}, [authUrl]);
|
}, [authUrl, userCode]);
|
||||||
|
|
||||||
if (!authUrl) {
|
if (!authUrl) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -29,7 +31,8 @@ export const CodexLoginLinkCopyButton = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void navigator.clipboard.writeText(authUrl).then(
|
const text = userCode ? `${authUrl}\nCode: ${userCode}` : authUrl;
|
||||||
|
void navigator.clipboard.writeText(text).then(
|
||||||
() => setCopyState('copied'),
|
() => setCopyState('copied'),
|
||||||
() => setCopyState('failed')
|
() => setCopyState('failed')
|
||||||
);
|
);
|
||||||
|
|
@ -47,10 +50,40 @@ export const CodexLoginLinkCopyButton = ({
|
||||||
borderColor: 'rgba(245, 158, 11, 0.28)',
|
borderColor: 'rgba(245, 158, 11, 0.28)',
|
||||||
backgroundColor: 'rgba(245, 158, 11, 0.08)',
|
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' ? <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>
|
</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 { RuntimeProviderManagementPanel } from '@features/runtime-provider-management/renderer';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
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 { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -718,6 +721,7 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
const codexLoginPending =
|
const codexLoginPending =
|
||||||
codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending';
|
codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending';
|
||||||
const codexLoginAuthUrl = codexConnection?.login.authUrl ?? null;
|
const codexLoginAuthUrl = codexConnection?.login.authUrl ?? null;
|
||||||
|
const codexLoginUserCode = codexConnection?.login.userCode ?? null;
|
||||||
const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? [];
|
const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? [];
|
||||||
const configuredAuthMode: CliProviderAuthMode | undefined =
|
const configuredAuthMode: CliProviderAuthMode | undefined =
|
||||||
selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined;
|
selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined;
|
||||||
|
|
@ -1395,8 +1399,10 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
<>
|
<>
|
||||||
<CodexLoginLinkCopyButton
|
<CodexLoginLinkCopyButton
|
||||||
authUrl={codexLoginAuthUrl}
|
authUrl={codexLoginAuthUrl}
|
||||||
|
userCode={codexLoginUserCode}
|
||||||
disabled={codexActionBusy}
|
disabled={codexActionBusy}
|
||||||
/>
|
/>
|
||||||
|
<CodexLoginUserCodeBadge userCode={codexLoginUserCode} />
|
||||||
{codexLoginAuthUrl ? (
|
{codexLoginAuthUrl ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1430,8 +1436,10 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
<>
|
<>
|
||||||
<CodexLoginLinkCopyButton
|
<CodexLoginLinkCopyButton
|
||||||
authUrl={codexLoginAuthUrl}
|
authUrl={codexLoginAuthUrl}
|
||||||
|
userCode={codexLoginUserCode}
|
||||||
disabled={codexActionBusy}
|
disabled={codexActionBusy}
|
||||||
/>
|
/>
|
||||||
|
<CodexLoginUserCodeBadge userCode={codexLoginUserCode} />
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import React from 'react';
|
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 { api } from '@renderer/api';
|
||||||
import { LogIn } from 'lucide-react';
|
import { LogIn } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -62,10 +65,12 @@ export function shouldShowCodexReconnectPrompt({
|
||||||
|
|
||||||
export const CodexReconnectPrompt = ({
|
export const CodexReconnectPrompt = ({
|
||||||
authUrl,
|
authUrl,
|
||||||
|
userCode,
|
||||||
reconnectBusy,
|
reconnectBusy,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
}: {
|
}: {
|
||||||
authUrl: string | null;
|
authUrl: string | null;
|
||||||
|
userCode: string | null;
|
||||||
reconnectBusy: boolean;
|
reconnectBusy: boolean;
|
||||||
onReconnect: () => void;
|
onReconnect: () => void;
|
||||||
}): React.JSX.Element => {
|
}): React.JSX.Element => {
|
||||||
|
|
@ -80,9 +85,15 @@ export const CodexReconnectPrompt = ({
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="min-w-0 flex-1 text-[11px] text-amber-100/90">
|
<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,
|
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>
|
</p>
|
||||||
<CodexLoginLinkCopyButton authUrl={authUrl} disabled={reconnectBusy} size="xs" />
|
<CodexLoginUserCodeBadge userCode={userCode} />
|
||||||
|
<CodexLoginLinkCopyButton
|
||||||
|
authUrl={authUrl}
|
||||||
|
userCode={userCode}
|
||||||
|
disabled={reconnectBusy}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
||||||
|
|
@ -2177,6 +2177,7 @@ export const CreateTeamDialog = ({
|
||||||
<div className="pl-6">
|
<div className="pl-6">
|
||||||
<CodexReconnectPrompt
|
<CodexReconnectPrompt
|
||||||
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
||||||
|
userCode={codexAccount.snapshot?.login.userCode ?? null}
|
||||||
reconnectBusy={codexAccount.loading}
|
reconnectBusy={codexAccount.loading}
|
||||||
onReconnect={handleCodexReconnect}
|
onReconnect={handleCodexReconnect}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2910,6 +2910,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
<div className="pl-6">
|
<div className="pl-6">
|
||||||
<CodexReconnectPrompt
|
<CodexReconnectPrompt
|
||||||
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
|
||||||
|
userCode={codexAccount.snapshot?.login.userCode ?? null}
|
||||||
reconnectBusy={codexAccount.loading}
|
reconnectBusy={codexAccount.loading}
|
||||||
onReconnect={handleCodexReconnect}
|
onReconnect={handleCodexReconnect}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,10 @@ function createSession(overrides?: {
|
||||||
const request =
|
const request =
|
||||||
overrides?.request ??
|
overrides?.request ??
|
||||||
vi.fn().mockResolvedValue({
|
vi.fn().mockResolvedValue({
|
||||||
type: 'chatgpt',
|
type: 'chatgptDeviceCode',
|
||||||
loginId: 'login-1',
|
loginId: 'login-1',
|
||||||
authUrl: 'https://chatgpt.com/auth',
|
verificationUrl: 'https://chatgpt.com/auth',
|
||||||
|
userCode: 'ABCD-EFGH',
|
||||||
});
|
});
|
||||||
const close = overrides?.close ?? vi.fn().mockResolvedValue(undefined);
|
const close = overrides?.close ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
|
@ -101,9 +102,10 @@ describe('CodexLoginSessionManager', () => {
|
||||||
await Promise.all([firstStart, secondStart]);
|
await Promise.all([firstStart, secondStart]);
|
||||||
|
|
||||||
expect(fakeSession.request).toHaveBeenCalledTimes(1);
|
expect(fakeSession.request).toHaveBeenCalledTimes(1);
|
||||||
expect(openExternalMock).toHaveBeenCalledTimes(1);
|
expect(openExternalMock).not.toHaveBeenCalled();
|
||||||
expect(manager.getState().status).toBe('pending');
|
expect(manager.getState().status).toBe('pending');
|
||||||
expect(manager.getState().authUrl).toBe('https://chatgpt.com/auth');
|
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 () => {
|
it('cancels a login cleanly while the app-server session is still starting', async () => {
|
||||||
|
|
@ -137,6 +139,7 @@ describe('CodexLoginSessionManager', () => {
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
authUrl: null,
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -173,6 +176,7 @@ describe('CodexLoginSessionManager', () => {
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
authUrl: null,
|
authUrl: null,
|
||||||
|
userCode: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1294,6 +1294,70 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
expect(persist).toHaveBeenCalledWith(run, 'finished');
|
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', () => {
|
describe('member spawn status launch reads', () => {
|
||||||
|
|
@ -12406,6 +12470,70 @@ describe('TeamProvisioningService', () => {
|
||||||
await svc.cancelProvisioning(runId);
|
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 () => {
|
it('clears stale team-scoped transient state before starting a new launch run', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,16 @@ vi.mock('@features/tmux-installer/main', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('runtimeTeammateMode', () => {
|
describe('runtimeTeammateMode', () => {
|
||||||
|
const originalTeamMateModeEnv = process.env.CLAUDE_TEAM_TEAMMATE_MODE;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.clearAllMocks();
|
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 () => {
|
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();
|
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 () => {
|
it('treats explicit auto mode as automatic process teammate selection without injection', async () => {
|
||||||
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
||||||
const { resolveDesktopTeammateModeDecision } =
|
const { resolveDesktopTeammateModeDecision } =
|
||||||
|
|
@ -49,6 +71,45 @@ describe('runtimeTeammateMode', () => {
|
||||||
expect(mockIsTmuxRuntimeReadyForCurrentPlatform).not.toHaveBeenCalled();
|
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 () => {
|
it('honors explicit in-process mode as an opt-out from process teammates', async () => {
|
||||||
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
||||||
const { resolveDesktopTeammateModeDecision } =
|
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.'
|
'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(
|
const reconnectButton = Array.from(host.querySelectorAll('button')).find(
|
||||||
(button) => button.textContent?.trim() === 'Reconnect ChatGPT'
|
(button) => button.textContent?.trim() === 'Generate link'
|
||||||
);
|
);
|
||||||
expect(reconnectButton).toBeTruthy();
|
expect(reconnectButton).toBeTruthy();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -968,7 +968,7 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
||||||
expect(host.textContent).toContain(
|
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.'
|
'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).not.toContain('Disconnect account');
|
||||||
expect(host.textContent).toContain('Reconnect required');
|
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 () => {
|
it('renders lead log previews and opens the lead profile logs tab', async () => {
|
||||||
const leadNode: GraphNode = {
|
const leadNode: GraphNode = {
|
||||||
id: 'lead:alpha-team',
|
id: 'lead:alpha-team',
|
||||||
|
|
@ -427,6 +642,15 @@ describe('GraphMemberLogPreviewHud', () => {
|
||||||
preview: 'stored',
|
preview: 'stored',
|
||||||
tone: 'success',
|
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' }],
|
coverage: [{ provider: 'claude_transcript', status: 'included' }],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
|
|
@ -482,6 +706,13 @@ describe('GraphMemberLogPreviewHud', () => {
|
||||||
);
|
);
|
||||||
expect(genericResultRow?.textContent).toContain('Tool result');
|
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(() => {
|
act(() => {
|
||||||
root.unmount();
|
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:
|
let teamChangeListener:
|
||||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||||
| null = null;
|
| null = null;
|
||||||
|
|
@ -449,6 +449,19 @@ describe('useGraphMemberLogPreviews', () => {
|
||||||
expect.objectContaining({ forceRefresh: true })
|
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(() => {
|
act(() => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue