Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> <cmd> connects one live
/help list commands
exit | /exit | ctrl-d | ctrl-c(empty) quit
Expand Down Expand Up @@ -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` |
Expand All @@ -555,6 +557,27 @@ See [`sdk/README.md`](sdk/README.md).
| `attempt_completion` | meta | carry the final answer out; ends the turn |
| `mcp__<server>__*` | 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
Expand Down
7 changes: 6 additions & 1 deletion src/commands_misc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand All @@ -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;
Expand Down
23 changes: 19 additions & 4 deletions src/exec.zig
Original file line number Diff line number Diff line change
@@ -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/
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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 };
Expand Down
Loading
Loading