From 55c2e9831155d56c5add1d0d7d0358a5b2e4884b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:20:38 +0200 Subject: [PATCH 1/4] feat(daemon): daemon status lists the registered provider plugins `daemon status [--json]` reads the daemon record without contacting the daemon and reports every provider it registered, with the reason any plugin failed. `plugins list --json` reports the running daemon's inventory next to the store selections. Co-Authored-By: Claude Sonnet 5.5 --- .../src/daemon-registration-status.test.ts | 142 ++++++++++++++++++ .../src/daemon-registration.ts | 104 ++++++++++--- .../daemon-contracts/src/daemon-resolution.ts | 34 +++++ src/cli/commands/__tests__/daemon.test.ts | 54 ++++++- src/cli/commands/__tests__/plugins.test.ts | 65 +++++++- src/cli/commands/daemon.ts | 46 +++++- src/cli/commands/plugins.ts | 27 +++- src/commands/schema/command-overrides.ts | 21 ++- src/daemon-client/daemon-client-metadata.ts | 23 +-- website/docs/docs/commands.md | 2 + 10 files changed, 466 insertions(+), 52 deletions(-) create mode 100644 packages/daemon-contracts/src/daemon-registration-status.test.ts diff --git a/packages/daemon-contracts/src/daemon-registration-status.test.ts b/packages/daemon-contracts/src/daemon-registration-status.test.ts new file mode 100644 index 0000000000..4458549220 --- /dev/null +++ b/packages/daemon-contracts/src/daemon-registration-status.test.ts @@ -0,0 +1,142 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +const processProbe = vi.hoisted(() => ({ startTimeUnreadable: false })); +vi.mock('@agent-device/host-kit/process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readProcessStartTime: (pid: number) => + processProbe.startTimeUnreadable ? null : actual.readProcessStartTime(pid), + }; +}); + +import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { resolveDaemonPaths } from './daemon-resolution.ts'; +import { readDaemonStatus } from './daemon-registration.ts'; + +const FAILED = { + provider: 'broken', + source: 'store', + package: '@example/broken', + version: '1.0.0', + state: 'failed', + error: { + code: 'INVALID_ARGS', + message: 'Plugin must export a default factory', + reason: 'invalid_plugin', + }, + credentials: { variables: [], byBackend: {} }, +}; +const ACTIVE = { + provider: 'browserstack', + source: 'builtin', + state: 'active', + credentials: { variables: ['BROWSERSTACK_USERNAME'], byBackend: { 'ios-instance': ['A'] } }, +}; + +function publish(stateDir: string, record: Record): void { + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, 'daemon.json'), JSON.stringify(record)); +} + +test('a state dir without a daemon record reports no running daemon', () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-daemon-status-')); + expect(readDaemonStatus(paths)).toEqual({ + state: 'stopped', + stateDir: paths.baseDir, + }); +}); + +test('a live registered daemon reports its identity and the plugin inventory it published', () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-status-'); + const owner = readCurrentOwnerIdentity(); + publish(stateDir, { + token: 't', + port: 1, + pid: owner.pid, + processStartTime: owner.startTime, + version: '0.21.30', + transport: 'http', + plugins: [ACTIVE, FAILED, { state: 'unknown' }], + }); + + expect(readDaemonStatus(resolveDaemonPaths(stateDir))).toEqual({ + state: 'running', + stateDir, + pid: owner.pid, + version: '0.21.30', + transport: 'http', + plugins: [ACTIVE, FAILED, { state: 'unknown' }], + }); +}); + +test('a record whose process is gone or was replaced is not a running daemon', () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-status-'); + const owner = readCurrentOwnerIdentity(); + publish(stateDir, { + token: 't', + port: 1, + pid: owner.pid, + processStartTime: 'Thu Jan 1 00:00:00 1970', + plugins: [ACTIVE], + }); + expect(readDaemonStatus(resolveDaemonPaths(stateDir)).state).toBe('stopped'); + publish(stateDir, { + token: 't', + port: 1, + pid: 2_147_483_000, + processStartTime: owner.startTime, + plugins: [ACTIVE], + }); + expect(readDaemonStatus(resolveDaemonPaths(stateDir))).toMatchObject({ + state: 'stopped', + }); +}); + +test('a live process whose start time cannot be read still counts as the running daemon', () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-status-'); + const owner = readCurrentOwnerIdentity(); + publish(stateDir, { + token: 't', + port: 1, + pid: owner.pid, + processStartTime: owner.startTime, + plugins: [ACTIVE], + }); + processProbe.startTimeUnreadable = true; + try { + expect(readDaemonStatus(resolveDaemonPaths(stateDir))).toMatchObject({ + state: 'running', + plugins: [ACTIVE], + }); + } finally { + processProbe.startTimeUnreadable = false; + } +}); + +test('text that is not a daemon record is invalid, not a stopped daemon', () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-status-'); + for (const text of ['{ not json', '[]', 'null']) { + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, 'daemon.json'), text); + expect(readDaemonStatus(resolveDaemonPaths(stateDir))).toEqual({ state: 'invalid', stateDir }); + } +}); + +test('a record that exists but cannot be read is unreadable, not a stopped daemon', () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-status-'); + publish(stateDir, { token: 't', port: 1, pid: 1, processStartTime: 'x' }); + const infoPath = path.join(stateDir, 'daemon.json'); + fs.chmodSync(infoPath, 0o000); + try { + if (process.getuid?.() === 0) return; + expect(readDaemonStatus(resolveDaemonPaths(stateDir))).toEqual({ + state: 'unreadable', + stateDir, + }); + } finally { + fs.chmodSync(infoPath, 0o600); + } +}); diff --git a/packages/daemon-contracts/src/daemon-registration.ts b/packages/daemon-contracts/src/daemon-registration.ts index 0e677e45a4..11c4aa2869 100644 --- a/packages/daemon-contracts/src/daemon-registration.ts +++ b/packages/daemon-contracts/src/daemon-registration.ts @@ -1,12 +1,19 @@ -import fs from 'node:fs'; import type { ProcessLockInspection } from '@agent-device/host-kit/file'; import { + isProcessAlive, ownerIdentityDiffers, isProcessPid, ownerIdentityMatches, + readProcessStartTime, type OwnerIdentity, } from '@agent-device/host-kit/process'; -import { resolveDaemonPaths } from './daemon-resolution.ts'; +import { + readDaemonRecord, + readRecordedDaemonTransport, + resolveDaemonPaths, + type DaemonPaths, + type DaemonServerMode, +} from './daemon-resolution.ts'; /** * The daemon identity published in a state dir's `daemon.json`. It is the only @@ -34,32 +41,29 @@ export function processLockHoldsDaemonIdentity( /** The raw record's identity. A pid of `null` is a file that names no owner, not owner zero. */ type ParsedRegistration = { pid: number | null; startTime: string | null }; +type DaemonRecord = Readonly>; + +/** + * Whether `daemon.json` registers a daemon: a projection of the one record reader that adds the + * identity, so a record that names no owner is `ownerless` however it came to. + */ function readRegistration( infoPath: string, ): - | Readonly<{ status: 'registered'; identity: OwnerIdentity }> + | Readonly<{ status: 'registered'; identity: OwnerIdentity; record: DaemonRecord }> | Readonly<{ status: 'absent' | 'unreadable' | 'ownerless' }> { - let raw: string; - try { - raw = fs.readFileSync(infoPath, 'utf8'); - } catch (error) { - // Only a path the host reports as gone proves there is no record. A permission error, an I/O - // fault, or a read this process lost leaves a file present whose contents are unknown, which no - // caller may read as "someone removed it". - const code = (error as NodeJS.ErrnoException | null)?.code; - return code === 'ENOENT' || code === 'ENOTDIR' - ? { status: 'absent' } - : { status: 'unreadable' }; - } - let parsed: ParsedRegistration; - try { - parsed = parseRegistration(JSON.parse(raw) as { pid?: unknown; processStartTime?: unknown }); - } catch { - return { status: 'ownerless' }; + const read = readDaemonRecord(infoPath); + if (read.status !== 'present') { + return { status: read.status === 'invalid' ? 'ownerless' : read.status }; } + const parsed = parseRegistration(read.record); return parsed.pid === null ? { status: 'ownerless' } - : { status: 'registered', identity: { pid: parsed.pid, startTime: parsed.startTime } }; + : { + status: 'registered', + identity: { pid: parsed.pid, startTime: parsed.startTime }, + record: read.record, + }; } function parseRegistration(parsed: { @@ -110,6 +114,64 @@ export function readRegisteredDaemonOwnership( return { state: 'unproven' }; } +/** What `daemon.json` says about the daemon serving a state dir, checked against the process. */ +export type DaemonStatus = Readonly<{ + /** + * `unreadable`: the record exists but cannot be read; `invalid`: it is not a record. Either way + * whether a daemon runs is unknown. + */ + state: 'running' | 'stopped' | 'unreadable' | 'invalid'; + stateDir: string; + pid?: number; + version?: string; + transport?: DaemonServerMode; + /** + * The provider plugin inventory the running daemon published, as recorded; `readPluginInventory` + * validates it where it is shown, so this module stays out of the inventory vocabulary's closure. + */ + plugins?: unknown; +}>; + +/** + * Reads the daemon's published record without contacting it. The record counts while its pid names + * a live process, unless a readable start time differs from the recorded one: a record a crashed + * daemon left behind, or whose pid was reused, is `stopped`, and a failed start-time read does not + * end the record. + */ +export function readDaemonStatus(paths: DaemonPaths): DaemonStatus { + const base = { stateDir: paths.baseDir } as const; + const read = readDaemonRecord(paths.infoPath); + if (read.status !== 'present') { + return read.status === 'absent' + ? { state: 'stopped', ...base } + : { state: read.status, ...base }; + } + const { record } = read; + const parsed = parseRegistration(record); + if (parsed.pid === null) return { state: 'stopped', ...base }; + const identity = { pid: parsed.pid, startTime: parsed.startTime }; + if (identity.startTime === null || !isProcessAlive(identity.pid)) { + return { state: 'stopped', ...base }; + } + if ( + ownerIdentityDiffers(identity, { + pid: identity.pid, + startTime: readProcessStartTime(identity.pid), + }) + ) { + return { state: 'stopped', ...base }; + } + const transport = readRecordedDaemonTransport(record.transport); + return { + state: 'running', + stateDir: paths.baseDir, + pid: identity.pid, + ...(typeof record.version === 'string' ? { version: record.version } : {}), + ...(transport ? { transport } : {}), + plugins: record.plugins, + }; +} + function readableStartTime(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value : null; } diff --git a/packages/daemon-contracts/src/daemon-resolution.ts b/packages/daemon-contracts/src/daemon-resolution.ts index f0ae06c273..773940d0ba 100644 --- a/packages/daemon-contracts/src/daemon-resolution.ts +++ b/packages/daemon-contracts/src/daemon-resolution.ts @@ -80,6 +80,40 @@ function resolveRealPath(filePath: string): string { } } +/** What `daemon.json` holds: no file, a file that cannot be read, text that is not a record, or the record. */ +export type DaemonRecordRead = + | Readonly<{ status: 'absent' | 'unreadable' | 'invalid' }> + | Readonly<{ status: 'present'; record: Readonly> }>; + +/** + * The one reader of `daemon.json`; registration, status and the client's connection info are + * projections of it. Only a path the host reports as gone proves there is no record: a permission + * error or I/O fault leaves a file whose contents are unknown, which no caller may read as "someone + * removed it". + */ +export function readDaemonRecord(infoPath: string): DaemonRecordRead { + let raw: string; + try { + raw = fs.readFileSync(infoPath, 'utf8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + return { status: code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'unreadable' }; + } + try { + const parsed: unknown = JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? { status: 'present', record: parsed as Record } + : { status: 'invalid' }; + } catch { + return { status: 'invalid' }; + } +} + +/** The transport a daemon recorded in `daemon.json`; anything else is no recorded transport. */ +export function readRecordedDaemonTransport(value: unknown): DaemonServerMode | undefined { + return value === 'socket' || value === 'http' || value === 'dual' ? value : undefined; +} + export function resolveDaemonServerMode(raw: string | undefined): DaemonServerMode { const normalized = (raw ?? '').trim().toLowerCase(); if (normalized === 'http') return 'http'; diff --git a/src/cli/commands/__tests__/daemon.test.ts b/src/cli/commands/__tests__/daemon.test.ts index 7012146f17..dd2043907e 100644 --- a/src/cli/commands/__tests__/daemon.test.ts +++ b/src/cli/commands/__tests__/daemon.test.ts @@ -7,6 +7,7 @@ import { mkdtempForTestSync } from '@agent-device/test-kit'; const mocks = vi.hoisted(() => ({ cleanupDaemonOwner: vi.fn(async () => undefined), readDaemonShutdownReport: vi.fn(), + readDaemonStatus: vi.fn(), readDaemonStopIdentity: vi.fn(), stopDaemon: vi.fn(), writeCommandOutput: vi.fn(), @@ -25,6 +26,9 @@ vi.mock('@agent-device/daemon-contracts/daemon-shutdown-report', () => ({ vi.mock('../../../platform-runtime-daemon-owner-cleanup.ts', () => ({ createDaemonOwnerCleanup: () => ({ cleanup: mocks.cleanupDaemonOwner }), })); +vi.mock('@agent-device/daemon-contracts/daemon-registration', () => ({ + readDaemonStatus: mocks.readDaemonStatus, +})); vi.mock('../shared.ts', () => ({ writeCommandOutput: mocks.writeCommandOutput })); import { daemonCommand } from '../daemon.ts'; @@ -45,7 +49,7 @@ afterEach(() => { vi.clearAllMocks(); }); -test('accepts only daemon stop', async () => { +test('accepts only daemon stop and status', async () => { await assert.rejects( async () => await daemonCommand({ @@ -223,3 +227,51 @@ test('routes an unattributable claim to the default status view, never to --stal fs.rmSync(stateDir, { recursive: true, force: true }); } }); + +test('daemon status reads the daemon record without stopping or contacting the daemon', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-command-'); + const status = { + state: 'running', + stateDir, + pid: 321, + version: '0.21.30', + plugins: [ + { + provider: 'browserstack', + source: 'builtin', + state: 'active', + credentials: { variables: [], byBackend: {} }, + }, + { + provider: 'testmu', + source: 'store', + package: '@agent-device/testmu', + version: '1.0.0', + state: 'failed', + error: { code: 'INVALID_ARGS', message: 'bad plugin' }, + credentials: { variables: [], byBackend: {} }, + }, + ], + }; + mocks.readDaemonStatus.mockReturnValue(status); + + try { + await daemonCommand({ + positionals: ['status'], + flags: { help: false, json: true, stateDir, version: false }, + client: {} as never, + }); + + expect(mocks.stopDaemon).not.toHaveBeenCalled(); + expect(mocks.writeCommandOutput).toHaveBeenCalledWith( + expect.objectContaining({ json: true }), + status, + expect.any(Function), + ); + const render = mocks.writeCommandOutput.mock.calls[0]![2] as () => string; + expect(render()).toContain('testmu [store] @agent-device/testmu@1.0.0 - failed: bad plugin'); + expect(render()).toContain('browserstack [builtin] builtin - active'); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/src/cli/commands/__tests__/plugins.test.ts b/src/cli/commands/__tests__/plugins.test.ts index b7dc661b41..7d4f506ad8 100644 --- a/src/cli/commands/__tests__/plugins.test.ts +++ b/src/cli/commands/__tests__/plugins.test.ts @@ -3,8 +3,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import { runCli } from '../../../cli.ts'; +import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; import { pluginHome, selectPlugin } from '@agent-device/test-kit'; -import { parseRawArgs, usageForCommand } from '../../parser/args.ts'; +import { parseArgs, parseRawArgs, usageForCommand } from '../../parser/args.ts'; import { beginStdoutCapture } from '../../../__tests__/test-utils/stream-capture.ts'; vi.mock('../../../provider-device-runtimes.ts', () => { @@ -57,5 +58,65 @@ test('plugin schema exposes operator help and preserves scoped npm package specs assert.deepEqual(args.positionals, ['add', '@example/provider@^1.0.0']); const help = await usageForCommand('plugins'); assert.ok(help); - assert.match(help, /plugins list\|add/); + assert.match(help, /plugins list \[--state-dir \]\|add/); +}); + +test('plugins list reports the running daemon inventory next to the store selections', async () => { + const { home } = pluginHome(); + const stateDir = path.join(home, 'state'); + vi.stubEnv('AGENT_DEVICE_HOME', home); + vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); + vi.stubEnv('AGENT_DEVICE_NO_UPDATE_NOTIFIER', '1'); + const owner = readCurrentOwnerIdentity(); + const failed = { + provider: 'example', + source: 'store', + package: 'example', + version: '1.2.3', + state: 'failed', + error: { code: 'INVALID_ARGS', message: 'bad plugin', reason: 'invalid_plugin' }, + credentials: { variables: [], byBackend: {} }, + }; + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, 'daemon.json'), + JSON.stringify({ + token: 't', + port: 1, + pid: owner.pid, + processStartTime: owner.startTime, + plugins: [failed], + }), + ); + selectPlugin(home, 'example', 'example', 'export default () => {};'); + const stdoutCapture = beginStdoutCapture(); + try { + await runCli(['plugins', 'list', '--json'], { + sendToDaemon: vi.fn(async () => { + throw new Error('unexpected daemon access'); + }), + }); + const { data } = JSON.parse(stdoutCapture.read()); + assert.deepEqual(data.daemon, { pid: owner.pid, stateDir, plugins: [failed] }); + assert.equal(data.plugins[0].name, 'example'); + } finally { + stdoutCapture.restore(); + } +}); + +test('--state-dir belongs to plugins list and daemon status, not to the commands that change the store', async () => { + assert.equal( + parseArgs(['plugins', 'list', '--state-dir', '/tmp/state']).flags.stateDir, + '/tmp/state', + ); + assert.throws(() => parseArgs(['plugins', 'add', 'example', '--state-dir', '/tmp/state']), { + code: 'INVALID_ARGS', + }); + assert.equal( + parseArgs(['daemon', 'status', '--state-dir', '/tmp/state']).flags.stateDir, + '/tmp/state', + ); + assert.throws(() => parseArgs(['daemon', 'status', '--clean']), { code: 'INVALID_ARGS' }); + const help = await usageForCommand('daemon'); + assert.match(help ?? '', /daemon status \[--state-dir \]/); }); diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 0cac73a814..f6fab41204 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -1,9 +1,17 @@ +import { + readPluginInventory, + type PluginInventoryEntry, +} from '@agent-device/contracts/plugin-inventory'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { readDaemonStopIdentity, stopDaemon, type DaemonStopResult, } from '@agent-device/daemon-contracts/daemon-stop'; +import { + readDaemonStatus, + type DaemonStatus, +} from '@agent-device/daemon-contracts/daemon-registration'; import { readDaemonShutdownReport } from '@agent-device/daemon-contracts/daemon-shutdown-report'; import { AppError } from '@agent-device/kernel/errors'; import { writeCommandOutput } from './shared.ts'; @@ -14,10 +22,16 @@ const daemonOwnerCleanup = createDaemonOwnerCleanup(); export const daemonCommand: ClientCommandHandler = async ({ positionals, flags }) => { const subcommand = positionals[0]; - if (subcommand !== 'stop' || positionals.length !== 1) { - throw new AppError('INVALID_ARGS', 'daemon accepts only: stop'); + if ((subcommand !== 'stop' && subcommand !== 'status') || positionals.length !== 1) { + throw new AppError('INVALID_ARGS', 'daemon accepts only: stop, status'); } const paths = resolveDaemonPaths(flags.stateDir); + if (subcommand === 'status') { + const status = readDaemonStatus(paths); + const shown = { ...status, plugins: readPluginInventory(status.plugins) }; + await writeCommandOutput(flags, shown, () => renderDaemonStatus(shown)); + return true; + } const identity = readDaemonStopIdentity(paths.infoPath); const stopped = await stopDaemon({ paths }); const report = stopped.mode === 'graceful' ? readDaemonShutdownReport(paths.baseDir) : null; @@ -122,3 +136,31 @@ function renderDaemonStop( .filter((line): line is string => Boolean(line)) .join('\n'); } + +function renderDaemonStatus( + status: Omit & { plugins: readonly PluginInventoryEntry[] }, +): string { + if (status.state === 'unreadable' || status.state === 'invalid') { + return `The daemon record for ${status.stateDir} is ${status.state === 'invalid' ? 'not a valid record' : 'unreadable'}, so whether a daemon runs there is unknown.`; + } + if (status.state === 'stopped') return `No running daemon for ${status.stateDir}.`; + const details = [`pid ${status.pid}`, status.version, status.transport].filter(Boolean); + return [ + `Daemon running (${details.join(', ')}) for ${status.stateDir}.`, + ...(status.plugins.length === 0 + ? ['No providers registered.'] + : [ + 'Providers:', + ...status.plugins.map((plugin) => { + const origin = plugin.package + ? `${plugin.package}${plugin.version ? `@${plugin.version}` : ''}` + : plugin.source; + const outcome = + plugin.state === 'active' + ? 'active' + : `failed: ${plugin.error?.message ?? 'unknown'}`; + return ` ${plugin.provider ?? '(unknown provider)'} [${plugin.source}] ${origin} - ${outcome}`; + }), + ]), + ].join('\n'); +} diff --git a/src/cli/commands/plugins.ts b/src/cli/commands/plugins.ts index 15cedede8f..29cfa4ff55 100644 --- a/src/cli/commands/plugins.ts +++ b/src/cli/commands/plugins.ts @@ -1,3 +1,6 @@ +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { readPluginInventory } from '@agent-device/contracts/plugin-inventory'; +import { readDaemonStatus } from '@agent-device/daemon-contracts/daemon-registration'; import { AppError } from '@agent-device/kernel/errors'; import { changePlugin, listPlugins } from '@agent-device/plugins/store'; import { BUILTIN_PROVIDER_IDS } from '../../builtin-provider-ids.ts'; @@ -25,7 +28,20 @@ export const pluginsCommand: ClientCommandHandler = async ({ positionals, flags ); } const plugins = await listPlugins(process.env, BUILTIN_PROVIDER_IDS); - await writeCommandOutput(flags, { plugins, restartRequired: action !== 'list' }, () => + const daemon = readDaemonStatus(resolveDaemonPaths(flags.stateDir)); + const daemonPlugins = readPluginInventory(daemon.plugins); + const failedInDaemon = daemonPlugins.filter(({ state }) => state === 'failed'); + const data = { + plugins, + ...(daemon.state === 'running' + ? { daemon: { pid: daemon.pid, stateDir: daemon.stateDir, plugins: daemonPlugins } } + : {}), + ...(daemon.state === 'unreadable' || daemon.state === 'invalid' + ? { daemonRecord: daemon.state } + : {}), + restartRequired: action !== 'list', + }; + await writeCommandOutput(flags, data, () => [ ...plugins.map((plugin) => plugin.compatible @@ -38,6 +54,15 @@ export const pluginsCommand: ClientCommandHandler = async ({ positionals, flags ? ['Restart the local daemon after closing active sessions to use the changed plugin set.'] : []), ...(plugins.length === 0 ? ['No provider plugins installed.'] : []), + ...(daemon.state === 'unreadable' || daemon.state === 'invalid' + ? [ + `The daemon record for ${daemon.stateDir} is ${daemon.state}, so its providers are unknown.`, + ] + : []), + ...failedInDaemon.map( + (plugin) => + `The running daemon did not register ${plugin.package ?? plugin.provider ?? plugin.source}: ${plugin.error?.message}`, + ), ].join('\n'), ); return true; diff --git a/src/commands/schema/command-overrides.ts b/src/commands/schema/command-overrides.ts index 1d3fa753c5..8c815ad47a 100644 --- a/src/commands/schema/command-overrides.ts +++ b/src/commands/schema/command-overrides.ts @@ -14,12 +14,14 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { text: { summary: 'Manage installed provider plugins', description: - 'Install, list, update, or remove npm provider plugins in AGENT_DEVICE_HOME (default ~/.agent-device). Changes take effect after restarting the local daemon. Use --json for structured results.', + 'Install, list, update, or remove npm provider plugins in AGENT_DEVICE_HOME (default ~/.agent-device). Changes take effect after restarting the local daemon. list works offline and, when a daemon is running for --state-dir, also reports the providers it registered. Use --json for structured results.', }, - usageOverride: 'plugins list|add |update |remove ', + usageOverride: + 'plugins list [--state-dir ]|add |update |remove ', listUsageOverride: 'plugins', positionalArgs: ['list|add|update|remove', 'package?'], - supportedFlags: [], + flagsByAction: { list: ['stateDir'], add: [], update: [], remove: [] }, + supportedFlags: ['stateDir'], }, cdp: { text: { @@ -45,14 +47,17 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { }, daemon: { text: { - summary: 'Stop a local daemon and clean up resources', + summary: 'Stop a local daemon or show the providers it registered', description: - 'Stop a local daemon after verifying its PID/start-time identity. Use --clean to remove retained Apple runner processes and leases owned by that daemon.', + 'stop stops a local daemon after verifying its PID/start-time identity. Use --clean to remove retained Apple runner processes and leases owned by that daemon. status reads the daemon record without contacting the daemon and lists every provider plugin it registered, built in or installed, with the reason any plugin failed to register. Use --json for structured results.', }, - usageOverride: 'daemon stop [--state-dir ]', - listUsageOverride: 'daemon stop', - positionalArgs: ['stop'], + usageOverride: + 'daemon stop [--clean] [--state-dir ] | daemon status [--state-dir ]', + usageFlags: [], + listUsageOverride: 'daemon stop|status', + positionalArgs: ['stop|status'], allowedFlags: ['clean'], + flagsByAction: { stop: ['clean'], status: [] }, supportedFlags: ['stateDir'], }, device: { diff --git a/src/daemon-client/daemon-client-metadata.ts b/src/daemon-client/daemon-client-metadata.ts index 4709c85ced..882d7da57c 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -4,6 +4,8 @@ import { isProcessPid } from '@agent-device/host-kit/process'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; import { readDaemonCapabilities, + readDaemonRecord, + readRecordedDaemonTransport, resolveDaemonPaths, type DaemonCapability, type DaemonPaths, @@ -37,9 +39,9 @@ export type DaemonMetadataState = { }; export function readDaemonInfo(infoPath: string): DaemonInfo | null { - const data = readJsonFile(infoPath); - if (!data || typeof data !== 'object') return null; - const parsed = data as Partial; + const read = readDaemonRecord(infoPath); + if (read.status !== 'present') return null; + const parsed = read.record as Partial; const token = readRequiredDaemonToken(parsed); if (!token) return null; const ports = readDaemonInfoPorts(parsed); @@ -47,7 +49,7 @@ export function readDaemonInfo(infoPath: string): DaemonInfo | null { return { token, ...ports, - transport: readDaemonInfoTransport(parsed.transport), + transport: readRecordedDaemonTransport(parsed.transport), pid: isProcessPid(parsed.pid) ? parsed.pid : 0, version: readOptionalString(parsed.version), codeOrigin: readDaemonInfoCodeOrigin(parsed.codeOrigin), @@ -76,10 +78,6 @@ function readDaemonInfoCodeOrigin(value: unknown): DaemonInfo['codeOrigin'] { return value === 'installed' || value === 'checkout' ? value : undefined; } -function readDaemonInfoTransport(value: unknown): DaemonInfo['transport'] { - return value === 'socket' || value === 'http' || value === 'dual' ? value : undefined; -} - function readOptionalString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } @@ -110,12 +108,3 @@ export function resolveDaemonStartupHint( .join(' and '); return `Daemon startup did not establish a reachable owner. ${artifacts ? `State was retained at ${artifacts}. ` : ''}Retry with --debug and inspect daemon diagnostics. Before upgrading, stop all older clients and daemons with their original CLI and prevent them from returning to this state directory. Unverified lock state requires confirming every user stopped before manual recovery; deleting metadata alone is not a safe reset.`; } - -function readJsonFile(filePath: string): unknown | null { - if (!fs.existsSync(filePath)) return null; - try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; - } catch { - return null; - } -} diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 9a4010823d..3528b9066c 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -64,6 +64,7 @@ agent-device telephony sms 5551234 "Your code is 1234" agent-device device status --platform android --serial emulator-5554 agent-device device release --stale agent-device daemon stop --clean +agent-device daemon status --json ``` Boot and shut down: @@ -85,6 +86,7 @@ Device claims and the daemon: - `device release --stale` settles the durable resources of an owner that provably can't release its claim (its process exited or a newer daemon replaced it), then clears the claim. Live, uncertain, PID-reused, corrupt, and deleted-state-directory claims are refused with the reason. See [Sessions](/docs/sessions#release-a-device-another-workspace-holds). - A command that needs the shared local daemon starts it for its state directory when none runs; `device status` and `device release --stale` never contact one. When the running daemon comes from another version or build, the command replaces it only if it reports no work: no request in flight, no session, no repair session and no live lease. A resumable upload still streaming and a host human-control hold that names no lease are not counted, so a replacement can end them. Otherwise the command fails with `daemon-busy` and leaves the daemon alone. A daemon from a release that predates this check is never replaced, and fails with `daemon-incompatible`. End a busy or older daemon deliberately with `daemon stop`. Node.js clients choose this per client; see [Daemon lifecycle](/docs/client-api#daemon-lifecycle). - `daemon stop --state-dir ` stops a local daemon after verifying its identity. `--clean` also removes the Apple runner processes and leases that daemon retained. +- `daemon status [--state-dir ] [--json]` reads the daemon's record without contacting it. For a running daemon it lists every provider it registered, built in or installed, and the reason any plugin failed to register. See [provider plugins](/docs/plugins). Open apps and links: From d1d1361de90d9ac16132dfed37e1d0709b70974e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:48:39 +0200 Subject: [PATCH 2/4] test(cli): cover the plugins list text output for failed and unusable daemon records Co-Authored-By: Claude Sonnet 5.5 --- src/cli/commands/__tests__/plugins.test.ts | 57 ++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/cli/commands/__tests__/plugins.test.ts b/src/cli/commands/__tests__/plugins.test.ts index 7d4f506ad8..c9ac4b24dd 100644 --- a/src/cli/commands/__tests__/plugins.test.ts +++ b/src/cli/commands/__tests__/plugins.test.ts @@ -120,3 +120,60 @@ test('--state-dir belongs to plugins list and daemon status, not to the commands const help = await usageForCommand('daemon'); assert.match(help ?? '', /daemon status \[--state-dir \]/); }); + +async function listPluginsAsText(home: string, stateDir: string): Promise { + vi.stubEnv('AGENT_DEVICE_HOME', home); + vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); + vi.stubEnv('AGENT_DEVICE_NO_UPDATE_NOTIFIER', '1'); + const stdoutCapture = beginStdoutCapture(); + try { + await runCli(['plugins', 'list'], { + sendToDaemon: vi.fn(async () => { + throw new Error('unexpected daemon access'); + }), + }); + return stdoutCapture.read(); + } finally { + stdoutCapture.restore(); + } +} + +test('plugins list names a plugin the running daemon did not register', async () => { + const { home } = pluginHome(); + const stateDir = path.join(home, 'state'); + const owner = readCurrentOwnerIdentity(); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, 'daemon.json'), + JSON.stringify({ + token: 't', + port: 1, + pid: owner.pid, + processStartTime: owner.startTime, + plugins: [ + { + provider: 'example', + source: 'store', + package: 'example', + state: 'failed', + error: { code: 'INVALID_ARGS', message: 'bad plugin', reason: 'invalid_plugin' }, + credentials: { variables: [], byBackend: {} }, + }, + ], + }), + ); + selectPlugin(home, 'example', 'example', 'export default () => {};'); + const text = await listPluginsAsText(home, stateDir); + assert.match(text, /example@.* \(example\)/); + assert.match(text, /The running daemon did not register example: bad plugin/); +}); + +test('plugins list says when the daemon record cannot be used instead of hiding it', async () => { + const { home } = pluginHome(); + const stateDir = path.join(home, 'state'); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, 'daemon.json'), '{not json'); + const text = await listPluginsAsText(home, stateDir); + assert.match(text, /No provider plugins installed\./); + assert.match(text, /daemon record for .* is (unreadable|invalid), so its providers are unknown/); +}); From 0ffb8d04c9343c9ed4491e529023e5d599e1dd40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 12:13:04 +0200 Subject: [PATCH 3/4] test(cli): pin the invalid daemon record state in plugins list Co-Authored-By: Claude Sonnet 5.5 --- src/cli/commands/__tests__/plugins.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/commands/__tests__/plugins.test.ts b/src/cli/commands/__tests__/plugins.test.ts index c9ac4b24dd..8819afe8ef 100644 --- a/src/cli/commands/__tests__/plugins.test.ts +++ b/src/cli/commands/__tests__/plugins.test.ts @@ -175,5 +175,5 @@ test('plugins list says when the daemon record cannot be used instead of hiding fs.writeFileSync(path.join(stateDir, 'daemon.json'), '{not json'); const text = await listPluginsAsText(home, stateDir); assert.match(text, /No provider plugins installed\./); - assert.match(text, /daemon record for .* is (unreadable|invalid), so its providers are unknown/); + assert.match(text, /daemon record for .* is invalid, so its providers are unknown/); }); From 0c81506f8413ef39a0d1dd0456283efb1e6af5ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 12:48:51 +0200 Subject: [PATCH 4/4] refactor(daemon): one predicate for an unreadable or invalid daemon record Co-Authored-By: Claude Sonnet 5.5 --- packages/daemon-contracts/src/daemon-registration.ts | 5 +++++ src/cli/commands/__tests__/daemon.test.ts | 3 ++- src/cli/commands/daemon.ts | 3 ++- src/cli/commands/plugins.ts | 11 ++++++----- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/daemon-contracts/src/daemon-registration.ts b/packages/daemon-contracts/src/daemon-registration.ts index 11c4aa2869..fb2723e4d0 100644 --- a/packages/daemon-contracts/src/daemon-registration.ts +++ b/packages/daemon-contracts/src/daemon-registration.ts @@ -132,6 +132,11 @@ export type DaemonStatus = Readonly<{ plugins?: unknown; }>; +/** Whether the record exists but cannot say if a daemon runs: it is `unreadable` or `invalid`. */ +export function isUnusableRecord(status: Pick): boolean { + return status.state === 'unreadable' || status.state === 'invalid'; +} + /** * Reads the daemon's published record without contacting it. The record counts while its pid names * a live process, unless a readable start time differs from the recorded one: a record a crashed diff --git a/src/cli/commands/__tests__/daemon.test.ts b/src/cli/commands/__tests__/daemon.test.ts index dd2043907e..3c1e1d2f48 100644 --- a/src/cli/commands/__tests__/daemon.test.ts +++ b/src/cli/commands/__tests__/daemon.test.ts @@ -26,7 +26,8 @@ vi.mock('@agent-device/daemon-contracts/daemon-shutdown-report', () => ({ vi.mock('../../../platform-runtime-daemon-owner-cleanup.ts', () => ({ createDaemonOwnerCleanup: () => ({ cleanup: mocks.cleanupDaemonOwner }), })); -vi.mock('@agent-device/daemon-contracts/daemon-registration', () => ({ +vi.mock('@agent-device/daemon-contracts/daemon-registration', async (importOriginal) => ({ + ...(await importOriginal()), readDaemonStatus: mocks.readDaemonStatus, })); vi.mock('../shared.ts', () => ({ writeCommandOutput: mocks.writeCommandOutput })); diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index f6fab41204..5bb2a3b9c2 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -9,6 +9,7 @@ import { type DaemonStopResult, } from '@agent-device/daemon-contracts/daemon-stop'; import { + isUnusableRecord, readDaemonStatus, type DaemonStatus, } from '@agent-device/daemon-contracts/daemon-registration'; @@ -140,7 +141,7 @@ function renderDaemonStop( function renderDaemonStatus( status: Omit & { plugins: readonly PluginInventoryEntry[] }, ): string { - if (status.state === 'unreadable' || status.state === 'invalid') { + if (isUnusableRecord(status)) { return `The daemon record for ${status.stateDir} is ${status.state === 'invalid' ? 'not a valid record' : 'unreadable'}, so whether a daemon runs there is unknown.`; } if (status.state === 'stopped') return `No running daemon for ${status.stateDir}.`; diff --git a/src/cli/commands/plugins.ts b/src/cli/commands/plugins.ts index 29cfa4ff55..284a0f840f 100644 --- a/src/cli/commands/plugins.ts +++ b/src/cli/commands/plugins.ts @@ -1,6 +1,9 @@ import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { readPluginInventory } from '@agent-device/contracts/plugin-inventory'; -import { readDaemonStatus } from '@agent-device/daemon-contracts/daemon-registration'; +import { + isUnusableRecord, + readDaemonStatus, +} from '@agent-device/daemon-contracts/daemon-registration'; import { AppError } from '@agent-device/kernel/errors'; import { changePlugin, listPlugins } from '@agent-device/plugins/store'; import { BUILTIN_PROVIDER_IDS } from '../../builtin-provider-ids.ts'; @@ -36,9 +39,7 @@ export const pluginsCommand: ClientCommandHandler = async ({ positionals, flags ...(daemon.state === 'running' ? { daemon: { pid: daemon.pid, stateDir: daemon.stateDir, plugins: daemonPlugins } } : {}), - ...(daemon.state === 'unreadable' || daemon.state === 'invalid' - ? { daemonRecord: daemon.state } - : {}), + ...(isUnusableRecord(daemon) ? { daemonRecord: daemon.state } : {}), restartRequired: action !== 'list', }; await writeCommandOutput(flags, data, () => @@ -54,7 +55,7 @@ export const pluginsCommand: ClientCommandHandler = async ({ positionals, flags ? ['Restart the local daemon after closing active sessions to use the changed plugin set.'] : []), ...(plugins.length === 0 ? ['No provider plugins installed.'] : []), - ...(daemon.state === 'unreadable' || daemon.state === 'invalid' + ...(isUnusableRecord(daemon) ? [ `The daemon record for ${daemon.stateDir} is ${daemon.state}, so its providers are unknown.`, ]