feat: add thinking option support and autonomous run handling
This commit is contained in:
+12
-16
@@ -9,6 +9,7 @@ import { createWorktreeCommand } from './commands/worktree/index.js'
|
||||
import { startCommand as daemonStartCommand } from './commands/daemon/start.js'
|
||||
import { runStatusCommand as runDaemonStatusCommand } from './commands/daemon/status.js'
|
||||
import { runRestartCommand as runDaemonRestartCommand } from './commands/daemon/restart.js'
|
||||
import { runDaemonUpdateCommandOrExit } from './commands/daemon/update.js'
|
||||
import { runLsCommand } from './commands/agent/ls.js'
|
||||
import { runRunCommand } from './commands/agent/run.js'
|
||||
import { runLogsCommand } from './commands/agent/logs.js'
|
||||
@@ -17,7 +18,6 @@ import { runSendCommand } from './commands/agent/send.js'
|
||||
import { runInspectCommand } from './commands/agent/inspect.js'
|
||||
import { runWaitCommand } from './commands/agent/wait.js'
|
||||
import { runAttachCommand } from './commands/agent/attach.js'
|
||||
import { runUpdateCommand } from './commands/agent/update.js'
|
||||
import { withOutput } from './output/index.js'
|
||||
import { onboardCommand } from './commands/onboard.js'
|
||||
|
||||
@@ -63,6 +63,7 @@ export function createCli(): Command {
|
||||
.option('-a, --all', 'Include archived agents')
|
||||
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
|
||||
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
||||
.option('--thinking <id>', 'Filter by thinking option ID')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runLsCommand))
|
||||
@@ -75,6 +76,7 @@ export function createCli(): Command {
|
||||
.option('--name <name>', 'Assign a name/title to the agent')
|
||||
.option('--provider <provider>', 'Agent provider: claude | codex | opencode', 'claude')
|
||||
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
|
||||
.option('--thinking <id>', 'Thinking option ID to use for this run')
|
||||
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
|
||||
.option('--worktree <name>', 'Create agent in a new git worktree')
|
||||
.option('--base <branch>', 'Base branch for worktree (default: current branch)')
|
||||
@@ -143,25 +145,19 @@ export function createCli(): Command {
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runWaitCommand))
|
||||
|
||||
program
|
||||
.command('update')
|
||||
.description('Update an agent (alias for "paseo agent update")')
|
||||
.argument('<id>', 'Agent ID (or prefix)')
|
||||
.option('--name <name>', "Update the agent's display name")
|
||||
.option(
|
||||
'--label <label>',
|
||||
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
|
||||
collectMultiple,
|
||||
[]
|
||||
)
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runUpdateCommand))
|
||||
|
||||
// Top-level local daemon shortcuts
|
||||
program.addCommand(onboardCommand())
|
||||
program.addCommand(daemonStartCommand())
|
||||
|
||||
program
|
||||
.command('update')
|
||||
.description('Update local daemon package (alias for "paseo daemon update")')
|
||||
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||
.option('-y, --yes', 'Restart automatically after update')
|
||||
.action(async (options: { home?: string; yes?: boolean }) => {
|
||||
await runDaemonUpdateCommandOrExit(options)
|
||||
})
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Show local daemon status (alias for "paseo daemon status")')
|
||||
|
||||
@@ -27,6 +27,7 @@ export function createAgentCommand(): Command {
|
||||
.option('-a, --all', 'Include archived agents')
|
||||
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
|
||||
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
|
||||
.option('--thinking <id>', 'Filter by thinking option ID')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runLsCommand))
|
||||
@@ -39,6 +40,7 @@ export function createAgentCommand(): Command {
|
||||
.option('--name <name>', 'Assign a name/title to the agent')
|
||||
.option('--provider <provider>', 'Agent provider: claude | codex | opencode', 'claude')
|
||||
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
|
||||
.option('--thinking <id>', 'Thinking option ID to use for this run')
|
||||
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
|
||||
.option('--cwd <path>', 'Working directory (default: current)')
|
||||
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
|
||||
|
||||
@@ -9,6 +9,7 @@ interface AgentInspect {
|
||||
Name: string
|
||||
Provider: string
|
||||
Model: string
|
||||
Thinking: string
|
||||
Status: string
|
||||
Mode: string
|
||||
Cwd: string
|
||||
@@ -84,6 +85,17 @@ function formatCost(costUsd: number): string {
|
||||
return `$${costUsd.toFixed(2)}`
|
||||
}
|
||||
|
||||
function normalizeModelId(value: string | null | undefined): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const normalized = value.trim()
|
||||
if (!normalized || normalized.toLowerCase() === 'default') return null
|
||||
return normalized
|
||||
}
|
||||
|
||||
function resolveModel(snapshot: AgentSnapshotPayload): string | null {
|
||||
return normalizeModelId(snapshot.runtimeInfo?.model) ?? normalizeModelId(snapshot.model)
|
||||
}
|
||||
|
||||
/** Convert agent snapshot to inspection data */
|
||||
function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
|
||||
const lastUsage = snapshot.lastUsage
|
||||
@@ -112,7 +124,8 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
|
||||
Id: snapshot.id,
|
||||
Name: snapshot.title ?? '-',
|
||||
Provider: snapshot.provider,
|
||||
Model: snapshot.model ?? '-',
|
||||
Model: resolveModel(snapshot) ?? '-',
|
||||
Thinking: snapshot.effectiveThinkingOptionId ?? 'auto',
|
||||
Status: snapshot.status,
|
||||
Mode: snapshot.currentModeId ?? 'default',
|
||||
Cwd: snapshot.cwd,
|
||||
@@ -139,6 +152,7 @@ function toInspectRows(agent: AgentInspect): InspectRow[] {
|
||||
{ key: 'Name', value: agent.Name },
|
||||
{ key: 'Provider', value: agent.Provider },
|
||||
{ key: 'Model', value: agent.Model },
|
||||
{ key: 'Thinking', value: agent.Thinking },
|
||||
{ key: 'Status', value: agent.Status },
|
||||
{ key: 'Mode', value: agent.Mode },
|
||||
{ key: 'Cwd', value: shortenPath(agent.Cwd) },
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AgentListItem {
|
||||
shortId: string
|
||||
name: string
|
||||
provider: string
|
||||
thinking: string
|
||||
status: string
|
||||
cwd: string
|
||||
created: string
|
||||
@@ -35,6 +36,13 @@ function shortenPath(path: string): string {
|
||||
return path
|
||||
}
|
||||
|
||||
function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||
if (typeof modelId !== 'string') return null
|
||||
const normalized = modelId.trim()
|
||||
if (!normalized || normalized.toLowerCase() === 'default') return null
|
||||
return normalized
|
||||
}
|
||||
|
||||
/** Schema for agent ls output */
|
||||
export const agentLsSchema: OutputSchema<AgentListItem> = {
|
||||
idField: 'shortId',
|
||||
@@ -42,6 +50,7 @@ export const agentLsSchema: OutputSchema<AgentListItem> = {
|
||||
{ header: 'AGENT ID', field: 'shortId', width: 12 },
|
||||
{ header: 'NAME', field: 'name', width: 20 },
|
||||
{ header: 'PROVIDER', field: 'provider', width: 15 },
|
||||
{ header: 'THINKING', field: 'thinking', width: 12 },
|
||||
{
|
||||
header: 'STATUS',
|
||||
field: 'status',
|
||||
@@ -60,11 +69,13 @@ export const agentLsSchema: OutputSchema<AgentListItem> = {
|
||||
|
||||
/** Transform agent snapshot to AgentListItem */
|
||||
function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
const model = normalizeModelId(agent.runtimeInfo?.model) ?? normalizeModelId(agent.model)
|
||||
return {
|
||||
id: agent.id,
|
||||
shortId: agent.id.slice(0, 7),
|
||||
name: agent.title ?? '-',
|
||||
provider: agent.model ? `${agent.provider}/${agent.model}` : agent.provider,
|
||||
provider: model ? `${agent.provider}/${model}` : agent.provider,
|
||||
thinking: agent.effectiveThinkingOptionId ?? 'auto',
|
||||
status: agent.status,
|
||||
cwd: shortenPath(agent.cwd),
|
||||
created: relativeTime(agent.createdAt),
|
||||
@@ -84,6 +95,8 @@ export interface AgentLsOptions extends CommandOptions {
|
||||
cwd?: string
|
||||
/** Filter by labels (key=value format) */
|
||||
label?: string[]
|
||||
/** Filter by thinking option ID */
|
||||
thinking?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,8 +124,45 @@ export async function runLsCommand(
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedThinkingOptionId = options.thinking?.trim()
|
||||
if (options.thinking !== undefined && !normalizedThinkingOptionId) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_THINKING_OPTION',
|
||||
message: '--thinking cannot be empty',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Parse --label filters (key=value).
|
||||
const labelFilters: Record<string, string> = {}
|
||||
if (options.label) {
|
||||
for (const labelStr of options.label) {
|
||||
const eqIndex = labelStr.indexOf('=')
|
||||
if (eqIndex !== -1) {
|
||||
const key = labelStr.slice(0, eqIndex)
|
||||
const value = labelStr.slice(eqIndex + 1)
|
||||
labelFilters[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const daemonFilter: {
|
||||
includeArchived?: boolean
|
||||
labels?: Record<string, string>
|
||||
thinkingOptionId?: string
|
||||
} = {}
|
||||
if (options.all) {
|
||||
daemonFilter.includeArchived = true
|
||||
}
|
||||
if (Object.keys(labelFilters).length > 0) {
|
||||
daemonFilter.labels = labelFilters
|
||||
}
|
||||
if (normalizedThinkingOptionId) {
|
||||
daemonFilter.thinkingOptionId = normalizedThinkingOptionId
|
||||
}
|
||||
|
||||
const fetchPayload = await client.fetchAgents({
|
||||
filter: options.all ? { includeArchived: true } : undefined,
|
||||
filter: Object.keys(daemonFilter).length > 0 ? daemonFilter : undefined,
|
||||
})
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent)
|
||||
|
||||
@@ -135,19 +185,6 @@ export async function runLsCommand(
|
||||
})
|
||||
}
|
||||
|
||||
// Parse --label filters (key=value).
|
||||
const labelFilters: Record<string, string> = {}
|
||||
if (options.label) {
|
||||
for (const labelStr of options.label) {
|
||||
const eqIndex = labelStr.indexOf('=')
|
||||
if (eqIndex !== -1) {
|
||||
const key = labelStr.slice(0, eqIndex)
|
||||
const value = labelStr.slice(eqIndex + 1)
|
||||
labelFilters[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply label filtering only when explicitly requested.
|
||||
if (Object.keys(labelFilters).length > 0) {
|
||||
agents = agents.filter((a) => {
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface AgentRunOptions extends CommandOptions {
|
||||
name?: string
|
||||
provider?: string
|
||||
model?: string
|
||||
thinking?: string
|
||||
mode?: string
|
||||
worktree?: string
|
||||
base?: string
|
||||
@@ -219,6 +220,16 @@ export async function runRunCommand(
|
||||
try {
|
||||
// Resolve working directory
|
||||
const cwd = options.cwd ?? process.cwd()
|
||||
const thinkingOptionId = options.thinking?.trim()
|
||||
if (options.thinking !== undefined && !thinkingOptionId) {
|
||||
const error: CommandError = {
|
||||
code: 'INVALID_THINKING_OPTION',
|
||||
message: '--thinking cannot be empty',
|
||||
details:
|
||||
'Provide a thinking option ID. Use "paseo provider models <provider> --thinking" to list valid IDs.',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Process images if provided
|
||||
let images: Array<{ data: string; mimeType: string }> | undefined
|
||||
@@ -290,6 +301,7 @@ export async function runRunCommand(
|
||||
title: options.name,
|
||||
modeId: options.mode,
|
||||
model: options.model,
|
||||
thinkingOptionId,
|
||||
initialPrompt: structuredPrompt,
|
||||
images,
|
||||
git,
|
||||
@@ -387,6 +399,7 @@ export async function runRunCommand(
|
||||
title: options.name,
|
||||
modeId: options.mode,
|
||||
model: options.model,
|
||||
thinkingOptionId,
|
||||
initialPrompt: prompt,
|
||||
images,
|
||||
git,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { runStatusCommand } from './status.js'
|
||||
import { runStopCommand } from './stop.js'
|
||||
import { runRestartCommand } from './restart.js'
|
||||
import { pairCommand } from './pair.js'
|
||||
import { updateCommand } from './update.js'
|
||||
import { withOutput } from '../../output/index.js'
|
||||
|
||||
export function createDaemonCommand(): Command {
|
||||
@@ -11,6 +12,7 @@ export function createDaemonCommand(): Command {
|
||||
|
||||
daemon.addCommand(startCommand())
|
||||
daemon.addCommand(pairCommand())
|
||||
daemon.addCommand(updateCommand())
|
||||
|
||||
daemon
|
||||
.command('status')
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export type NodePathSource = 'daemon_pid' | 'current_process'
|
||||
|
||||
export interface NodePathResolution {
|
||||
nodePath: string
|
||||
source: NodePathSource
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface NodePathFromPidResult {
|
||||
nodePath: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface NpmInvocation {
|
||||
nodePath: string
|
||||
npmPath: string
|
||||
command: string
|
||||
argsPrefix: string[]
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
|
||||
const result = spawnSync('ps', ['-o', 'comm=', '-p', String(pid)], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
nodePath: null,
|
||||
error: `ps failed: ${normalizeError(result.error)}`,
|
||||
}
|
||||
}
|
||||
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
const details = result.stderr?.trim()
|
||||
return {
|
||||
nodePath: null,
|
||||
error: details ? `ps failed: ${details}` : `ps exited with code ${result.status ?? 1}`,
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = result.stdout.trim()
|
||||
if (!resolved) {
|
||||
return {
|
||||
nodePath: null,
|
||||
error: 'ps returned an empty command path',
|
||||
}
|
||||
}
|
||||
|
||||
return { nodePath: resolved }
|
||||
}
|
||||
|
||||
export function resolvePreferredNodePath(args: {
|
||||
daemonPid?: number | null
|
||||
fallbackNodePath?: string
|
||||
}): NodePathResolution {
|
||||
const fallback = args.fallbackNodePath ?? process.execPath
|
||||
const daemonPid = args.daemonPid
|
||||
|
||||
if (typeof daemonPid === 'number' && Number.isInteger(daemonPid) && daemonPid > 0) {
|
||||
const fromPid = resolveNodePathFromPid(daemonPid)
|
||||
if (fromPid.nodePath) {
|
||||
return {
|
||||
nodePath: fromPid.nodePath,
|
||||
source: 'daemon_pid',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodePath: fallback,
|
||||
source: 'current_process',
|
||||
note: `Could not resolve node from daemon PID ${daemonPid}; using current process node (${fromPid.error ?? 'unknown error'})`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodePath: fallback,
|
||||
source: 'current_process',
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveNpmInvocationFromNode(nodePath: string): NpmInvocation {
|
||||
const binDir = path.dirname(nodePath)
|
||||
const prefix = path.dirname(binDir)
|
||||
const npmBinary = path.join(binDir, process.platform === 'win32' ? 'npm.cmd' : 'npm')
|
||||
|
||||
if (existsSync(npmBinary)) {
|
||||
return {
|
||||
nodePath,
|
||||
npmPath: npmBinary,
|
||||
command: npmBinary,
|
||||
argsPrefix: [],
|
||||
}
|
||||
}
|
||||
|
||||
const npmCliCandidates = [
|
||||
path.join(prefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
||||
path.join(prefix, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
||||
]
|
||||
|
||||
for (const candidate of npmCliCandidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return {
|
||||
nodePath,
|
||||
npmPath: candidate,
|
||||
command: nodePath,
|
||||
argsPrefix: [candidate],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to resolve npm for node executable: ${nodePath}`)
|
||||
}
|
||||
|
||||
export function formatNpmInvocation(invocation: NpmInvocation): string {
|
||||
if (invocation.argsPrefix.length === 0) {
|
||||
return invocation.command
|
||||
}
|
||||
return `${invocation.command} ${invocation.argsPrefix.join(' ')}`
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { Command } from 'commander'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tryConnectToDaemon } from '../../utils/client.js'
|
||||
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from './local-daemon.js'
|
||||
import {
|
||||
formatNpmInvocation,
|
||||
resolveNpmInvocationFromNode,
|
||||
resolvePreferredNodePath,
|
||||
type NpmInvocation,
|
||||
} from './runtime-toolchain.js'
|
||||
|
||||
interface DaemonStatus {
|
||||
status: 'running' | 'stopped' | 'unresponsive'
|
||||
@@ -13,6 +21,11 @@ interface DaemonStatus {
|
||||
logPath: string
|
||||
runningAgents: number | null
|
||||
idleAgents: number | null
|
||||
runtimeNode: string
|
||||
runtimeNpm: string
|
||||
cliVersion: string
|
||||
latestCliVersion: string
|
||||
updateStatus: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
@@ -21,6 +34,140 @@ interface StatusRow {
|
||||
value: string
|
||||
}
|
||||
|
||||
type CliPackageJson = {
|
||||
version?: unknown
|
||||
}
|
||||
|
||||
type LatestCliVersionResult = {
|
||||
version: string | null
|
||||
note?: string
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const CLI_UPDATE_CHECK_TIMEOUT_MS = 3000
|
||||
|
||||
function normalizeError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function shortenMessage(message: string, max = 120): string {
|
||||
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||
if (normalized.length <= max) {
|
||||
return normalized
|
||||
}
|
||||
return `${normalized.slice(0, max - 3)}...`
|
||||
}
|
||||
|
||||
function appendNote(current: string | undefined, next: string | undefined): string | undefined {
|
||||
if (!next) return current
|
||||
if (!current) return next
|
||||
return `${current}; ${next}`
|
||||
}
|
||||
|
||||
function resolveCliVersion(): string {
|
||||
try {
|
||||
const packageJson = require('../../../package.json') as CliPackageJson
|
||||
if (typeof packageJson.version === 'string' && packageJson.version.trim().length > 0) {
|
||||
return packageJson.version.trim()
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function parseVersionFromNpmOutput(raw: string): string | null {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown
|
||||
if (typeof parsed === 'string' && parsed.trim().length > 0) {
|
||||
return parsed.trim()
|
||||
}
|
||||
} catch {
|
||||
// Fall back to plain output.
|
||||
}
|
||||
|
||||
return trimmed.replace(/^"+|"+$/g, '').trim() || null
|
||||
}
|
||||
|
||||
function parseSemver(version: string): [number, number, number] | null {
|
||||
const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const major = Number(match[1])
|
||||
const minor = Number(match[2])
|
||||
const patch = Number(match[3])
|
||||
if (![major, minor, patch].every((part) => Number.isInteger(part) && part >= 0)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [major, minor, patch]
|
||||
}
|
||||
|
||||
function compareSemver(left: string, right: string): number | null {
|
||||
const leftParts = parseSemver(left)
|
||||
const rightParts = parseSemver(right)
|
||||
if (!leftParts || !rightParts) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (let index = 0; index < leftParts.length; index += 1) {
|
||||
if (leftParts[index]! < rightParts[index]!) return -1
|
||||
if (leftParts[index]! > rightParts[index]!) return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function fetchLatestCliVersion(npm: NpmInvocation): LatestCliVersionResult {
|
||||
const result = spawnSync(
|
||||
npm.command,
|
||||
[...npm.argsPrefix, 'view', '@getpaseo/cli', 'version', '--json'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
timeout: CLI_UPDATE_CHECK_TIMEOUT_MS,
|
||||
}
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
version: null,
|
||||
note: `update check failed: ${shortenMessage(normalizeError(result.error))}`,
|
||||
}
|
||||
}
|
||||
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
const stderr = result.stderr?.trim()
|
||||
return {
|
||||
version: null,
|
||||
note: stderr
|
||||
? `update check failed: ${shortenMessage(stderr)}`
|
||||
: `update check failed: npm exited with code ${result.status ?? 1}`,
|
||||
}
|
||||
}
|
||||
|
||||
const version = parseVersionFromNpmOutput(result.stdout)
|
||||
if (!version) {
|
||||
return {
|
||||
version: null,
|
||||
note: 'update check failed: empty npm response',
|
||||
}
|
||||
}
|
||||
|
||||
return { version }
|
||||
}
|
||||
|
||||
function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
|
||||
return {
|
||||
idField: 'key',
|
||||
@@ -56,6 +203,11 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
{ key: 'Started', value: status.startedAt ?? '-' },
|
||||
{ key: 'Owner', value: status.owner ?? '-' },
|
||||
{ key: 'Logs', value: status.logPath },
|
||||
{ key: 'Node', value: status.runtimeNode },
|
||||
{ key: 'npm', value: status.runtimeNpm },
|
||||
{ key: 'CLI', value: status.cliVersion },
|
||||
{ key: 'Latest CLI', value: status.latestCliVersion },
|
||||
{ key: 'Update', value: status.updateStatus },
|
||||
]
|
||||
|
||||
if (status.runningAgents !== null && status.idleAgents !== null) {
|
||||
@@ -96,14 +248,22 @@ export async function runStatusCommand(
|
||||
const state = resolveLocalDaemonState({ home })
|
||||
|
||||
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname)
|
||||
const resolvedNode = resolvePreferredNodePath({
|
||||
daemonPid: state.running ? state.pidInfo?.pid : null,
|
||||
fallbackNodePath: process.execPath,
|
||||
})
|
||||
let status: DaemonStatus['status'] = state.running ? 'running' : 'stopped'
|
||||
let runningAgents: number | null = null
|
||||
let idleAgents: number | null = null
|
||||
let note: string | undefined
|
||||
let runtimeNpm = '-'
|
||||
let latestCliVersion = 'unknown'
|
||||
let updateStatus = 'unknown'
|
||||
|
||||
if (!state.running && state.stalePidFile && state.pidInfo) {
|
||||
note = `Stale PID file found for PID ${state.pidInfo.pid}`
|
||||
}
|
||||
note = appendNote(note, resolvedNode.note)
|
||||
|
||||
if (state.running) {
|
||||
const host = resolveTcpHostFromListen(state.listen)
|
||||
@@ -117,19 +277,52 @@ export async function runStatusCommand(
|
||||
idleAgents = agents.filter(a => a.status === 'idle').length
|
||||
} catch {
|
||||
status = 'unresponsive'
|
||||
note = `Daemon PID is running but API requests to ${host} failed`
|
||||
note = appendNote(note, `Daemon PID is running but API requests to ${host} failed`)
|
||||
} finally {
|
||||
await client.close().catch(() => {})
|
||||
}
|
||||
} else {
|
||||
status = 'unresponsive'
|
||||
note = `Daemon PID is running but websocket at ${host} is not reachable`
|
||||
note = appendNote(note, `Daemon PID is running but websocket at ${host} is not reachable`)
|
||||
}
|
||||
} else {
|
||||
note = 'Daemon is configured for unix socket listen; API probe skipped'
|
||||
note = appendNote(note, 'Daemon is configured for unix socket listen; API probe skipped')
|
||||
}
|
||||
}
|
||||
|
||||
let npmInvocation: NpmInvocation | null = null
|
||||
try {
|
||||
npmInvocation = resolveNpmInvocationFromNode(resolvedNode.nodePath)
|
||||
runtimeNpm = formatNpmInvocation(npmInvocation)
|
||||
} catch (err) {
|
||||
runtimeNpm = `unresolved (${shortenMessage(normalizeError(err))})`
|
||||
}
|
||||
|
||||
const cliVersion = resolveCliVersion()
|
||||
if (npmInvocation) {
|
||||
const latest = fetchLatestCliVersion(npmInvocation)
|
||||
if (latest.version) {
|
||||
latestCliVersion = latest.version
|
||||
if (cliVersion === 'unknown') {
|
||||
updateStatus = 'unknown (local CLI version unavailable)'
|
||||
} else {
|
||||
const comparison = compareSemver(cliVersion, latest.version)
|
||||
if (comparison === null) {
|
||||
updateStatus = 'unknown (version format not comparable)'
|
||||
} else if (comparison < 0) {
|
||||
updateStatus = `update available (${cliVersion} -> ${latest.version})`
|
||||
} else {
|
||||
updateStatus = 'up to date'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
latestCliVersion = 'unknown'
|
||||
updateStatus = latest.note ?? 'unknown'
|
||||
}
|
||||
} else {
|
||||
updateStatus = 'unknown (npm unresolved)'
|
||||
}
|
||||
|
||||
const daemonStatus: DaemonStatus = {
|
||||
status,
|
||||
home: state.home,
|
||||
@@ -140,6 +333,11 @@ export async function runStatusCommand(
|
||||
logPath: state.logPath,
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
runtimeNode: `${resolvedNode.nodePath} (${resolvedNode.source})`,
|
||||
runtimeNpm,
|
||||
cliVersion,
|
||||
latestCliVersion,
|
||||
updateStatus,
|
||||
note,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { confirm, isCancel } from '@clack/prompts'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import { runRestartCommand } from './restart.js'
|
||||
import { resolveLocalDaemonState } from './local-daemon.js'
|
||||
import {
|
||||
resolveNpmInvocationFromNode,
|
||||
resolvePreferredNodePath,
|
||||
type NpmInvocation,
|
||||
} from './runtime-toolchain.js'
|
||||
import { getErrorMessage } from '../../utils/errors.js'
|
||||
|
||||
export interface DaemonUpdateOptions {
|
||||
home?: string
|
||||
yes?: boolean
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[]): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
})
|
||||
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`Command exited via signal ${signal}`))
|
||||
return
|
||||
}
|
||||
resolve(code ?? 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function runDaemonUpdateCommand(options: DaemonUpdateOptions): Promise<void> {
|
||||
const daemonState = resolveLocalDaemonState({ home: options.home })
|
||||
const resolvedNode = resolvePreferredNodePath({
|
||||
daemonPid: daemonState.running ? daemonState.pidInfo?.pid : null,
|
||||
fallbackNodePath: process.execPath,
|
||||
})
|
||||
const npm: NpmInvocation = resolveNpmInvocationFromNode(resolvedNode.nodePath)
|
||||
const args = [...npm.argsPrefix, 'install', '-g', '@getpaseo/cli@latest']
|
||||
|
||||
const exitCode = await runCommand(npm.command, args)
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Update command failed with exit code ${exitCode}`)
|
||||
}
|
||||
|
||||
let shouldRestart = options.yes === true
|
||||
if (!shouldRestart) {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
console.log(chalk.yellow('Update complete. Restart skipped (non-interactive terminal).'))
|
||||
console.log(chalk.dim('Run `paseo daemon restart` when ready.'))
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Restart will stop running agents, but they can continue from persisted state after restart.'
|
||||
)
|
||||
)
|
||||
const answer = await confirm({
|
||||
message: 'Restart daemon now?',
|
||||
active: 'Restart now',
|
||||
inactive: 'Later',
|
||||
initialValue: true,
|
||||
})
|
||||
|
||||
if (isCancel(answer)) {
|
||||
console.log(chalk.yellow('Update complete. Restart skipped.'))
|
||||
return
|
||||
}
|
||||
|
||||
shouldRestart = answer
|
||||
}
|
||||
|
||||
if (!shouldRestart) {
|
||||
console.log(chalk.yellow('Update complete. Restart skipped.'))
|
||||
return
|
||||
}
|
||||
|
||||
const restartResult = await runRestartCommand({ home: options.home }, new Command())
|
||||
console.log(chalk.green(restartResult.data.message))
|
||||
}
|
||||
|
||||
export async function runDaemonUpdateCommandOrExit(options: DaemonUpdateOptions): Promise<void> {
|
||||
try {
|
||||
await runDaemonUpdateCommand(options)
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Failed to update daemon: ${getErrorMessage(err)}`))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCommand(): Command {
|
||||
return new Command('update')
|
||||
.description('Update local daemon package')
|
||||
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||
.option('-y, --yes', 'Restart automatically after update')
|
||||
.action(async (options: DaemonUpdateOptions) => {
|
||||
await runDaemonUpdateCommandOrExit(options)
|
||||
})
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function createProviderCommand(): Command {
|
||||
.command('models')
|
||||
.description('List models for a provider')
|
||||
.argument('<provider>', 'Provider name (claude, codex, opencode)')
|
||||
.option('--thinking', 'Include thinking option IDs for each model')
|
||||
.option('--json', 'Output in JSON format')
|
||||
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
|
||||
.action(withOutput(runModelsCommand))
|
||||
|
||||
@@ -7,10 +7,11 @@ export interface ModelListItem {
|
||||
model: string
|
||||
id: string
|
||||
description: string
|
||||
thinkingOptionIds: string[]
|
||||
defaultThinkingOptionId: string | null
|
||||
thinkingOptions: string
|
||||
}
|
||||
|
||||
const VALID_PROVIDERS = ['claude', 'codex', 'opencode']
|
||||
|
||||
/** Schema for provider models output */
|
||||
export const providerModelsSchema: OutputSchema<ModelListItem> = {
|
||||
idField: 'id',
|
||||
@@ -21,10 +22,25 @@ export const providerModelsSchema: OutputSchema<ModelListItem> = {
|
||||
],
|
||||
}
|
||||
|
||||
const providerModelsWithThinkingSchema: OutputSchema<ModelListItem> = {
|
||||
idField: 'id',
|
||||
columns: [
|
||||
{ header: 'ID', field: 'id', width: 30 },
|
||||
{ header: 'MODEL', field: 'model', width: 30 },
|
||||
{ header: 'THINKING IDS', field: 'thinkingOptions', width: 40 },
|
||||
{
|
||||
header: 'DEFAULT THINKING',
|
||||
field: (item) => item.defaultThinkingOptionId ?? 'auto',
|
||||
width: 18,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export type ProviderModelsResult = ListResult<ModelListItem>
|
||||
|
||||
export interface ProviderModelsOptions extends CommandOptions {
|
||||
host?: string
|
||||
thinking?: boolean
|
||||
}
|
||||
|
||||
export async function runModelsCommand(
|
||||
@@ -34,14 +50,6 @@ export async function runModelsCommand(
|
||||
): Promise<ProviderModelsResult> {
|
||||
const normalizedProvider = provider.toLowerCase()
|
||||
|
||||
if (!VALID_PROVIDERS.includes(normalizedProvider)) {
|
||||
throw {
|
||||
code: 'UNKNOWN_PROVIDER',
|
||||
message: `Unknown provider: ${provider}`,
|
||||
details: `Valid providers: ${VALID_PROVIDERS.join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
const client = await connectToDaemon({ host: options.host })
|
||||
try {
|
||||
const result = await client.listProviderModels(normalizedProvider)
|
||||
@@ -57,12 +65,16 @@ export async function runModelsCommand(
|
||||
model: m.label,
|
||||
id: m.id,
|
||||
description: m.description ?? '',
|
||||
thinkingOptionIds: (m.thinkingOptions ?? []).map((option) => option.id),
|
||||
defaultThinkingOptionId: m.defaultThinkingOptionId ?? null,
|
||||
thinkingOptions:
|
||||
(m.thinkingOptions ?? []).map((option) => option.id).join(', ') || 'none',
|
||||
}))
|
||||
|
||||
return {
|
||||
type: 'list',
|
||||
data: models,
|
||||
schema: providerModelsSchema,
|
||||
schema: options.thinking ? providerModelsWithThinkingSchema : providerModelsSchema,
|
||||
}
|
||||
} finally {
|
||||
await client.close()
|
||||
|
||||
Reference in New Issue
Block a user