import type { BrowserManageResponse, DelegationPauseResponse, ProcessStopResponse, ReloadMcpResponse, RollbackDiffResponse, RollbackListResponse, RollbackRestoreResponse, SlashExecResponse, SpawnTreeListResponse, SpawnTreeLoadResponse, ToolsConfigureResponse } from '../../../gatewayTypes.js' import type { PanelSection } from '../../../types.js' import { applyDelegationStatus, getDelegationState } from '../../delegationStore.js' import { patchOverlayState } from '../../overlayStore.js' import { getSpawnHistory, pushDiskSnapshot, setDiffPair, type SpawnSnapshot } from '../../spawnHistoryStore.js' import type { SlashCommand } from '../types.js' interface SkillInfo { category?: string description?: string name?: string path?: string } interface SkillsListResponse { skills?: Record } interface SkillsInspectResponse { info?: SkillInfo } interface SkillsSearchResponse { results?: { description?: string; name: string }[] } interface SkillsInstallResponse { installed?: boolean name?: string } interface SkillsBrowseItem { description?: string name: string source?: string trust?: string } interface SkillsBrowseResponse { items?: SkillsBrowseItem[] page?: number total?: number total_pages?: number } export const opsCommands: SlashCommand[] = [ { help: 'stop background processes', name: 'stop', run: (_arg, ctx) => { ctx.gateway .rpc('process.stop', {}) .then( ctx.guarded(r => { const killed = Number(r.killed ?? 0) const noun = killed === 1 ? 'process' : 'processes' ctx.transcript.sys(`stopped ${killed} background ${noun}`) }) ) .catch(ctx.guardedErr) } }, { aliases: ['reload_mcp'], help: 'reload MCP servers in the live session', name: 'reload-mcp', run: (_arg, ctx) => { ctx.gateway .rpc('reload.mcp', { session_id: ctx.sid }) .then( ctx.guarded(r => { ctx.transcript.sys(r.status === 'reloaded' ? 'MCP servers reloaded' : 'reload complete') }) ) .catch(ctx.guardedErr) } }, { help: 'manage browser CDP connection [connect|disconnect|status]', name: 'browser', run: (arg, ctx) => { const trimmed = arg.trim() const [rawAction, ...rest] = trimmed ? trimmed.split(/\s+/) : ['status'] const action = (rawAction || 'status').toLowerCase() if (!['connect', 'disconnect', 'status'].includes(action)) { return ctx.transcript.sys( 'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml' ) } const payload: Record = { action } const requested = rest.join(' ').trim() if (action === 'connect') { payload.url = requested || 'http://localhost:9222' } ctx.gateway .rpc('browser.manage', payload) .then( ctx.guarded(r => { if (action === 'status') { return ctx.transcript.sys( r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser not connected (try /browser connect or set browser.cdp_url in config.yaml)' ) } if (action === 'connect') { if (r.connected) { ctx.transcript.sys(`browser connected: ${r.url || '(url unavailable)'}`) ctx.transcript.sys('next browser tool call will use this CDP endpoint') return } return ctx.transcript.sys('browser connect failed') } ctx.transcript.sys('browser disconnected') }) ) .catch(ctx.guardedErr) } }, { help: 'list, diff, or restore checkpoints', name: 'rollback', run: (arg, ctx) => { if (!ctx.sid) { return ctx.transcript.sys('no active session — nothing to rollback') } const trimmed = arg.trim() const [first = '', ...rest] = trimmed.split(/\s+/).filter(Boolean) const lower = first.toLowerCase() if (!trimmed || lower === 'list' || lower === 'ls') { return ctx.gateway .rpc('rollback.list', { session_id: ctx.sid }) .then( ctx.guarded(r => { if (!r.enabled) { return ctx.transcript.sys('checkpoints are not enabled') } const checkpoints = r.checkpoints ?? [] if (!checkpoints.length) { return ctx.transcript.sys('no checkpoints found') } ctx.transcript.panel('Rollback checkpoints', [ { rows: checkpoints.map((c, idx) => [ `${idx + 1}. ${c.hash.slice(0, 10)}`, [c.timestamp, c.message].filter(Boolean).join(' · ') || '(no metadata)' ]) } ]) }) ) .catch(ctx.guardedErr) } if (lower === 'diff') { const hash = rest[0] if (!hash) { return ctx.transcript.sys('usage: /rollback diff ') } return ctx.gateway .rpc('rollback.diff', { hash, session_id: ctx.sid }) .then( ctx.guarded(r => { const body = (r.rendered || r.diff || '').trim() if (!body && !r.stat) { return ctx.transcript.sys('no changes since this checkpoint') } const text = [r.stat || '', body].filter(Boolean).join('\n\n') ctx.transcript.page(text, 'Rollback diff') }) ) .catch(ctx.guardedErr) } const hash = first const filePath = rest.join(' ').trim() return ctx.gateway .rpc('rollback.restore', { ...(filePath ? { file_path: filePath } : {}), hash, session_id: ctx.sid }) .then( ctx.guarded(r => { if (!r.success) { return ctx.transcript.sys(`rollback failed: ${r.error || r.message || 'unknown error'}`) } const target = filePath || 'workspace' const detail = r.reason || r.message || r.restored_to || 'restored' ctx.transcript.sys(`rollback restored ${target}: ${detail}`) if ((r.history_removed ?? 0) > 0) { ctx.transcript.setHistoryItems(prev => ctx.transcript.trimLastExchange(prev)) } }) ) .catch(ctx.guardedErr) } }, { aliases: ['tasks'], help: 'open the spawn-tree dashboard (live audit + kill/pause controls)', name: 'agents', run: (arg, ctx) => { const sub = arg.trim().toLowerCase() // Stay compatible with the gateway `/agents [pause|resume|status]` CLI — // explicit subcommands skip the overlay and act directly so scripts and // multi-step flows can drive it without entering interactive mode. if (sub === 'pause' || sub === 'resume' || sub === 'unpause') { const paused = sub === 'pause' ctx.gateway.gw .request('delegation.pause', { paused }) .then(r => { applyDelegationStatus({ paused: r?.paused }) ctx.transcript.sys(`delegation · ${r?.paused ? 'paused' : 'resumed'}`) }) .catch(ctx.guardedErr) return } if (sub === 'status') { const d = getDelegationState() ctx.transcript.sys( `delegation · ${d.paused ? 'paused' : 'active'} · caps d${d.maxSpawnDepth ?? '?'}/${d.maxConcurrentChildren ?? '?'}` ) return } patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 }) } }, { help: 'replay a completed spawn tree · `/replay [N|last|list|load ]`', name: 'replay', run: (arg, ctx) => { const history = getSpawnHistory() const raw = arg.trim() const lower = raw.toLowerCase() // ── Disk-backed listing ───────────────────────────────────── if (lower === 'list' || lower === 'ls') { ctx.gateway .rpc('spawn_tree.list', { limit: 30, session_id: ctx.sid ?? 'default' }) .then( ctx.guarded(r => { const entries = r.entries ?? [] if (!entries.length) { return ctx.transcript.sys('no archived spawn trees on disk for this session') } const rows: [string, string][] = entries.map(e => { const ts = e.finished_at ? new Date(e.finished_at * 1000).toLocaleString() : '?' const label = e.label || `${e.count} subagents` return [`${ts} · ${e.count}×`, `${label}\n ${e.path}`] }) ctx.transcript.panel('Archived spawn trees', [{ rows }]) }) ) .catch(ctx.guardedErr) return } // ── Disk-backed load by path ───────────────────────────────── if (lower.startsWith('load ')) { const path = raw.slice(5).trim() if (!path) { return ctx.transcript.sys('usage: /replay load ') } ctx.gateway .rpc('spawn_tree.load', { path }) .then( ctx.guarded(r => { if (!r.subagents?.length) { return ctx.transcript.sys('snapshot empty or unreadable') } // Push onto the in-memory history so the overlay picks it up // by index 1 just like any other snapshot. pushDiskSnapshot(r, path) patchOverlayState({ agents: true, agentsInitialHistoryIndex: 1 }) }) ) .catch(ctx.guardedErr) return } // ── In-memory nav (same-session) ───────────────────────────── if (!history.length) { return ctx.transcript.sys('no completed spawn trees this session · try /replay list') } let index = 1 if (raw && lower !== 'last') { const parsed = parseInt(raw, 10) if (Number.isNaN(parsed) || parsed < 1 || parsed > history.length) { return ctx.transcript.sys(`replay: index out of range 1..${history.length} · use /replay list for disk`) } index = parsed } patchOverlayState({ agents: true, agentsInitialHistoryIndex: index }) } }, { help: 'diff two completed spawn trees · `/replay-diff ` (indexes from /replay list or history N)', name: 'replay-diff', run: (arg, ctx) => { const parts = arg.trim().split(/\s+/).filter(Boolean) if (parts.length !== 2) { return ctx.transcript.sys('usage: /replay-diff (e.g. /replay-diff 1 2 for last two)') } const [a, b] = parts const history = getSpawnHistory() const resolve = (token: string): null | SpawnSnapshot => { const n = parseInt(token!, 10) if (Number.isFinite(n) && n >= 1 && n <= history.length) { return history[n - 1] ?? null } return null } const baseline = resolve(a!) const candidate = resolve(b!) if (!baseline || !candidate) { return ctx.transcript.sys(`replay-diff: could not resolve indices · history has ${history.length} entries`) } setDiffPair({ baseline, candidate }) patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 }) } }, { help: 'browse, inspect, install skills', name: 'skills', run: (arg, ctx) => { const text = arg.trim() if (!text) { return patchOverlayState({ skillsHub: true }) } const [sub, ...rest] = text.split(/\s+/) const query = rest.join(' ').trim() const { rpc } = ctx.gateway const { panel, sys } = ctx.transcript if (sub === 'list') { rpc('skills.manage', { action: 'list' }) .then( ctx.guarded(r => { const cats = Object.entries(r.skills ?? {}).sort() if (!cats.length) { return sys('no skills available') } panel( 'Skills', cats.map(([title, items]) => ({ items, title })) ) }) ) .catch(ctx.guardedErr) return } if (sub === 'inspect') { if (!query) { return sys('usage: /skills inspect ') } rpc('skills.manage', { action: 'inspect', query }) .then( ctx.guarded(r => { const info = r.info ?? {} if (!info.name) { return sys(`unknown skill: ${query}`) } const rows: [string, string][] = [ ['Name', String(info.name)], ['Category', String(info.category ?? '')], ['Path', String(info.path ?? '')] ] const sections: PanelSection[] = [{ rows }] if (info.description) { sections.push({ text: String(info.description) }) } panel('Skill', sections) }) ) .catch(ctx.guardedErr) return } if (sub === 'search') { if (!query) { return sys('usage: /skills search ') } rpc('skills.manage', { action: 'search', query }) .then( ctx.guarded(r => { const results = r.results ?? [] if (!results.length) { return sys(`no results for: ${query}`) } panel(`Search: ${query}`, [{ rows: results.map(s => [s.name, s.description ?? '']) }]) }) ) .catch(ctx.guardedErr) return } if (sub === 'install') { if (!query) { return sys('usage: /skills install ') } sys(`installing ${query}…`) rpc('skills.manage', { action: 'install', query }) .then( ctx.guarded(r => sys(r.installed ? `installed ${r.name ?? query}` : 'install failed') ) ) .catch(ctx.guardedErr) return } if (sub === 'browse') { const pageNum = query ? parseInt(query, 10) : 1 if (Number.isNaN(pageNum) || pageNum < 1) { return sys('usage: /skills browse [page] (page must be a positive number)') } sys('fetching community skills (scans 6 sources, may take ~15s)…') rpc('skills.manage', { action: 'browse', page: pageNum }) .then( ctx.guarded(r => { const items = r.items ?? [] if (!items.length) { return sys(`no skills on page ${pageNum}${r.total ? ` (total ${r.total})` : ''}`) } const rows: [string, string][] = items.map(s => [ s.trust ? `${s.name} · ${s.trust}` : s.name, String(s.description ?? '').slice(0, 160) ]) const footer: string[] = [] if (r.page && r.total_pages) { footer.push(`page ${r.page} of ${r.total_pages}`) } if (r.total) { footer.push(`${r.total} skills total`) } if (r.page && r.total_pages && r.page < r.total_pages) { footer.push(`/skills browse ${r.page + 1} for more`) } panel(`Browse Skills${pageNum > 1 ? ` — p${pageNum}` : ''}`, [ { rows }, ...(footer.length ? [{ text: footer.join(' · ') }] : []) ]) }) ) .catch(ctx.guardedErr) return } sys('usage: /skills [list | inspect | install | search | browse [page]]') } }, { help: 'enable or disable tools (client-side history reset on change)', name: 'tools', run: (arg, ctx, cmd) => { const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean) if (subcommand !== 'disable' && subcommand !== 'enable') { ctx.gateway.gw .request('slash.exec', { command: cmd.slice(1), session_id: ctx.sid }) .then(r => { if (ctx.stale()) { return } const body = r?.output || '/tools: no output' const text = r?.warning ? `warning: ${r.warning}\n${body}` : body const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2 long ? ctx.transcript.page(text, 'Tools') : ctx.transcript.sys(text) }) .catch(ctx.guardedErr) return } if (!names.length) { ctx.transcript.sys(`usage: /tools ${subcommand} [name ...]`) ctx.transcript.sys(`built-in toolset: /tools ${subcommand} web`) ctx.transcript.sys(`MCP tool: /tools ${subcommand} github:create_issue`) return } ctx.gateway .rpc('tools.configure', { action: subcommand, names, session_id: ctx.sid }) .then( ctx.guarded(r => { if (r.info) { ctx.session.setSessionStartedAt(Date.now()) ctx.session.resetVisibleHistory(r.info) } if (r.changed?.length) { ctx.transcript.sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`) } if (r.unknown?.length) { ctx.transcript.sys(`unknown toolsets: ${r.unknown.join(', ')}`) } if (r.missing_servers?.length) { ctx.transcript.sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) } if (r.reset) { ctx.transcript.sys('session reset. new tool configuration is active.') } }) ) .catch(ctx.guardedErr) } } ]