chore: checkpoint agent launch hardening

This commit is contained in:
777genius 2026-05-07 23:26:37 +03:00
parent 5730ddc7af
commit 26baaf6924
26 changed files with 5512 additions and 87 deletions

View file

@ -0,0 +1,291 @@
# Agent launch architecture comparison research
Research date: 2026-05-07
Purpose: record factual research on how different systems launch or execute agents. This is informational context only, not an implementation recommendation.
## Scope
Systems compared:
| System | Repository / source | Snapshot |
|---|---|---|
| Our Agent Teams | Local `claude_team` + `agent_teams_orchestrator` | Local working tree, 2026-05-07 |
| Paperclip | `paperclipai` docs/code research from earlier pass | Public docs / local research |
| Gastown | `github.com/gastownhall/gastown` | cloned `cfbdf3c` |
| GoClaw Enterprise / Teams | `github.com/nextlevelbuilder/goclaw` | cloned `a97e502` |
| GoClaw OpenClaw-compatible gateway | `github.com/roelfdiedericks/goclaw` | cloned `6a7ccdb` |
Primary external references:
| Topic | Source |
|---|---|
| Gastown README | https://github.com/gastownhall/gastown/blob/main/README.md |
| Gastown agent provider integration | https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md |
| GoClaw agent loop | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/01-agent-loop.md |
| GoClaw agent teams | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/11-agent-teams.md |
| GoClaw team WS events | https://github.com/nextlevelbuilder/goclaw/blob/main/docs/13-ws-team-events.md |
| Paperclip agent runtime | https://github.com/paperclipai/docs/blob/main/agents-runtime.md |
| Paperclip adapters overview | https://paperclip.inc/docs/adapters/overview/ |
## Short answer
There are four distinct launch/execution models:
| Model | Used by | Essence |
|---|---|---|
| External live CLI process | Our Agent Teams | App/orchestrator launches real teammate runtimes and tracks bootstrap, PID, stderr, process health, runtime evidence, task/message state. |
| Bounded adapter run | Paperclip | A heartbeat or job starts a short agent run, adapter invokes CLI/provider, result is captured, run exits or times out. |
| Tmux session orchestration | Gastown | `tmux` is the universal runtime adapter. Agents run in terminal sessions, receive input through tmux, and are observed through panes/session state. |
| In-process agent loop | GoClaw Enterprise / Teams | Agent execution is a Go `Loop.Run(ctx, RunRequest)` scheduled through lanes. The agent is a logical loop inside the gateway, not necessarily a separate CLI teammate process. |
## What “in-process agent loop” removes from our live teammate product
In-process loop does not mean “bad”. It is often cleaner. But compared to our external process teammate model, it removes or changes several product properties.
| Product property | External process teammate, our model | In-process GoClaw-style loop |
|---|---|---|
| Real process identity | Each teammate can have PID/RSS/stdout/stderr/process lifetime. | Agent run is a gateway invocation; no independent teammate PID by default. |
| CLI-realism | Claude/Codex/OpenCode behave as their real CLI runtimes, including auth, prompts, provider errors, stderr quirks. | Provider/driver behavior is normalized inside gateway; fewer raw CLI lifecycle surfaces. |
| Per-member restart semantics | Restart means kill/relaunch or reattach a concrete runtime for that member. | Restart is usually cancel/reschedule a logical run/session. |
| Bootstrap evidence | We can distinguish process alive, bootstrap submitted, bootstrap confirmed, delivery proof, task proof. | The loop itself is already the controlled runtime; less need for low-level bootstrap proof. |
| UI runtime cards | UI can show memory, process state, liveness source, failed/stalled bootstrap, exact runtime diagnostics. | UI tends to show run/session/task status rather than OS/process-level teammate state. |
| TTY/process debugging | Process/tmux mode can expose raw CLI behavior when needed. | Debugging is gateway traces/events/logs, not a live CLI pane/process per member. |
| Failure classes | Auth prompt, no stdin, CLI did not submit bootstrap, process died, stale PID, provider CLI stderr. | Mostly provider/tool/session/run errors inside the loop. |
| Isolation boundary | OS process boundary per teammate. | Mostly logical/session isolation inside one gateway process, unless it delegates to external providers/tools. |
Important distinction: in-process loop is simpler and can be more stable for gateway/chat products. It is not a drop-in replacement for a desktop product whose value includes live external teammate runtimes.
## Our Agent Teams launch/execution model
Our current direction is app-managed live external teammate runtime.
Observed local architecture:
| Layer | Role |
|---|---|
| `claude_team` Electron app | UI, provisioning, runtime projection, team messages, tasks, diagnostics, retries. |
| `agent_teams_orchestrator` | Multi-agent runtime orchestration, teammate spawning, provider/runtime bridging. |
| Process backend | Default for app-launched teammates after recent changes. Launch-owned processes are tracked as runtime entities. |
| Optional tmux mode | Debug/manual mode, not production default. Useful for real TTY inspection. |
| App-managed bootstrap | Backend injects/records startup context and requires durable readiness evidence instead of trusting “process exists”. |
| Runtime projection | Maps launch state, process liveness, bootstrap proof, delivery proof, task state and diagnostics to UI. |
Key properties:
| Dimension | Current behavior |
|---|---|
| Agent lifetime | Long-lived teammate process/session, not just one request. |
| Availability proof | Process alive is not enough. Need bootstrap/runtime evidence. |
| Provider mix | Claude, Codex, OpenCode can coexist in one team. |
| User experience | Live team room: cards, memory, tasks, messages, runtime errors, restart/retry controls. |
| Complexity cost | High. Many edge cases around launch, cleanup, stale state, delivery, work-sync, retries. |
Technical assessment:
| Criterion | Score |
|---|---:|
| Live team product fit | 9.2/10 |
| Mixed provider fidelity | 8.7/10 |
| Runtime proof strictness | 8.8/10 |
| Simplicity | 5.8/10 |
| Maintainability today | 7.2/10 |
| Overall technical score | 8.5/10 |
## Paperclip launch/execution model
Paperclip is closest to a bounded job/heartbeat runner.
Research summary from earlier pass:
| Piece | Behavior |
|---|---|
| Agent invocation | Heartbeat or scheduled run calls adapter execution. |
| Runtime | Adapter starts/calls CLI or provider, captures output/status/errors. |
| Lifecycle | Run exits, times out, or is cancelled. |
| Concurrency | Wakeups coalesce if agent is already running. |
| Persistence | Status/logs/tokens/errors are stored per run. |
This is operationally clean because there is no expectation that every teammate is a continuously alive process with card-level runtime state.
Technical assessment:
| Criterion | Score |
|---|---:|
| Bounded execution design | 9.1/10 |
| Simplicity | 8.8/10 |
| Failure boundedness | 8.7/10 |
| Live teammate room fit | 6.3/10 |
| External CLI fidelity | 7.5/10 |
| Overall technical score | 8.2/10 |
## Gastown launch/execution model
Gastown is tmux-first.
Facts from `gastownhall/gastown`:
| Piece | Behavior |
|---|---|
| Main runtime adapter | `tmux` sessions. |
| Universal integration | Any CLI that runs in terminal can be started and controlled. |
| Work unit | Beads/issues and convoys. |
| Worker identity | Polecats have persistent identity and reusable worktrees. |
| Session lifetime | Sessions are ephemeral; identity and sandbox can persist. |
| Communication | Mail, nudges, hooks, Beads state, tmux input/output. |
| Monitoring | Witness, Deacon, Dogs, Doctor, cleanup commands. |
| Provider integration | Built-in/custom presets with command, args, env, process names, hooks, readiness delay/prompt. |
Gastown explicitly documents a Tier 0 tmux shim: start CLI in tmux, send work through keystrokes, detect liveness through pane process, read output through captured pane. It also notes that this level is timing-sensitive and lacks delivery confirmation.
Core model:
```text
gt sling <bead> <rig>
-> allocate or reuse polecat identity/worktree
-> create tmux session
-> set env: GT_ROLE, GT_RIG, GT_POLECAT, BD_ACTOR, GT_AGENT, etc.
-> inject startup beacon / prompt / hook context
-> nudge with instructions if provider needs fallback
-> Witness/Deacon patrol health and cleanup
```
Technical assessment:
| Criterion | Score |
|---|---:|
| Terminal-native ops | 9.0/10 |
| Persistent worker identity | 8.7/10 |
| Cleanup / doctor culture | 8.8/10 |
| Delivery proof strictness | 6.4/10 |
| Live product state consistency | 6.8/10 |
| Overall technical score | 8.0/10 |
## GoClaw Enterprise / Teams launch/execution model
This is `nextlevelbuilder/goclaw`, the relevant GoClaw for agent teams.
Core architecture from docs/code:
| Piece | Behavior |
|---|---|
| Agent unit | `Loop` configured with provider, model, tools, workspace and agent type. |
| Run entrypoint | `Loop.Run(ctx, RunRequest)`. |
| Loop pattern | Think -> Act -> Observe, with max iterations and tool execution. |
| Scheduler | First-class lane scheduler. |
| Lanes | `main`, `subagent`, `team`, `cron`. |
| Queueing | Per-session queues with debounce, drop policy, max concurrent. |
| Team model | Lead/member, task board, mailbox, delegation. |
| Task semantics | Atomic claim, status lifecycle, dependencies, blocker escalation, task events. |
| Events | Typed WS events for delegation, tasks, team messages and agent lifecycle. |
Core execution shape:
```text
Inbound message / teammate message / cron / delegation
-> Scheduler.Schedule(lane, RunRequest)
-> SessionQueue serializes or bounds per session
-> Lane worker admits execution
-> Router.Get(agentID)
-> Loop.Run(ctx, req)
-> Provider call + tools + finalization
-> Events + stored session/task/trace state
```
GoClaw team member execution is conceptually a scheduled agent run, not an externally spawned teammate CLI process with bootstrap/check-in.
Technical assessment:
| Criterion | Score |
|---|---:|
| Scheduler architecture | 9.2/10 |
| Agent loop clarity | 8.9/10 |
| Team task model | 8.8/10 |
| Typed event model | 8.8/10 |
| Real external teammate runtime fidelity | 6.6/10 |
| Live process UI fit | 6.5/10 |
| Overall technical score | 8.7/10 |
## GoClaw OpenClaw-compatible gateway model
This is `roelfdiedericks/goclaw`. It is a different project than `nextlevelbuilder/goclaw`.
High-level facts:
| Piece | Behavior |
|---|---|
| Product class | Personal AI gateway / OpenClaw-compatible bot runtime. |
| Main strengths | Transcript search, memory graph, channels, persistent memory, delegated runs, ACP sessions. |
| Delegated work | `subagent_spawn`, `subagent_fanout`, `subagent_status`, `subagent_cancel`. |
| Runner | `DefaultRunner` starts active runs as goroutines with run IDs, timeout/cancel, optional concurrency lane semaphore. |
| UI/control | `/runners` dashboard, SSE events, Telegram/TUI summaries. |
| Cursor integration | ACP attachment to live Cursor session. |
Runner shape:
```text
subagent_spawn / fanout
-> DefaultRunner.Start(ctx, RunSpec)
-> create RunRecord queued
-> goroutine waits for lane admission
-> execute function runs child work
-> registry records completed/failed/canceled/timeout
-> events emitted
```
This is closer to Paperclip-style delegated bounded runs than to our live teammate process model.
Technical assessment:
| Criterion | Score |
|---|---:|
| Personal gateway/memory architecture | 8.8/10 |
| Delegated run boundedness | 8.5/10 |
| Channel/memory richness | 9.0/10 |
| Live external teammate fidelity | 5.8/10 |
| Team room fit | 6.4/10 |
| Overall technical score | 8.1/10 |
## Direct comparison table
| System | Launch/execution primitive | Separate OS process per agent? | Long-lived teammate? | Task board | Team messages | Scheduler | Tmux | Best fit |
|---|---|---:|---:|---:|---:|---:|---:|---|
| Our Agent Teams | Launch-owned external CLI/process runtime | Yes | Yes | Yes | Yes | Partial/ad-hoc today | Optional/debug | Desktop live mixed-provider team room |
| Paperclip | Bounded adapter heartbeat run | Usually per run | No | Limited/not central | Not team-room focused | Yes, job-like | No core tmux | Reliable background/job agents |
| Gastown | Tmux session + worktree + Beads | Yes, through tmux | Session ephemeral, identity persistent | Beads/convoys | Mail/nudges | Scheduler/capacity exists | Core | Terminal-native multi-agent ops |
| GoClaw Enterprise | In-process scheduled agent loop | Not by default | Logical sessions/runs | Yes | Yes | First-class lanes | No core tmux | Multi-agent gateway/platform |
| GoClaw OpenClaw-compatible | Delegated goroutine runner + gateway sessions | Not by default | Logical runs/sessions | Not primary team board in same way | Channels | Runner lane semaphore | No core tmux | Personal gateway, memory, delegated runs |
## Honest overall scores
| System | Overall technical score | Why |
|---|---:|---|
| GoClaw Enterprise / Teams | 8.7/10 | Cleanest scheduler/team/task/event architecture among compared systems. |
| Our Agent Teams | 8.5/10 | Best fit for real live external Claude/Codex/OpenCode teammate product, but high complexity. |
| Paperclip | 8.2/10 | Very clean bounded runtime model, but not a live team-room system. |
| GoClaw OpenClaw-compatible | 8.1/10 | Strong personal gateway/memory/delegated run model, less comparable to our team runtime. |
| Gastown | 8.0/10 | Strong terminal ops and lifecycle culture, but tmux-first delivery/readiness is less proof-strict. |
## Research conclusions
The systems optimize for different truths:
| System | Optimized for |
|---|---|
| Our Agent Teams | User-visible live team of real external coding agents. |
| Paperclip | Bounded, simple, resumable background agent runs. |
| Gastown | Terminal-native agent ops at scale with durable work identity. |
| GoClaw Enterprise | Clean gateway-native multi-agent scheduling and team task orchestration. |
| GoClaw OpenClaw-compatible | Long-memory personal agent gateway with delegated subruns. |
Most useful conceptual takeaways for future reference:
| Idea | Source | Why it matters |
|---|---|---|
| First-class scheduler lanes | GoClaw Enterprise | Separates main/team/subagent/cron load and makes cancellation/backpressure more deterministic. |
| Typed team event catalog | GoClaw Enterprise | Makes UI and state transitions easier to reason about. |
| Persistent identity vs ephemeral session | Gastown | Useful framing for member identity, runtime session, task ownership and cleanup. |
| Bounded adapter runs | Paperclip | Good model for cron, background checks and non-live workers. |
| Patrol/doctor cleanup culture | Gastown | Good operational model for stale runtime/process/data cleanup. |
Non-recommendation note: this document intentionally does not propose changing our architecture. It records observed models for future design discussions.

View file

@ -0,0 +1,92 @@
# Paperclip agent launch research
Research date: 2026-05-07
This note records factual findings only. It is not an implementation plan and does not make recommendations.
## Scope
Compared Paperclip agent launch/runtime behavior with the local Agent Teams orchestrator.
Local orchestrator inspected:
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/tools/shared/spawnMultiAgent.ts`
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/teamBootstrap/teamBootstrapRunner.ts`
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/processBackend.ts`
- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/teammateRuntimeEvents.ts`
Paperclip inspected from GitHub and a shallow local clone at `/tmp/paperclip-inspect`.
## Paperclip runtime model
Paperclip agents do not run continuously. They run in heartbeat windows triggered by wakeups such as timer, assignment, on-demand, or automation.
Each heartbeat starts an adapter, gives it prompt/context, lets it run until exit, timeout, or cancellation, stores run status/tokens/errors/logs, and updates UI.
Source: https://github.com/paperclipai/paperclip/blob/master/docs/agents-runtime.md
## Paperclip process execution
Paperclip uses child processes for local adapters. Normal execution is not tmux-based.
The shared process runner uses `node:child_process.spawn`, streams stdout/stderr, records pid and process group information, supports timeout, sends graceful termination, and escalates to kill after a grace period.
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapter-utils/src/server-utils.ts
The generic process adapter is documented as executing arbitrary shell commands as child processes with env injection and exit-code based success/failure.
Source: https://github.com/paperclipai/paperclip/blob/master/docs/adapters/process.md
## Paperclip adapter examples
Claude local adapter:
- Uses `claude --print - --output-format stream-json --verbose`.
- Supports session resume with `--resume`.
- Supports model/effort/max-turns/append-system-prompt/add-dir style options.
- Parses stream JSON for terminal result, session id, and usage.
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/claude-local/src/server/execute.ts
Codex local adapter:
- Uses `codex exec --json ... -`.
- Supports session continuation through `resume`.
- Manages `CODEX_HOME`, injected skills/config/auth context, and fresh-session fallback paths.
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/codex-local/src/server/execute.ts
OpenCode local adapter:
- Uses `opencode run --format json`.
- Supports `--session`, `--model`, and `--variant`.
- Uses temp config and model/session validation paths.
Source: https://github.com/paperclipai/paperclip/blob/master/packages/adapters/opencode-local/src/server/execute.ts
## Paperclip orchestration/lifecycle
Paperclip stores heartbeat runs and events, updates agent runtime state, publishes live events, stores run logs, supports cancellation by pid/process group, and has recovery paths for lost/orphaned running runs.
Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/heartbeat.ts
It has a per-agent start lock so concurrent starts for the same agent are coalesced or blocked.
Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/agent-start-lock.ts
It also has run liveness classification/recovery paths for cases like empty or low-signal runs.
Source: https://github.com/paperclipai/paperclip/blob/master/server/src/services/run-liveness.ts
## Comparison facts
Paperclip is organized around short, resumable heartbeat runs. It waits for CLI run completion and records result/logs/state.
Agent Teams is organized around live team members, mixed providers, direct messages, tasks, work-sync, runtime evidence, and durable bootstrap/check-in proof.
Paperclip does not need the same live teammate readiness model because it does not maintain a long-running team room with continuously addressable members.
Agent Teams still supports tmux/pane backends in the orchestrator, but current app-launched teammates can use process backend with app-managed runtime evidence.
Paperclip's process lifecycle primitives are more centralized. Agent Teams has more live multi-agent protocol surface and therefore more runtime states to reconcile.

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,7 @@ import type {
const LOG_PREVIEW_FALLBACK_WIDTH = 260;
const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
const NEW_LOG_HIGHLIGHT_MS = 1_000;
const COMPACT_ROW_TITLE_LIMIT = 24;
const COMPACT_ROW_TEXT_LIMIT = 76;
const COMPACT_ROW_MIN_PREVIEW_LIMIT = 40;
@ -63,7 +64,7 @@ function normalizeMemberName(value: string): string {
}
function buildRenderedItemKey(memberName: string, itemId: string): string {
return `${normalizeMemberName(memberName)}:${itemId}`;
return JSON.stringify([normalizeMemberName(memberName), itemId]);
}
function formatRelativeTime(timestamp: string): string {
@ -120,8 +121,21 @@ function resolveEmptyText(
return 'No recent logs';
}
function fallbackDisplayTitle(item: MemberLogPreviewItem): string {
if (item.kind === 'tool_result') {
return item.tone === 'error' ? 'Tool error' : 'Tool result';
}
if (item.kind === 'tool_use') {
return item.toolName?.trim() || 'Tool use';
}
if (item.kind === 'thinking') {
return 'Thinking';
}
return item.tone === 'error' ? 'Error' : 'Log event';
}
function compactDisplayTitle(item: MemberLogPreviewItem): string {
const title = item.title.trim();
const title = item.title.trim() || fallbackDisplayTitle(item);
if (title.toLowerCase() === 'tool result') {
return title;
}
@ -131,6 +145,14 @@ function compactDisplayTitle(item: MemberLogPreviewItem): string {
return title;
}
function truncateCompactTitle(value: string): string {
const compact = value.replace(/\s+/g, ' ').trim();
if (compact.length <= COMPACT_ROW_TITLE_LIMIT) {
return compact;
}
return `${compact.slice(0, COMPACT_ROW_TITLE_LIMIT - 3).trimEnd()}...`;
}
function trimRepeatedTitlePrefix(preview: string, title: string): string {
const normalizedPreview = preview.toLowerCase();
const normalizedTitle = title.toLowerCase();
@ -146,12 +168,16 @@ function trimRepeatedTitlePrefix(preview: string, title: string): string {
return preview;
}
function compactPreviewText(item: MemberLogPreviewItem, displayTitle: string): string {
function compactPreviewText(
item: MemberLogPreviewItem,
displayTitle: string,
rawDisplayTitle = displayTitle
): string {
const preview = item.preview?.trim();
if (preview) {
const rawTitle = item.title.trim();
const compact = trimRepeatedTitlePrefix(
trimRepeatedTitlePrefix(preview, rawTitle),
trimRepeatedTitlePrefix(trimRepeatedTitlePrefix(preview, rawTitle), rawDisplayTitle),
displayTitle
);
return compact || preview;
@ -177,6 +203,13 @@ function truncateCompactRowPreview(
return `${normalized.slice(0, Math.max(0, previewLimit - 3)).trimEnd()}...`;
}
function compactRowLabel(parts: readonly (string | null | undefined)[]): string {
return parts
.map((part) => part?.trim())
.filter((part): part is string => Boolean(part))
.join(' ');
}
function setShellHidden(shell: HTMLDivElement): void {
shell.style.opacity = '0';
shell.style.pointerEvents = 'none';
@ -426,12 +459,11 @@ export const GraphMemberLogPreviewHud = ({
const renderItem = useCallback(
(memberName: string, item: MemberLogPreviewItem) => {
const relativeTime = formatRelativeTime(item.timestamp);
const displayTitle = compactDisplayTitle(item);
const fullPreviewText = compactPreviewText(item, displayTitle);
const rawDisplayTitle = compactDisplayTitle(item);
const displayTitle = truncateCompactTitle(rawDisplayTitle);
const fullPreviewText = compactPreviewText(item, displayTitle, rawDisplayTitle);
const previewText = truncateCompactRowPreview(fullPreviewText, displayTitle, relativeTime);
const titleText = relativeTime
? `${displayTitle} ${relativeTime} ${fullPreviewText}`
: `${displayTitle} ${fullPreviewText}`;
const titleText = compactRowLabel([rawDisplayTitle, relativeTime, fullPreviewText]);
const isHighlighted = highlightedItemIds.has(buildRenderedItemKey(memberName, item.id));
const isError = item.tone === 'error';
const rowStateClassName = isHighlighted
@ -442,28 +474,29 @@ export const GraphMemberLogPreviewHud = ({
? 'border-rose-400/35 bg-rose-950/20 hover:border-rose-300/50 hover:bg-rose-950/30'
: 'border-white/10 bg-[rgba(8,14,28,0.52)] hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]';
const iconClassName = isError
? 'float-left mr-2 mt-px inline-flex size-5 shrink-0 items-center justify-center rounded bg-rose-500/10'
: 'float-left mr-2 mt-px inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5';
? 'float-left mr-2 mt-0 inline-flex size-5 shrink-0 items-center justify-center rounded bg-rose-500/10'
: 'float-left mr-2 mt-0 inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5';
const headerClassName = 'inline align-baseline';
const titleClassName = isError
? 'align-baseline text-[11px] font-medium leading-[18px] text-rose-100'
: 'align-baseline text-[11px] font-medium leading-[18px] text-slate-200';
? 'align-baseline text-[11px] font-medium leading-5 text-rose-100'
: 'align-baseline text-[11px] font-medium leading-5 text-slate-200';
const timeClassName = isError
? 'ml-1 align-baseline text-[9px] font-normal leading-[18px] text-rose-300/70'
: 'ml-1 align-baseline text-[9px] font-normal leading-[18px] text-slate-500';
? 'ml-1 align-baseline text-[9px] font-normal leading-5 text-rose-300/70'
: 'ml-1 align-baseline text-[9px] font-normal leading-5 text-slate-500';
const previewClassName = isError
? 'ml-1 break-words align-baseline text-[10px] leading-[18px] text-rose-100/85'
: 'ml-1 break-words align-baseline text-[10px] leading-[18px] text-slate-300/85';
? 'ml-1 break-words align-baseline text-[10px] leading-5 text-rose-100/85'
: 'ml-1 break-words align-baseline text-[10px] leading-5 text-slate-300/85';
return (
<button
key={item.id}
type="button"
className={[
'block h-[68px] min-h-[68px] w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1.5 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500',
'block h-[72px] min-h-[72px] w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500',
rowStateClassName,
].join(' ')}
title={titleText}
aria-label={titleText}
onClick={() => openLogs(memberName)}
>
<span className={iconClassName} aria-hidden="true">
@ -517,7 +550,7 @@ export const GraphMemberLogPreviewHud = ({
}}
>
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
<div className="mb-1 flex h-5 min-h-5 items-center gap-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
<div className="flex h-5 min-h-5 items-center gap-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
<Wrench className="size-3 text-slate-500" />
Logs
</div>
@ -527,7 +560,7 @@ export const GraphMemberLogPreviewHud = ({
) : (
<button
type="button"
className="flex h-[68px] min-h-[68px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60"
className="flex h-[72px] min-h-[72px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60"
onClick={() => openLogs(memberName)}
>
{resolveEmptyText(preview, loading, error)}

View file

@ -1,4 +1,8 @@
import type { CodexAccountSnapshotDto } from './dto';
import type { CodexAccountSnapshotDto, CodexChatgptLoginMode } from './dto';
export interface CodexStartChatgptLoginOptions {
mode?: CodexChatgptLoginMode;
}
export interface CodexAccountElectronApi {
getCodexAccountSnapshot: () => Promise<CodexAccountSnapshotDto>;
@ -6,7 +10,9 @@ export interface CodexAccountElectronApi {
includeRateLimits?: boolean;
forceRefreshToken?: boolean;
}) => Promise<CodexAccountSnapshotDto>;
startCodexChatgptLogin: () => Promise<CodexAccountSnapshotDto>;
startCodexChatgptLogin: (
options?: CodexStartChatgptLoginOptions
) => Promise<CodexAccountSnapshotDto>;
cancelCodexChatgptLogin: () => Promise<CodexAccountSnapshotDto>;
logoutCodexAccount: () => Promise<CodexAccountSnapshotDto>;
onCodexAccountSnapshotChanged: (

View file

@ -16,6 +16,7 @@ export type CodexAccountAppServerState =
| 'runtime-missing'
| 'incompatible';
export type CodexAccountLoginStatus = 'idle' | 'starting' | 'pending' | 'failed' | 'cancelled';
export type CodexChatgptLoginMode = 'browser' | 'device_code';
export type CodexLaunchReadinessState =
| 'ready_chatgpt'
| 'ready_api_key'

View file

@ -4,6 +4,7 @@ import {
CODEX_ACCOUNT_LOGOUT,
CODEX_ACCOUNT_REFRESH_SNAPSHOT,
CODEX_ACCOUNT_START_CHATGPT_LOGIN,
type CodexStartChatgptLoginOptions,
} from '@features/codex-account/contracts';
import type { CodexAccountFeatureFacade } from '../../../composition/createCodexAccountFeature';
@ -19,7 +20,10 @@ export function registerCodexAccountIpc(
(_event, options?: { includeRateLimits?: boolean; forceRefreshToken?: boolean }) =>
feature.refreshSnapshot(options)
);
ipcMain.handle(CODEX_ACCOUNT_START_CHATGPT_LOGIN, () => feature.startChatgptLogin());
ipcMain.handle(
CODEX_ACCOUNT_START_CHATGPT_LOGIN,
(_event, options?: CodexStartChatgptLoginOptions) => feature.startChatgptLogin(options)
);
ipcMain.handle(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN, () => feature.cancelLogin());
ipcMain.handle(CODEX_ACCOUNT_LOGOUT, () => feature.logout());
}

View file

@ -2,6 +2,7 @@ import {
type CodexAccountAuthMode,
type CodexAccountSnapshotDto,
type CodexApiKeyAvailabilityDto,
type CodexChatgptLoginMode,
type CodexCreditsSnapshotDto,
type CodexLoginStateDto,
type CodexManagedAccountDto,
@ -234,7 +235,7 @@ export interface CodexAccountFeatureFacade {
includeRateLimits?: boolean;
forceRefreshToken?: boolean;
}): Promise<CodexAccountSnapshotDto>;
startChatgptLogin(): Promise<CodexAccountSnapshotDto>;
startChatgptLogin(options?: { mode?: CodexChatgptLoginMode }): Promise<CodexAccountSnapshotDto>;
cancelLogin(): Promise<CodexAccountSnapshotDto>;
logout(): Promise<CodexAccountSnapshotDto>;
subscribe(listener: (snapshot: CodexAccountSnapshotDto) => void): () => void;
@ -323,7 +324,9 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
return this.refreshPromise;
}
async startChatgptLogin(): Promise<CodexAccountSnapshotDto> {
async startChatgptLogin(options?: {
mode?: CodexChatgptLoginMode;
}): Promise<CodexAccountSnapshotDto> {
let binaryMissing = false;
await this.runSerializedMutation(async () => {
const binaryPath = await CodexBinaryResolver.resolve();
@ -333,7 +336,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
}
const env = this.envBuilder.buildControlPlaneEnv({ binaryPath });
await this.loginSessionManager.start({ binaryPath, env });
await this.loginSessionManager.start({ binaryPath, env, mode: options?.mode });
});
if (binaryMissing) {

View file

@ -5,7 +5,7 @@ import {
type CodexAppServerSession,
} from '@main/services/infrastructure/codexAppServer';
import type { CodexLoginStateDto } from '@features/codex-account/contracts';
import type { CodexChatgptLoginMode, CodexLoginStateDto } from '@features/codex-account/contracts';
import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer';
const LOGIN_REQUEST_TIMEOUT_MS = 5_000;
@ -59,7 +59,11 @@ export class CodexLoginSessionManager {
return structuredClone(this.state);
}
async start(options: { binaryPath: string; env: NodeJS.ProcessEnv }): Promise<void> {
async start(options: {
binaryPath: string;
env: NodeJS.ProcessEnv;
mode?: CodexChatgptLoginMode;
}): Promise<void> {
if (this.activeSession || this.pendingStartToken) {
return;
}
@ -89,9 +93,11 @@ export class CodexLoginSessionManager {
return;
}
const requestedResponseType =
options.mode === 'device_code' ? 'chatgptDeviceCode' : 'chatgpt';
const response = await session.request<CodexAppServerLoginAccountResponse>(
'account/login/start',
{ type: 'chatgptDeviceCode' },
{ type: requestedResponseType },
LOGIN_REQUEST_TIMEOUT_MS
);
@ -100,16 +106,19 @@ export class CodexLoginSessionManager {
return;
}
if (response.type !== 'chatgptDeviceCode') {
if (response.type !== requestedResponseType) {
throw new Error('Codex app-server returned an unexpected login response type');
}
const authUrl = new URL(response.verificationUrl);
const authUrl = new URL(
response.type === 'chatgptDeviceCode' ? response.verificationUrl : response.authUrl
);
if (authUrl.protocol !== 'https:') {
throw new Error('Codex app-server returned a non-https auth URL');
}
if (!response.userCode.trim()) {
const userCode = response.type === 'chatgptDeviceCode' ? response.userCode.trim() : null;
if (response.type === 'chatgptDeviceCode' && !userCode) {
throw new Error('Codex app-server returned an empty ChatGPT login code');
}
@ -143,7 +152,7 @@ export class CodexLoginSessionManager {
error: null,
startedAt: this.state.startedAt,
authUrl: authUrl.toString(),
userCode: response.userCode,
userCode,
});
} catch (error) {
const wasAbandonedDuringStart =

View file

@ -0,0 +1,284 @@
import { describe, expect, it, vi } from 'vitest';
import { CodexLoginSessionManager } from '../CodexLoginSessionManager';
import type {
CodexAppServerLoginAccountResponse,
CodexAppServerSession,
CodexAppServerSessionFactory,
} from '@main/services/infrastructure/codexAppServer';
function createSessionManagerHarness(loginResponse: CodexAppServerLoginAccountResponse): {
manager: CodexLoginSessionManager;
session: CodexAppServerSession;
request: ReturnType<typeof vi.fn>;
emitNotification: (method: string, params: unknown) => void;
} {
let notificationListener: ((method: string, params: unknown) => void) | null = null;
const request = vi.fn(async (method: string) => {
if (method === 'account/login/start') {
return loginResponse;
}
if (method === 'account/login/cancel') {
return { status: 'canceled' };
}
throw new Error(`Unexpected method ${method}`);
});
const session = {
initializeResponse: {
userAgent: 'codex-cli 0.125.0',
codexHome: '/Users/me/.codex',
platformFamily: 'macos',
platformOs: 'darwin',
},
request,
notify: async () => undefined,
onNotification: (listener: (method: string, params: unknown) => void) => {
notificationListener = listener;
return () => {
notificationListener = null;
};
},
close: vi.fn(async () => undefined),
} as unknown as CodexAppServerSession;
const factory = {
openSession: vi.fn(async () => session),
} as unknown as CodexAppServerSessionFactory;
return {
manager: new CodexLoginSessionManager(factory, { warn: () => undefined }),
session,
request,
emitNotification: (method, params) => {
notificationListener?.(method, params);
},
};
}
describe('CodexLoginSessionManager', () => {
it('uses the documented ChatGPT browser flow by default', async () => {
const { manager, request } = createSessionManagerHarness({
type: 'chatgpt',
loginId: 'browser-login',
authUrl:
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
});
await manager.start({ binaryPath: '/usr/local/bin/codex', env: {} });
expect(request).toHaveBeenCalledWith(
'account/login/start',
{ type: 'chatgpt' },
expect.any(Number)
);
expect(manager.getState()).toMatchObject({
status: 'pending',
authUrl:
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
userCode: null,
});
await manager.cancel();
});
it('uses the documented device-code flow only when explicitly requested', async () => {
const { manager, request } = createSessionManagerHarness({
type: 'chatgptDeviceCode',
loginId: 'device-login',
verificationUrl: 'https://auth.openai.com/codex/device',
userCode: 'ABCD-1234',
});
await manager.start({
binaryPath: '/usr/local/bin/codex',
env: {},
mode: 'device_code',
});
expect(request).toHaveBeenCalledWith(
'account/login/start',
{ type: 'chatgptDeviceCode' },
expect.any(Number)
);
expect(manager.getState()).toMatchObject({
status: 'pending',
authUrl: 'https://auth.openai.com/codex/device',
userCode: 'ABCD-1234',
});
await manager.cancel();
});
it('rejects a non-https browser auth URL', async () => {
const { manager } = createSessionManagerHarness({
type: 'chatgpt',
loginId: 'browser-login',
authUrl: 'http://chatgpt.com/auth',
});
await expect(manager.start({ binaryPath: '/usr/local/bin/codex', env: {} })).rejects.toThrow(
'non-https auth URL'
);
expect(manager.getState()).toMatchObject({
status: 'failed',
});
});
it('rejects a device-code response for the default browser flow', async () => {
const { manager } = createSessionManagerHarness({
type: 'chatgptDeviceCode',
loginId: 'device-login',
verificationUrl: 'https://auth.openai.com/codex/device',
userCode: 'ABCD-1234',
});
await expect(manager.start({ binaryPath: '/usr/local/bin/codex', env: {} })).rejects.toThrow(
'unexpected login response type'
);
expect(manager.getState()).toMatchObject({
status: 'failed',
});
});
it('rejects a browser response for the explicit device-code flow', async () => {
const { manager } = createSessionManagerHarness({
type: 'chatgpt',
loginId: 'browser-login',
authUrl:
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
});
await expect(
manager.start({
binaryPath: '/usr/local/bin/codex',
env: {},
mode: 'device_code',
})
).rejects.toThrow('unexpected login response type');
expect(manager.getState()).toMatchObject({
status: 'failed',
});
});
it('rejects an empty device-code user code', async () => {
const { manager } = createSessionManagerHarness({
type: 'chatgptDeviceCode',
loginId: 'device-login',
verificationUrl: 'https://auth.openai.com/codex/device',
userCode: ' ',
});
await expect(
manager.start({
binaryPath: '/usr/local/bin/codex',
env: {},
mode: 'device_code',
})
).rejects.toThrow('empty ChatGPT login code');
expect(manager.getState()).toMatchObject({
status: 'failed',
});
});
it('keeps the active login pending when an unrelated completion notification arrives', async () => {
const { manager, emitNotification } = createSessionManagerHarness({
type: 'chatgpt',
loginId: 'browser-login',
authUrl:
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
});
await manager.start({ binaryPath: '/usr/local/bin/codex', env: {} });
emitNotification('account/login/completed', {
loginId: 'other-login',
success: true,
error: null,
});
expect(manager.getState()).toMatchObject({
status: 'pending',
authUrl:
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
});
await manager.cancel();
});
it('clears login state when the matching browser login completes successfully', async () => {
const { manager, emitNotification, session } = createSessionManagerHarness({
type: 'chatgpt',
loginId: 'browser-login',
authUrl:
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
});
await manager.start({ binaryPath: '/usr/local/bin/codex', env: {} });
emitNotification('account/login/completed', {
loginId: 'browser-login',
success: true,
error: null,
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(session.close).toHaveBeenCalledTimes(1);
expect(manager.getState()).toMatchObject({
status: 'idle',
authUrl: null,
userCode: null,
});
});
it('keeps device-code details visible when the matching login fails', async () => {
const { manager, emitNotification } = createSessionManagerHarness({
type: 'chatgptDeviceCode',
loginId: 'device-login',
verificationUrl: 'https://auth.openai.com/codex/device',
userCode: 'ABCD-1234',
});
await manager.start({
binaryPath: '/usr/local/bin/codex',
env: {},
mode: 'device_code',
});
emitNotification('account/login/completed', {
loginId: 'device-login',
success: false,
error: 'Login was not completed',
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(manager.getState()).toMatchObject({
status: 'failed',
error: 'Login was not completed',
authUrl: 'https://auth.openai.com/codex/device',
userCode: 'ABCD-1234',
});
});
it('cancels an active browser login through app-server and clears copied link state', async () => {
const { manager, request, session } = createSessionManagerHarness({
type: 'chatgpt',
loginId: 'browser-login',
authUrl:
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
});
await manager.start({ binaryPath: '/usr/local/bin/codex', env: {} });
await manager.cancel();
expect(request).toHaveBeenCalledWith(
'account/login/cancel',
{ loginId: 'browser-login' },
expect.any(Number)
);
expect(session.close).toHaveBeenCalledTimes(1);
expect(manager.getState()).toMatchObject({
status: 'cancelled',
authUrl: null,
userCode: null,
});
});
});

View file

@ -21,7 +21,8 @@ export function createCodexAccountBridge({
getCodexAccountSnapshot: () => ipcRenderer.invoke(CODEX_ACCOUNT_GET_SNAPSHOT),
refreshCodexAccountSnapshot: (options) =>
ipcRenderer.invoke(CODEX_ACCOUNT_REFRESH_SNAPSHOT, options),
startCodexChatgptLogin: () => ipcRenderer.invoke(CODEX_ACCOUNT_START_CHATGPT_LOGIN),
startCodexChatgptLogin: (options) =>
ipcRenderer.invoke(CODEX_ACCOUNT_START_CHATGPT_LOGIN, options),
cancelCodexChatgptLogin: () => ipcRenderer.invoke(CODEX_ACCOUNT_CANCEL_CHATGPT_LOGIN),
logoutCodexAccount: () => ipcRenderer.invoke(CODEX_ACCOUNT_LOGOUT),
onCodexAccountSnapshotChanged: (callback) => {

View file

@ -2,7 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type {
CodexAccountSnapshotDto,
CodexChatgptLoginMode,
} from '@features/codex-account/contracts';
const CODEX_PENDING_LOGIN_REFRESH_MS = 3_000;
const CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS = 10_000;
@ -47,7 +50,7 @@ export function useCodexAccountSnapshot(options: {
forceRefreshToken?: boolean;
silent?: boolean;
}) => Promise<void>;
startChatgptLogin: () => Promise<boolean>;
startChatgptLogin: (mode?: CodexChatgptLoginMode) => Promise<boolean>;
cancelChatgptLogin: () => Promise<boolean>;
logout: () => Promise<boolean>;
} {
@ -223,7 +226,7 @@ export function useCodexAccountSnapshot(options: {
loading,
error,
refresh,
startChatgptLogin: () => runAction(() => api.startCodexChatgptLogin()),
startChatgptLogin: (mode) => runAction(() => api.startCodexChatgptLogin({ mode })),
cancelChatgptLogin: () => runAction(() => api.cancelCodexChatgptLogin()),
logout: () => runAction(() => api.logoutCodexAccount()),
}),

View file

@ -1,5 +1,3 @@
import { isLeadMember } from '@shared/utils/leadDetection';
import type {
MemberLogPreviewSource,
MemberLogPreviewSourceInput,
@ -18,9 +16,7 @@ export class CodexNativeMemberTracePreviewSource implements MemberLogPreviewSour
(item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase()
);
const isCodexMember =
member?.providerId === 'codex' ||
member?.providerBackendId === 'codex-native' ||
(member ? false : isLeadMember({ name: input.memberName }));
member?.providerId === 'codex' || member?.providerBackendId === 'codex-native';
return {
provider: this.provider,

View file

@ -1,5 +1,3 @@
import { isLeadMember } from '@shared/utils/leadDetection';
import type {
MemberLogStreamSource,
MemberLogStreamSourceInput,
@ -18,9 +16,7 @@ export class CodexNativeMemberTraceStreamSource implements MemberLogStreamSource
(item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase()
);
const isCodexMember =
member?.providerId === 'codex' ||
member?.providerBackendId === 'codex-native' ||
(member ? false : isLeadMember({ name: input.memberName }));
member?.providerId === 'codex' || member?.providerBackendId === 'codex-native';
return {
provider: this.provider,

View file

@ -411,6 +411,9 @@ describe('CodexNativeMemberTraceStreamSource', () => {
members: [{ name: 'alice', providerId: 'opencode' }],
}),
} as never);
const unknownLeadSource = new CodexNativeMemberTraceStreamSource({
getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')),
} as never);
await expect(codexSource.load(sourceInput())).resolves.toMatchObject({
status: 'skipped',
@ -420,22 +423,39 @@ describe('CodexNativeMemberTraceStreamSource', () => {
status: 'skipped',
warnings: [],
});
await expect(
unknownLeadSource.load(sourceInput({ memberName: 'team-lead' }))
).resolves.toMatchObject({
status: 'skipped',
warnings: [],
});
});
});
describe('CodexNativeMemberTracePreviewSource', () => {
it('returns unsupported empty coverage for Codex preview without breaking the batch', async () => {
const source = new CodexNativeMemberTracePreviewSource({
it('returns unsupported empty coverage for known Codex previews without breaking the batch', async () => {
const codexSource = new CodexNativeMemberTracePreviewSource({
getConfig: vi.fn().mockResolvedValue({
members: [{ name: 'alice', providerId: 'codex' }],
}),
} as never);
const unknownLeadSource = new CodexNativeMemberTracePreviewSource({
getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')),
} as never);
await expect(source.loadPreview(previewInput())).resolves.toMatchObject({
await expect(codexSource.loadPreview(previewInput())).resolves.toMatchObject({
provider: 'codex_native_trace',
status: 'skipped',
items: [],
warnings: [{ code: 'codex_member_wide_not_supported' }],
});
await expect(
unknownLeadSource.loadPreview(previewInput({ memberName: 'team-lead' }))
).resolves.toMatchObject({
provider: 'codex_native_trace',
status: 'skipped',
items: [],
warnings: [],
});
});
});

View file

@ -1757,6 +1757,19 @@ interface OpenCodeSecondaryRetryOutcome {
reason?: string;
}
type MemberLifecycleOperationKind =
| 'manual_restart'
| 'opencode_retry'
| 'opencode_member_added'
| 'opencode_member_updated'
| 'opencode_member_removed';
interface MemberLifecycleOperation {
kind: MemberLifecycleOperationKind;
token: symbol;
startedAtMs: number;
}
function formatOpenCodeLaneTimingMs(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value)
? `${Math.max(0, Math.round(value))}ms`
@ -5372,6 +5385,7 @@ export class TeamProvisioningService {
string,
Promise<RetryFailedOpenCodeSecondaryLanesResult>
>();
private readonly memberLifecycleOperations = new Map<string, MemberLifecycleOperation>();
private memberRuntimeAdvisoryInvalidator:
| ((teamName: string, memberName: string) => void)
| null = null;
@ -13081,7 +13095,79 @@ export class TeamProvisioningService {
this.setMemberSpawnStatus(input.run, input.memberName, 'waiting');
}
private getMemberLifecycleOperationKey(teamName: string, memberName: string): string {
return `${teamName.trim().toLowerCase()}\u0000${memberName.trim().toLowerCase()}`;
}
private getActiveMemberLifecycleOperation(
teamName: string,
memberName: string
): MemberLifecycleOperation | null {
return (
this.memberLifecycleOperations.get(
this.getMemberLifecycleOperationKey(teamName, memberName)
) ?? null
);
}
private isMemberLifecycleOperationActive(teamName: string, memberName: string): boolean {
return this.getActiveMemberLifecycleOperation(teamName, memberName) !== null;
}
private createMemberLifecycleOperationInProgressError(memberName: string): Error {
return new Error(`Lifecycle operation for teammate "${memberName}" is already in progress`);
}
private isMemberLifecycleOperationInProgressError(error: unknown): boolean {
return (
error instanceof Error &&
/^Lifecycle operation for teammate ".+" is already in progress$/.test(error.message)
);
}
private async runMemberLifecycleOperation<T>(
teamName: string,
memberName: string,
kind: MemberLifecycleOperationKind,
operation: () => Promise<T>
): Promise<T> {
const key = this.getMemberLifecycleOperationKey(teamName, memberName);
if (this.memberLifecycleOperations.has(key)) {
throw this.createMemberLifecycleOperationInProgressError(memberName);
}
const token = Symbol(`${kind}:${teamName}:${memberName}`);
this.memberLifecycleOperations.set(key, {
kind,
token,
startedAtMs: Date.now(),
});
this.invalidateRuntimeSnapshotCaches(teamName);
try {
return await operation();
} finally {
if (this.memberLifecycleOperations.get(key)?.token === token) {
this.memberLifecycleOperations.delete(key);
}
this.invalidateRuntimeSnapshotCaches(teamName);
}
}
private getOpenCodeReattachLifecycleKind(
reason?: 'member_added' | 'member_updated' | 'manual_restart'
): MemberLifecycleOperationKind {
if (reason === 'member_added') return 'opencode_member_added';
if (reason === 'member_updated') return 'opencode_member_updated';
return 'manual_restart';
}
async restartMember(teamName: string, memberName: string): Promise<void> {
return this.runMemberLifecycleOperation(teamName, memberName, 'manual_restart', () =>
this.restartMemberUnlocked(teamName, memberName)
);
}
private async restartMemberUnlocked(teamName: string, memberName: string): Promise<void> {
const runId = this.getAliveRunId(teamName);
if (!runId) {
throw new Error(`Team "${teamName}" is not currently running`);
@ -13142,7 +13228,7 @@ export class TeamProvisioningService {
const leadProviderId = resolveTeamProviderId(run.request.providerId);
const desiredSecondaryLane = desiredProviderId === 'opencode' && leadProviderId !== 'opencode';
if (liveSecondaryLaneMemberName === memberName || desiredSecondaryLane) {
await this.reattachOpenCodeOwnedMemberLane(teamName, memberName, {
await this.reattachOpenCodeOwnedMemberLaneUnlocked(teamName, memberName, {
reason: 'manual_restart',
});
return;
@ -13447,9 +13533,15 @@ export class TeamProvisioningService {
}
try {
await this.reattachOpenCodeOwnedMemberLane(teamName, candidate.memberName, {
reason: 'manual_restart',
});
await this.runMemberLifecycleOperation(
teamName,
candidate.memberName,
'opencode_retry',
() =>
this.reattachOpenCodeOwnedMemberLaneUnlocked(teamName, candidate.memberName, {
reason: 'manual_restart',
})
);
result.attempted.push(candidate.memberName);
const outcome = await this.readOpenCodeSecondaryRetryOutcome(
@ -13473,10 +13565,17 @@ export class TeamProvisioningService {
result.pending.push(candidate.memberName);
}
} catch (error) {
result.failed.push({
memberName: candidate.memberName,
error: error instanceof Error ? error.message : String(error),
});
if (this.isMemberLifecycleOperationInProgressError(error)) {
result.skipped.push({
memberName: candidate.memberName,
reason: 'Lifecycle operation already in progress',
});
} else {
result.failed.push({
memberName: candidate.memberName,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
@ -13904,6 +14003,19 @@ export class TeamProvisioningService {
teamName: string,
memberName: string,
options?: { reason?: 'member_added' | 'member_updated' | 'manual_restart' }
): Promise<void> {
return this.runMemberLifecycleOperation(
teamName,
memberName,
this.getOpenCodeReattachLifecycleKind(options?.reason),
() => this.reattachOpenCodeOwnedMemberLaneUnlocked(teamName, memberName, options)
);
}
private async reattachOpenCodeOwnedMemberLaneUnlocked(
teamName: string,
memberName: string,
options?: { reason?: 'member_added' | 'member_updated' | 'manual_restart' }
): Promise<void> {
const run = this.getMutableAliveRunOrThrow(teamName);
const leadProviderId = resolveTeamProviderId(run.request.providerId);
@ -14058,6 +14170,15 @@ export class TeamProvisioningService {
}
async detachOpenCodeOwnedMemberLane(teamName: string, memberName: string): Promise<void> {
return this.runMemberLifecycleOperation(teamName, memberName, 'opencode_member_removed', () =>
this.detachOpenCodeOwnedMemberLaneUnlocked(teamName, memberName)
);
}
private async detachOpenCodeOwnedMemberLaneUnlocked(
teamName: string,
memberName: string
): Promise<void> {
const run = this.getMutableAliveRunOrThrow(teamName);
const laneIndex = run.mixedSecondaryLanes.findIndex((lane) =>
matchesTeamMemberIdentity(lane.member.name, memberName)
@ -16529,15 +16650,53 @@ export class TeamProvisioningService {
}
private flushStdoutParserCarry(run: ProvisioningRun): void {
const trimmed = run.stdoutParserCarry.trim();
const stdoutParserCarry =
typeof run.stdoutParserCarry === 'string' ? run.stdoutParserCarry : '';
const trimmed = stdoutParserCarry.trim();
if (!trimmed || !run.stdoutParserCarryIsCompleteJson) {
return;
}
logger.warn(
`[${run.teamName}] Flushing final stream-json stdout carry before process close handling`,
this.buildStdoutCarryDiagnostic(run)
);
this.handleStdoutParserLine(run, trimmed);
this.updateStdoutParserCarry(run, '');
}
private buildStdoutCarryDiagnostic(run: ProvisioningRun): Record<string, unknown> {
const stdoutParserCarry =
typeof run.stdoutParserCarry === 'string' ? run.stdoutParserCarry : '';
const diagnostic: Record<string, unknown> = {
runId: run.runId,
stdoutCarryLength: stdoutParserCarry.length,
stdoutCarryCompleteJson: run.stdoutParserCarryIsCompleteJson === true,
stdoutCarryLooksLikeClaudeJson: run.stdoutParserCarryLooksLikeClaudeJson === true,
};
if (run.stdoutParserCarryIsCompleteJson === true) {
try {
const parsed = JSON.parse(stdoutParserCarry.trim()) as Record<string, unknown>;
diagnostic.messageType = typeof parsed.type === 'string' ? parsed.type : null;
diagnostic.messageSubtype = typeof parsed.subtype === 'string' ? parsed.subtype : null;
diagnostic.bootstrapEvent = typeof parsed.event === 'string' ? parsed.event : null;
diagnostic.sequence = typeof parsed.seq === 'number' ? parsed.seq : null;
} catch {
diagnostic.messageType = null;
}
}
return diagnostic;
}
private getUnconfirmedBootstrapMemberNames(run: ProvisioningRun): string[] {
return run.expectedMembers.filter((expected) => {
const status = run.memberSpawnStatuses.get(expected);
return status?.bootstrapConfirmed !== true && status?.skippedForLaunch !== true;
});
}
private handleStdoutParserLine(run: ProvisioningRun, trimmed: string): void {
if (!trimmed) {
return;
@ -20366,6 +20525,9 @@ export class TeamProvisioningService {
if (matchedRuntimeNames.length > 0) {
continue;
}
if (this.isMemberLifecycleOperationActive(run.teamName, expected)) {
continue;
}
const current = run.memberSpawnStatuses.get(expected);
if (
@ -20399,22 +20561,26 @@ export class TeamProvisioningService {
if (prev.bootstrapConfirmed || prev.skippedForLaunch) {
continue;
}
const hasExistingFailure =
if (this.isMemberLifecycleOperationActive(run.teamName, expected)) {
continue;
}
const hasExistingTerminalFailure =
prev.status === 'error' ||
prev.launchState === 'failed_to_start' ||
prev.hardFailure === true ||
Boolean(prev.error) ||
Boolean(prev.hardFailureReason);
if (options?.preserveExistingFailure && hasExistingFailure) {
continue;
}
const preservedFailureReason =
options?.preserveExistingFailure && hasExistingTerminalFailure
? (prev.hardFailureReason ?? prev.error)?.trim()
: undefined;
const runtimeWasAlive = prev.runtimeAlive === true || prev.livenessSource === 'process';
const hardFailureReason = runtimeWasAlive
const fallbackFailureReason = runtimeWasAlive
? `${baseReason} Runtime process was alive after bootstrap failure${
options?.cleanupRequested ? '; launch-owned cleanup requested.' : '.'
}`
: baseReason;
const hardFailureReason = preservedFailureReason || fallbackFailureReason;
const next: MemberSpawnStatusEntry = {
...prev,
status: 'error',
@ -28356,6 +28522,15 @@ export class TeamProvisioningService {
: run.progress.state === 'failed' && run.progress.message.trim()
? run.progress.message.trim()
: 'Launch ended before teammate bootstrap completed.';
logger.warn(`[${run.teamName}] Launch cleanup finalizing unconfirmed bootstrap members`, {
runId: run.runId,
progressState: run.progress.state,
progressMessage: run.progress.message,
progressError: run.progress.error ?? null,
cleanupReason,
unconfirmedMembers: this.getUnconfirmedBootstrapMemberNames(run),
...this.buildStdoutCarryDiagnostic(run),
});
this.markUnconfirmedBootstrapMembersFailed(run, cleanupReason, {
cleanupRequested: true,
preserveExistingFailure: true,
@ -28640,6 +28815,10 @@ export class TeamProvisioningService {
}
}
private isProvisioningRunFailed(run: ProvisioningRun): boolean {
return run.progress.state === 'failed';
}
private async handleProcessExit(run: ProvisioningRun, code: number | null): Promise<void> {
if (run.finalizingByTimeout) {
return;
@ -28655,7 +28834,25 @@ export class TeamProvisioningService {
return;
}
if (
(typeof run.stdoutParserCarry === 'string' ? run.stdoutParserCarry.trim() : '') &&
!run.stdoutParserCarryIsCompleteJson &&
run.stdoutParserCarryLooksLikeClaudeJson
) {
logger.warn(
`[${run.teamName}] Process closed with incomplete stream-json stdout carry`,
this.buildStdoutCarryDiagnostic(run)
);
}
this.flushStdoutParserCarry(run);
if (
this.isProvisioningRunFailed(run) ||
run.cancelRequested ||
run.processKilled ||
run.authRetryInProgress
) {
return;
}
// IMPORTANT: stopStallWatchdog MUST be AFTER authRetryInProgress guard above!
// During respawn, the old process exit fires but run.stallCheckHandle already

View file

@ -11,7 +11,10 @@ import {
createEmptyMemberLogStreamResponse,
} from '@features/member-log-stream/contracts';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type {
CodexAccountSnapshotDto,
CodexStartChatgptLoginOptions,
} from '@features/codex-account/contracts';
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
@ -240,7 +243,9 @@ export class HttpAPIClient implements ElectronAPI {
}): Promise<CodexAccountSnapshotDto> =>
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
startCodexChatgptLogin = (): Promise<CodexAccountSnapshotDto> =>
startCodexChatgptLogin = (
_options?: CodexStartChatgptLoginOptions
): Promise<CodexAccountSnapshotDto> =>
Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));
cancelCodexChatgptLogin = (): Promise<CodexAccountSnapshotDto> =>

View file

@ -281,6 +281,7 @@ interface InstalledBannerProps {
onProviderManage: (providerId: CliProviderId) => void;
onProviderRefresh: (providerId: CliProviderId) => void;
onCodexReconnect: () => void;
onCodexDeviceCodeLogin: () => void;
codexReconnectBusy: boolean;
variant: BannerVariant;
}
@ -584,6 +585,7 @@ const InstalledBanner = ({
onProviderManage,
onProviderRefresh,
onCodexReconnect,
onCodexDeviceCodeLogin,
codexReconnectBusy,
variant,
}: InstalledBannerProps): React.JSX.Element => {
@ -899,6 +901,21 @@ const InstalledBanner = ({
size="xs"
/>
<CodexLoginUserCodeBadge userCode={codexLoginUserCode} />
{!codexLoginAuthUrl ? (
<button
type="button"
onClick={onCodexDeviceCodeLogin}
disabled={codexReconnectBusy || actionDisabled}
className="shrink-0 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'rgba(245, 158, 11, 0.22)',
backgroundColor: 'rgba(245, 158, 11, 0.05)',
color: '#fbbf24',
}}
>
Use code
</button>
) : null}
<button
type="button"
onClick={() => {
@ -1161,7 +1178,13 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const handleCodexDashboardLogin = useCallback(() => {
void (async () => {
await codexAccount.startChatgptLogin();
await codexAccount.startChatgptLogin('browser');
})();
}, [codexAccount]);
const handleCodexDashboardDeviceCodeLogin = useCallback(() => {
void (async () => {
await codexAccount.startChatgptLogin('device_code');
})();
}, [codexAccount]);
@ -1412,6 +1435,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
onProviderManage={handleProviderManage}
onProviderRefresh={handleProviderRefresh}
onCodexReconnect={handleCodexDashboardLogin}
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
codexReconnectBusy={codexAccount.loading}
variant="info"
/>
@ -1637,6 +1661,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
onProviderManage={handleProviderManage}
onProviderRefresh={handleProviderRefresh}
onCodexReconnect={handleCodexDashboardLogin}
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
codexReconnectBusy={codexAccount.loading}
variant={variant}
/>
@ -1696,6 +1721,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
onProviderManage={handleProviderManage}
onProviderRefresh={handleProviderRefresh}
onCodexReconnect={handleCodexDashboardLogin}
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
codexReconnectBusy={codexAccount.loading}
variant={variant}
/>
@ -1915,6 +1941,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
onProviderManage={handleProviderManage}
onProviderRefresh={handleProviderRefresh}
onCodexReconnect={handleCodexDashboardLogin}
onCodexDeviceCodeLogin={handleCodexDashboardDeviceCodeLogin}
codexReconnectBusy={codexAccount.loading}
variant={variant}
/>

View file

@ -0,0 +1,120 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CodexLoginLinkCopyButton, CodexLoginUserCodeBadge } from './CodexLoginLinkCopyButton';
async function renderNode(
node: React.ReactElement
): Promise<{ host: HTMLDivElement; cleanup: () => Promise<void> }> {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(node);
await Promise.resolve();
});
return {
host,
cleanup: async () => {
await act(async () => {
root.unmount();
await Promise.resolve();
});
},
};
}
function stubClipboard(writeText: ReturnType<typeof vi.fn>): void {
vi.stubGlobal('navigator', {
...navigator,
clipboard: { writeText },
});
}
describe('CodexLoginLinkCopyButton', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('renders nothing until an auth URL is available', async () => {
const { host, cleanup } = await renderNode(<CodexLoginLinkCopyButton authUrl={null} />);
expect(host.textContent).toBe('');
await cleanup();
});
it('copies only the browser login URL when no user code is present', async () => {
const writeText = vi.fn(async () => undefined);
stubClipboard(writeText);
const authUrl =
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback';
const { host, cleanup } = await renderNode(<CodexLoginLinkCopyButton authUrl={authUrl} />);
await act(async () => {
host.querySelector('button')?.click();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith(authUrl);
expect(host.textContent).toContain('Copied');
await cleanup();
});
it('copies the device login URL and code together', async () => {
const writeText = vi.fn(async () => undefined);
stubClipboard(writeText);
const { host, cleanup } = await renderNode(
<CodexLoginLinkCopyButton
authUrl="https://auth.openai.com/codex/device"
userCode="ABCD-1234"
/>
);
await act(async () => {
host.querySelector('button')?.click();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith('https://auth.openai.com/codex/device\nCode: ABCD-1234');
expect(host.textContent).toContain('Copied');
await cleanup();
});
it('shows copy failure when clipboard write fails', async () => {
const writeText = vi.fn(async () => {
throw new Error('clipboard denied');
});
stubClipboard(writeText);
const { host, cleanup } = await renderNode(
<CodexLoginLinkCopyButton authUrl="https://chatgpt.com/auth" />
);
await act(async () => {
host.querySelector('button')?.click();
await Promise.resolve();
});
expect(host.textContent).toContain('Copy failed');
await cleanup();
});
it('renders a user code badge only for device-code login state', async () => {
const empty = await renderNode(<CodexLoginUserCodeBadge userCode={null} />);
expect(empty.host.textContent).toBe('');
await empty.cleanup();
const filled = await renderNode(<CodexLoginUserCodeBadge userCode="ABCD-1234" />);
expect(filled.host.textContent).toContain('Code');
expect(filled.host.textContent).toContain('ABCD-1234');
await filled.cleanup();
});
});

View file

@ -1019,9 +1019,11 @@ export const ProviderRuntimeSettingsDialog = ({
}
};
const handleCodexStartLogin = async (): Promise<void> => {
const handleCodexStartLogin = async (
mode: 'browser' | 'device_code' = 'browser'
): Promise<void> => {
setConnectionError(null);
const success = await codexAccount.startChatgptLogin();
const success = await codexAccount.startChatgptLogin(mode);
if (!success && codexAccount.error) {
setConnectionError(codexAccount.error);
}
@ -1444,7 +1446,16 @@ export const ProviderRuntimeSettingsDialog = ({
size="sm"
variant="outline"
disabled={codexActionBusy}
onClick={() => void handleCodexStartLogin()}
onClick={() => void handleCodexStartLogin('device_code')}
>
<Link2 className="mr-1 size-3.5" />
Use code
</Button>
<Button
size="sm"
variant="outline"
disabled={codexActionBusy}
onClick={() => void handleCodexStartLogin('browser')}
>
<Link2 className="mr-1 size-3.5" />
{codexNeedsReconnect ? 'Generate link' : 'Connect ChatGPT'}

View file

@ -0,0 +1,122 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CodexReconnectPrompt } from './CodexReconnectPrompt';
const apiMock = vi.hoisted(() => ({
openExternal: vi.fn(),
}));
vi.mock('@renderer/api', () => ({
api: apiMock,
}));
async function renderPrompt(
props: React.ComponentProps<typeof CodexReconnectPrompt>
): Promise<{ host: HTMLDivElement; cleanup: () => Promise<void> }> {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(<CodexReconnectPrompt {...props} />);
await Promise.resolve();
});
return {
host,
cleanup: async () => {
await act(async () => {
root.unmount();
await Promise.resolve();
});
},
};
}
function getButton(host: HTMLElement, label: string): HTMLButtonElement {
const button = [...host.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes(label)
);
if (!button) {
throw new Error(`Button not found: ${label}`);
}
return button;
}
describe('CodexReconnectPrompt', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it('offers browser login and device-code fallback before a link exists', async () => {
const onReconnect = vi.fn();
const onDeviceCodeReconnect = vi.fn();
const { host, cleanup } = await renderPrompt({
authUrl: null,
userCode: null,
reconnectBusy: false,
onReconnect,
onDeviceCodeReconnect,
});
await act(async () => {
getButton(host, 'Generate link').click();
getButton(host, 'Use code').click();
await Promise.resolve();
});
expect(onReconnect).toHaveBeenCalledTimes(1);
expect(onDeviceCodeReconnect).toHaveBeenCalledTimes(1);
expect(apiMock.openExternal).not.toHaveBeenCalled();
await cleanup();
});
it('opens the generated browser link without starting another login', async () => {
const onReconnect = vi.fn();
const onDeviceCodeReconnect = vi.fn();
const authUrl =
'https://chatgpt.com/auth?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback';
const { host, cleanup } = await renderPrompt({
authUrl,
userCode: null,
reconnectBusy: false,
onReconnect,
onDeviceCodeReconnect,
});
expect(host.textContent).not.toContain('Use code');
await act(async () => {
getButton(host, 'Open login').click();
await Promise.resolve();
});
expect(apiMock.openExternal).toHaveBeenCalledWith(authUrl);
expect(onReconnect).not.toHaveBeenCalled();
expect(onDeviceCodeReconnect).not.toHaveBeenCalled();
await cleanup();
});
it('shows the code badge for a pending device-code login', async () => {
const { host, cleanup } = await renderPrompt({
authUrl: 'https://auth.openai.com/codex/device',
userCode: 'ABCD-1234',
reconnectBusy: false,
onReconnect: vi.fn(),
onDeviceCodeReconnect: vi.fn(),
});
expect(host.textContent).toContain('Code');
expect(host.textContent).toContain('ABCD-1234');
expect(host.textContent).toContain('Copy link + code');
await cleanup();
});
});

View file

@ -68,11 +68,13 @@ export const CodexReconnectPrompt = ({
userCode,
reconnectBusy,
onReconnect,
onDeviceCodeReconnect,
}: {
authUrl: string | null;
userCode: string | null;
reconnectBusy: boolean;
onReconnect: () => void;
onDeviceCodeReconnect: () => void;
}): React.JSX.Element => {
return (
<div
@ -94,6 +96,20 @@ export const CodexReconnectPrompt = ({
disabled={reconnectBusy}
size="xs"
/>
{!authUrl ? (
<button
type="button"
onClick={onDeviceCodeReconnect}
disabled={reconnectBusy}
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium text-amber-300 transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'rgba(245, 158, 11, 0.24)',
backgroundColor: 'rgba(245, 158, 11, 0.05)',
}}
>
Use code
</button>
) : null}
<button
type="button"
onClick={() => {

View file

@ -711,11 +711,14 @@ export const CreateTeamDialog = ({
});
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
const handleCodexReconnect = useCallback(() => {
void (async () => {
await codexAccount.startChatgptLogin();
})();
}, [codexAccount]);
const handleCodexReconnect = useCallback(
(mode: 'browser' | 'device_code' = 'browser') => {
void (async () => {
await codexAccount.startChatgptLogin(mode);
})();
},
[codexAccount]
);
useEffect(() => {
if (!open || !canCreate || !launchTeam) {
@ -2179,7 +2182,8 @@ export const CreateTeamDialog = ({
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
userCode={codexAccount.snapshot?.login.userCode ?? null}
reconnectBusy={codexAccount.loading}
onReconnect={handleCodexReconnect}
onReconnect={() => handleCodexReconnect('browser')}
onDeviceCodeReconnect={() => handleCodexReconnect('device_code')}
/>
</div>
) : null}

View file

@ -588,11 +588,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
});
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]);
const handleCodexReconnect = React.useCallback(() => {
void (async () => {
await codexAccount.startChatgptLogin();
})();
}, [codexAccount]);
const handleCodexReconnect = React.useCallback(
(mode: 'browser' | 'device_code' = 'browser') => {
void (async () => {
await codexAccount.startChatgptLogin(mode);
})();
},
[codexAccount]
);
// Schedule store actions
const createSchedule = useStore((s) => s.createSchedule);
@ -2912,7 +2915,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
authUrl={codexAccount.snapshot?.login.authUrl ?? null}
userCode={codexAccount.snapshot?.login.userCode ?? null}
reconnectBusy={codexAccount.loading}
onReconnect={handleCodexReconnect}
onReconnect={() => handleCodexReconnect('browser')}
onDeviceCodeReconnect={() => handleCodexReconnect('device_code')}
/>
</div>
) : null}

View file

@ -1237,6 +1237,7 @@ describe('TeamProvisioningService', () => {
});
it('finalizes unconfirmed launch members as failed before cleanup removes the run', () => {
allowConsoleLogs();
const svc = new TeamProvisioningService();
const run = createClaudeLogsRun({
runId: 'run-cleanup-finalizes-launch',
@ -1296,6 +1297,7 @@ describe('TeamProvisioningService', () => {
});
it('preserves specific member launch failures when cleanup applies its fallback reason', () => {
allowConsoleLogs();
const svc = new TeamProvisioningService();
const timeoutReason = 'Teammate was registered but did not bootstrap-confirm before timeout.';
const specificReason = 'OpenCode bridge reported member launch failure';
@ -1326,6 +1328,8 @@ describe('TeamProvisioningService', () => {
hardFailure: true,
hardFailureReason: specificReason,
bootstrapConfirmed: false,
runtimeAlive: true,
livenessSource: 'process',
}),
],
[
@ -1347,17 +1351,73 @@ describe('TeamProvisioningService', () => {
(svc as any).cleanupRun(run);
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
const bobStatus = run.memberSpawnStatuses.get('bob');
expect(bobStatus).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
hardFailureReason: specificReason,
runtimeAlive: false,
runtimeDiagnostic:
'Bootstrap failed before teammate check-in; launch-owned runtime cleanup requested.',
runtimeDiagnosticSeverity: 'warning',
});
expect(bobStatus?.livenessSource).toBeUndefined();
expect(run.memberSpawnStatuses.get('carol')).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
hardFailureReason: timeoutReason,
});
});
it('does not treat non-terminal error text as an existing launch failure during cleanup', () => {
allowConsoleLogs();
const svc = new TeamProvisioningService();
const timeoutReason = 'Teammate was registered but did not bootstrap-confirm before timeout.';
const run = createClaudeLogsRun({
runId: 'run-cleanup-non-terminal-error-text',
teamName: 'cleanup-non-terminal-error-text-team',
isLaunch: true,
provisioningComplete: false,
cancelRequested: false,
expectedMembers: ['bob'],
provisioningOutputParts: [],
progress: {
runId: 'run-cleanup-non-terminal-error-text',
teamName: 'cleanup-non-terminal-error-text-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: 'waiting',
launchState: 'runtime_pending_bootstrap',
error: 'Transient runtime diagnostic, not terminal launch failure',
agentToolAccepted: true,
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',
hardFailure: true,
hardFailureReason: timeoutReason,
error: timeoutReason,
});
});
});
describe('member spawn status launch reads', () => {
@ -12534,6 +12594,132 @@ describe('TeamProvisioningService', () => {
await vi.waitFor(() => expect(complete).toHaveBeenCalledTimes(1));
});
it('does not verify provisioning again after flushing a final newline-less error result', async () => {
allowConsoleLogs();
const teamName = 'launch-close-flushes-final-error-team';
const leadSessionId = 'lead-session-final-error-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 waitForValidConfig = vi
.spyOn(svc as any, 'waitForValidConfig')
.mockResolvedValue({ ok: false });
const progressStates: string[] = [];
await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, (progress) => {
progressStates.push(progress.state);
});
child.stdout.emit(
'data',
Buffer.from(
JSON.stringify({
type: 'result',
subtype: 'error',
error: 'runtime failed before bootstrap completed',
}),
'utf8'
)
);
child.emit('close', 1);
await Promise.resolve();
expect(waitForValidConfig).not.toHaveBeenCalled();
expect(progressStates).toContain('failed');
expect(progressStates).not.toContain('verifying');
});
it('does not verify provisioning while auth retry is scheduled from final newline-less output', async () => {
allowConsoleLogs();
const teamName = 'launch-close-flushes-final-auth-team';
const leadSessionId = 'lead-session-final-auth-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 waitForValidConfig = vi
.spyOn(svc as any, 'waitForValidConfig')
.mockResolvedValue({ ok: false });
const respawnAfterAuthFailure = vi
.spyOn(svc as any, 'respawnAfterAuthFailure')
.mockResolvedValue(undefined);
const progressStates: string[] = [];
const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, (progress) => {
progressStates.push(progress.state);
});
const run = (svc as any).runs.get(runId);
child.stdout.emit(
'data',
Buffer.from(
JSON.stringify({
type: 'assistant',
content: [{ type: 'text', text: 'invalid api key' }],
}),
'utf8'
)
);
child.emit('close', 1);
await Promise.resolve();
expect(run.authRetryInProgress).toBe(true);
expect(respawnAfterAuthFailure).toHaveBeenCalledWith(run);
expect(waitForValidConfig).not.toHaveBeenCalled();
expect(progressStates).not.toContain('verifying');
});
it('clears stale team-scoped transient state before starting a new launch run', async () => {
allowConsoleLogs();
vi.useFakeTimers();
@ -17975,7 +18161,7 @@ describe('TeamProvisioningService', () => {
{ memberName: 'nova', laneId: 'secondary:opencode:nova' },
]);
const reattach = vi
.spyOn(svc as any, 'reattachOpenCodeOwnedMemberLane')
.spyOn(svc as any, 'reattachOpenCodeOwnedMemberLaneUnlocked')
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('OpenCode bridge crashed'));
@ -18015,4 +18201,247 @@ describe('TeamProvisioningService', () => {
});
expect(notify).toHaveBeenCalledWith(run, result);
});
it('rejects a concurrent manual restart for the same teammate', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'codex-lifecycle-team',
expectedMembers: ['bob'],
memberSpawnStatuses: new Map([
[
'bob',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
}),
],
]),
});
run.child = { pid: 111 };
run.processKilled = false;
run.cancelRequested = false;
const configReady = createDeferred<{
name: string;
members: Array<{ name: string; agentType?: string }>;
}>();
(svc as any).configReader = {
getConfig: vi.fn(() => configReady.promise),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.4',
agentType: 'general-purpose',
},
]),
};
(svc as any).sendMessageToRun = vi.fn(async () => undefined);
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
(svc as any).aliveRunByTeam.set(run.teamName, run.runId);
(svc as any).runs.set(run.runId, run);
const firstRestart = svc.restartMember(run.teamName, 'bob');
await Promise.resolve();
await expect(svc.restartMember(run.teamName, 'bob')).rejects.toThrow(
'Lifecycle operation for teammate "bob" is already in progress'
);
configReady.resolve({
name: 'Codex Lifecycle Team',
members: [{ name: 'team-lead', agentType: 'team-lead' }],
});
await firstRestart;
});
it('does not let one teammate lifecycle operation block another teammate or team', async () => {
const svc = new TeamProvisioningService();
const aliceDone = createDeferred<void>();
const firstOperation = (svc as any).runMemberLifecycleOperation(
'same-team',
'alice',
'manual_restart',
() => aliceDone.promise
);
await expect(
(svc as any).runMemberLifecycleOperation('same-team', 'bob', 'manual_restart', async () =>
'bob-ok'
)
).resolves.toBe('bob-ok');
await expect(
(svc as any).runMemberLifecycleOperation('other-team', 'alice', 'manual_restart', async () =>
'other-ok'
)
).resolves.toBe('other-ok');
aliceDone.resolve();
await firstOperation;
});
it('skips busy OpenCode retry candidates while continuing other candidates', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'mixed-retry-busy-team',
runId: 'run-mixed-retry-busy',
expectedMembers: ['alice', 'tom'],
});
run.isLaunch = true;
run.provisioningComplete = true;
(svc as any).runs.set(run.runId, run);
(svc as any).aliveRunByTeam.set(run.teamName, run.runId);
const busyDone = createDeferred<void>();
const busyOperation = (svc as any).runMemberLifecycleOperation(
run.teamName,
'alice',
'manual_restart',
() => busyDone.promise
);
vi.spyOn(svc as any, 'collectFailedOpenCodeSecondaryRetryCandidates').mockResolvedValue([
{ memberName: 'alice', laneId: 'secondary:opencode:alice' },
{ memberName: 'tom', laneId: 'secondary:opencode:tom' },
]);
const reattach = vi
.spyOn(svc as any, 'reattachOpenCodeOwnedMemberLaneUnlocked')
.mockResolvedValue(undefined);
vi.spyOn(svc as any, 'readOpenCodeSecondaryRetryOutcome').mockResolvedValue({
launchState: 'confirmed_alive',
});
vi.spyOn(svc as any, 'notifyLeadAboutConfirmedOpenCodeRetries').mockResolvedValue(undefined);
const result = await svc.retryFailedOpenCodeSecondaryLanes(run.teamName);
expect(reattach).toHaveBeenCalledTimes(1);
expect(reattach).toHaveBeenCalledWith(run.teamName, 'tom', { reason: 'manual_restart' });
expect(result).toMatchObject({
attempted: ['tom'],
confirmed: ['tom'],
skipped: [
{
memberName: 'alice',
reason: 'Lifecycle operation already in progress',
},
],
});
busyDone.resolve();
await busyOperation;
});
it('blocks manual restart while an OpenCode member update reattach is active', async () => {
const svc = new TeamProvisioningService();
const reattachDone = createDeferred<void>();
const reattachOperation = (svc as any).runMemberLifecycleOperation(
'mixed-update-team',
'bob',
'opencode_member_updated',
() => reattachDone.promise
);
await expect(svc.restartMember('mixed-update-team', 'bob')).rejects.toThrow(
'Lifecycle operation for teammate "bob" is already in progress'
);
reattachDone.resolve();
await reattachOperation;
});
it('blocks manual restart while an OpenCode member removal detach is active', async () => {
const svc = new TeamProvisioningService();
const detachDone = createDeferred<void>();
const detachOperation = (svc as any).runMemberLifecycleOperation(
'mixed-remove-team',
'bob',
'opencode_member_removed',
() => detachDone.promise
);
await expect(svc.restartMember('mixed-remove-team', 'bob')).rejects.toThrow(
'Lifecycle operation for teammate "bob" is already in progress'
);
detachDone.resolve();
await detachOperation;
});
it('does not let launch cleanup overwrite a teammate with an active lifecycle operation', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'cleanup-guard-team',
expectedMembers: ['bob'],
memberSpawnStatuses: new Map([
[
'bob',
createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
bootstrapConfirmed: false,
}),
],
]),
});
const lifecycleDone = createDeferred<void>();
const lifecycleOperation = (svc as any).runMemberLifecycleOperation(
run.teamName,
'bob',
'manual_restart',
() => lifecycleDone.promise
);
(svc as any).markUnconfirmedBootstrapMembersFailed(run, 'launch cleanup failure', {
cleanupRequested: true,
});
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
hardFailure: false,
});
lifecycleDone.resolve();
await lifecycleOperation;
});
it('still marks unconfirmed teammates failed during cleanup when no lifecycle operation is active', () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
teamName: 'cleanup-no-guard-team',
expectedMembers: ['bob'],
memberSpawnStatuses: new Map([
[
'bob',
createMemberSpawnStatusEntry({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
livenessSource: 'process',
}),
],
]),
});
(svc as any).markUnconfirmedBootstrapMembersFailed(run, 'launch cleanup failure', {
cleanupRequested: true,
});
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
runtimeAlive: false,
livenessSource: undefined,
});
});
});

View file

@ -164,8 +164,8 @@ describe('GraphMemberLogPreviewHud', () => {
expect(row).not.toBeUndefined();
expect(row?.querySelector('.float-left')).not.toBeNull();
expect(row?.querySelector('.line-clamp-3')).toBeNull();
expect(row?.className).toContain('h-[68px]');
expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-[18px]');
expect(row?.className).toContain('h-[72px]');
expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-5');
expect(row?.textContent).toContain('pnpm test');
const errorRow = Array.from(host.querySelectorAll('button')).find((button) =>
@ -564,6 +564,87 @@ describe('GraphMemberLogPreviewHud', () => {
});
});
it('renders distinct empty states for unsupported and simply empty members', async () => {
const codexNode: GraphNode = {
id: 'member:alpha-team:codex-dev',
kind: 'member',
label: 'codex-dev',
state: 'idle',
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'codex-dev' },
};
const quietNode: GraphNode = {
id: 'member:alpha-team:quiet-dev',
kind: 'member',
label: 'quiet-dev',
state: 'idle',
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'quiet-dev' },
};
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
[
'codex-dev',
{
memberName: 'codex-dev',
items: [],
coverage: [{ provider: 'codex_native_trace', status: 'skipped' }],
warnings: [
{
code: 'codex_member_wide_not_supported',
message: 'Codex member-wide native trace is not available in this variant yet.',
},
],
truncated: false,
overflowCount: 0,
generatedAt: '2026-04-03T00:00:00.000Z',
},
],
[
'quiet-dev',
{
memberName: 'quiet-dev',
items: [],
coverage: [{ provider: 'claude_transcript', status: 'skipped' }],
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);
await act(async () => {
root.render(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[codexNode, quietNode]}
getLogWorldRect={(ownerNodeId) => ({
left: ownerNodeId.includes('quiet') ? 360 : 40,
top: 80,
right: ownerNodeId.includes('quiet') ? 620 : 300,
bottom: 372,
width: 260,
height: 292,
})}
getCameraZoom={() => 1}
worldToScreen={(x, y) => ({ x, y })}
getViewportSize={() => ({ width: 1200, height: 800 })}
focusNodeIds={null}
/>
);
await Promise.resolve();
});
expect(host.textContent).toContain('Unsupported provider');
expect(host.textContent).toContain('No recent logs');
act(() => {
root.unmount();
});
});
it('renders lead log previews and opens the lead profile logs tab', async () => {
const leadNode: GraphNode = {
id: 'lead:alpha-team',
@ -717,4 +798,155 @@ describe('GraphMemberLogPreviewHud', () => {
root.unmount();
});
});
it('truncates long event titles without losing the full tooltip context', async () => {
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
[
'alice',
{
memberName: 'alice',
items: [
{
id: 'long-title-preview',
kind: 'tool_use',
provider: 'claude_transcript',
timestamp: '2026-04-03T00:01:00.000Z',
title: 'Very long custom provider tool name',
preview:
'Very long custom provider tool name: compact body should still remain visible',
tone: 'warning',
},
],
coverage: [{ provider: 'claude_transcript', status: 'included' }],
warnings: [],
truncated: false,
overflowCount: 0,
generatedAt: '2026-04-03T00:01:00.000Z',
},
],
]);
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);
await act(async () => {
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}
/>
);
await Promise.resolve();
});
const row = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('compact body')
);
expect(row?.textContent).toContain('Very long custom prov...');
expect(row?.textContent).toContain('compact body should still remain visible');
expect(row?.textContent).not.toContain('Very long custom provider tool nameVery long');
expect(row?.getAttribute('title')).toContain('Very long custom provider tool name');
expect(row?.getAttribute('aria-label')).toContain('Very long custom provider tool name');
act(() => {
root.unmount();
});
});
it('uses safe fallback titles for malformed compact events', async () => {
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
[
'alice',
{
memberName: 'alice',
items: [
{
id: 'empty-title-result',
kind: 'tool_result',
provider: 'claude_transcript',
timestamp: '2026-04-03T00:01:00.000Z',
title: ' ',
preview: 'stored',
tone: 'success',
},
{
id: 'empty-title-error',
kind: 'text',
provider: 'claude_transcript',
timestamp: 'bad timestamp',
title: '',
preview: 'provider returned malformed timestamp',
tone: 'error',
},
],
coverage: [{ provider: 'claude_transcript', status: 'included' }],
warnings: [],
truncated: false,
overflowCount: 0,
generatedAt: '2026-04-03T00:01:00.000Z',
},
],
]);
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);
await act(async () => {
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}
/>
);
await Promise.resolve();
});
expect(host.textContent).toContain('Tool result');
expect(host.textContent).toContain('stored');
expect(host.textContent).toContain('Error');
expect(host.textContent).toContain('provider returned malformed timestamp');
expect(host.textContent).not.toContain('undefined');
act(() => {
root.unmount();
});
});
});