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
4 changes: 2 additions & 2 deletions docs/guide/browser-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ The daemon manages the WebSocket connection between your CLI commands and the Ch

## Daemon Lifecycle

The daemon auto-starts on first browser command and stays alive for **4 hours** by default. It exits only when both conditions are met: no CLI requests for the timeout period AND no Chrome extension connected.
The daemon auto-starts on first browser command and stays alive persistently.

```bash
opencli daemon status # Check daemon state (PID, uptime, extension, memory)
opencli daemon stop # Graceful shutdown
opencli daemon restart # Stop + restart
```

Override the timeout via the `OPENCLI_DAEMON_TIMEOUT` environment variable (milliseconds). Set to `0` to keep the daemon alive indefinitely.
The daemon is persistent — it stays alive until you explicitly stop it (`opencli daemon stop`) or uninstall the package.
2 changes: 1 addition & 1 deletion docs/guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ opencli daemon restart
opencli doctor
```

> The daemon auto-exits after 4 hours of inactivity (no CLI requests and no extension connection). Override with `OPENCLI_DAEMON_TIMEOUT` (milliseconds, `0` = never timeout).
> The daemon is persistent and stays alive until explicitly stopped (`opencli daemon stop`) or the package is uninstalled.

### Desktop adapter connection issues

Expand Down
4 changes: 2 additions & 2 deletions docs/zh/guide/browser-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ opencli doctor # 检查扩展 + 守护进程连接

## Daemon 生命周期

Daemon 在首次运行浏览器命令时自动启动,默认保持 **4 小时**。仅当 CLI 空闲超时**且** Chrome 扩展未连接时才会退出
Daemon 在首次运行浏览器命令时自动启动,之后保持常驻运行

```bash
opencli daemon status # 查看 daemon 状态(PID、运行时长、扩展连接、内存)
opencli daemon stop # 优雅关停
opencli daemon restart # 重启
```

通过 `OPENCLI_DAEMON_TIMEOUT` 环境变量覆盖超时时间(毫秒)。设为 `0` 则永不超时
Daemon 为常驻模式,会一直运行直到你显式停止(`opencli daemon stop`)或卸载包
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"copy-yaml": "node scripts/copy-yaml.cjs",
"start": "node dist/src/main.js",
"start:bun": "bun dist/src/main.js",
"preuninstall": "node -e \"fetch('http://127.0.0.1:19825/shutdown',{method:'POST',headers:{'X-OpenCLI':'1'},signal:AbortSignal.timeout(3000)}).catch(()=>{})\" || true",
"postinstall": "node scripts/postinstall.js || true; node scripts/fetch-adapters.js || true",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
Expand Down
1 change: 0 additions & 1 deletion src/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,6 @@ describe('BrowserBridge state', () => {
uptime: 0,
extensionConnected: false,
pending: 0,
lastCliRequestTime: 0,
memoryMB: 0,
port: 0,
},
Expand Down
2 changes: 1 addition & 1 deletion src/browser/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export class BrowserBridge implements IBrowserFactory {
async close(): Promise<void> {
if (this._state === 'closed') return;
this._state = 'closing';
// We don't kill the daemon — it auto-exits on idle.
// We don't kill the daemon — it's persistent.
// Just clean up our reference.
this._page = null;
this._state = 'closed';
Expand Down
3 changes: 0 additions & 3 deletions src/browser/daemon-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ describe('daemon-client', () => {
extensionConnected: true,
extensionVersion: '1.2.3',
pending: 0,
lastCliRequestTime: Date.now(),
memoryMB: 32,
port: 19825,
};
Expand Down Expand Up @@ -75,7 +74,6 @@ describe('daemon-client', () => {
uptime: 10,
extensionConnected: false,
pending: 0,
lastCliRequestTime: Date.now(),
memoryMB: 16,
port: 19825,
};
Expand All @@ -95,7 +93,6 @@ describe('daemon-client', () => {
extensionConnected: true,
extensionVersion: '1.2.3',
pending: 0,
lastCliRequestTime: Date.now(),
memoryMB: 32,
port: 19825,
};
Expand Down
1 change: 0 additions & 1 deletion src/browser/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ export interface DaemonStatus {
extensionConnected: boolean;
extensionVersion?: string;
pending: number;
lastCliRequestTime: number;
memoryMB: number;
port: number;
}
Expand Down
10 changes: 1 addition & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { printCompletionScript } from './completion.js';
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
import { registerAllCommands } from './commanderAdapter.js';
import { EXIT_CODES, getErrorMessage } from './errors.js';
import { daemonStatus, daemonStop, daemonRestart } from './commands/daemon.js';
import { daemonStop } from './commands/daemon.js';

const CLI_FILE = fileURLToPath(import.meta.url);

Expand Down Expand Up @@ -936,18 +936,10 @@ cli({

// ── Built-in: daemon ──────────────────────────────────────────────────────
const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
daemonCmd
.command('status')
.description('Show daemon status')
.action(async () => { await daemonStatus(); });
daemonCmd
.command('stop')
.description('Stop the daemon')
.action(async () => { await daemonStop(); });
daemonCmd
.command('restart')
.description('Restart the daemon')
.action(async () => { await daemonRestart(); });

// ── External CLIs ─────────────────────────────────────────────────────────

Expand Down
169 changes: 24 additions & 145 deletions src/commands/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,19 @@ const {
vi.mock('chalk', () => ({
default: {
green: (s: string) => s,
yellow: (s: string) => s,
red: (s: string) => s,
dim: (s: string) => s,
},
}));

const mockConnect = vi.fn();
vi.mock('../browser/bridge.js', () => ({
BrowserBridge: class {
connect = mockConnect;
},
}));

vi.mock('../browser/daemon-client.js', () => ({
fetchDaemonStatus: fetchDaemonStatusMock,
requestDaemonShutdown: requestDaemonShutdownMock,
}));

import { daemonStatus, daemonStop, daemonRestart } from './daemon.js';
import { daemonStop } from './daemon.js';

describe('daemon commands', () => {
describe('daemonStop', () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;

Expand All @@ -44,161 +36,48 @@ describe('daemon commands', () => {

afterEach(() => {
vi.restoreAllMocks();
mockConnect.mockReset();
});

describe('daemonStatus', () => {
it('shows "not running" when daemon is unreachable', async () => {
fetchDaemonStatusMock.mockResolvedValue(null);

await daemonStatus();

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
});

it('shows "not running" when daemon returns non-ok response', async () => {
fetchDaemonStatusMock.mockResolvedValue(null);

await daemonStatus();

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
});

it('shows daemon info when running', async () => {
const status = {
ok: true,
pid: 12345,
uptime: 3661,
extensionConnected: true,
pending: 0,
lastCliRequestTime: Date.now() - 30_000,
memoryMB: 64,
port: 19825,
};

fetchDaemonStatusMock.mockResolvedValue(status);

await daemonStatus();

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PID 12345'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('1h 1m'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('connected'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('64 MB'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('19825'));
});

it('shows disconnected when extension is not connected', async () => {
const status = {
ok: true,
pid: 99,
uptime: 120,
extensionConnected: false,
pending: 0,
lastCliRequestTime: Date.now() - 5000,
memoryMB: 32,
port: 19825,
};

fetchDaemonStatusMock.mockResolvedValue(status);
it('reports "not running" when daemon is unreachable', async () => {
fetchDaemonStatusMock.mockResolvedValue(null);

await daemonStatus();
await daemonStop();

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('disconnected'));
});
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
});

describe('daemonStop', () => {
it('reports "not running" when daemon is unreachable', async () => {
fetchDaemonStatusMock.mockResolvedValue(null);

await daemonStop();

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
it('sends shutdown and reports success', async () => {
fetchDaemonStatusMock.mockResolvedValue({
ok: true,
pid: 12345,
uptime: 100,
extensionConnected: true,
pending: 0,
memoryMB: 50,
port: 19825,
});
requestDaemonShutdownMock.mockResolvedValue(true);

it('sends shutdown and reports success', async () => {
fetchDaemonStatusMock.mockResolvedValue({
ok: true,
pid: 12345,
uptime: 100,
extensionConnected: true,
pending: 0,
lastCliRequestTime: Date.now(),
memoryMB: 50,
port: 19825,
});
requestDaemonShutdownMock.mockResolvedValue(true);

await daemonStop();

expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
});
await daemonStop();

it('reports failure when shutdown request fails', async () => {
fetchDaemonStatusMock.mockResolvedValue({
ok: true,
pid: 12345,
uptime: 100,
extensionConnected: true,
pending: 0,
lastCliRequestTime: Date.now(),
memoryMB: 50,
port: 19825,
});
requestDaemonShutdownMock.mockResolvedValue(false);

await daemonStop();

expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon'));
});
expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
});

describe('daemonRestart', () => {
const statusData = {
it('reports failure when shutdown request fails', async () => {
fetchDaemonStatusMock.mockResolvedValue({
ok: true,
pid: 12345,
uptime: 100,
extensionConnected: true,
pending: 0,
lastCliRequestTime: Date.now(),
memoryMB: 50,
port: 19825,
};

it('starts daemon directly when not running', async () => {
fetchDaemonStatusMock.mockResolvedValue(null);
mockConnect.mockResolvedValue(undefined);

await daemonRestart();

expect(mockConnect).toHaveBeenCalledWith({ timeout: 10 });
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon restarted'));
});

it('stops then starts when daemon is running', async () => {
fetchDaemonStatusMock
.mockResolvedValueOnce(statusData)
.mockResolvedValueOnce(null);
requestDaemonShutdownMock.mockResolvedValue(true);
mockConnect.mockResolvedValue(undefined);

await daemonRestart();

expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1);
expect(mockConnect).toHaveBeenCalledWith({ timeout: 10 });
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon restarted'));
});
requestDaemonShutdownMock.mockResolvedValue(false);

it('aborts when shutdown fails', async () => {
fetchDaemonStatusMock.mockResolvedValue(statusData);
requestDaemonShutdownMock.mockResolvedValue(false);
await daemonStop();

await daemonRestart();

expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon'));
expect(mockConnect).not.toHaveBeenCalled();
});
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon'));
});
});
Loading
Loading