Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
40 changes: 40 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;

// Daemon and Client mode options
daemon: boolean | undefined;
daemonStatus: boolean | undefined;
daemonStop: boolean | undefined;
client: boolean | undefined;
session: string | undefined;
close: boolean | undefined;
verbose: boolean | undefined;
}

export async function parseArguments(
Expand Down Expand Up @@ -288,6 +297,37 @@ export async function parseArguments(
.option('accept-raw-output-risk', {
type: 'boolean',
description: 'Suppress the security warning when using --raw-output.',
})
.option('daemon', {
type: 'boolean',
description:
'Run the Gemini CLI engine as a persistent background process.',
})
.option('daemon-status', {
type: 'boolean',
description: 'Check if the daemon is running.',
})
.option('daemon-stop', {
type: 'boolean',
description: 'Stop the running daemon gracefully.',
})
.option('client', {
type: 'boolean',
description: 'Connect to the running daemon to send prompts.',
})
.option('session', {
type: 'string',
description:
'Named session for client connections to maintain context.',
})
.option('close', {
type: 'boolean',
description: 'Close the specified session with the daemon.',
})
.option('verbose', {
type: 'boolean',
description:
'Verbose intermediate tool call and logging output for the client.',
}),
)
// Register MCP subcommands
Expand Down
81 changes: 81 additions & 0 deletions packages/cli/src/daemon/daemon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import net from 'node:net';
import fs from 'node:fs';
import os from 'node:os';
import { getDaemonSocketPath, checkDaemonStatus } from './daemonClient.js';

vi.mock('../config/config.js');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
debugLogger: {
error: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
writeToStdout: vi.fn(),
writeToStderr: vi.fn(),
};
});

describe('Daemon Mode', () => {
const testHome = '/tmp/gemini-test-home';
const socketPath = `${testHome}/.gemini/daemon.sock`;
let mockServer: net.Server;

beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(process, 'exit').mockImplementation((() => {}) as unknown as (
code?: number,
) => never);

// Mock os.homedir to avoid polluting real user dirs
vi.spyOn(os, 'homedir').mockReturnValue(testHome);
if (!fs.existsSync(`${testHome}/.gemini`)) {
fs.mkdirSync(`${testHome}/.gemini`, { recursive: true });
}
});

afterEach(() => {
if (mockServer) {
mockServer.close();
}
if (fs.existsSync(socketPath)) {
fs.unlinkSync(socketPath);
}
});

describe('daemonClient', () => {
it('should throw an error on Windows', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
expect(() => getDaemonSocketPath()).toThrow(
'Daemon mode is currently not supported on Windows.',
);
Object.defineProperty(process, 'platform', { value: originalPlatform });
});

it('should return false if daemon is not running', async () => {
const isRunning = await checkDaemonStatus();
expect(isRunning).toBe(false);
});

it('should return true if daemon is running', async () => {
mockServer = net.createServer().listen(socketPath);
await new Promise((resolve) => setTimeout(resolve, 100)); // wait for listen

const isRunning = await checkDaemonStatus();
expect(isRunning).toBe(true);
});

// We can add more comprehensive e2e test if necessary.
});
});
173 changes: 173 additions & 0 deletions packages/cli/src/daemon/daemonClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import {
ExitCodes,
writeToStderr,
writeToStdout,
} from '@google/gemini-cli-core';
import type { CliArgs } from '../config/config.js';

export function getDaemonSocketPath(): string {
if (process.platform === 'win32') {
throw new Error('Daemon mode is currently not supported on Windows.');
}
return path.join(os.homedir(), '.gemini', 'daemon.sock');
}

export async function checkDaemonStatus(): Promise<boolean> {
const socketPath = getDaemonSocketPath();
return new Promise((resolve) => {
const client = net.createConnection(socketPath, () => {
client.end();
resolve(true);
});
client.on('error', () => {
resolve(false);
});
});
}

function connectToDaemon(socketPath: string): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const client = net.createConnection(socketPath, () => {
resolve(client);
});
client.on('error', (err) => {
reject(err);
});
});
}

export async function runDaemonClientCommands(
argv: CliArgs,
input: string | undefined,
): Promise<void> {
const socketPath = getDaemonSocketPath();

if (argv.daemonStatus) {
const isRunning = await checkDaemonStatus();
if (isRunning) {
writeToStdout('Daemon is running.\n');
process.exit(ExitCodes.SUCCESS);
} else {
writeToStderr('Daemon is not running.\n');
process.exit(1);
}
}

if (argv.daemonStop) {
try {
const client = await connectToDaemon(socketPath);
client.write(JSON.stringify({ action: 'stop' }) + '\n');
client.end();
writeToStdout('Daemon stop signal sent.\n');
process.exit(ExitCodes.SUCCESS);
} catch (_err) {
writeToStderr('Error: Daemon not running.\n');
process.exit(1);
}
}

if (argv.close) {
if (!argv.session) {
writeToStderr(
'Error: Please provide a session name with --session when using --close.\n',
);
process.exit(1);
}
try {
const client = await connectToDaemon(socketPath);
client.write(
JSON.stringify({ action: 'close_session', session: argv.session }) +
'\n',
);
await new Promise<void>((resolve) => {
let buffer = '';
client.on('data', (d) => {
buffer += d.toString();
if (buffer.includes('\n')) {
client.end();
resolve();
}
});
client.on('end', resolve);
}).then(() => {
writeToStdout(`Session '${argv.session}' closed.\n`);
process.exit(ExitCodes.SUCCESS);
});
return;
} catch (_err) {
writeToStderr('Error: Daemon not running.\n');
process.exit(1);
}
}

if (argv.client) {
if (!input) {
writeToStderr('Error: No prompt provided.\n');
process.exit(1);
}

try {
const client = await connectToDaemon(socketPath);

const payload = {
action: 'prompt',
session: argv.session || 'default',
cwd: process.cwd(),
input,
verbose: argv.verbose || false,
};

client.write(JSON.stringify(payload) + '\n');

client.on('data', (data) => {
// Output might contain chunks of text or specific formatted messages.
// For Phase 1 we can just assume the daemon streams back raw text or json lines.
const lines = data.toString().split('\n');
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i];
if (!line) continue;
try {
const msg = JSON.parse(line);
if (msg.type === 'output') {
writeToStdout(msg.content);
} else if (msg.type === 'error') {
writeToStderr(msg.content + '\n');
process.exit(1);
} else if (msg.type === 'verbose' && argv.verbose) {
writeToStderr(msg.content + '\n');
} else if (msg.type === 'end') {
client.end();
process.exit(ExitCodes.SUCCESS);
}
} catch (_e) {
// Unparseable, just print generic
writeToStdout(line + '\n');
}
}
});

client.on('end', () => {
process.exit(ExitCodes.SUCCESS);
});

client.on('error', (err) => {
writeToStderr(`Stream error: ${err.message}\n`);
process.exit(1);
});
} catch (_err) {
writeToStderr(
'Error: Daemon not running. Start with `gemini --daemon`\n',
);
process.exit(1);
}
}
}
Loading