feat: add thinking option support and autonomous run handling

This commit is contained in:
Mohamed Boudra
2026-02-21 22:09:48 +07:00
parent c3c8103563
commit d7e2ab4437
35 changed files with 4701 additions and 593 deletions
+201 -3
View File
@@ -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,
}