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
5 changes: 5 additions & 0 deletions .changeset/clever-pillows-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agendex-cli': patch
---

Updates to how we render daemon registry info
61 changes: 61 additions & 0 deletions packages/cli/src/pid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,67 @@ test('daemon PID ownership accepts only CLI or marked desktop daemon commands',
).toBe(false);
});

test('desktop launcher ownership accepts Electron utility processes without visible Node args', () => {
const info = {
pid: process.pid,
hostname: 'current-host',
bootId: 'boot-current',
launcher: 'desktop' as const,
parentPid: process.pid,
};
const runtime = {
currentHostname: 'current-host',
currentBootId: 'boot-current',
processRunning: true,
parentProcessRunning: true,
};

// Real Electron utilityProcess listings often omit fork() Node args from `ps`/WMI.
expect(
isDaemonPidInfoRunning(info, {
...runtime,
processCommand:
'Agendex Helper (Plugin) --type=utility --utility-sub-type=node.mojom.NodeService',
}),
).toBe(true);
expect(
isDaemonPidInfoRunning(info, {
...runtime,
processCommand: 'RenamedDesktop.exe --type=utility --utility-sub-type=network',
}),
).toBe(false);
expect(
isDaemonPidInfoRunning(info, {
...runtime,
processCommand: 'Agendex.exe --type=utility',
}),
).toBe(false);
expect(
isDaemonPidInfoRunning(info, {
...runtime,
parentProcessRunning: false,
processCommand:
'Agendex Helper (Plugin) --type=utility --utility-sub-type=node.mojom.NodeService',
}),
).toBe(false);
expect(
isDaemonPidInfoRunning(info, {
...runtime,
processCommand: 'unrelated-process --serve',
}),
).toBe(false);
expect(
isDaemonPidInfoRunning(
{ ...info, launcher: 'cli' },
{
...runtime,
processCommand:
'Agendex Helper (Plugin) --type=utility --utility-sub-type=node.mojom.NodeService',
},
),
).toBe(false);
});

test('legacy PID files retain metadata and require daemon process ownership', () => {
useTempConfigDir();
const configDir = process.env.AGENDEX_CONFIG_DIR as string;
Expand Down
28 changes: 27 additions & 1 deletion packages/cli/src/pid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export interface DaemonPidFreshnessOptions {
currentBootId?: string | null;
processCommand?: string | null;
processRunning?: boolean;
/** Override for desktop launcher parent liveness checks (tests). */
parentProcessRunning?: boolean;
}

/** Checks record provenance only; validate each live PID separately before signaling it. */
Expand All @@ -170,7 +172,13 @@ export function isDaemonPidInfoRunning(
if (!running) return false;
const command =
options.processCommand !== undefined ? options.processCommand : readProcessCommand(info.pid);
return isAgendexDaemonCommand(command);
if (isAgendexDaemonCommand(command)) return true;

// Electron utilityProcess.fork Node args are process.argv inside the worker, but often
// do not appear in OS process listings (`ps` / WMI). Trust desktop pid-file provenance
// while the recorded Electron parent is still alive and the live process still looks
// like an Electron utility worker — avoids false "not running" in `agendex status`.
return isDesktopDaemonOwnership(info, command, options);
}

export function isAgendexDaemonProcess(pid: number): boolean {
Expand Down Expand Up @@ -277,6 +285,24 @@ function isAgendexDaemonCommand(command: string | null): boolean {
return normalized.includes('--daemon') || normalized.includes('--worker');
}

function isElectronUtilityCommand(command: string | null): boolean {
if (!command) return false;
// Node utilityProcess.fork workers only — not Chromium network/audio/GPU helpers.
return command.toLowerCase().includes('--utility-sub-type=node.mojom.nodeservice');
}
Comment thread
Tyru5 marked this conversation as resolved.

function isDesktopDaemonOwnership(
info: DaemonPidInfo,
command: string | null,
options: DaemonPidFreshnessOptions,
): boolean {
if (info.launcher !== 'desktop') return false;
if (!Number.isInteger(info.parentPid) || (info.parentPid as number) <= 0) return false;
const parentRunning = options.parentProcessRunning ?? isRunning(info.parentPid as number);
if (!parentRunning) return false;
return isElectronUtilityCommand(command);
}

export function requestDaemonStop(pid: number, options: DaemonPathOptions = {}): void {
const path = getStopRequestPath(pid, options);
mkdirSync(dirname(path), { recursive: true });
Expand Down
26 changes: 24 additions & 2 deletions packages/cli/src/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ test('renders grouped status with daemon, cloud, and source summaries', () => {
const output = renderStatus({
config: config(),
configPath: '/tmp/agendex/config.json',
pidInfo: { pid: 123, startedAtMs: NOW - 90_000, hostname: 'workstation' },
pidInfo: { pid: 123, startedAtMs: NOW - 90_000, hostname: 'workstation', launcher: 'cli' },
running: true,
cliVersion: '2.0.0',
devices,
Expand All @@ -62,7 +62,7 @@ test('renders grouped status with daemon, cloud, and source summaries', () => {
expect(output).toContain('Cloud:');
expect(output).toContain('Plan sources:');
expect(output).toContain('✓ running');
expect(output).toContain('PID 123 • up 1m 30s • host workstation');
expect(output).toContain('PID 123 • up 1m 30s • host workstation • via CLI');
expect(output).toContain('✓ 2 devices');
expect(output).toContain('1 alive • 1 stale');
expect(output).toContain('workstation (this machine)');
Expand All @@ -74,6 +74,28 @@ test('renders grouped status with daemon, cloud, and source summaries', () => {
expect(output).toContain('agendex cleanup --stale');
});

test('renders desktop spawn origin for Electron-launched daemons', () => {
const output = renderStatus({
config: config(),
configPath: '/tmp/agendex/config.json',
pidInfo: {
pid: 456,
startedAtMs: NOW - 30_000,
hostname: 'workstation',
launcher: 'desktop',
parentPid: 100,
},
running: true,
cliVersion: '2.0.0',
devices: [],
now: NOW,
color: false,
});

expect(output).toContain('✓ running');
expect(output).toContain('PID 456 • up 30s • host workstation • via desktop app');
});

test('renders actionable setup guidance when config is missing', () => {
const output = renderStatus({
config: null,
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ export function formatDuration(ms: number): string {
return `${days}d ${remainingHours}h`;
}

function formatLauncherOrigin(launcher: DaemonPidInfo['launcher']): string | null {
if (launcher === 'desktop') return 'via desktop app';
if (launcher === 'cli') return 'via CLI';
return null;
}

function localDaemonDetail(options: RenderStatusOptions, now: number): string {
if (!options.running) return 'run `agendex start` to begin background sync';

Expand All @@ -133,6 +139,8 @@ function localDaemonDetail(options: RenderStatusOptions, now: number): string {
}
if (options.pidInfo?.hostname) parts.push(`host ${options.pidInfo.hostname}`);
else parts.push('host unknown');
const origin = formatLauncherOrigin(options.pidInfo?.launcher);
if (origin) parts.push(origin);
return parts.join(' • ');
}

Expand Down
16 changes: 13 additions & 3 deletions packages/desktop/src/main/desktop-daemon-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,20 @@ afterEach(() => {
test('starts one utility worker without putting credentials in its environment', async () => {
useTempConfigDir('agendex daemon path with spaces ');
const child = new FakeUtilityProcess();
const forkCalls: Array<{ path: string; options: { env?: NodeJS.ProcessEnv } }> = [];
const forkCalls: Array<{
path: string;
args: string[];
options: { env?: NodeJS.ProcessEnv; execArgv?: string[] };
}> = [];
const manager = new DesktopDaemonManager({
isDev: true,
workerEntry: join(tempRoot, 'worker path with spaces', 'daemon-worker.js'),
forkWorker: ((path: string, _args: string[], options: { env?: NodeJS.ProcessEnv }) => {
forkCalls.push({ path, options });
forkWorker: ((
path: string,
args: string[],
options: { env?: NodeJS.ProcessEnv; execArgv?: string[] },
) => {
forkCalls.push({ path, args, options });
queueMicrotask(() => child.emit('spawn'));
return child;
}) as never,
Expand All @@ -120,8 +128,10 @@ test('starts one utility worker without putting credentials in its environment',
expect(second).toBe('started');
expect(forkCalls).toHaveLength(1);
expect(forkCalls[0]?.path).toContain('worker path with spaces');
expect(forkCalls[0]?.args).toEqual(['--agendex-daemon-worker']);
expect(forkCalls[0]?.options.env?.AGENDEX_DEV).toBe('1');
expect(forkCalls[0]?.options.env?.AGENDEX_CLOUD_TOKEN).toBeUndefined();
expect(forkCalls[0]?.options.execArgv).toBeUndefined();
expect(
child.messages.some(
(message) =>
Expand Down
29 changes: 27 additions & 2 deletions packages/desktop/src/main/desktop-daemon-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
acquireDaemonStartLock,
isAgendexDaemonProcess,
isDaemonPidInfoCurrent,
isDaemonPidInfoRunning,
isRunning,
readPidInfo,
removePid,
Expand Down Expand Up @@ -164,7 +165,8 @@ export class DesktopDaemonManager {
const orphanPid =
observedIsCurrent &&
observed?.launcher === 'desktop' &&
this.processIsDaemon(observed.pid) &&
this.processIsRunning(observed.pid) &&
this.processLooksLikeDaemon(observed) &&
observed.parentPid !== undefined &&
!this.processIsRunning(observed.parentPid)
? observed.pid
Expand Down Expand Up @@ -194,7 +196,7 @@ export class DesktopDaemonManager {

try {
const current = readPidInfo(pathOptions);
if (current && this.pidInfoIsCurrent(current) && this.processIsDaemon(current.pid)) {
if (current && this.pidInfoMatchesRunningDaemon(current)) {
return 'already-running';
}
if (current) removePid(current.pid, pathOptions);
Expand All @@ -207,6 +209,8 @@ export class DesktopDaemonManager {
if (this.options.isDev) env.AGENDEX_DEV = '1';
else delete env.AGENDEX_DEV;

// Script args land on the worker's process.argv. Do not put the marker in execArgv —
// Node rejects unrecognized -- flags at startup ("bad option").
const child = this.options.forkWorker(workerEntry, ['--agendex-daemon-worker'], {
env,
serviceName: 'Agendex Sync',
Expand Down Expand Up @@ -314,6 +318,27 @@ export class DesktopDaemonManager {
return (this.options.isPidInfoCurrent ?? isDaemonPidInfoCurrent)(info);
}

private pidInfoMatchesRunningDaemon(info: DaemonPidInfo): boolean {
if (!this.pidInfoIsCurrent(info)) return false;
if (this.options.isDaemonProcess) {
return this.processIsDaemon(info.pid);
}
// Honor injected liveness/freshness; force provenance checks already satisfied above.
return isDaemonPidInfoRunning(info, {
processRunning: this.processIsRunning(info.pid),
currentHostname: info.hostname,
currentBootId: info.bootId ?? null,
});
}
Comment thread
Tyru5 marked this conversation as resolved.

/** Orphan workers may still look like Electron utilities after the parent exits. */
private processLooksLikeDaemon(info: DaemonPidInfo): boolean {
if (this.options.isDaemonProcess) return this.processIsDaemon(info.pid);
if (this.processIsDaemon(info.pid)) return true;
// Force parent-alive so desktop utility ownership can match after Electron exits.
return isDaemonPidInfoRunning(info, { parentProcessRunning: true });
}

private postTo(child: UtilityProcess, message: DesktopDaemonParentMessage): boolean {
try {
child.postMessage(message);
Expand Down
Loading