UNPKG

codex-status

Version:

Terminal status viewer for Codex token usage sessions.

907 lines (833 loc) โ€ข 27.7 kB
#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const os = require('os'); const readline = require('readline'); const { spawnSync } = require('child_process'); const { playAlertSound, generateBeepWav, generateG6ChordBeep } = require('./sound'); function trimPath(p) { if (!p) return ''; const home = os.homedir(); let result = p.startsWith(home) ? p.slice(home.length) : p; if (result.startsWith(path.sep)) result = result.slice(1); const parts = result.split(path.sep); if (parts.length > 1 && parts[0] === 'dev') { result = parts.slice(1).join(path.sep); } return result || '.'; } function stripModelPrefix(model) { if (typeof model !== 'string') return model; return model.startsWith('gpt-') ? model.slice(4) : model; } const MARK_REGEX = /\p{Mark}/u; const EXTENDED_PICTOGRAPHIC_REGEX = /\p{Extended_Pictographic}/u; function isFullWidthCodePoint(codePoint) { return ( codePoint >= 0x1100 && ( codePoint <= 0x115f || codePoint === 0x2329 || codePoint === 0x232a || (codePoint >= 0x2e80 && codePoint <= 0x303e) || (codePoint >= 0x3040 && codePoint <= 0xa4cf) || (codePoint >= 0xac00 && codePoint <= 0xd7a3) || (codePoint >= 0xf900 && codePoint <= 0xfaff) || (codePoint >= 0xfe10 && codePoint <= 0xfe19) || (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || (codePoint >= 0xff00 && codePoint <= 0xff60) || (codePoint >= 0xffe0 && codePoint <= 0xffe6) || (codePoint >= 0x1f300 && codePoint <= 0x1f64f) || (codePoint >= 0x1f900 && codePoint <= 0x1f9ff) || (codePoint >= 0x1fa70 && codePoint <= 0x1faff) || (codePoint >= 0x20000 && codePoint <= 0x3fffd) ) ); } function codePointWidth(codePoint) { if (codePoint === 0) return 0; if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) return 0; if (codePoint === 0x200d) return 0; // zero-width joiner if (codePoint >= 0xfe00 && codePoint <= 0xfe0f) return 0; // variation selectors const char = String.fromCodePoint(codePoint); if (MARK_REGEX.test(char)) return 0; if (EXTENDED_PICTOGRAPHIC_REGEX.test(char)) return 2; if (isFullWidthCodePoint(codePoint)) return 2; return 1; } function truncateToTerminal(text, columns) { if (!columns || columns <= 0) return text; let width = 0; let result = ''; for (let i = 0; i < text.length; i += 1) { const codePoint = text.codePointAt(i); const char = String.fromCodePoint(codePoint); if (codePoint > 0xffff) i += 1; const charWidth = codePointWidth(codePoint); if (width + charWidth > columns) break; result += char; width += charWidth; } return result; } function compareVersions(a, b) { const toNumeric = (version) => version.split('.').map((part) => Number.parseInt(part, 10) || 0); const maxLength = 3; const [aParts, bParts] = [toNumeric(a), toNumeric(b)]; for (let i = 0; i < maxLength; i += 1) { const aVal = aParts[i] ?? 0; const bVal = bParts[i] ?? 0; if (aVal > bVal) return 1; if (aVal < bVal) return -1; } return 0; } function ensureCodexCli(required = '0.41.0', overrides = {}) { const installHint = 'Install or upgrade via "npm install -g @openai/codex" or follow https://github.com/openai/codex for instructions.'; const missingMessage = (reason) => `codex-status requires Codex CLI @ ${required} or newer (${reason}). ${installHint}`; const defaultLoaders = [ () => require('@openai/codex/package.json'), () => require('codex-cli/package.json'), ]; const loadPackage = overrides.loadPackage || (() => { for (const loader of defaultLoaders) { try { return loader(); } catch (err) { // try next loader } } throw new Error('package not found'); }); const execSpawn = overrides.execSpawn || (() => spawnSync('codex', ['--version'], { encoding: 'utf8' })); try { const codexPackage = loadPackage(); const current = codexPackage.version || '0.0.0'; if (compareVersions(current, required) < 0) { console.error(missingMessage(`detected ${current}`)); return 1; } return true; } catch (err) { // fall back to checking PATH for other installations } const result = execSpawn(); if (result.error) { console.error(missingMessage('binary not found on PATH')); return 1; } const output = `${result.stdout || ''}${result.stderr || ''}`.trim(); const match = output.match(/(\d+\.\d+\.\d+)/); if (!match) { console.error(missingMessage('unable to detect version')); return 1; } const current = match[1]; if (compareVersions(current, required) < 0) { console.error(missingMessage(`detected ${current}`)); return 1; } return true; } const CANONICAL_FIELDS = [ 'time', 'error', 'model', 'approval', 'sandbox', 'daily', 'weekly', 'recent', 'total', 'activity', 'directory', ]; const FIELD_ALIASES = { time: 'time', timestamp: 'time', age: 'time', error: 'error', model: 'model', agent: 'model', approval: 'approval', policy: 'approval', sandbox: 'sandbox', 'sandbox-policy': 'sandbox', env: 'sandbox', primary: 'daily', daily: 'daily', quota: 'daily', secondary: 'weekly', weekly: 'weekly', billing: 'weekly', recent: 'recent', 'recent-tokens': 'recent', latest: 'recent', total: 'total', 'total-tokens': 'total', cumulative: 'total', activity: 'activity', role: 'activity', action: 'activity', directory: 'directory', cwd: 'directory', path: 'directory', }; const DEFAULT_FORMAT_ORDER = [ 'time', 'activity', 'daily', 'weekly', 'recent', 'total', 'error', 'model', 'approval', 'sandbox', 'directory', ]; function normalizeFieldKey(key) { if (typeof key !== 'string') return null; const lookup = FIELD_ALIASES[key.trim().toLowerCase()]; if (!lookup) return null; return CANONICAL_FIELDS.includes(lookup) ? lookup : null; } function parseFormatList(raw) { if (typeof raw !== 'string') { throw new Error('Format must be a comma-separated list of field names.'); } const parts = raw .split(',') .map((part) => part.trim()) .filter((part) => part.length > 0); if (!parts.length) { throw new Error('Format must include at least one field.'); } const seen = new Set(); const result = []; for (const part of parts) { const normalized = normalizeFieldKey(part); if (!normalized) { throw new Error(`Unknown field in format: ${part}`); } if (!seen.has(normalized)) { seen.add(normalized); result.push(normalized); } } return result; } function parseArgs(argv) { const options = { baseDir: path.join(os.homedir(), '.codex', 'sessions'), watch: false, interval: 15, limit: 1, minimal: false, formatOrder: null, labelOverrides: {}, sound: 'off', soundVolume: 100, soundReverb: 'default', }; let showHelp = false; let showVersion = false; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg.startsWith('--format=')) { const value = arg.slice('--format='.length); options.formatOrder = parseFormatList(value); } else if (arg.startsWith('--override-')) { const [flag, inlineValue] = arg.split('=', 2); const keyPart = flag.slice('--override-'.length); if (!keyPart) { throw new Error('Override flag requires a field name.'); } let overrideValue = inlineValue; if (overrideValue === undefined) { overrideValue = argv[i + 1]; if (overrideValue === undefined) { throw new Error(`Override for ${keyPart} requires a value.`); } i += 1; } const normalizedKey = normalizeFieldKey(keyPart); if (!normalizedKey) { throw new Error(`Unknown override field: ${keyPart}`); } options.labelOverrides[normalizedKey] = overrideValue; } else if ((arg === '--base' || arg === '-b') && argv[i + 1]) { options.baseDir = argv[i + 1]; i += 1; } else if (arg === '--watch' || arg === '-w') { options.watch = true; } else if ((arg === '--interval' || arg === '-n') && argv[i + 1]) { const value = Number(argv[i + 1]); if (!Number.isFinite(value) || value <= 0) { throw new Error('Interval must be a positive number of seconds.'); } options.interval = value; i += 1; } else if ((arg === '--limit' || arg === '-l') && argv[i + 1]) { const value = Number(argv[i + 1]); if (!Number.isInteger(value) || value <= 0) { throw new Error('Limit must be a positive integer.'); } options.limit = value; i += 1; } else if (arg === '--minimal' || arg === '-m') { options.minimal = true; } else if (arg === '--format' || arg === '-f') { const value = argv[i + 1]; if (value === undefined) { throw new Error('Format flag requires a comma-separated list of fields.'); } options.formatOrder = parseFormatList(value); i += 1; } else if (arg.startsWith('--sound=')) { const value = arg.slice('--sound='.length); if (!['all', 'some', 'assistant'].includes(value)) { throw new Error('Sound mode must be one of: all, some, assistant'); } options.sound = value; } else if (arg === '--sound' || arg === '-s') { const nextArg = argv[i + 1]; if (nextArg && ['all', 'some', 'assistant'].includes(nextArg)) { options.sound = nextArg; i += 1; } else { options.sound = 'some'; } } else if (arg.startsWith('--sound-volume=')) { const value = Number(arg.slice('--sound-volume='.length)); if (!Number.isInteger(value) || value < 1 || value > 100) { throw new Error('Sound volume must be an integer between 1 and 100'); } options.soundVolume = value; } else if (arg === '--sound-volume') { const value = Number(argv[i + 1]); if (!Number.isInteger(value) || value < 1 || value > 100) { throw new Error('Sound volume must be an integer between 1 and 100'); } options.soundVolume = value; i += 1; } else if (arg.startsWith('--sound-reverb=')) { const value = arg.slice('--sound-reverb='.length); if (!['none', 'subtle', 'default', 'lush'].includes(value)) { throw new Error('Sound reverb must be one of: none, subtle, default, lush'); } options.soundReverb = value; } else if (arg === '--sound-reverb') { const value = argv[i + 1]; if (!value || !['none', 'subtle', 'default', 'lush'].includes(value)) { throw new Error('Sound reverb must be one of: none, subtle, default, lush'); } options.soundReverb = value; i += 1; } else if (arg === '--help' || arg === '-h') { showHelp = true; } else if (arg === '--version' || arg === '-v') { showVersion = true; } else { throw new Error(`Unknown argument: ${arg}`); } } return { options, showHelp, showVersion }; } function buildHelpMessage() { return `Usage: codex-status [options] Options: --base, -b <path> Override base sessions directory (default: ~/.codex/sessions) --watch, -w Continuously refresh status until interrupted --interval, -n <sec> Seconds between refresh updates (default: 15) --limit, -l <count> Maximum sessions to display (default: 1) --minimal, -m Hide policy and directory details for a compact view --format, -f <fields> Comma-separated field order (e.g., time,model,directory) --override-<field> <label> Replace a field label emoji/text (e.g., --override-model=๐Ÿคฉ) --sound, -s [mode] Play alert sounds in watch mode (modes: all, some, assistant) Default: some when -s is used without value --sound-volume <1-100> Set sound volume (1=quiet, 100=max, default: 100) --sound-reverb <type> Set reverb effect (none, subtle, default, lush) Default: default --version, -v Show version information --help, -h Show this message `; } async function findSessionLogs(baseDir, limit) { const stack = [baseDir]; const sessions = []; while (stack.length) { const current = stack.pop(); let entries; try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch (err) { if (err.code === 'ENOENT') continue; throw err; } for (const entry of entries) { const entryPath = path.join(current, entry.name); if (entry.isDirectory()) { stack.push(entryPath); } else if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) { let stats; try { stats = await fs.promises.stat(entryPath); } catch (err) { continue; } sessions.push({ path: entryPath, mtime: stats.mtime, }); } } } sessions.sort((a, b) => b.mtime - a.mtime); if (Number.isFinite(limit) && limit > 0) { return sessions.slice(0, limit); } return sessions; } function formatDuration(seconds, maxUnits = 2) { if (seconds == null || Number.isNaN(seconds)) return 'unknown'; const abs = Math.max(0, Math.floor(seconds)); const units = [ { label: 'd', value: 24 * 60 * 60 }, { label: 'h', value: 60 * 60 }, { label: 'm', value: 60 }, { label: 's', value: 1 }, ]; const parts = []; let remaining = abs; for (const unit of units) { if (unit.value > remaining && parts.length === 0) continue; const count = Math.floor(remaining / unit.value); if (count > 0 || parts.length > 0) { parts.push(`${count}${unit.label}`); remaining -= count * unit.value; } if (parts.length >= maxUnits) break; } return parts.length ? parts.join(' ') : '0s'; } function formatAgoShort(date) { if (!date) return 'n/a'; const diffSeconds = Math.floor((Date.now() - date.getTime()) / 1000); if (Number.isNaN(diffSeconds)) return 'n/a'; if (diffSeconds < 5) return 'now'; const duration = formatDuration(diffSeconds, 1); return duration.replace(/\s+/g, ''); } function formatResetTarget(seconds, now = Date.now()) { if (!Number.isFinite(seconds)) return 'n/a'; if (!Number.isFinite(now)) return 'n/a'; if (seconds <= 0) return 'now'; const targetMs = now + (seconds * 1000); if (!Number.isFinite(targetMs)) return 'n/a'; const target = new Date(targetMs); if (Number.isNaN(target.getTime())) return 'n/a'; const current = new Date(now); const sameDay = ( target.getFullYear() === current.getFullYear() && target.getMonth() === current.getMonth() && target.getDate() === current.getDate() ); if (sameDay) { const hours = String(target.getHours()).padStart(2, '0'); const minutes = String(target.getMinutes()).padStart(2, '0'); return `${hours}:${minutes}`; } const month = String(target.getMonth() + 1).padStart(2, '0'); const day = String(target.getDate()).padStart(2, '0'); return `${month}/${day}`; } function parseTimestampMs(value) { if (typeof value === 'number') { if (!Number.isFinite(value)) return null; return value > 1e12 ? value : value * 1000; } if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed) return null; const numeric = Number(trimmed); if (Number.isFinite(numeric)) { return numeric > 1e12 ? numeric : numeric * 1000; } const parsed = Date.parse(trimmed); if (!Number.isNaN(parsed)) return parsed; } return null; } function resolveResetSeconds(windowData, now = Date.now()) { if (!windowData || typeof windowData !== 'object') return null; const resetsSecondsRaw = windowData.resets_in_seconds; if (Number.isFinite(resetsSecondsRaw)) { return resetsSecondsRaw; } if (typeof resetsSecondsRaw === 'string') { const trimmed = resetsSecondsRaw.trim(); if (trimmed) { const numeric = Number(trimmed); if (Number.isFinite(numeric)) return numeric; } } const nowMs = Number.isFinite(now) ? now : Date.now(); const candidates = ['resets_at', 'reset_at', 'resetsAt', 'resetAt']; for (const key of candidates) { if (!(key in windowData)) continue; const ms = parseTimestampMs(windowData[key]); if (!Number.isFinite(ms)) continue; const diffSeconds = Math.floor((ms - nowMs) / 1000); if (Number.isFinite(diffSeconds)) return diffSeconds; } return null; } const compactFormatter = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1, }); function formatCompact(value) { return typeof value === 'number' ? compactFormatter.format(value) : 'n/a'; } async function readLog(filePath) { const stream = fs.createReadStream(filePath, 'utf8'); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); let lastContext = null; let lastTokenCount = null; let lastTimestamp = null; let lastAssistantMessageTime = null; let lastActivity = null; for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) continue; let record; try { record = JSON.parse(trimmed); } catch (err) { continue; } if (record.timestamp) { const ts = new Date(record.timestamp); if (!Number.isNaN(ts.getTime())) lastTimestamp = ts; } if (record.type === 'turn_context') { lastContext = record.payload || null; } else if (record.type === 'event_msg' && record.payload && record.payload.type === 'token_count') { lastTokenCount = record.payload; } else if (record.type === 'response_item' && record.payload) { const payload = record.payload; // Track assistant messages for sound alerts if (payload.role === 'assistant') { if (record.timestamp) { const ts = new Date(record.timestamp); if (!Number.isNaN(ts.getTime())) lastAssistantMessageTime = ts; } } // Track activity type for display if (payload.role === 'user') { lastActivity = 'user'; } else if (payload.role === 'assistant') { lastActivity = 'assistant'; } else if (payload.type === 'function_call') { lastActivity = 'tool'; } else if (payload.type === 'reasoning') { lastActivity = 'thinking'; } } } return { lastContext, lastTokenCount, lastTimestamp, lastAssistantMessageTime, lastActivity }; } async function gatherStatuses(baseDir, limit) { const sessions = await findSessionLogs(baseDir, limit); if (!sessions.length) { return { error: `No rollout logs found in ${baseDir}` }; } const details = []; for (const session of sessions) { try { const info = await readLog(session.path); details.push({ log: session, ...info, }); } catch (err) { details.push({ log: session, error: err.message || String(err), }); } } return { sessions: details }; } function formatRateWindow(windowData) { if (!windowData) return 'n/a'; const used = windowData.used_percent != null ? `${windowData.used_percent}%` : 'n/a'; const nowMs = Date.now(); const resetSeconds = resolveResetSeconds(windowData, nowMs); const reset = formatResetTarget(resetSeconds, nowMs); return `${used}/${reset}`; } const FIELD_DEFINITIONS = { time: { defaultLabel: '๐Ÿ•’', build: ({ detail }) => formatAgoShort(detail.log.mtime), }, error: { defaultLabel: 'โŒ', build: ({ detail }) => detail.error || null, }, model: { defaultLabel: '๐Ÿค–', build: ({ context }) => { if (typeof context.model === 'string' && context.model) { return stripModelPrefix(context.model); } return null; }, }, approval: { defaultLabel: '๐Ÿ›‚', build: ({ context, minimal }) => { if (minimal) return null; if (context.approval_policy) return context.approval_policy; return null; }, }, sandbox: { defaultLabel: '๐Ÿงช', build: ({ context, minimal }) => { if (minimal) return null; const policy = context.sandbox_policy; if (policy && policy.mode) { let label = policy.mode; if (policy.network_access === false) label += '๐Ÿšซ'; return label; } return null; }, }, daily: { defaultLabel: '๐Ÿ•”', build: ({ rateLimits }) => { if (rateLimits && rateLimits.primary) { return formatRateWindow(rateLimits.primary); } return null; }, }, weekly: { defaultLabel: '๐Ÿ—“', build: ({ rateLimits }) => { if (rateLimits && rateLimits.secondary) { return formatRateWindow(rateLimits.secondary); } return null; }, }, recent: { defaultLabel: '๐Ÿ”„', build: ({ tokenInfo }) => { if (tokenInfo && tokenInfo.last_token_usage && typeof tokenInfo.last_token_usage.total_tokens === 'number') { return formatCompact(tokenInfo.last_token_usage.total_tokens); } if (!tokenInfo) return 'n/a'; return null; }, }, total: { defaultLabel: '๐Ÿ“ฆ', build: ({ tokenInfo }) => { if (tokenInfo && tokenInfo.total_token_usage && typeof tokenInfo.total_token_usage.total_tokens === 'number') { return formatCompact(tokenInfo.total_token_usage.total_tokens); } return null; }, }, activity: { defaultLabel: '๐Ÿ’ญ', build: ({ detail, minimal }) => { if (minimal) return null; const activity = detail.lastActivity; if (!activity) return null; const activityMap = { user: '๐Ÿ‘ค', assistant: 'โ‰๏ธ', tool: '๐Ÿ”ง', thinking: '๐Ÿค”', }; return activityMap[activity] || activity; }, }, directory: { defaultLabel: '๐Ÿ“', build: ({ context, minimal }) => { if (minimal) return null; if (context.cwd) { const display = trimPath(context.cwd); if (display) return display; } return null; }, }, }; function formatSessionSummary(detail, options = {}) { const minimal = Boolean(options.minimal); const labelOverrides = options.labelOverrides || {}; const orderSource = Array.isArray(options.formatOrder) && options.formatOrder.length > 0 ? options.formatOrder : DEFAULT_FORMAT_ORDER; const order = []; for (const entry of orderSource) { const key = normalizeFieldKey(entry); if (key && !order.includes(key)) order.push(key); } const context = detail.lastContext || {}; const tokenCount = detail.lastTokenCount || null; const tokenInfo = tokenCount ? tokenCount.info || null : null; const rateLimits = tokenCount ? tokenCount.rate_limits || null : null; const fieldContext = { detail, minimal, context, tokenInfo, rateLimits, }; const pieces = []; for (const key of order) { const definition = FIELD_DEFINITIONS[key]; if (!definition) continue; const value = definition.build(fieldContext); if (value == null || value === '') continue; const override = Object.prototype.hasOwnProperty.call(labelOverrides, key) ? labelOverrides[key] : undefined; const label = override !== undefined ? override : definition.defaultLabel; if (label && String(label).length > 0) { pieces.push(`${label}${value}`); } else { pieces.push(String(value)); } } if (!pieces.length) { return 'โšก no status'; } return pieces.join(' '); } function buildReport(status, options = {}) { if (status.error) return status.error; const detail = status.sessions[0]; if (!detail) return 'โšก no sessions'; return formatSessionSummary(detail, options); } async function runOnce(options, stdout) { const status = await gatherStatuses(path.resolve(options.baseDir), options.limit); console.clear(); const columns = stdout && Number.isInteger(stdout.columns) ? stdout.columns : null; stdout.write(`${truncateToTerminal(buildReport(status, options), columns)}\n`); } async function runWatch(options, stdout, deps = {}) { const baseDir = path.resolve(options.baseDir); const intervalMs = Math.max(1, options.interval) * 1000; const columns = () => (stdout && Number.isInteger(stdout.columns) ? stdout.columns : null); const gather = deps.gatherStatuses || gatherStatuses; const setIntervalFn = deps.setIntervalFn || setInterval; const playSound = deps.playSound || playAlertSound; let running = false; let lastSeenActivity = null; let lastSeenTimestamp = null; let lastRefreshCount = 0; async function draw() { if (running) return; running = true; try { const status = await gather(baseDir, options.limit); const summary = buildReport(status, options); console.clear(); stdout.write(`${truncateToTerminal(summary, columns())}\n`); // Check for any new activity if sound is enabled if (options.sound !== 'off' && status.sessions && status.sessions.length > 0) { const detail = status.sessions[0]; const currentActivity = detail.lastActivity; const currentTimestamp = detail.lastTimestamp ? detail.lastTimestamp.getTime() : null; if (lastSeenActivity === null && lastSeenTimestamp === null) { // First run, just record the state without playing sound lastSeenActivity = currentActivity; lastSeenTimestamp = currentTimestamp; } else if (currentTimestamp && currentTimestamp > lastSeenTimestamp) { // New activity detected (timestamp changed)! lastSeenActivity = currentActivity; lastSeenTimestamp = currentTimestamp; lastRefreshCount += 1; // Determine if we should play sound based on mode and activity let shouldPlay = false; if (options.sound === 'assistant') { // Only play for assistant messages shouldPlay = currentActivity === 'assistant'; } else if (options.sound === 'some') { // Play every other refresh, skip user messages shouldPlay = currentActivity !== 'user' && (lastRefreshCount % 2 === 1); } else if (options.sound === 'all') { // Play for all non-user activities shouldPlay = currentActivity !== 'user'; } if (shouldPlay) { playSound(currentActivity, options.sound, options.soundVolume, options.soundReverb); } } } } finally { running = false; } } await draw(); setIntervalFn(() => { draw().catch((err) => { console.error('Watch update failed:', err.message || err); }); }, intervalMs); } async function runCli({ argv = process.argv.slice(2), version = '0.0.0', stdout = process.stdout }) { let parsed; try { parsed = parseArgs(argv); } catch (err) { console.error(err.message || err); return 1; } const { options, showHelp, showVersion } = parsed; if (showHelp) { stdout.write(buildHelpMessage()); return 0; } if (showVersion) { stdout.write(`codex-status v${version}\n`); return 0; } const requirementResult = ensureCodexCli('0.41.0'); if (requirementResult !== true) { return requirementResult; } try { if (options.watch) { await runWatch(options, stdout); } else { await runOnce(options, stdout); } return 0; } catch (err) { console.error(err.message || err); return 1; } } module.exports = { runCli, ensureCodexCli, parseArgs, compareVersions, truncateToTerminal, runWatch, readLog, };