Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/main/agents.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { getProvider, availableAgents, resolveProjectOpenCommand } from './agents';
import { join } from 'node:path';
import { getProvider, availableAgents, resolveProjectOpenCommand, agentAvailableAtHome } from './agents';

describe('agent providers', () => {
it('claude buildCommand maps kinds correctly', () => {
Expand Down Expand Up @@ -31,6 +32,13 @@ describe('agent providers', () => {
expect(availableAgents(() => true).sort()).toEqual(['antigravity', 'claude', 'codex']);
});

it('recognizes a fresh login root before the first session creates a projects/sessions folder', () => {
const home = join(process.cwd(), 'fresh-home');
expect(agentAvailableAtHome('codex', home, (path) => path === join(home, '.codex'))).toBe(true);
expect(agentAvailableAtHome('claude', home, (path) => path === join(home, '.claude'))).toBe(true);
expect(agentAvailableAtHome('codex', home, () => false)).toBe(false);
});

it('resolves an external launch only inside the explicitly requested provider', () => {
const codex = getProvider('codex');
expect(resolveProjectOpenCommand(codex, { mode: 'auto', sessionId: null, hasHistory: true }))
Expand Down
15 changes: 11 additions & 4 deletions src/main/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects');
const ANTIGRAVITY_DIR = join(homedir(), '.gemini', 'antigravity');
const CODEX_SESSIONS = join(homedir(), '.codex', 'sessions');

/** A login creates the provider root before the first conversation creates its session subfolder. */
export function agentAvailableAtHome(id: AgentId, home: string, exists: (path: string) => boolean = existsSync): boolean {
if (id === 'claude') return exists(join(home, '.claude'));
if (id === 'codex') return exists(join(home, '.codex'));
return exists(join(home, '.gemini', 'antigravity'));
}

export type LaunchKind = 'new' | 'continue' | 'resume';

export interface AgentProvider {
Expand All @@ -30,7 +37,7 @@ const claudeProvider: AgentProvider = {
id: 'claude',
label: 'Claude',
supportsSessionId: true,
isAvailable: () => existsSync(CLAUDE_PROJECTS),
isAvailable: () => agentAvailableAtHome('claude', homedir()),
listSessions: (p, limit) => listSessions(p, CLAUDE_PROJECTS, limit),
listSessionIds: (p) => listSessionIds(p, CLAUDE_PROJECTS),
lastUserMessage: (p, id) => lastUserMessageForSession(p, id, CLAUDE_PROJECTS),
Expand All @@ -46,7 +53,7 @@ const antigravityProvider: AgentProvider = {
id: 'antigravity',
label: 'Antigravity',
supportsSessionId: false, // agy has no --session-id pin; --conversation resumes by id only
isAvailable: () => antigravityAvailable(ANTIGRAVITY_DIR),
isAvailable: () => agentAvailableAtHome('antigravity', homedir()) || antigravityAvailable(ANTIGRAVITY_DIR),
// Async to match the interface; antigravity's own reads stay sync (rare provider, small .db files).
listSessions: async (p, limit) => listAntigravitySessions(p, ANTIGRAVITY_DIR, limit),
listSessionIds: (p) => listAntigravitySessionIds(p, ANTIGRAVITY_DIR),
Expand All @@ -61,7 +68,7 @@ const codexProvider: AgentProvider = {
id: 'codex',
label: 'Codex',
supportsSessionId: false,
isAvailable: () => codexAvailable(CODEX_SESSIONS),
isAvailable: () => agentAvailableAtHome('codex', homedir()) || codexAvailable(CODEX_SESSIONS),
listSessions: async (p, limit) => listCodexSessions(p, CODEX_SESSIONS, limit),
listSessionIds: (p) => listCodexSessionIds(p, CODEX_SESSIONS),
lastUserMessage: async (p, id) => lastUserMessageForCodexSession(p, id, CODEX_SESSIONS),
Expand Down Expand Up @@ -91,7 +98,7 @@ export function resolveProjectOpenCommand(
return provider.buildCommand(intent.hasHistory ? 'continue' : 'new');
}

/** Installed agents (claude, antigravity, and Codex when their session directories exist). `probe` overridable for tests. */
/** Known agents (a login/config root is enough; the first session may not exist yet). `probe` overridable for tests. */
export function availableAgents(probe?: (id: AgentId) => boolean): AgentId[] {
const ids: AgentId[] = ['claude', 'antigravity', 'codex'];
const isAvail = probe ?? ((id) => PROVIDERS[id].isAvailable());
Expand Down
49 changes: 49 additions & 0 deletions src/main/cliExecutable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { win32 as pathWin32 } from 'node:path';
import type { AgentId } from '../shared/types';

type LoginProvider = Extract<AgentId, 'claude' | 'codex'>;

function knownWindowsCandidates(providerId: LoginProvider, env: NodeJS.ProcessEnv): string[] {
const appData = env.APPDATA;
const localAppData = env.LOCALAPPDATA;
const profile = env.USERPROFILE;
if (providerId === 'codex') return [
...(appData ? [pathWin32.join(appData, 'npm', 'codex.cmd')] : []),
...(localAppData ? [pathWin32.join(localAppData, 'Programs', 'OpenAI', 'Codex', 'bin', 'codex.exe')] : []),
];
return [
...(appData ? [pathWin32.join(appData, 'npm', 'claude.cmd')] : []),
...(profile ? [pathWin32.join(profile, '.local', 'bin', 'claude.exe')] : []),
...(localAppData ? [pathWin32.join(localAppData, 'Programs', 'Claude', 'claude.exe')] : []),
];
}

/**
* Resolve the concrete CLI executable used by background adapters and setup terminals.
* Known per-user install locations are checked even when DevDeck's long-lived tray process has an
* older PATH than a terminal opened after installation.
*/
export function resolveAgentCliPath(
providerId: LoginProvider,
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
exists: (path: string) => boolean = existsSync,
): string {
if (platform !== 'win32') return providerId;
try {
const found = execFileSync('where', [providerId], { env, windowsHide: true })
.toString().split(/\r?\n/).map((line) => line.trim()).find((line) => /\.(?:cmd|exe)$/i.test(line) && exists(line));
if (found) return found;
} catch { /* stale/missing PATH — check stable per-user locations below */ }
return knownWindowsCandidates(providerId, env).find(exists) ?? providerId;
}

/** Fixed, provider-owned login command for the visible PowerShell setup terminal. */
export function loginPowerShellCommand(providerId: LoginProvider): string {
const executable = resolveAgentCliPath(providerId).replace(/'/g, "''");
const args = providerId === 'codex' ? 'login' : 'auth login';
return `& '${executable}' ${args}`;
}

58 changes: 57 additions & 1 deletion src/main/codexUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
import { describe, it, expect, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { getCodexUsage, parseCodexRateLimits, type CodexUsageDeps } from './codexUsage';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { getCodexUsage, parseCodexRateLimits, spawnCodexAppServer, type CodexUsageDeps } from './codexUsage';

const NOW = 1_700_000_000_000;

Expand Down Expand Up @@ -260,3 +263,56 @@ describe('getCodexUsage protocol', () => {
expect(h.child.kill).toHaveBeenCalledTimes(1);
});
});

describe.runIf(process.platform === 'win32')('spawnCodexAppServer on Windows', () => {
it('runs an npm codex.cmd shim instead of reporting the CLI missing', async () => {
const home = mkdtempSync(join(tmpdir(), 'devdeck-codex-shim-'));
const dir = join(home, 'npm');
const previousPath = process.env.PATH;
const previousAppData = process.env.APPDATA;
const previousLocalAppData = process.env.LOCALAPPDATA;
try {
mkdirSync(dir);
writeFileSync(join(dir, 'codex.cmd'), '@echo off\r\nexit /b 0\r\n', 'utf8');
process.env.APPDATA = home;
process.env.LOCALAPPDATA = home;
process.env.PATH = dir;
const child = spawnCodexAppServer();
const result = await new Promise<{ code: number | null; error: NodeJS.ErrnoException | null }>((resolve) => {
let error: NodeJS.ErrnoException | null = null;
child.on('error', (value) => { error = value; });
child.on('close', (code) => resolve({ code, error }));
});
expect(result).toEqual({ code: 0, error: null });
} finally {
process.env.PATH = previousPath;
process.env.APPDATA = previousAppData;
process.env.LOCALAPPDATA = previousLocalAppData;
rmSync(home, { recursive: true, force: true });
}
});

it('finds the npm shim from APPDATA when the tray process has a stale PATH', async () => {
const home = mkdtempSync(join(tmpdir(), 'devdeck-stale-path-'));
const npmDir = join(home, 'npm');
const previousPath = process.env.PATH;
const previousAppData = process.env.APPDATA;
try {
mkdirSync(npmDir);
writeFileSync(join(npmDir, 'codex.cmd'), '@echo off\r\nexit /b 0\r\n', 'utf8');
process.env.APPDATA = home;
process.env.PATH = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32');
const child = spawnCodexAppServer();
const result = await new Promise<{ code: number | null; error: NodeJS.ErrnoException | null }>((resolve) => {
let error: NodeJS.ErrnoException | null = null;
child.on('error', (value) => { error = value; });
child.on('close', (code) => resolve({ code, error }));
});
expect(result).toEqual({ code: 0, error: null });
} finally {
process.env.PATH = previousPath;
process.env.APPDATA = previousAppData;
rmSync(home, { recursive: true, force: true });
}
});
});
8 changes: 7 additions & 1 deletion src/main/codexUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// first-party request; we only speak its JSON-RPC and normalize the answer.
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { clampPercent, parseResetTime, safeUsageLabel, type ProviderUsage, type UsageCredits, type UsageLimit } from '../shared/usageWindows';
import { resolveAgentCliPath } from './cliExecutable';

const STARTUP_TIMEOUT_MS = 8_000;
const REQUEST_TIMEOUT_MS = 12_000;
Expand Down Expand Up @@ -203,5 +204,10 @@ function loginOrOffline(error: unknown, now: number): ProviderUsage {

/** Production spawn: the official CLI, stdio pipes only, no shell. */
export function spawnCodexAppServer(): ChildProcessWithoutNullStreams {
return spawn('codex', ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) as ChildProcessWithoutNullStreams;
const command = resolveAgentCliPath('codex');
// npm installs a `codex.cmd` shim on Windows. CreateProcess cannot execute that shim directly;
// route the fixed command through cmd.exe there (there is no user-controlled argv in this call).
return spawn(command, ['app-server'], {
stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: process.platform === 'win32',
}) as ChildProcessWithoutNullStreams;
}
29 changes: 29 additions & 0 deletions src/main/ipc.cockpit.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { join } from 'node:path';
import { homedir } from 'node:os';

const { handlers, claudeStats, codexStats, codexIndex, codexSessions, claudeIds, probe } = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => unknown>(),
Expand Down Expand Up @@ -241,6 +242,34 @@ describe('cockpit:sessionMeta summary per provider', () => {
});
});

describe('usage:login embedded terminal', () => {
it('opens the requested provider login in a visible PTY using fixed commands', async () => {
const open = handlers.get('usage:login')!;
ptyCreate.mockClear();

const codex = await open(null, 'codex', 92, 28) as { id: string; providerId: string };
expect(codex).toMatchObject({ providerId: 'codex' });
expect(codex.id).toMatch(/^usage-login:codex:/);
expect(ptyCreate).toHaveBeenLastCalledWith(
codex.id, expect.any(String), ['-NoExit', '-Command', expect.stringMatching(/codex(?:\.cmd|\.exe)?['"]?\s+login/i)],
homedir(), 92, 28, expect.any(Function), expect.any(Function),
);

const claude = await open(null, 'claude', 80, 24) as { id: string; providerId: string };
expect(claude).toMatchObject({ providerId: 'claude' });
expect(ptyCreate).toHaveBeenLastCalledWith(
claude.id, expect.any(String), ['-NoExit', '-Command', expect.stringMatching(/claude(?:\.cmd|\.exe)?['"]?\s+auth\s+login/i)],
homedir(), 80, 24, expect.any(Function), expect.any(Function),
);
});

it('refuses unsupported providers instead of exposing a general command terminal', async () => {
ptyCreate.mockClear();
expect(await handlers.get('usage:login')!(null, 'antigravity', 80, 24)).toBeNull();
expect(ptyCreate).not.toHaveBeenCalled();
});
});

// A saved entry names ONE conversation. When that conversation is gone the tile comes back as a fresh
// session under the same name (resolveRestoreTarget) — so the "Previous" list has to be able to say so
// BEFORE the click. This answers the whole list at once: per-entry would re-index the flat Codex
Expand Down
20 changes: 20 additions & 0 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { getCodexUsage, spawnCodexAppServer } from './codexUsage';
import { UsageCoordinator, antigravityUsage } from './usageProviders';
import { pickAdoptedSessionId, pickDriftedSessionId, type PersistedSession } from '../shared/cockpitPersist';
import { makeAgentProbe } from './agentProcess';
import { loginPowerShellCommand } from './cliExecutable';
import { listSessionStats, listSessionIds } from './sessions';
import { listCodexSessionStats, indexCodexSessionsByCwd, readCodexSessionMeta } from './codexSessions';
import { indexAntigravitySessionsByCwd } from './antigravitySessions';
Expand Down Expand Up @@ -391,6 +392,25 @@ export function registerIpc(cfg: IpcConfig): void {
// Coalesce pty output (~one frame) before it crosses IPC so many streaming sessions don't flood the
// renderer's single UI thread; input is never batched, and a big burst flushes immediately via the cap.
const ptyBatch = new PtyBatcher((id, chunk) => sendToWin('cockpit:data', { id, chunk }), (flush) => { setTimeout(flush, 16); });
// Visible, interactive OAuth setup inside DevDeck. This is intentionally NOT a general command
// runner: the renderer chooses only a provider id and main owns the two fixed login commands.
ipcMain.handle('usage:login', (_e, rawProviderId: unknown, cols: number, rows: number) => {
const providerId = rawProviderId === 'claude' || rawProviderId === 'codex' ? rawProviderId : null;
if (!providerId || !cfg.ptyAvailable) return null;
const id = `usage-login:${providerId}:${++cockpitSeq}`;
try {
cfg.ptyHost.create(
id, resolveShellPath(), ['-NoExit', '-Command', loginPowerShellCommand(providerId)], homedir(),
Math.max(20, Number(cols) | 0), Math.max(5, Number(rows) | 0),
(chunk) => ptyBatch.push(id, chunk),
(exit) => { ptyBatch.flush(); sendToWin('cockpit:exit', { id, exitCode: exit.exitCode }); },
);
return { id, providerId };
} catch (err) {
cfg.sendError(`Could not open ${providerId} login: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
});
ipcMain.handle('cockpit:open', async (_e, req: { projectPath: string; sessionId: string | null; cols: number; rows: number; mode?: OpenMode; agentId?: AgentId }) => {
const folders = effFolders();
if (!isAllowedPath(folders, req.projectPath)) {
Expand Down
1 change: 1 addition & 0 deletions src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ contextBridge.exposeInMainWorld('devdeck', {
setAiSessionSummary: (on: boolean) => ipcRenderer.invoke('settings:setAiSessionSummary', on),
usageSnapshot: () => ipcRenderer.invoke('usage:snapshot'),
refreshUsageProviders: (opts?: { force?: boolean }) => ipcRenderer.invoke('usage:refresh', { force: opts?.force === true }),
openUsageLogin: (providerId: 'claude' | 'codex', cols: number, rows: number) => ipcRenderer.invoke('usage:login', providerId, cols, rows),
onUpdate: (cb: (p: import('../shared/update').UpdatePayload) => void) =>
ipcRenderer.on('devdeck:update', (_e, p) => cb(p as import('../shared/update').UpdatePayload)),
downloadUpdate: () => ipcRenderer.invoke('update:download'),
Expand Down
1 change: 1 addition & 0 deletions src/renderer/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ declare global {
setAiSessionSummary(on: boolean): Promise<void>;
usageSnapshot(): Promise<import('../shared/usageWindows').UsageSnapshot | null>;
refreshUsageProviders(opts?: { force?: boolean }): Promise<import('../shared/usageWindows').UsageSnapshot>;
openUsageLogin(providerId: 'claude' | 'codex', cols: number, rows: number): Promise<{ id: string; providerId: 'claude' | 'codex' } | null>;
onUpdate(cb: (p: import('../shared/update').UpdatePayload) => void): void;
downloadUpdate(): Promise<void>;
installUpdate(): Promise<void>;
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@
"usage.credits_off": "None",
"usage.guidance_cli": "Check this provider from its CLI:",
"usage.copy_command": "Copy command",
"usage.login_open": "Open login terminal",
"usage.install_hint": "Install the CLI, then refresh:",
"usage.login_title": "{provider} CLI login",
"usage.login_hint": "Complete the browser sign-in, then close this terminal. Usage status refreshes automatically.",
"usage.login_exited": "Login terminal exited with code",
"usage.login_unavailable": "The embedded terminal is unavailable on this device.",
"usage.state_ready": "Current",
"usage.state_stale": "Last known",
"usage.state_login_required": "Login required",
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@
"usage.credits_off": "なし",
"usage.guidance_cli": "このプロバイダーはCLIで確認してください:",
"usage.copy_command": "コマンドをコピー",
"usage.login_open": "ログイン端末を開く",
"usage.install_hint": "CLIをインストールして更新:",
"usage.login_title": "{provider} CLI ログイン",
"usage.login_hint": "ブラウザーでログインを完了してから、この端末を閉じてください。使用状況は自動的に更新されます。",
"usage.login_exited": "ログイン端末の終了コード",
"usage.login_unavailable": "このデバイスでは内蔵端末を使用できません。",
"usage.state_ready": "最新",
"usage.state_stale": "以前のデータ",
"usage.state_login_required": "ログインが必要",
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@
"usage.credits_off": "없음",
"usage.guidance_cli": "이 프로바이더는 CLI에서 직접 확인하세요:",
"usage.copy_command": "명령 복사",
"usage.login_open": "로그인 터미널 열기",
"usage.install_hint": "CLI 설치 후 새로고침:",
"usage.login_title": "{provider} CLI 로그인",
"usage.login_hint": "브라우저 로그인을 완료한 뒤 이 터미널을 닫으세요. 사용량 상태가 자동으로 새로고침됩니다.",
"usage.login_exited": "로그인 터미널 종료 코드",
"usage.login_unavailable": "이 기기에서는 내장 터미널을 사용할 수 없습니다.",
"usage.state_ready": "최신",
"usage.state_stale": "이전 데이터",
"usage.state_login_required": "로그인 필요",
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@
"usage.credits_off": "无",
"usage.guidance_cli": "该提供方请在其 CLI 中查看:",
"usage.copy_command": "复制命令",
"usage.login_open": "打开登录终端",
"usage.install_hint": "安装 CLI 后刷新:",
"usage.login_title": "{provider} CLI 登录",
"usage.login_hint": "在浏览器中完成登录,然后关闭此终端。用量状态将自动刷新。",
"usage.login_exited": "登录终端退出代码",
"usage.login_unavailable": "此设备无法使用内置终端。",
"usage.state_ready": "最新",
"usage.state_stale": "上次数据",
"usage.state_login_required": "需要登录",
Expand Down
Loading
Loading