mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
fix(tui): make mutating slash paths native and lifecycle-safe
Route /browser, /reload-mcp, /rollback, /stop, /fast, and /busy through direct TUI RPC handlers so state changes hit the live gateway session instead of slash-worker fallback. Add TUI session finalize/reset parity hooks (memory commit + plugin boundaries) and parity matrix tests to keep mutating commands off fallback.
This commit is contained in:
@@ -274,6 +274,69 @@ def _session(agent=None, **extra):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_close_commits_memory_and_fires_finalize_hook(monkeypatch):
|
||||||
|
calls = {"hooks": []}
|
||||||
|
|
||||||
|
agent = types.SimpleNamespace(session_id="session-key")
|
||||||
|
agent.commit_memory_session = lambda history: calls.setdefault("history", history)
|
||||||
|
server._sessions["sid"] = _session(
|
||||||
|
agent=agent, history=[{"role": "user", "content": "hello"}]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_notify_session_boundary",
|
||||||
|
lambda event, session_id: calls["hooks"].append((event, session_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = server.handle_request(
|
||||||
|
{"id": "1", "method": "session.close", "params": {"session_id": "sid"}}
|
||||||
|
)
|
||||||
|
assert resp["result"]["closed"] is True
|
||||||
|
assert calls["history"] == [{"role": "user", "content": "hello"}]
|
||||||
|
assert ("on_session_finalize", "session-key") in calls["hooks"]
|
||||||
|
finally:
|
||||||
|
server._sessions.pop("sid", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_session_fires_reset_hook(monkeypatch):
|
||||||
|
hooks = []
|
||||||
|
|
||||||
|
class _FakeWorker:
|
||||||
|
def __init__(self, key, model):
|
||||||
|
self.key = key
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
|
||||||
|
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
|
||||||
|
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_notify_session_boundary",
|
||||||
|
lambda event, session_id: hooks.append((event, session_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
import tools.approval as _approval
|
||||||
|
|
||||||
|
monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None)
|
||||||
|
monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)
|
||||||
|
|
||||||
|
sid = "sid"
|
||||||
|
try:
|
||||||
|
server._init_session(
|
||||||
|
sid,
|
||||||
|
"session-key",
|
||||||
|
types.SimpleNamespace(model="x"),
|
||||||
|
history=[],
|
||||||
|
cols=80,
|
||||||
|
)
|
||||||
|
assert ("on_session_reset", "session-key") in hooks
|
||||||
|
finally:
|
||||||
|
server._sessions.pop(sid, None)
|
||||||
|
|
||||||
|
|
||||||
def test_session_title_queues_when_db_row_not_ready(monkeypatch):
|
def test_session_title_queues_when_db_row_not_ready(monkeypatch):
|
||||||
class _FakeDB:
|
class _FakeDB:
|
||||||
def get_session_title(self, _key):
|
def get_session_title(self, _key):
|
||||||
@@ -604,6 +667,58 @@ def test_config_set_yolo_toggles_session_scope():
|
|||||||
server._sessions.clear()
|
server._sessions.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_set_fast_updates_live_agent_and_config(monkeypatch):
|
||||||
|
writes = []
|
||||||
|
emits = []
|
||||||
|
agent = types.SimpleNamespace(service_tier=None)
|
||||||
|
server._sessions["sid"] = _session(agent=agent)
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_write_config_key", lambda path, value: writes.append((path, value)))
|
||||||
|
monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"})
|
||||||
|
monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args))
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = server.handle_request(
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"method": "config.set",
|
||||||
|
"params": {"session_id": "sid", "key": "fast", "value": "fast"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert resp["result"]["value"] == "fast"
|
||||||
|
assert agent.service_tier == "priority"
|
||||||
|
assert ("agent.service_tier", "fast") in writes
|
||||||
|
assert ("session.info", "sid", {"model": "x"}) in emits
|
||||||
|
finally:
|
||||||
|
server._sessions.pop("sid", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_busy_get_and_set(monkeypatch):
|
||||||
|
writes = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"_load_cfg",
|
||||||
|
lambda: {"display": {"busy_input_mode": "steer"}},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "_write_config_key", lambda path, value: writes.append((path, value)))
|
||||||
|
|
||||||
|
get_resp = server.handle_request(
|
||||||
|
{"id": "1", "method": "config.get", "params": {"key": "busy"}}
|
||||||
|
)
|
||||||
|
assert get_resp["result"]["value"] == "steer"
|
||||||
|
|
||||||
|
set_resp = server.handle_request(
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"method": "config.set",
|
||||||
|
"params": {"key": "busy", "value": "interrupt"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert set_resp["result"]["value"] == "interrupt"
|
||||||
|
assert ("display.busy_input_mode", "interrupt") in writes
|
||||||
|
|
||||||
|
|
||||||
def test_config_get_statusbar_survives_non_dict_display(monkeypatch):
|
def test_config_get_statusbar_survives_non_dict_display(monkeypatch):
|
||||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"display": "broken"})
|
monkeypatch.setattr(server, "_load_cfg", lambda: {"display": "broken"})
|
||||||
|
|
||||||
|
|||||||
@@ -251,11 +251,60 @@ class _SlashWorker:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
atexit.register(
|
def _load_busy_input_mode() -> str:
|
||||||
lambda: [
|
raw = (
|
||||||
s.get("slash_worker") and s["slash_worker"].close() for s in _sessions.values()
|
str((_load_cfg().get("display") or {}).get("busy_input_mode", "") or "")
|
||||||
]
|
.strip()
|
||||||
)
|
.lower()
|
||||||
|
)
|
||||||
|
return raw if raw in {"queue", "steer", "interrupt"} else "interrupt"
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_session_boundary(event_type: str, session_id: str | None) -> None:
|
||||||
|
"""Fire session lifecycle hooks with CLI parity."""
|
||||||
|
try:
|
||||||
|
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||||
|
|
||||||
|
_invoke_hook(event_type, session_id=session_id, platform="tui")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_session(session: dict | None) -> None:
|
||||||
|
"""Best-effort finalize hook + memory commit for a session."""
|
||||||
|
if not session or session.get("_finalized"):
|
||||||
|
return
|
||||||
|
session["_finalized"] = True
|
||||||
|
|
||||||
|
agent = session.get("agent")
|
||||||
|
lock = session.get("history_lock")
|
||||||
|
if lock is not None:
|
||||||
|
with lock:
|
||||||
|
history = list(session.get("history", []))
|
||||||
|
else:
|
||||||
|
history = list(session.get("history", []))
|
||||||
|
if agent is not None and history and hasattr(agent, "commit_memory_session"):
|
||||||
|
try:
|
||||||
|
agent.commit_memory_session(history)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
session_id = getattr(agent, "session_id", None) or session.get("session_key")
|
||||||
|
_notify_session_boundary("on_session_finalize", session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_sessions() -> None:
|
||||||
|
for session in list(_sessions.values()):
|
||||||
|
_finalize_session(session)
|
||||||
|
try:
|
||||||
|
worker = session.get("slash_worker")
|
||||||
|
if worker:
|
||||||
|
worker.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
atexit.register(_shutdown_sessions)
|
||||||
|
|
||||||
|
|
||||||
# ── Plumbing ──────────────────────────────────────────────────────────
|
# ── Plumbing ──────────────────────────────────────────────────────────
|
||||||
@@ -1420,6 +1469,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
_wire_callbacks(sid)
|
_wire_callbacks(sid)
|
||||||
|
_notify_session_boundary("on_session_reset", key)
|
||||||
_emit("session.info", sid, _session_info(agent))
|
_emit("session.info", sid, _session_info(agent))
|
||||||
|
|
||||||
|
|
||||||
@@ -1637,6 +1687,7 @@ def _(rid, params: dict) -> dict:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
_wire_callbacks(sid)
|
_wire_callbacks(sid)
|
||||||
|
_notify_session_boundary("on_session_reset", key)
|
||||||
|
|
||||||
info = _session_info(agent)
|
info = _session_info(agent)
|
||||||
warn = _probe_credentials(agent)
|
warn = _probe_credentials(agent)
|
||||||
@@ -1960,6 +2011,7 @@ def _(rid, params: dict) -> dict:
|
|||||||
session = _sessions.pop(sid, None)
|
session = _sessions.pop(sid, None)
|
||||||
if not session:
|
if not session:
|
||||||
return _ok(rid, {"closed": False})
|
return _ok(rid, {"closed": False})
|
||||||
|
_finalize_session(session)
|
||||||
try:
|
try:
|
||||||
from tools.approval import unregister_gateway_notify
|
from tools.approval import unregister_gateway_notify
|
||||||
|
|
||||||
@@ -2827,6 +2879,39 @@ def _(rid, params: dict) -> dict:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(rid, 5001, str(e))
|
return _err(rid, 5001, str(e))
|
||||||
|
|
||||||
|
if key == "fast":
|
||||||
|
raw = str(value or "").strip().lower()
|
||||||
|
if session and session.get("agent") is not None:
|
||||||
|
current_fast = getattr(session["agent"], "service_tier", None) == "priority"
|
||||||
|
else:
|
||||||
|
current_fast = _load_service_tier() == "priority"
|
||||||
|
|
||||||
|
if raw in ("", "toggle"):
|
||||||
|
nv = "normal" if current_fast else "fast"
|
||||||
|
elif raw in {"status"}:
|
||||||
|
nv = "fast" if current_fast else "normal"
|
||||||
|
elif raw in {"fast", "on"}:
|
||||||
|
nv = "fast"
|
||||||
|
elif raw in {"normal", "off"}:
|
||||||
|
nv = "normal"
|
||||||
|
else:
|
||||||
|
return _err(rid, 4002, f"unknown fast mode: {value}")
|
||||||
|
|
||||||
|
_write_config_key("agent.service_tier", nv)
|
||||||
|
if session and session.get("agent") is not None:
|
||||||
|
session["agent"].service_tier = "priority" if nv == "fast" else None
|
||||||
|
_emit("session.info", params.get("session_id", ""), _session_info(session["agent"]))
|
||||||
|
return _ok(rid, {"key": key, "value": nv})
|
||||||
|
|
||||||
|
if key == "busy":
|
||||||
|
raw = str(value or "").strip().lower()
|
||||||
|
if raw in ("", "status"):
|
||||||
|
return _ok(rid, {"key": key, "value": _load_busy_input_mode()})
|
||||||
|
if raw not in {"queue", "steer", "interrupt"}:
|
||||||
|
return _err(rid, 4002, f"unknown busy mode: {value}")
|
||||||
|
_write_config_key("display.busy_input_mode", raw)
|
||||||
|
return _ok(rid, {"key": key, "value": raw})
|
||||||
|
|
||||||
if key == "verbose":
|
if key == "verbose":
|
||||||
cycle = ["off", "new", "all", "verbose"]
|
cycle = ["off", "new", "all", "verbose"]
|
||||||
cur = (
|
cur = (
|
||||||
@@ -3100,6 +3185,22 @@ def _(rid, params: dict) -> dict:
|
|||||||
else "hide"
|
else "hide"
|
||||||
)
|
)
|
||||||
return _ok(rid, {"value": effort, "display": display})
|
return _ok(rid, {"value": effort, "display": display})
|
||||||
|
if key == "fast":
|
||||||
|
return _ok(
|
||||||
|
rid,
|
||||||
|
{
|
||||||
|
"value": "fast"
|
||||||
|
if (session := _sessions.get(params.get("session_id", "")))
|
||||||
|
and getattr(session.get("agent"), "service_tier", None) == "priority"
|
||||||
|
else (
|
||||||
|
"fast"
|
||||||
|
if _load_service_tier() == "priority"
|
||||||
|
else "normal"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if key == "busy":
|
||||||
|
return _ok(rid, {"value": _load_busy_input_mode()})
|
||||||
if key == "details_mode":
|
if key == "details_mode":
|
||||||
allowed_dm = frozenset({"hidden", "collapsed", "expanded"})
|
allowed_dm = frozenset({"hidden", "collapsed", "expanded"})
|
||||||
raw = (
|
raw = (
|
||||||
@@ -4126,10 +4227,6 @@ def _(rid, params: dict) -> dict:
|
|||||||
|
|
||||||
# Skill slash commands and _pending_input commands must NOT go through the
|
# Skill slash commands and _pending_input commands must NOT go through the
|
||||||
# slash worker — see _PENDING_INPUT_COMMANDS definition above.
|
# slash worker — see _PENDING_INPUT_COMMANDS definition above.
|
||||||
# (/browser connect/disconnect also uses _pending_input for context
|
|
||||||
# notes, but the actual browser operations need the slash worker's
|
|
||||||
# env-var side effects, so they stay in slash.exec — only the context
|
|
||||||
# note to the model is lost, which is low-severity.)
|
|
||||||
_cmd_parts = cmd.split() if not cmd.startswith("/") else cmd.lstrip("/").split()
|
_cmd_parts = cmd.split() if not cmd.startswith("/") else cmd.lstrip("/").split()
|
||||||
_cmd_base = _cmd_parts[0] if _cmd_parts else ""
|
_cmd_base = _cmd_parts[0] if _cmd_parts else ""
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,22 @@ describe('createSlashHandler', () => {
|
|||||||
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(3, 'MCP tool: /tools enable github:create_issue')
|
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(3, 'MCP tool: /tools enable github:create_issue')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['/browser status', 'browser.manage', { action: 'status' }],
|
||||||
|
['/reload-mcp', 'reload.mcp', { session_id: null }],
|
||||||
|
['/rollback', 'rollback.list', { session_id: null }],
|
||||||
|
['/stop', 'process.stop', {}],
|
||||||
|
['/fast status', 'config.get', { key: 'fast', session_id: null }],
|
||||||
|
['/busy status', 'config.get', { key: 'busy' }]
|
||||||
|
])('routes %s through native RPC (no slash worker)', (command, method, params) => {
|
||||||
|
const rpc = vi.fn(() => Promise.resolve({}))
|
||||||
|
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||||
|
|
||||||
|
expect(createSlashHandler(ctx)(command)).toBe(true)
|
||||||
|
expect(rpc).toHaveBeenCalledWith(method, params)
|
||||||
|
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('drops stale slash.exec output after a newer slash', async () => {
|
it('drops stale slash.exec output after a newer slash', async () => {
|
||||||
let resolveLate: (v: { output?: string }) => void
|
let resolveLate: (v: { output?: string }) => void
|
||||||
let slashExecCalls = 0
|
let slashExecCalls = 0
|
||||||
@@ -222,7 +238,7 @@ describe('createSlashHandler', () => {
|
|||||||
|
|
||||||
const h = createSlashHandler(ctx)
|
const h = createSlashHandler(ctx)
|
||||||
expect(h('/slow')).toBe(true)
|
expect(h('/slow')).toBe(true)
|
||||||
expect(h('/fast')).toBe(true)
|
expect(h('/later')).toBe(true)
|
||||||
resolveLate!({ output: 'too late' })
|
resolveLate!({ output: 'too late' })
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(ctx.transcript.sys).toHaveBeenCalled()
|
expect(ctx.transcript.sys).toHaveBeenCalled()
|
||||||
|
|||||||
88
ui-tui/src/__tests__/slashParity.test.ts
Normal file
88
ui-tui/src/__tests__/slashParity.test.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { SLASH_COMMANDS } from '../app/slash/registry.js'
|
||||||
|
|
||||||
|
type CommandRoute = 'fallback' | 'local' | 'native'
|
||||||
|
|
||||||
|
const NATIVE_MUTATING_COMMANDS = new Set([
|
||||||
|
'browser',
|
||||||
|
'busy',
|
||||||
|
'fast',
|
||||||
|
'reload-mcp',
|
||||||
|
'rollback',
|
||||||
|
'stop'
|
||||||
|
])
|
||||||
|
|
||||||
|
const MUTATING_COMMANDS = [
|
||||||
|
'background',
|
||||||
|
'branch',
|
||||||
|
'browser',
|
||||||
|
'busy',
|
||||||
|
'clear',
|
||||||
|
'compress',
|
||||||
|
'fast',
|
||||||
|
'model',
|
||||||
|
'new',
|
||||||
|
'personality',
|
||||||
|
'queue',
|
||||||
|
'reasoning',
|
||||||
|
'reload-mcp',
|
||||||
|
'retry',
|
||||||
|
'rollback',
|
||||||
|
'steer',
|
||||||
|
'stop',
|
||||||
|
'title',
|
||||||
|
'tools',
|
||||||
|
'undo',
|
||||||
|
'verbose',
|
||||||
|
'voice',
|
||||||
|
'yolo'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const loadCommandRegistryNames = (): string[] => {
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const source = readFileSync(resolve(here, '../../../hermes_cli/commands.py'), 'utf8')
|
||||||
|
const names = [...source.matchAll(/CommandDef\("([^"]+)"/g)].map(match => match[1]!)
|
||||||
|
|
||||||
|
return [...new Set(names)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL_COMMAND_NAMES = new Set(
|
||||||
|
SLASH_COMMANDS.flatMap(command => [command.name, ...(command.aliases ?? [])].map(name => name.toLowerCase()))
|
||||||
|
)
|
||||||
|
|
||||||
|
const classifyRoute = (name: string): CommandRoute => {
|
||||||
|
const normalized = name.toLowerCase()
|
||||||
|
if (NATIVE_MUTATING_COMMANDS.has(normalized)) {
|
||||||
|
return 'native'
|
||||||
|
}
|
||||||
|
if (LOCAL_COMMAND_NAMES.has(normalized)) {
|
||||||
|
return 'local'
|
||||||
|
}
|
||||||
|
return 'fallback'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('slash parity matrix', () => {
|
||||||
|
it('classifies each command registry command as local/native/fallback', () => {
|
||||||
|
const routes = Object.fromEntries(loadCommandRegistryNames().map(name => [name, classifyRoute(name)]))
|
||||||
|
|
||||||
|
expect(routes['model']).toBe('local')
|
||||||
|
expect(routes['browser']).toBe('native')
|
||||||
|
expect(routes['reload-mcp']).toBe('native')
|
||||||
|
expect(routes['rollback']).toBe('native')
|
||||||
|
expect(routes['stop']).toBe('native')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps every mutating command off slash-worker fallback', () => {
|
||||||
|
const routes = Object.fromEntries(loadCommandRegistryNames().map(name => [name, classifyRoute(name)]))
|
||||||
|
|
||||||
|
for (const name of MUTATING_COMMANDS) {
|
||||||
|
expect(routes[name], `missing command in registry: ${name}`).toBeDefined()
|
||||||
|
expect(routes[name], `mutating command must not fallback: ${name}`).not.toBe('fallback')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
|
BrowserManageResponse,
|
||||||
DelegationPauseResponse,
|
DelegationPauseResponse,
|
||||||
|
ProcessStopResponse,
|
||||||
|
ReloadMcpResponse,
|
||||||
|
RollbackDiffResponse,
|
||||||
|
RollbackListResponse,
|
||||||
|
RollbackRestoreResponse,
|
||||||
SlashExecResponse,
|
SlashExecResponse,
|
||||||
SpawnTreeListResponse,
|
SpawnTreeListResponse,
|
||||||
SpawnTreeLoadResponse,
|
SpawnTreeLoadResponse,
|
||||||
@@ -50,6 +56,155 @@ interface SkillsBrowseResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const opsCommands: SlashCommand[] = [
|
export const opsCommands: SlashCommand[] = [
|
||||||
|
{
|
||||||
|
help: 'stop background processes',
|
||||||
|
name: 'stop',
|
||||||
|
run: (_arg, ctx) => {
|
||||||
|
ctx.gateway
|
||||||
|
.rpc<ProcessStopResponse>('process.stop', {})
|
||||||
|
.then(
|
||||||
|
ctx.guarded<ProcessStopResponse>(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<ReloadMcpResponse>('reload.mcp', { session_id: ctx.sid })
|
||||||
|
.then(
|
||||||
|
ctx.guarded<ReloadMcpResponse>(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]')
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = { action }
|
||||||
|
if (action === 'connect') {
|
||||||
|
payload.url = rest.join(' ').trim() || 'http://localhost:9222'
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.gateway
|
||||||
|
.rpc<BrowserManageResponse>('browser.manage', payload)
|
||||||
|
.then(
|
||||||
|
ctx.guarded<BrowserManageResponse>(r => {
|
||||||
|
if (action === 'status') {
|
||||||
|
return ctx.transcript.sys(
|
||||||
|
r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser not connected'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (action === 'connect') {
|
||||||
|
return ctx.transcript.sys(
|
||||||
|
r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser connect failed'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ctx.transcript.sys('browser disconnected')
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch(ctx.guardedErr)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
help: 'list, diff, or restore checkpoints',
|
||||||
|
name: 'rollback',
|
||||||
|
run: (arg, ctx) => {
|
||||||
|
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<RollbackListResponse>('rollback.list', { session_id: ctx.sid })
|
||||||
|
.then(
|
||||||
|
ctx.guarded<RollbackListResponse>(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 <checkpoint>')
|
||||||
|
}
|
||||||
|
return ctx.gateway
|
||||||
|
.rpc<RollbackDiffResponse>('rollback.diff', { hash, session_id: ctx.sid })
|
||||||
|
.then(
|
||||||
|
ctx.guarded<RollbackDiffResponse>(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<RollbackRestoreResponse>('rollback.restore', {
|
||||||
|
...(filePath ? { file_path: filePath } : {}),
|
||||||
|
hash,
|
||||||
|
session_id: ctx.sid
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
ctx.guarded<RollbackRestoreResponse>(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'],
|
aliases: ['tasks'],
|
||||||
help: 'open the spawn-tree dashboard (live audit + kill/pause controls)',
|
help: 'open the spawn-tree dashboard (live audit + kill/pause controls)',
|
||||||
|
|||||||
@@ -307,6 +307,83 @@ export const sessionCommands: SlashCommand[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
help: 'toggle fast mode [normal|fast|status]',
|
||||||
|
name: 'fast',
|
||||||
|
run: (arg, ctx) => {
|
||||||
|
const mode = arg.trim().toLowerCase()
|
||||||
|
const valid = new Set(['', 'status', 'normal', 'fast', 'on', 'off', 'toggle'])
|
||||||
|
if (!valid.has(mode)) {
|
||||||
|
return ctx.transcript.sys('usage: /fast [normal|fast|status]')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mode || mode === 'status') {
|
||||||
|
return ctx.gateway
|
||||||
|
.rpc<ConfigGetValueResponse>('config.get', { key: 'fast', session_id: ctx.sid })
|
||||||
|
.then(
|
||||||
|
ctx.guarded<ConfigGetValueResponse>(r =>
|
||||||
|
ctx.transcript.sys(`fast mode: ${r.value === 'fast' ? 'fast' : 'normal'}`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.catch(ctx.guardedErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.gateway
|
||||||
|
.rpc<ConfigSetResponse>('config.set', { key: 'fast', session_id: ctx.sid, value: mode })
|
||||||
|
.then(
|
||||||
|
ctx.guarded<ConfigSetResponse>(r => {
|
||||||
|
const next = r.value === 'fast' ? 'fast' : 'normal'
|
||||||
|
ctx.transcript.sys(`fast mode: ${next}`)
|
||||||
|
patchUiState(state => ({
|
||||||
|
...state,
|
||||||
|
info: state.info
|
||||||
|
? {
|
||||||
|
...state.info,
|
||||||
|
fast: next === 'fast',
|
||||||
|
service_tier: next === 'fast' ? 'priority' : ''
|
||||||
|
}
|
||||||
|
: state.info
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch(ctx.guardedErr)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
help: 'control busy enter mode [queue|steer|interrupt|status]',
|
||||||
|
name: 'busy',
|
||||||
|
run: (arg, ctx) => {
|
||||||
|
const mode = arg.trim().toLowerCase()
|
||||||
|
const valid = new Set(['', 'status', 'queue', 'steer', 'interrupt'])
|
||||||
|
if (!valid.has(mode)) {
|
||||||
|
return ctx.transcript.sys('usage: /busy [queue|steer|interrupt|status]')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mode || mode === 'status') {
|
||||||
|
return ctx.gateway
|
||||||
|
.rpc<ConfigGetValueResponse>('config.get', { key: 'busy' })
|
||||||
|
.then(
|
||||||
|
ctx.guarded<ConfigGetValueResponse>(r => {
|
||||||
|
const current = r.value || 'interrupt'
|
||||||
|
ctx.transcript.sys(`busy input mode: ${current}`)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch(ctx.guardedErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.gateway
|
||||||
|
.rpc<ConfigSetResponse>('config.set', { key: 'busy', value: mode })
|
||||||
|
.then(
|
||||||
|
ctx.guarded<ConfigSetResponse>(r => {
|
||||||
|
const next = r.value || mode
|
||||||
|
ctx.transcript.sys(`busy input mode: ${next}`)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch(ctx.guardedErr)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
help: 'cycle verbose tool-output mode (updates live agent)',
|
help: 'cycle verbose tool-output mode (updates live agent)',
|
||||||
name: 'verbose',
|
name: 'verbose',
|
||||||
|
|||||||
@@ -288,7 +288,42 @@ export interface ModelOptionsResponse {
|
|||||||
// ── MCP ──────────────────────────────────────────────────────────────
|
// ── MCP ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ReloadMcpResponse {
|
export interface ReloadMcpResponse {
|
||||||
ok?: boolean
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessStopResponse {
|
||||||
|
killed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserManageResponse {
|
||||||
|
connected?: boolean
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RollbackCheckpoint {
|
||||||
|
hash: string
|
||||||
|
message?: string
|
||||||
|
timestamp?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RollbackListResponse {
|
||||||
|
checkpoints?: RollbackCheckpoint[]
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RollbackDiffResponse {
|
||||||
|
diff?: string
|
||||||
|
rendered?: string
|
||||||
|
stat?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RollbackRestoreResponse {
|
||||||
|
error?: string
|
||||||
|
history_removed?: number
|
||||||
|
message?: string
|
||||||
|
reason?: string
|
||||||
|
restored_to?: string
|
||||||
|
success?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Subagent events ──────────────────────────────────────────────────
|
// ── Subagent events ──────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user