From b29a2716d1c356b12f0370925f1245b40f5024e2 Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Mon, 20 Apr 2026 09:34:24 +0000 Subject: [PATCH] =?UTF-8?q?v0.5.2:=20shell=5Fbg=20/=20remote=5Fshell=5Fbg?= =?UTF-8?q?=20=E2=80=94=20background=20launch=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server.lua gains a shell_bg tool that launches a detached command via setsid + nohup + stdio-redirect + &, returns immediately with PID and log path. Linux-only for MVP (Windows Start-Process equivalent TBD). hub.lua gains remote_shell_bg, forwarding to backend shell_bg. lmcp-only, no ssh fallback — fallback for fire-and-forget is semantically murky. Addresses the 'how do I launch a daemon over lmcp without the sentinel- file wrapper blocking forever' question. Existing remote_shell keeps its current synchronous-with-timeout behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) --- hub.lua | 13 ++++++++++++- server.lua | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/hub.lua b/hub.lua index 30c4f7c..f4566da 100644 --- a/hub.lua +++ b/hub.lua @@ -355,7 +355,7 @@ math.randomseed(os.time()) local server = lmcp.new(os.getenv("LMCP_NAME") or "hub-tools", { port = tonumber(os.getenv("LMCP_PORT") or arg[1]) or 8090, - version = "0.5.0", + version = "0.5.2", conf = os.getenv("LMCP_HUB_CONF") or "/opt/herding/etc/lmcp-hub.conf", }) @@ -433,6 +433,17 @@ server:tool("remote_edit_file", function(a) return call_remote("edit_file", a, false, nil) end ) +server:tool("remote_shell_bg", + "Launch a detached background command on a fleet host (Linux). Returns PID + log path immediately. Requires backend lmcp v0.5.2+.", + { type = "object", properties = { + host = HOST_ARG, + command = { type = "string", description = "Shell command" }, + cwd = { type = "string" }, + log = { type = "string", description = "Log file path" }, + }, required = { "host", "command" } }, + function(a) return call_remote("shell_bg", a, false, nil) end +) + server:tool("remote_list_dir", "List directory entries on a fleet host.", { type = "object", properties = { host = HOST_ARG, diff --git a/server.lua b/server.lua index 2eddf7d..aa7438a 100644 --- a/server.lua +++ b/server.lua @@ -170,6 +170,49 @@ server:tool("shell", "Execute a shell command.", { return run(cmd, a.timeout or 120) end) +server:tool("shell_bg", + "Fire-and-forget shell command (Linux-only). Fully detaches via setsid+nohup+stdio-redirect and returns immediately with PID and log path. Use for daemons that must outlive the lmcp request.", + { + type = "object", + properties = { + command = { type = "string", description = "Shell command to launch" }, + cwd = { type = "string", description = "Working directory" }, + log = { type = "string", description = "Log file (stdout+stderr). Default: /tmp/lmcp-bg--.log" }, + }, + required = { "command" }, + }, + function(a) + if WINDOWS then + return "Error: shell_bg is Linux-only (Windows Start-Process equivalent TBD)" + end + if type(a.command) ~= "string" or a.command == "" then + return "Error: command required" + end + local log = a.log + if not log or log == "" then + log = string.format("/tmp/lmcp-bg-%d-%d.log", os.time(), math.random(1000, 9999)) + end + local pid_file = log .. ".pid" + local inner = a.command + if a.cwd and a.cwd ~= "" then + inner = "cd '" .. a.cwd:gsub("'", "'\\''") .. "' && " .. inner + end + local sq = function(s) return "'" .. s:gsub("'", "'\\''") .. "'" end + local full = string.format( + "setsid nohup sh -c %s %s 2>&1 & echo $! > %s", + sq(inner), sq(log), sq(pid_file) + ) + os.execute(full) + local f = io.open(pid_file, 'r') + local pid = "?" + if f then + pid = (f:read('*a') or ""):match("(%d+)") or "?" + f:close() + os.remove(pid_file) + end + return string.format("launched pid=%s log=%s", pid, log) + end) + server:tool("read_file", "Read a file.", { type = "object", properties = { path = { type = "string" } },