From 4786547927145e9105d72159e5e356b25e247f86 Mon Sep 17 00:00:00 2001 From: yxlyx <85774423+yxlyx@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:09:51 +0800 Subject: [PATCH] Manage idle localhost server jobs Automatically supervise recognized development servers, pause and stop them after configurable inactivity, expose resume and keep-alive controls, and terminate POSIX process trees instead of leaving descendants behind. Closes #199 Related to #198 Co-Authored-By: Codegraff --- README.md | 25 ++++- src/commands_misc.zig | 7 +- src/exec.zig | 23 +++- src/jobs.zig | 252 +++++++++++++++++++++++++++++++++++++++--- src/main.zig | 1 + src/schema.zig | 15 ++- 6 files changed, 298 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ebde6275..c1270345 100644 --- a/README.md +++ b/README.md @@ -419,6 +419,7 @@ prefixes on body lines. /compact summarize history into a fresh context /save | /resume | /sessions session persistence; bare /resume → interactive picker /todo show the current task list +/jobs list background jobs and managed localhost server state /mcp [add …] list MCP servers/tools; /mcp add connects one live /help list commands exit | /exit | ctrl-d | ctrl-c(empty) quit @@ -543,7 +544,8 @@ See [`sdk/README.md`](sdk/README.md). | Tool | Kind | Implementation | |----------------------|----------|-----------------------------------------------------------| -| `bash` | built-in | `std.process.run` → `/bin/sh -c`, stdout+stderr+exit code | +| `bash` | built-in | `/bin/sh -c`, capped output, managed background jobs | +| `bash_output`/`bash_resume`/`bash_kill` | built-in | inspect, resume, or stop background jobs and their process trees | | `read_file` | built-in | `Io.Dir.cwd().readFileAlloc` (256 KB cap) | | `edit_file` | built-in | exact string replace; unique match required unless `replace_all` | | `write_file` | built-in | `Io.Dir.cwd().writeFile` | @@ -555,6 +557,27 @@ See [`sdk/README.md`](sdk/README.md). | `attempt_completion` | meta | carry the final answer out; ends the turn | | `mcp____*` | MCP | tools discovered from `.mcp.json` servers (see below) | +**Managed localhost servers.** Simple dev-server commands such as `npm run dev`, +`pnpm preview`, `next start`, `vite`, and `python -m http.server` are moved into +a background job automatically, even if the model forgets +`run_in_background`. On POSIX, each shell owns a process group so stopping the +job reaches npm/node/test descendants instead of leaving them orphaned. + +By default a managed server pauses after **30 minutes** without process output +or a `bash_output`/`bash_resume` call, then stops and releases its process tree +after **2 hours**. `/jobs` shows running, paused, pinned, and idle-stopped jobs; +`bash_resume` wakes a paused server. Pass `keep_alive: true` on the `bash` tool +to pin a server intentionally. Configure the defaults before starting graff: + +```sh +GRAFF_SERVER_PAUSE_MINUTES=15 GRAFF_SERVER_STOP_MINUTES=60 graff +GRAFF_SERVER_IDLE=off graff # disable the policy +``` + +A zero pause or stop value disables that transition. The policy only applies +to server commands launched by graff; it never scans for or stops unrelated +localhost services. + **Meta tools** act on the agent or the conversation, not the outside world, so the orchestrator handles them inline rather than on a pool thread. `ask_user` + `attempt_completion` make the human↔agent conversation fully tool-mediated: the diff --git a/src/commands_misc.zig b/src/commands_misc.zig index e00bc161..743f3b42 100644 --- a/src/commands_misc.zig +++ b/src/commands_misc.zig @@ -144,8 +144,12 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, try out.print("{s}background jobs{s}\n", .{ style.bold, style.reset }); for (jobs.g_jobs.list.items) |job| { var sbuf: [32]u8 = undefined; - const status: []const u8 = if (!job.done) + const status: []const u8 = if (!job.done and job.paused) + "paused" + else if (!job.done) "running" + else if (job.idle_stopped) + "idle-stop" else if (job.killed) "killed" else if (job.exit_code) |c| @@ -157,6 +161,7 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, if (job.done) style.dim else style.green, status, style.reset, job.buf.items.len - job.cursor, utf8Prefix(job.cmd, 60), }); + if (job.managed_server) try out.print(" managed localhost{s}\n", .{if (job.keep_alive) " · keep alive" else ""}); } try out.flush(); return true; diff --git a/src/exec.zig b/src/exec.zig index c0b4d2dc..9151c511 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -1,6 +1,6 @@ //! Tool dispatch: `execTool` (the timed/traced/hooked outer wrapper) and //! `execToolInner` (the big per-tool-name switch — bash, bash_output, -//! bash_kill, webfetch, read_file, codedb, edit_file, write_file, subagent, +//! bash_resume, bash_kill, webfetch, read_file, codedb, edit_file, write_file, subagent, //! workflow). Split out of main.zig (600-line goal); LAST in the tool-exec //! region since it's the glue that imports tools.zig/subagent.zig/ //! workflow.zig as siblings, plus approvals.zig/mcp.zig/jobs.zig/skills.zig/ @@ -49,6 +49,7 @@ const jobs = @import("jobs.zig"); const runCapped = jobs.runCapped; const spawnJob = jobs.spawnJob; const jobOutput = jobs.jobOutput; +const jobResume = jobs.jobResume; const jobKill = jobs.jobKill; const shellArgv = jobs.shellArgv; const skills = @import("skills.zig"); @@ -132,9 +133,13 @@ fn execToolInner(ctx: ToolCtx, call: ToolCall) !ToolOutput { .text = try gpa.dupe(u8, "command not pre-approved — subagents may only run user-approved or read-only commands, with no chaining/pipes/redirection. Use read_file/edit_file/write_file, or report back what you need run."), .is_error = true, }; - const bg = if (input == .object) (if (input.object.get("run_in_background")) |v| v == .bool and v.bool else false) else false; - if (bg) { - const job = spawnJob(gpa, io, cmd) catch |err| return .{ + const requested_bg = if (input == .object) (if (input.object.get("run_in_background")) |v| v == .bool and v.bool else false) else false; + const managed_server = jobs.looksLikeLocalServer(cmd); + const keep_alive = if (input == .object) (if (input.object.get("keep_alive")) |v| v == .bool and v.bool else false) else false; + // A simple, recognized dev-server command is always backgrounded so it + // enters the managed lifecycle even if the model forgot the flag. + if (requested_bg or managed_server) { + const job = spawnJob(gpa, io, cmd, .{ .managed_server = managed_server, .keep_alive = keep_alive }) catch |err| return .{ // #122: backgrounding costs MORE fds (pipes + pump task), so the // generic "run it in the foreground" advice is right for every // error except the fd-quota one — special-case that. @@ -144,6 +149,11 @@ fn execToolInner(ctx: ToolCtx, call: ToolCall) !ToolOutput { try std.fmt.allocPrint(gpa, "could not start background job ({t}) — run it in the foreground instead", .{err}), .is_error = true, }; + if (managed_server and !keep_alive and jobs.g_server_idle_enabled) return .{ .text = try std.fmt.allocPrint( + gpa, + "[job {d} started: {s}]\nRecognized as a managed localhost server: pause after {d}m idle, stop after {d}m idle. Poll with bash_output, resume with bash_resume, stop with bash_kill; pass keep_alive=true to pin it.", + .{ job.id, job.cmd, jobs.g_server_pause_ms / std.time.ms_per_min, jobs.g_server_stop_ms / std.time.ms_per_min }, + ) }; return .{ .text = try std.fmt.allocPrint(gpa, "[job {d} started: {s}]\nIt keeps running across turns. Poll new output with bash_output (id {d}, optional wait_ms), stop it with bash_kill.", .{ job.id, job.cmd, job.id }) }; } const sh = shellArgv(cmd); @@ -176,6 +186,11 @@ fn execToolInner(ctx: ToolCtx, call: ToolCall) !ToolOutput { if (id < 0 or id > std.math.maxInt(u32)) return .{ .text = try gpa.dupe(u8, "invalid job id"), .is_error = true }; return jobOutput(gpa, io, @intCast(id), @intCast(@max(wait_ms, 0))); } + if (std.mem.eql(u8, call.name, "bash_resume")) { + const id = intField(input, "id") orelse return missingArg(gpa, "id"); + if (id < 0 or id > std.math.maxInt(u32)) return .{ .text = try gpa.dupe(u8, "invalid job id"), .is_error = true }; + return jobResume(gpa, io, @intCast(id)); + } if (std.mem.eql(u8, call.name, "bash_kill")) { const id = intField(input, "id") orelse return missingArg(gpa, "id"); if (id < 0 or id > std.math.maxInt(u32)) return .{ .text = try gpa.dupe(u8, "invalid job id"), .is_error = true }; diff --git a/src/jobs.zig b/src/jobs.zig index 9e0a1045..d07f8c52 100644 --- a/src/jobs.zig +++ b/src/jobs.zig @@ -10,6 +10,67 @@ const builtin = @import("builtin"); const Io = std.Io; const Allocator = std.mem.Allocator; +const supports_process_groups = builtin.os.tag != .windows and builtin.os.tag != .wasi; +const default_server_pause_ms: u64 = 30 * std.time.ms_per_min; +const default_server_stop_ms: u64 = 2 * std.time.ms_per_hour; + +/// Managed localhost jobs use these process-wide defaults. Minutes are used in +/// the environment so the values stay friendly to shell users; zero disables +/// that transition. `GRAFF_SERVER_IDLE=off` disables the policy entirely. +pub var g_server_idle_enabled = true; +pub var g_server_pause_ms = default_server_pause_ms; +pub var g_server_stop_ms = default_server_stop_ms; + +pub fn configureIdlePolicy(environ: anytype) void { + g_server_idle_enabled = true; + if (environ.get("GRAFF_SERVER_IDLE")) |v| { + if (std.ascii.eqlIgnoreCase(v, "off") or std.mem.eql(u8, v, "0") or std.ascii.eqlIgnoreCase(v, "false") or std.ascii.eqlIgnoreCase(v, "no")) + g_server_idle_enabled = false; + } + g_server_pause_ms = envMinutes(environ, "GRAFF_SERVER_PAUSE_MINUTES", default_server_pause_ms); + g_server_stop_ms = envMinutes(environ, "GRAFF_SERVER_STOP_MINUTES", default_server_stop_ms); + // A pause at/after stop has no useful observable state; go straight to stop. + if (g_server_stop_ms > 0 and g_server_pause_ms >= g_server_stop_ms) g_server_pause_ms = 0; +} + +fn envMinutes(environ: anytype, key: []const u8, fallback_ms: u64) u64 { + const raw = environ.get(key) orelse return fallback_ms; + const minutes = std.fmt.parseInt(u64, raw, 10) catch return fallback_ms; + return std.math.mul(u64, minutes, std.time.ms_per_min) catch fallback_ms; +} + +fn processGroupId() ?std.posix.pid_t { + return if (supports_process_groups) 0 else null; +} + +/// Every shell gets its own POSIX process group. Signalling the group is what +/// reaches npm -> node, xcodebuild -> XCTest, and similar grandchildren; killing +/// only `/bin/sh` is how stale localhost/test trees escaped in #198. +fn signalProcessGroup(child: *const std.process.Child, sig: std.posix.SIG) void { + if (comptime supports_process_groups) { + const pid = child.id orelse return; + std.posix.kill(-pid, sig) catch {}; + } +} + +fn pauseProcessGroup(child: *const std.process.Child) void { + if (comptime supports_process_groups) signalProcessGroup(child, .STOP); +} + +fn resumeProcessGroup(child: *const std.process.Child) void { + if (comptime supports_process_groups) signalProcessGroup(child, .CONT); +} + +fn terminateProcessTree(child: *std.process.Child, io: Io) void { + if (child.id == null) return; + if (comptime supports_process_groups) { + signalProcessGroup(child, .TERM); + io.sleep(.fromMilliseconds(200), .awake) catch {}; + signalProcessGroup(child, .KILL); + } + child.kill(io); // reaps the direct child and closes its pipes +} + const root = @import("main.zig"); const agent_mod = @import("agent.zig"); const tools_mod = @import("tools.zig"); @@ -48,8 +109,9 @@ pub fn runCapped(gpa: Allocator, io: Io, argv: []const []const u8, stdout_cap: u .stdin = .ignore, .stdout = .pipe, .stderr = .pipe, + .pgid = processGroupId(), }); - defer child.kill(io); + defer terminateProcessTree(&child, io); var multi_reader_buffer: Io.File.MultiReader.Buffer(2) = undefined; var multi_reader: Io.File.MultiReader = undefined; @@ -82,12 +144,12 @@ pub fn runCapped(gpa: Allocator, io: Io, argv: []const []const u8, stdout_cap: u } if (Agent.esc_cancel.load(.acquire)) { esc_killed = true; - child.kill(io); + terminateProcessTree(&child, io); break :loop; } if (deadline_ms > 0 and t0.untilNow(io, .awake).toMilliseconds() >= deadline_ms) { timed_out = true; - child.kill(io); + terminateProcessTree(&child, io); break :loop; } } @@ -306,7 +368,7 @@ pub fn worktreeCommand(gpa: Allocator, io: Io, arena: Allocator, args: []const [ /// a full pipe; bash_output returns the bytes past `cursor`; bash_kill stops /// it. Jobs are session-global — they deliberately survive the turn (and the /// Esc cancel) that started them — and are reaped at exit. -const Job = struct { +pub const Job = struct { id: u32, cmd: []u8, // gpa-owned, for /jobs display child: std.process.Child, @@ -317,9 +379,64 @@ const Job = struct { killed: bool = false, // ended via bash_kill rather than naturally kill_requested: bool = false, // pump notices within one 200ms tick dropped: bool = false, // unread output overflowed job_unread_cap + managed_server: bool = false, + keep_alive: bool = false, + paused: bool = false, + resume_requested: bool = false, + idle_stopped: bool = false, + last_activity: Io.Timestamp, future: Io.Future(void) = undefined, // the pump; awaited only by jobsReap }; +pub const JobOptions = struct { + managed_server: bool = false, + keep_alive: bool = false, +}; + +const IdleAction = enum { none, pause, stop }; + +fn idleAction(paused: bool, idle_ms: u64, pause_ms: u64, stop_ms: u64) IdleAction { + if (stop_ms > 0 and idle_ms >= stop_ms) return .stop; + if (!paused and pause_ms > 0 and idle_ms >= pause_ms) return .pause; + return .none; +} + +/// Conservative recognizer for commands that are expected to bind localhost +/// and run forever. Compound shell programs are excluded: auto-managing an +/// entire `build && test` pipeline because one token says `dev` is surprising. +pub fn looksLikeLocalServer(command: []const u8) bool { + const cmd = std.mem.trim(u8, command, " \t\r\n"); + if (cmd.len == 0 or std.mem.indexOfAny(u8, cmd, ";|&\n<>`$") != null) return false; + + var words = std.mem.tokenizeAny(u8, cmd, " \t"); + const first_raw = words.next() orelse return false; + const first = std.fs.path.basename(first_raw); + const second = words.next(); + const third = words.next(); + + if (std.mem.eql(u8, first, "npm") or std.mem.eql(u8, first, "pnpm") or std.mem.eql(u8, first, "yarn") or std.mem.eql(u8, first, "bun")) + return packageServerScript(second, third); + if (std.mem.eql(u8, first, "next")) return second != null and (std.mem.eql(u8, second.?, "dev") or std.mem.eql(u8, second.?, "start")); + if (std.mem.eql(u8, first, "vite")) return second == null or std.mem.eql(u8, second.?, "dev") or std.mem.eql(u8, second.?, "preview"); + if (std.mem.eql(u8, first, "astro") or std.mem.eql(u8, first, "nuxt") or std.mem.eql(u8, first, "wrangler")) + return second != null and (std.mem.eql(u8, second.?, "dev") or std.mem.eql(u8, second.?, "preview")); + if (std.mem.eql(u8, first, "webpack")) return second != null and std.mem.eql(u8, second.?, "serve"); + if (std.mem.eql(u8, first, "python") or std.mem.eql(u8, first, "python3")) + return second != null and std.mem.eql(u8, second.?, "-m") and third != null and std.mem.eql(u8, third.?, "http.server"); + if (std.mem.eql(u8, first, "php")) return second != null and std.mem.eql(u8, second.?, "-S"); + return false; +} + +fn packageServerScript(second: ?[]const u8, third: ?[]const u8) bool { + const script = second orelse return false; + if (std.mem.eql(u8, script, "run")) return third != null and serverScript(third.?); + return serverScript(script); +} + +fn serverScript(word: []const u8) bool { + return std.mem.eql(u8, word, "dev") or std.mem.eql(u8, word, "serve") or std.mem.eql(u8, word, "preview") or std.mem.eql(u8, word, "start"); +} + const job_unread_cap = 256 * 1024; const job_wait_cap_ms: u64 = 30_000; @@ -340,13 +457,16 @@ pub var g_jobs: Jobs = .{}; /// Drain whatever the MultiReader has buffered into the job's output buffer, /// dropping the oldest *unread* bytes past the cap (a chatty server must not /// grow memory unboundedly between bash_output polls). Caller holds the mutex. -fn jobDrain(job: *Job, gpa: Allocator, readers: []const *Io.Reader) void { +fn jobDrain(job: *Job, gpa: Allocator, io: Io, readers: []const *Io.Reader) void { + var received = false; for (readers) |r| { const b = r.buffered(); if (b.len == 0) continue; job.buf.appendSlice(gpa, b) catch {}; r.toss(b.len); + received = true; } + if (received) job.last_activity = .now(io, .awake); if (job.buf.items.len - job.cursor > job_unread_cap) { const drop = job.buf.items.len - job.cursor - job_unread_cap; job.buf.replaceRange(gpa, job.cursor, drop, &.{}) catch return; @@ -370,19 +490,54 @@ fn jobPump(job: *Job, gpa: Allocator, io: Io) void { error.Timeout => {}, // poll tick: check for a kill request else => break :loop, }; + var pause = false; + var idle_stop = false; + var should_resume = false; g_jobs.mutex.lockUncancelable(io); - jobDrain(job, gpa, &readers); + jobDrain(job, gpa, io, &readers); + if (job.resume_requested) { + job.resume_requested = false; + if (job.paused) { + job.paused = false; + should_resume = true; + job.buf.appendSlice(gpa, "\n[resumed by bash_resume]\n") catch {}; + } + } + if (job.managed_server and !job.keep_alive and g_server_idle_enabled and !job.kill_requested) { + const idle_ms: u64 = @intCast(@max(job.last_activity.untilNow(io, .awake).toMilliseconds(), 0)); + switch (idleAction(job.paused, idle_ms, g_server_pause_ms, g_server_stop_ms)) { + .none => {}, + .pause => if (supports_process_groups) { + job.paused = true; + pause = true; + job.buf.appendSlice(gpa, "\n[managed localhost server paused after idle timeout; use bash_resume to continue]\n") catch {}; + }, + .stop => { + job.idle_stopped = true; + job.kill_requested = true; + idle_stop = true; + job.buf.appendSlice(gpa, "\n[managed localhost server stopped after idle timeout]\n") catch {}; + }, + } + } killed = job.kill_requested; g_jobs.mutex.unlock(io); + if (should_resume) resumeProcessGroup(&job.child); + if (pause) { + pauseProcessGroup(&job.child); + std.debug.print("\n[job {d}] managed localhost server paused after inactivity; use bash_resume to continue\n", .{job.id}); + } + if (idle_stop) std.debug.print("\n[job {d}] managed localhost server stopped after inactivity\n", .{job.id}); if (killed) break :loop; } g_jobs.mutex.lockUncancelable(io); - jobDrain(job, gpa, &readers); // final drain of anything left at EOF/kill + jobDrain(job, gpa, io, &readers); // final drain of anything left at EOF/kill killed = killed or job.kill_requested; g_jobs.mutex.unlock(io); var code: ?u8 = null; if (killed) { - job.child.kill(io); // also reaps (wait would assert afterwards) + if (job.paused) resumeProcessGroup(&job.child); + terminateProcessTree(&job.child, io); // also reaps (wait would assert afterwards) } else if (job.child.wait(io)) |term| { code = switch (term) { .exited => |c| c, @@ -409,24 +564,32 @@ pub fn shellArgv(cmd: []const u8) [3][]const u8 { /// Spawn a background job and its pump. Uses io.concurrent (NOT io.async, /// which may run inline and block this tool forever on a long-lived child); /// no spare concurrency cleans up and surfaces the error to the model. -pub fn spawnJob(gpa: Allocator, io: Io, cmd: []const u8) !*Job { +pub fn spawnJob(gpa: Allocator, io: Io, cmd: []const u8, options: JobOptions) !*Job { const argv = shellArgv(cmd); var child = try std.process.spawn(io, .{ .argv = &argv, .stdin = .ignore, .stdout = .pipe, .stderr = .pipe, + .pgid = processGroupId(), }); const cmd_copy = gpa.dupe(u8, cmd) catch |e| { - child.kill(io); + terminateProcessTree(&child, io); return e; }; const job = gpa.create(Job) catch |e| { gpa.free(cmd_copy); - child.kill(io); + terminateProcessTree(&child, io); return e; }; - job.* = .{ .id = 0, .cmd = cmd_copy, .child = child }; + job.* = .{ + .id = 0, + .cmd = cmd_copy, + .child = child, + .managed_server = options.managed_server, + .keep_alive = options.keep_alive, + .last_activity = .now(io, .awake), + }; g_jobs.mutex.lockUncancelable(io); job.id = g_jobs.next_id; g_jobs.next_id += 1; @@ -436,7 +599,7 @@ pub fn spawnJob(gpa: Allocator, io: Io, cmd: []const u8) !*Job { }; g_jobs.mutex.unlock(io); if (!appended) { - job.child.kill(io); + terminateProcessTree(&job.child, io); gpa.free(job.cmd); gpa.destroy(job); return error.OutOfMemory; @@ -450,7 +613,7 @@ pub fn spawnJob(gpa: Allocator, io: Io, cmd: []const u8) !*Job { } } g_jobs.mutex.unlock(io); - job.child.kill(io); + terminateProcessTree(&job.child, io); gpa.free(job.cmd); gpa.destroy(job); return e; @@ -470,12 +633,17 @@ pub fn jobOutput(gpa: Allocator, io: Io, id: u32, wait_ms: u64) !ToolOutput { return .{ .text = try std.fmt.allocPrint(gpa, "no background job {d} — it may never have started; /jobs lists them", .{id}), .is_error = true }; }; const fresh = job.buf.items[job.cursor..]; + if (!job.done) job.last_activity = .now(io, .awake); if (fresh.len > 0 or job.done or waited >= deadline) { var aw: Io.Writer.Allocating = .init(gpa); errdefer aw.deinit(); const w = &aw.writer; - if (!job.done) { + if (!job.done and job.paused) { + try w.print("[job {d}: paused]", .{id}); + } else if (!job.done) { try w.print("[job {d}: running]", .{id}); + } else if (job.idle_stopped) { + try w.print("[job {d}: stopped after idle timeout]", .{id}); } else if (job.killed) { try w.print("[job {d}: killed]", .{id}); } else if (job.exit_code) |c| { @@ -510,6 +678,18 @@ pub fn jobOutput(gpa: Allocator, io: Io, id: u32, wait_ms: u64) !ToolOutput { } } +/// bash_resume: continue a managed server paused by the idle policy. +pub fn jobResume(gpa: Allocator, io: Io, id: u32) !ToolOutput { + g_jobs.mutex.lockUncancelable(io); + defer g_jobs.mutex.unlock(io); + const job = g_jobs.find(id) orelse return .{ .text = try std.fmt.allocPrint(gpa, "no background job {d} — /jobs lists them", .{id}), .is_error = true }; + if (job.done) return .{ .text = try std.fmt.allocPrint(gpa, "job {d} already finished", .{id}), .is_error = true }; + job.last_activity = .now(io, .awake); + if (!job.paused) return .{ .text = try std.fmt.allocPrint(gpa, "job {d} is already running", .{id}) }; + job.resume_requested = true; + return .{ .text = try std.fmt.allocPrint(gpa, "job {d}: resume requested", .{id}) }; +} + /// bash_kill: flag the job and wait (bounded) for the pump to kill + reap it. /// The pump's future is never awaited here — jobsReap owns it — so two /// racing kills are harmless. @@ -561,3 +741,45 @@ pub fn jobsReap(gpa: Allocator, io: Io) void { gpa.free(jobs); g_jobs.list.deinit(gpa); } + +test "localhost server recognizer is conservative" { + try std.testing.expect(looksLikeLocalServer("npm run dev -- --port 3002")); + try std.testing.expect(looksLikeLocalServer("pnpm preview")); + try std.testing.expect(looksLikeLocalServer("bun run dev")); + try std.testing.expect(looksLikeLocalServer("npm start")); + try std.testing.expect(looksLikeLocalServer("next start -p 3000")); + try std.testing.expect(looksLikeLocalServer("python3 -m http.server 8000")); + try std.testing.expect(looksLikeLocalServer("php -S localhost:8080")); + try std.testing.expect(!looksLikeLocalServer("npm test")); + try std.testing.expect(!looksLikeLocalServer("npm run develop")); + try std.testing.expect(!looksLikeLocalServer("npm run build && npm run dev")); + try std.testing.expect(!looksLikeLocalServer("echo next dev")); +} + +test "idle lifecycle pauses once then stops" { + try std.testing.expectEqual(IdleAction.none, idleAction(false, 99, 100, 200)); + try std.testing.expectEqual(IdleAction.pause, idleAction(false, 100, 100, 200)); + try std.testing.expectEqual(IdleAction.none, idleAction(true, 150, 100, 200)); + try std.testing.expectEqual(IdleAction.stop, idleAction(true, 200, 100, 200)); + try std.testing.expectEqual(IdleAction.stop, idleAction(false, 200, 0, 200)); +} + +test "runCapped timeout terminates the child process group" { + if (comptime !supports_process_groups) return error.SkipZigTest; + + const r = try runCapped( + std.testing.allocator, + std.testing.io, + &.{ "/bin/sh", "-c", "sleep 30 & echo $!; wait" }, + 4096, + 4096, + 200, + ); + defer { + std.testing.allocator.free(r.stdout); + std.testing.allocator.free(r.stderr); + } + try std.testing.expect(r.timed_out); + const pid = try std.fmt.parseInt(std.posix.pid_t, std.mem.trim(u8, r.stdout, " \t\r\n"), 10); + try std.testing.expectError(error.ProcessNotFound, std.posix.kill(pid, .CONT)); +} diff --git a/src/main.zig b/src/main.zig index 200810ce..74830e1a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -385,6 +385,7 @@ pub fn main(init: std.process.Init) !void { var snaps: Snapshots = .{ .gpa = gpa, .io = io }; defer snaps.deinit(); + jobs.configureIdlePolicy(init.environ_map); // Background bash jobs die with the session: kill, await pumps, free. defer jobsReap(gpa, io); // Root Agent construction + post-construction config (session name, persisted thinking/goal/eval settings, session-start trace note) + the diff --git a/src/schema.zig b/src/schema.zig index c234e6a0..6c8d3386 100644 --- a/src/schema.zig +++ b/src/schema.zig @@ -97,21 +97,28 @@ const empty_schema = const base_specs = [_]ToolSpec{ .{ .name = "bash", - .desc = "Run a shell command via /bin/sh -c in the current working directory. Returns stdout, stderr, and the exit code. For long-running commands (dev servers, watchers) set run_in_background true: it returns a job id immediately; poll output with bash_output and stop it with bash_kill.", + .desc = "Run a shell command via /bin/sh -c in the current working directory. Returns stdout, stderr, and the exit code. Long-running commands use background jobs; recognized localhost dev servers are managed automatically and pause/stop after inactivity unless keep_alive is true.", .schema = - \\{"type": "object", "properties": {"command": {"type": "string", "description": "Shell command to execute"}, "run_in_background": {"type": "boolean", "description": "Start as a background job and return its id immediately instead of waiting (default false)"}}, "required": ["command"]} + \\{"type": "object", "properties": {"command": {"type": "string", "description": "Shell command to execute"}, "run_in_background": {"type": "boolean", "description": "Start as a background job and return its id immediately instead of waiting (default false)"}, "keep_alive": {"type": "boolean", "description": "For a managed localhost server, disable automatic idle pause/stop (default false)"}}, "required": ["command"]} , }, .{ .name = "bash_output", - .desc = "Read new output from a background bash job (everything since the last bash_output call) plus its status: running, exited with code, or killed. Set wait_ms to block until new output or exit.", + .desc = "Read new output from a background bash job (everything since the last bash_output call) plus its status: running, paused, exited, or killed. Polling counts as activity for managed localhost servers.", .schema = \\{"type": "object", "properties": {"id": {"type": "integer", "description": "Job id returned by bash with run_in_background"}, "wait_ms": {"type": "integer", "description": "Max milliseconds to wait for new output or exit (0-30000, default 0)"}}, "required": ["id"]} , }, + .{ + .name = "bash_resume", + .desc = "Resume a managed localhost server paused by the idle policy.", + .schema = + \\{"type": "object", "properties": {"id": {"type": "integer", "description": "Paused job id to resume"}}, "required": ["id"]} + , + }, .{ .name = "bash_kill", - .desc = "Terminate a background bash job. Unread output stays readable via bash_output afterwards.", + .desc = "Terminate a background bash job and its process tree. Unread output stays readable via bash_output afterwards.", .schema = \\{"type": "object", "properties": {"id": {"type": "integer", "description": "Job id to terminate"}}, "required": ["id"]} ,