From 78a95d97f2b7839d28cd03ed5fed0520240d7b03 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:22:24 +0800 Subject: [PATCH] fix: stream foreground bash and close the small follow-up batch Foreground bash used to stay silent until the child exited, so a long build looked like a hang. runCapped now emits new stdout/stderr every 200ms into the live pane (and tool_output JSON). Also: rewind skip tally (#406), tool-results GC (#452), Codex persist + symlink write (#404/#405), shared-worktree warning (#320), reflector on the root seat (#388), view_image (#249), @[path] history (#108), and Windows write-only File.stat on playbook/serve appends (#462). --- sdk/py/harness_sdk.py | 2 +- sdk/ts/harness.ts | 4 +-- sdk/ts/remote.ts | 4 +-- src/agent_request_policy.zig | 3 ++ src/agent_tools.zig | 1 + src/credential_store.zig | 23 +++++++++++- src/exec.zig | 20 +++++------ src/exec_bash_stream.zig | 60 +++++++++++++++++++++++++++++++ src/imagegen_skill.zig | 5 ++- src/learn_credentials.zig | 6 ++-- src/oauth.zig | 1 + src/oauth_helpers.zig | 38 +++++++++++++++++++- src/playbook.zig | 9 +++-- src/playbook_reflect.zig | 21 ++++++++--- src/process_runner.zig | 43 ++++++++++++++++++++-- src/schema.zig | 4 ++- src/serde.zig | 5 +++ src/serve_events.zig | 32 ++++++++++++----- src/session_run.zig | 4 +++ src/session_start.zig | 2 ++ src/snapshots.zig | 5 ++- src/snapshots_tests.zig | 13 +++++++ src/tool_gates.zig | 17 +++++++-- src/tool_handle.zig | 41 +++++++++++++++++++++ src/tool_spill.zig | 1 + src/tools.zig | 1 + src/view_image.zig | 65 +++++++++++++++++++++++++++++++++ src/worktree_lease.zig | 69 ++++++++++++++++++++++++++++++++++++ 28 files changed, 453 insertions(+), 46 deletions(-) create mode 100644 src/exec_bash_stream.zig create mode 100644 src/view_image.zig diff --git a/sdk/py/harness_sdk.py b/sdk/py/harness_sdk.py index 9dcb0f24..20c2d422 100644 --- a/sdk/py/harness_sdk.py +++ b/sdk/py/harness_sdk.py @@ -14,7 +14,7 @@ from typing import Iterator, Optional MODELS = ["MiniMax-M2.5", "MiniMax-M2.7", "MiniMax-M3", "accounts/fireworks/models/deepseek-v4-flash", "accounts/fireworks/models/deepseek-v4-pro", "accounts/fireworks/models/glm-5p2", "accounts/fireworks/models/gpt-oss-120b", "accounts/fireworks/models/kimi-k2p6", "accounts/fireworks/models/kimi-k2p7-code", "accounts/fireworks/models/minimax-m3", "accounts/fireworks/models/qwen3p7-plus", "claude-fable-5", "claude-haiku-4-5", "claude-opus-4-5", "claude-opus-4-6", "claude-opus-4-7", "claude-opus-4-8", "claude-opus-4.8", "claude-opus-5", "claude-sonnet-4-5", "claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-5", "deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash", "deepseek-v4-pro", "fugu", "fugu-ultra", "fugu-ultra-20260615", "glm-4.5", "glm-4.7", "glm-5", "glm-5.2", "gpt-5-codex", "gpt-5.2", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-pro", "gpt-5.5", "gpt-5.6", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "grok-4.3", "grok-build", "k3", "kilo-auto/small", "kimi-for-coding", "kimi-for-coding-highspeed", "kimi-k2.6", "kimi-latest", "lmstudio", "mimo-v2-flash", "mimo-v2.5", "mimo-v2.5-pro", "mimo-v2.5-pro-ultraspeed", "minimax-m3", "mistral-medium-latest", "mlx-community/Qwen3.6-27B-OptiQ-4bit", "openai/gpt-oss-120b"] -TOOLS = ["bash", "bash_output", "bash_kill", "read_file", "edit_file", "write_file", "webfetch", "skill", "codedb", "todo_write", "todo_read", "eval", "note_constraint", "ask_user", "attempt_completion", "load_tool_schemas", "clock_sleep", "subagent", "workflow", "agent_output", "learn_candidate", "peer_message", "imagegen"] +TOOLS = ["bash", "bash_output", "bash_kill", "read_file", "edit_file", "write_file", "webfetch", "skill", "codedb", "todo_write", "todo_read", "eval", "note_constraint", "ask_user", "attempt_completion", "load_tool_schemas", "clock_sleep", "subagent", "workflow", "agent_output", "learn_candidate", "peer_message", "imagegen", "view_image"] PROVIDERS = ["anthropic", "codegraff", "deepseek", "openai", "minimax", "xiaomi", "kilo", "groq", "mistral", "kimi", "moonshot", "xai", "zai", "fugu", "fireworks", "mlx", "lmstudio", "codex"] diff --git a/sdk/ts/harness.ts b/sdk/ts/harness.ts index cb6ee6ca..933920c1 100644 --- a/sdk/ts/harness.ts +++ b/sdk/ts/harness.ts @@ -12,7 +12,7 @@ import type { Readable, Writable } from "node:stream"; export const HARNESS_VERSION = "0.12"; export type ModelName = "MiniMax-M2.5" | "MiniMax-M2.7" | "MiniMax-M3" | "accounts/fireworks/models/deepseek-v4-flash" | "accounts/fireworks/models/deepseek-v4-pro" | "accounts/fireworks/models/glm-5p2" | "accounts/fireworks/models/gpt-oss-120b" | "accounts/fireworks/models/kimi-k2p6" | "accounts/fireworks/models/kimi-k2p7-code" | "accounts/fireworks/models/minimax-m3" | "accounts/fireworks/models/qwen3p7-plus" | "claude-fable-5" | "claude-haiku-4-5" | "claude-opus-4-5" | "claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8" | "claude-opus-4.8" | "claude-opus-5" | "claude-sonnet-4-5" | "claude-sonnet-4-6" | "claude-sonnet-4.6" | "claude-sonnet-5" | "deepseek-chat" | "deepseek-reasoner" | "deepseek-v4-flash" | "deepseek-v4-pro" | "fugu" | "fugu-ultra" | "fugu-ultra-20260615" | "glm-4.5" | "glm-4.7" | "glm-5" | "glm-5.2" | "gpt-5-codex" | "gpt-5.2" | "gpt-5.3-codex-spark" | "gpt-5.4" | "gpt-5.4-mini" | "gpt-5.4-pro" | "gpt-5.5" | "gpt-5.6" | "gpt-5.6-luna" | "gpt-5.6-sol" | "gpt-5.6-terra" | "grok-4.3" | "grok-build" | "k3" | "kilo-auto/small" | "kimi-for-coding" | "kimi-for-coding-highspeed" | "kimi-k2.6" | "kimi-latest" | "lmstudio" | "mimo-v2-flash" | "mimo-v2.5" | "mimo-v2.5-pro" | "mimo-v2.5-pro-ultraspeed" | "minimax-m3" | "mistral-medium-latest" | "mlx-community/Qwen3.6-27B-OptiQ-4bit" | "openai/gpt-oss-120b" | (string & {}); -export type ToolName = "bash" | "bash_output" | "bash_kill" | "read_file" | "edit_file" | "write_file" | "webfetch" | "skill" | "codedb" | "todo_write" | "todo_read" | "eval" | "note_constraint" | "ask_user" | "attempt_completion" | "load_tool_schemas" | "clock_sleep" | "subagent" | "workflow" | "agent_output" | "learn_candidate" | "peer_message" | "imagegen"; +export type ToolName = "bash" | "bash_output" | "bash_kill" | "read_file" | "edit_file" | "write_file" | "webfetch" | "skill" | "codedb" | "todo_write" | "todo_read" | "eval" | "note_constraint" | "ask_user" | "attempt_completion" | "load_tool_schemas" | "clock_sleep" | "subagent" | "workflow" | "agent_output" | "learn_candidate" | "peer_message" | "imagegen" | "view_image"; export type ProviderId = "anthropic" | "codegraff" | "deepseek" | "openai" | "minimax" | "xiaomi" | "kilo" | "groq" | "mistral" | "kimi" | "moonshot" | "xai" | "zai" | "fugu" | "fireworks" | "mlx" | "lmstudio" | "codex"; /** Events streamed by `harness --json` (one JSON object per stdout line), each @@ -661,4 +661,4 @@ export async function* runAgent(opts: RunAgentOptions): AsyncGenerator { } export const MODELS: ModelName[] = ["MiniMax-M2.5", "MiniMax-M2.7", "MiniMax-M3", "accounts/fireworks/models/deepseek-v4-flash", "accounts/fireworks/models/deepseek-v4-pro", "accounts/fireworks/models/glm-5p2", "accounts/fireworks/models/gpt-oss-120b", "accounts/fireworks/models/kimi-k2p6", "accounts/fireworks/models/kimi-k2p7-code", "accounts/fireworks/models/minimax-m3", "accounts/fireworks/models/qwen3p7-plus", "claude-fable-5", "claude-haiku-4-5", "claude-opus-4-5", "claude-opus-4-6", "claude-opus-4-7", "claude-opus-4-8", "claude-opus-4.8", "claude-opus-5", "claude-sonnet-4-5", "claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-5", "deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash", "deepseek-v4-pro", "fugu", "fugu-ultra", "fugu-ultra-20260615", "glm-4.5", "glm-4.7", "glm-5", "glm-5.2", "gpt-5-codex", "gpt-5.2", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-pro", "gpt-5.5", "gpt-5.6", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "grok-4.3", "grok-build", "k3", "kilo-auto/small", "kimi-for-coding", "kimi-for-coding-highspeed", "kimi-k2.6", "kimi-latest", "lmstudio", "mimo-v2-flash", "mimo-v2.5", "mimo-v2.5-pro", "mimo-v2.5-pro-ultraspeed", "minimax-m3", "mistral-medium-latest", "mlx-community/Qwen3.6-27B-OptiQ-4bit", "openai/gpt-oss-120b"]; -export const TOOLS: ToolName[] = ["bash", "bash_output", "bash_kill", "read_file", "edit_file", "write_file", "webfetch", "skill", "codedb", "todo_write", "todo_read", "eval", "note_constraint", "ask_user", "attempt_completion", "load_tool_schemas", "clock_sleep", "subagent", "workflow", "agent_output", "learn_candidate", "peer_message", "imagegen"]; +export const TOOLS: ToolName[] = ["bash", "bash_output", "bash_kill", "read_file", "edit_file", "write_file", "webfetch", "skill", "codedb", "todo_write", "todo_read", "eval", "note_constraint", "ask_user", "attempt_completion", "load_tool_schemas", "clock_sleep", "subagent", "workflow", "agent_output", "learn_candidate", "peer_message", "imagegen", "view_image"]; diff --git a/sdk/ts/remote.ts b/sdk/ts/remote.ts index d7026999..b3694e55 100644 --- a/sdk/ts/remote.ts +++ b/sdk/ts/remote.ts @@ -10,7 +10,7 @@ export const HARNESS_VERSION = "0.12"; export type ModelName = "MiniMax-M2.5" | "MiniMax-M2.7" | "MiniMax-M3" | "accounts/fireworks/models/deepseek-v4-flash" | "accounts/fireworks/models/deepseek-v4-pro" | "accounts/fireworks/models/glm-5p2" | "accounts/fireworks/models/gpt-oss-120b" | "accounts/fireworks/models/kimi-k2p6" | "accounts/fireworks/models/kimi-k2p7-code" | "accounts/fireworks/models/minimax-m3" | "accounts/fireworks/models/qwen3p7-plus" | "claude-fable-5" | "claude-haiku-4-5" | "claude-opus-4-5" | "claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8" | "claude-opus-4.8" | "claude-opus-5" | "claude-sonnet-4-5" | "claude-sonnet-4-6" | "claude-sonnet-4.6" | "claude-sonnet-5" | "deepseek-chat" | "deepseek-reasoner" | "deepseek-v4-flash" | "deepseek-v4-pro" | "fugu" | "fugu-ultra" | "fugu-ultra-20260615" | "glm-4.5" | "glm-4.7" | "glm-5" | "glm-5.2" | "gpt-5-codex" | "gpt-5.2" | "gpt-5.3-codex-spark" | "gpt-5.4" | "gpt-5.4-mini" | "gpt-5.4-pro" | "gpt-5.5" | "gpt-5.6" | "gpt-5.6-luna" | "gpt-5.6-sol" | "gpt-5.6-terra" | "grok-4.3" | "grok-build" | "k3" | "kilo-auto/small" | "kimi-for-coding" | "kimi-for-coding-highspeed" | "kimi-k2.6" | "kimi-latest" | "lmstudio" | "mimo-v2-flash" | "mimo-v2.5" | "mimo-v2.5-pro" | "mimo-v2.5-pro-ultraspeed" | "minimax-m3" | "mistral-medium-latest" | "mlx-community/Qwen3.6-27B-OptiQ-4bit" | "openai/gpt-oss-120b" | (string & {}); -export type ToolName = "bash" | "bash_output" | "bash_kill" | "read_file" | "edit_file" | "write_file" | "webfetch" | "skill" | "codedb" | "todo_write" | "todo_read" | "eval" | "note_constraint" | "ask_user" | "attempt_completion" | "load_tool_schemas" | "clock_sleep" | "subagent" | "workflow" | "agent_output" | "learn_candidate" | "peer_message" | "imagegen"; +export type ToolName = "bash" | "bash_output" | "bash_kill" | "read_file" | "edit_file" | "write_file" | "webfetch" | "skill" | "codedb" | "todo_write" | "todo_read" | "eval" | "note_constraint" | "ask_user" | "attempt_completion" | "load_tool_schemas" | "clock_sleep" | "subagent" | "workflow" | "agent_output" | "learn_candidate" | "peer_message" | "imagegen" | "view_image"; export type ProviderId = "anthropic" | "codegraff" | "deepseek" | "openai" | "minimax" | "xiaomi" | "kilo" | "groq" | "mistral" | "kimi" | "moonshot" | "xai" | "zai" | "fugu" | "fireworks" | "mlx" | "lmstudio" | "codex"; /** Events streamed by the bridge (same `--json` contract as the stdio SDK). @@ -508,4 +508,4 @@ export async function* runAgentRemote(opts: RunAgentRemoteOptions): AsyncGenerat } export const MODELS: ModelName[] = ["MiniMax-M2.5", "MiniMax-M2.7", "MiniMax-M3", "accounts/fireworks/models/deepseek-v4-flash", "accounts/fireworks/models/deepseek-v4-pro", "accounts/fireworks/models/glm-5p2", "accounts/fireworks/models/gpt-oss-120b", "accounts/fireworks/models/kimi-k2p6", "accounts/fireworks/models/kimi-k2p7-code", "accounts/fireworks/models/minimax-m3", "accounts/fireworks/models/qwen3p7-plus", "claude-fable-5", "claude-haiku-4-5", "claude-opus-4-5", "claude-opus-4-6", "claude-opus-4-7", "claude-opus-4-8", "claude-opus-4.8", "claude-opus-5", "claude-sonnet-4-5", "claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-5", "deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash", "deepseek-v4-pro", "fugu", "fugu-ultra", "fugu-ultra-20260615", "glm-4.5", "glm-4.7", "glm-5", "glm-5.2", "gpt-5-codex", "gpt-5.2", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-pro", "gpt-5.5", "gpt-5.6", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "grok-4.3", "grok-build", "k3", "kilo-auto/small", "kimi-for-coding", "kimi-for-coding-highspeed", "kimi-k2.6", "kimi-latest", "lmstudio", "mimo-v2-flash", "mimo-v2.5", "mimo-v2.5-pro", "mimo-v2.5-pro-ultraspeed", "minimax-m3", "mistral-medium-latest", "mlx-community/Qwen3.6-27B-OptiQ-4bit", "openai/gpt-oss-120b"]; -export const TOOLS: ToolName[] = ["bash", "bash_output", "bash_kill", "read_file", "edit_file", "write_file", "webfetch", "skill", "codedb", "todo_write", "todo_read", "eval", "note_constraint", "ask_user", "attempt_completion", "load_tool_schemas", "clock_sleep", "subagent", "workflow", "agent_output", "learn_candidate", "peer_message", "imagegen"]; +export const TOOLS: ToolName[] = ["bash", "bash_output", "bash_kill", "read_file", "edit_file", "write_file", "webfetch", "skill", "codedb", "todo_write", "todo_read", "eval", "note_constraint", "ask_user", "attempt_completion", "load_tool_schemas", "clock_sleep", "subagent", "workflow", "agent_output", "learn_candidate", "peer_message", "imagegen", "view_image"]; diff --git a/src/agent_request_policy.zig b/src/agent_request_policy.zig index aebe3330..42158230 100644 --- a/src/agent_request_policy.zig +++ b/src/agent_request_policy.zig @@ -92,6 +92,9 @@ pub fn refreshLoginKeyBeforeSend(self: *Agent) void { // Only adopt on a real CHANGE: an unchanged credential must not churn the // session arena, nor hand the WS transport latch back on every request. if (std.mem.eql(u8, fresh.key, self.provider.api_key)) return; + // #404: a failed persist left the live token only in memory. Do not + // replace it with an older on-disk credential. + if (oauth.persistFailed() and !std.mem.eql(u8, fresh.key, self.provider.api_key)) return; adoptFreshAuth(self, fresh); } diff --git a/src/agent_tools.zig b/src/agent_tools.zig index d4d95816..ccf29c22 100644 --- a/src/agent_tools.zig +++ b/src/agent_tools.zig @@ -135,6 +135,7 @@ pub fn runTools(self: *Agent, calls: []const ToolCall) ![]ExecResult { .subagent_cross_provider = self.subagent_cross_provider, .registry = if (self.sub) null else self.registry, .from_sub = self.sub, + .out = self.out, .has_eval = self.eval_cmd != null, .approvals = self.approvals, .tracer = self.tracer, diff --git a/src/credential_store.zig b/src/credential_store.zig index 2a6b2c65..1e390aa1 100644 --- a/src/credential_store.zig +++ b/src/credential_store.zig @@ -31,7 +31,9 @@ pub const private_dir: Io.File.Permissions = if (Io.File.Permissions.has_executa /// `private_file` for anything holding a secret; pass `.default_file` to keep /// the ordinary umask-governed behaviour of a plain `createFile`. pub fn replaceFile(io: Io, dir: Io.Dir, sub_path: []const u8, bytes: []const u8, permissions: Io.File.Permissions) !void { - var atomic = try dir.createFileAtomic(io, sub_path, .{ .permissions = permissions, .replace = true }); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dest = if (dir.readLink(io, sub_path, &path_buf)) |n| path_buf[0..n] else |_| sub_path; + var atomic = try dir.createFileAtomic(io, dest, .{ .permissions = permissions, .replace = true }); defer atomic.deinit(io); if (Io.File.Permissions.has_executable_bit) { if (permissions != .default_file) { @@ -196,3 +198,22 @@ test "writeOAuth: the credential file is 0600 inside 0700 directories" { try std.testing.expectEqualStrings("refresh-1", parsed.object.get("refresh_token").?.string); try std.testing.expectEqual(@as(i64, 1234), parsed.object.get("expires_at").?.integer); } + +test "#405: replaceFile writes through a symlink instead of replacing it" { + if (@import("builtin").os.tag == .windows) return; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var real_buf: [std.fs.max_path_bytes]u8 = undefined; + const base = real_buf[0..try tmp.dir.realPath(io, &real_buf)]; + const target = try std.fmt.allocPrint(std.testing.allocator, "{s}/real.json", .{base}); + defer std.testing.allocator.free(target); + const link = try std.fmt.allocPrint(std.testing.allocator, "{s}/settings.json", .{base}); + defer std.testing.allocator.free(link); + try Io.Dir.cwd().writeFile(io, .{ .sub_path = target, .data = "old\n" }); + try Io.Dir.cwd().symLink(io, target, link, .{}); + try replaceFile(io, Io.Dir.cwd(), link, "new\n", .default_file); + const through = try Io.Dir.cwd().readFileAlloc(io, target, std.testing.allocator, .limited(64)); + defer std.testing.allocator.free(through); + try std.testing.expectEqualStrings("new\n", through); +} diff --git a/src/exec.zig b/src/exec.zig index 506cedb6..cbfa6478 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -54,6 +54,7 @@ const noSymlinkEscape = approvals_mod.noSymlinkEscape; const jobs = @import("jobs.zig"); const runCapped = jobs.runCapped; const runCappedWithOptions = jobs.runCappedWithOptions; +const exec_bash_stream = @import("exec_bash_stream.zig"); const toolRunOptions = jobs.toolRunOptions; // #266/#198: own the child's process group const spawnJob = jobs.spawnJob; const jobOutput = jobs.jobOutput; @@ -76,7 +77,8 @@ const no_local_tools = @import("no_local_tools.zig"); // #330: the hard --no-loc const native_fold = @import("native_fold.zig"); // folded native power tools: layer-2 refusal until load_tool_schemas unfolds const vision = @import("vision.zig"); // read_file stages images like MCP image results (#249) const input_util = @import("input_util.zig"); -const imagegen = @import("imagegen.zig"); // #352: the codex-gated image tool (advertising lives in schema.zig/tool_gates.zig) +const imagegen = @import("imagegen.zig"); +const view_image = @import("view_image.zig"); /// Wall-clock ceiling for one *subagent* bash command. Subagents run on pool /// threads with no TTY, so there is no Esc to kill a runaway command — without @@ -325,13 +327,11 @@ fn execToolInner(ctx: ToolCtx, call: ToolCall) !ToolOutput { } const sh = shellArgv(cmd); const deadline: u64 = if (ctx.from_sub) subagent_bash_deadline_ms else 0; - // #276 P0-1: a worktree-isolated agent's bash calls run pinned to its - // own worktree — via std.process.Child.Cwd, per spawn, never a - // process-wide chdir — so parallel siblings never share a cwd. - // #266/#198: toolRunOptions also gives the command its own process - // group, so an Esc cancel or the deadline kills what it spawned (ssh, - // xcodebuild) instead of leaving it running against a dead turn. - const run = try runCappedWithOptions(gpa, io, &sh, bash_stdout_cap, bash_stderr_cap, deadline, toolRunOptions(ctx.agent_cwd)); + // Isolated cwd + process group. + var opts = toolRunOptions(ctx.agent_cwd); + var stream = exec_bash_stream.Ctx{ .io = io, .w = ctx.out }; + exec_bash_stream.attach(&opts, !ctx.from_sub, &stream); + const run = try runCappedWithOptions(gpa, io, &sh, bash_stdout_cap, bash_stderr_cap, deadline, opts); defer gpa.free(run.stdout); defer gpa.free(run.stderr); @@ -545,9 +545,8 @@ fn execToolInner(ctx: ToolCtx, call: ToolCall) !ToolOutput { // Loads one SKILL.md body (or lists them). Rescans on every call, so a // skill written this session is loadable without a restart. if (std.mem.eql(u8, call.name, "skill")) return skill_docs.execSkill(gpa, io, input); - // #352: codex-gated. execImagegen answers a call that was never advertised - // (an unavailable session) with the same honest error it gives the model. if (std.mem.eql(u8, call.name, imagegen.tool_name)) return imagegen.execImagegen(ctx, input); + if (std.mem.eql(u8, call.name, view_image.name)) return view_image.exec(ctx, input); if (std.mem.eql(u8, call.name, "subagent")) return execSubagent(ctx, input); if (std.mem.eql(u8, call.name, "workflow")) return execWorkflow(ctx, input); if (std.mem.eql(u8, call.name, "agent_output")) { @@ -594,6 +593,7 @@ test "internal learning respects the parent privacy ceiling" { } test { // main.zig is at the 600-line cap; exec.zig is these modules' importer, so the compiled-in references live here (the reach check diffs the test binary, not which file holds the line) + _ = exec_bash_stream; _ = @import("codedbpro_report.zig"); _ = @import("tool_balance.zig"); } diff --git a/src/exec_bash_stream.zig b/src/exec_bash_stream.zig new file mode 100644 index 00000000..765dd785 --- /dev/null +++ b/src/exec_bash_stream.zig @@ -0,0 +1,60 @@ +//! Live foreground-bash output (#472). runCapped already polls pipes every +//! 200ms; this file is the UI half — write each new chunk so a long build +//! does not look like a hang. + +const std = @import("std"); +const Io = std.Io; + +const main_mod = @import("main.zig"); +const jobs = @import("jobs.zig"); + +pub const Ctx = struct { + io: Io, + w: ?*Io.Writer, +}; + +pub fn attach(opts: *jobs.CappedRunOptions, live: bool, stream: *Ctx) void { + if (!live) return; + opts.stream = &emit; + opts.stream_ctx = stream; +} + +pub fn emit(ctx: ?*anyopaque, which: u8, chunk: []const u8) void { + if (chunk.len == 0) return; + const c: *const Ctx = @ptrCast(@alignCast(ctx orelse return)); + if (main_mod.json_mode) { + const w = main_mod.g_out orelse return; + main_mod.g_gui_mu.lockUncancelable(c.io); + defer main_mod.g_gui_mu.unlock(c.io); + w.writeAll("{\"type\":\"tool_output\",\"name\":\"bash\",\"stream\":\"") catch return; + w.writeAll(if (which == 0) "stdout" else "stderr") catch return; + w.writeAll("\",\"text\":") catch return; + writeJsonString(w, chunk) catch return; + w.writeAll("}\n") catch return; + w.flush() catch {}; + return; + } + const w = c.w orelse main_mod.g_out orelse return; + w.writeAll(chunk) catch return; + w.flush() catch {}; +} + +fn writeJsonString(w: *Io.Writer, s: []const u8) !void { + try w.writeByte('"'); + for (s) |c| switch (c) { + '"' => try w.writeAll("\\\""), + '\\' => try w.writeAll("\\\\"), + '\n' => try w.writeAll("\\n"), + '\r' => try w.writeAll("\\r"), + '\t' => try w.writeAll("\\t"), + else => if (c < 0x20) try w.print("\\u{d:0>4}", .{c}) else try w.writeByte(c), + }; + try w.writeByte('"'); +} + +test "#472: writeJsonString escapes a newline" { + var buf: [64]u8 = undefined; + var w: Io.Writer = .fixed(&buf); + try writeJsonString(&w, "a\nb"); + try std.testing.expectEqualStrings("\"a\\nb\"", w.buffered()); +} diff --git a/src/imagegen_skill.zig b/src/imagegen_skill.zig index efa2a63a..b417ead2 100644 --- a/src/imagegen_skill.zig +++ b/src/imagegen_skill.zig @@ -26,6 +26,7 @@ //! edited their copy keeps their edits until Codex itself ships a new version. const std = @import("std"); +const oauth_helpers = @import("oauth_helpers.zig"); const builtin = @import("builtin"); const Io = std.Io; const Allocator = std.mem.Allocator; @@ -45,9 +46,7 @@ pub fn codexHomeDir(arena: Allocator, codex_home: ?[]const u8, home: ?[]const u8 if (codex_home) |value| { if (value.len > 0) return value; } - const h = home orelse return null; - if (h.len == 0) return null; - return std.fmt.allocPrint(arena, "{s}/.codex", .{h}) catch null; + return oauth_helpers.codexHomeDir(arena, home orelse ""); } /// `$CODEX_HOME/skills/.system/imagegen`. diff --git a/src/learn_credentials.zig b/src/learn_credentials.zig index 668fa2e2..f890cb51 100644 --- a/src/learn_credentials.zig +++ b/src/learn_credentials.zig @@ -83,10 +83,8 @@ pub fn passEnvFor(arena: Allocator, provider_id: []const u8) ![]const []const u8 } fn codexHome(io: Io, arena: Allocator, environ: *const std.process.Environ.Map, home: []const u8) ?[]const u8 { - const path = environ.get("CODEX_HOME") orelse blk: { - if (home.len == 0) return null; - break :blk std.fmt.allocPrint(arena, "{s}/.codex", .{home}) catch return null; - }; + if (environ.get("CODEX_HOME")) |v| oauth.initCodexHome(arena, v, home); + const path = oauth.codexHomeDir(arena, home) orelse return null; _ = oauth.loadCodexAuthFrom(io, arena, path) orelse return null; return path; } diff --git a/src/oauth.zig b/src/oauth.zig index 894e7479..48ec044b 100644 --- a/src/oauth.zig +++ b/src/oauth.zig @@ -67,6 +67,7 @@ pub const initCodexHome = helpers.initCodexHome; pub const initHome = credential_store.initHome; // #477: the kimi/xai half of the one-resolver pin pub const FreshKey = helpers.FreshKey; pub const takePersistError = helpers.takePersistError; +pub const persistFailed = helpers.persistFailed; // #148: how long before an OAuth access token's expiry to proactively refresh // it — wider than the old 60s so a mid-session refresh has headroom before a diff --git a/src/oauth_helpers.zig b/src/oauth_helpers.zig index bb3fd082..80e7b4b1 100644 --- a/src/oauth_helpers.zig +++ b/src/oauth_helpers.zig @@ -122,7 +122,16 @@ pub fn writeCodexAuthAt(io: Io, arena: Allocator, codex_home: []const u8, id_tok if (existing.get("tokens")) |t| if (t == .object) { tokens = t.object; }; - } + } else if (Io.Dir.cwd().statFile(io, path, .{})) |_| { + // File exists but was unreadable (torn write, EIO). Retry once, then + // refuse a destructive empty rewrite that would drop co-owned fields. + if (readCodexJson(io, arena, path)) |existing| { + object = existing; + if (existing.get("tokens")) |t| if (t == .object) { + tokens = t.object; + }; + } else return error.CodexAuthUnreadable; + } else |_| {} try tokens.put(arena, "id_token", .{ .string = id_token }); try tokens.put(arena, "access_token", .{ .string = access }); try tokens.put(arena, "refresh_token", .{ .string = refresh }); @@ -252,6 +261,10 @@ pub fn takePersistError() ?[]const u8 { return persist_error; } +pub fn persistFailed() bool { + return persist_error != null; +} + /// Persist a refreshed credential, retrying once. The failures that reach here /// (a concurrent writer's truncate, a transient EIO) are usually not sticky; /// a still-failing write is recorded for the caller to surface. @@ -544,3 +557,26 @@ pub fn openBrowser(io: Io, url: []const u8) void { var child = std.process.spawn(io, .{ .argv = argv, .stdin = .ignore, .stdout = .ignore, .stderr = .ignore }) catch return; _ = child.wait(io) catch {}; } + +test "#404: persistFailed stays set until takePersistError drains it" { + persist_error = "AccessDenied"; + try std.testing.expect(persistFailed()); + try std.testing.expectEqualStrings("AccessDenied", takePersistError().?); + try std.testing.expect(!persistFailed()); +} + +test "#404: writeCodexAuthAt refuses to clobber an unreadable existing auth.json" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var real_buf: [std.fs.max_path_bytes]u8 = undefined; + const base = real_buf[0..try tmp.dir.realPath(io, &real_buf)]; + const home = try std.fs.path.join(arena, &.{ base, "codex" }); + try Io.Dir.cwd().createDirPath(io, home); + const path = try codexAuthPath(arena, home); + try Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = "{not-json" }); + try std.testing.expectError(error.CodexAuthUnreadable, writeCodexAuthAt(io, arena, home, "id", "acc", "ref", "acct")); +} diff --git a/src/playbook.zig b/src/playbook.zig index 20c5aec1..373171dd 100644 --- a/src/playbook.zig +++ b/src/playbook.zig @@ -225,10 +225,13 @@ pub fn find(items: []const Item, id: []const u8) ?Item { /// of file so a whole line lands at once (serve_events.EventLog's shape). fn appendLine(io: Io, line: []const u8) bool { Io.Dir.cwd().createDirPath(io, dir) catch {}; - const f = Io.Dir.cwd().createFile(io, path, .{ .truncate = false }) catch return false; + const f = Io.Dir.cwd().createFile(io, path, .{ .truncate = false, .read = true }) catch return false; defer f.close(io); - const st = f.stat(io) catch return false; - f.writePositionalAll(io, line, st.size) catch return false; + const end: u64 = if (f.stat(io)) |st| st.size else |_| blk: { + const st = Io.Dir.cwd().statFile(io, path, .{}) catch return false; + break :blk st.size; + }; + f.writePositionalAll(io, line, end) catch return false; return true; } diff --git a/src/playbook_reflect.zig b/src/playbook_reflect.zig index 669e034f..3068164d 100644 --- a/src/playbook_reflect.zig +++ b/src/playbook_reflect.zig @@ -16,8 +16,8 @@ //! finished and there is a real outcome to distil. Not every turn, not every //! eval, not every subagent report. The call carries no tools (`request(null)` //! — the title-generation shape), so it cannot fan out, and it runs on the -//! WORKER seat (childProvider), inheriting the same routing every subagent -//! gets rather than reserving a model of its own. +//! ROOT seat (self.provider). A worker pin that is a different provider used +//! to refuse the call and look like a 0 ms success (#388). //! //! SCOPE CUT, stated: distilling from FAILED runs, and per-item fitness //! attribution (retiring bullets whose attributed score goes negative), are @@ -32,7 +32,6 @@ const Allocator = std.mem.Allocator; const Agent = @import("agent.zig").Agent; const playbook = @import("playbook.zig"); const playbook_glue = @import("playbook_glue.zig"); -const subagent_run = @import("subagent_run.zig"); const title = @import("title.zig"); const trace = @import("trace.zig"); const util = @import("util.zig"); @@ -118,7 +117,10 @@ fn askModel(self: *Agent, prompt: []const u8) ?[]const u8 { var arena_state = std.heap.ArenaAllocator.init(self.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); - const seat = subagent_run.childProvider(self.provider, self.subagent_provider, self.subagent_cross_provider); + // #388: reflect ON the root seat. A worker-tier pin that is a different + // provider (and is refused without --allow-cross-provider-subagents) used + // to fail before any HTTP call and look like a 0ms success on the wrong model. + const seat = self.provider; var agent: Agent = .{ .gpa = self.gpa, .arena = arena, @@ -137,7 +139,10 @@ fn askModel(self: *Agent, prompt: []const u8) ?[]const u8 { }; defer agent.tools_used.deinit(self.gpa); agent.messages.append(textMessage(arena, "user", prompt) catch return null) catch return null; - const root = agent.request(null) catch return null; + const root = agent.request(null) catch { + if (self.tracer) |tr| tr.note("playbook", "reflector call failed"); + return null; + }; return self.arena.dupe(u8, title.assistantText(seat.kind, root)) catch null; } @@ -173,3 +178,9 @@ pub fn afterEval(self: *Agent, note: []const u8, score: f64, output: []const u8) playbook_glue.refreshRoot(self, self.arena); self.say(" 📗 playbook: kept {d} learned insight(s) from this run\n", .{kept}) catch {}; } + +test "#388: reflector seats the root provider, not a worker pin" { + const src = @embedFile("playbook_reflect.zig"); + try std.testing.expect(std.mem.indexOf(u8, src, "const seat = self.provider;") != null); + try std.testing.expect(std.mem.indexOf(u8, src, "reflector call failed") != null); +} diff --git a/src/process_runner.zig b/src/process_runner.zig index f170d10e..a94fa08b 100644 --- a/src/process_runner.zig +++ b/src/process_runner.zig @@ -88,10 +88,16 @@ pub const CappedRun = struct { /// Supplying an explicit environment replaces (rather than augments) the /// parent environment. `kill_process_tree` creates an owned process group/job /// so descendants cannot outlive the orchestrated command. +/// Called with each newly-arrived stdout (0) or stderr (1) slice while the +/// child is still running. #472: foreground bash used to stay silent until exit. +pub const StreamFn = *const fn (ctx: ?*anyopaque, which: u8, chunk: []const u8) void; + pub const CappedRunOptions = struct { cwd: std.process.Child.Cwd = .inherit, environ_map: ?*const std.process.Environ.Map = null, kill_process_tree: bool = false, + stream: ?StreamFn = null, + stream_ctx: ?*anyopaque = null, }; /// #253: RLIMIT_NOFILE as this process actually observes it. main() calls @@ -208,21 +214,31 @@ pub fn runCappedWithOptions(gpa: Allocator, io: Io, argv: []const []const u8, st const caps = [2]usize{ stdout_cap, stderr_cap }; var saved: [2]?[]u8 = .{ null, null }; errdefer for (saved) |item| if (item) |bytes| gpa.free(bytes); + var streamed: [2]usize = .{ 0, 0 }; var esc_killed = false; var timed_out = false; const started: Io.Timestamp = .now(io, .awake); loop: while (true) { + var eof = false; multi_reader.fill(64, .{ .duration = .{ .raw = .fromMilliseconds(200), .clock = .awake } }) catch |err| switch (err) { - error.EndOfStream => break :loop, + error.EndOfStream => eof = true, error.Timeout => {}, else => |other| return other, }; - for (readers, caps, &saved) |reader, cap, *item| { + for (readers, caps, &saved, &streamed, [_]u8{ 0, 1 }) |reader, cap, *item, *seen, which| { const buffered = reader.buffered(); + if (options.stream) |fn_emit| { + if (buffered.len > seen.*) { + const end = if (item.* != null) @min(buffered.len, cap) else buffered.len; + if (end > seen.*) fn_emit(options.stream_ctx, which, buffered[seen.*..end]); + seen.* = end; + } + } if (item.* == null and buffered.len > cap) item.* = try gpa.dupe(u8, buffered[0..cap]); if (item.* != null) reader.toss(buffered.len); } + if (eof) break :loop; if (Agent.esc_cancel.load(.acquire)) { esc_killed = true; terminateTree(&child, io, &windows_job, group_id, options.kill_process_tree); @@ -338,3 +354,26 @@ test "#253: fdQuotaNote names the error and the observed limit" { const without = fdQuotaNote(&buf, "SystemFdQuotaExceeded", null); try std.testing.expect(std.mem.indexOf(u8, without, "unavailable") != null); } + +test "#472: runCapped streams stdout before the child exits" { + if (builtin.os.tag == .windows or builtin.os.tag == .wasi) return error.SkipZigTest; + const io = std.testing.io; + const gpa = std.testing.allocator; + const State = struct { + seen: bool = false, + fn emit(ctx: ?*anyopaque, which: u8, chunk: []const u8) void { + const self: *@This() = @ptrCast(@alignCast(ctx orelse return)); + if (which == 0 and std.mem.indexOf(u8, chunk, "STREAM_MARK") != null) self.seen = true; + } + }; + var state = State{}; + const r = try runCappedWithOptions(gpa, io, &.{ "/bin/sh", "-c", "printf STREAM_MARK; sleep 0.4; printf DONE" }, 1024, 1024, 2000, .{ + .stream = &State.emit, + .stream_ctx = &state, + }); + defer gpa.free(r.stdout); + defer gpa.free(r.stderr); + try std.testing.expect(state.seen); + try std.testing.expect(std.mem.indexOf(u8, r.stdout, "STREAM_MARK") != null); + try std.testing.expect(std.mem.indexOf(u8, r.stdout, "DONE") != null); +} diff --git a/src/schema.zig b/src/schema.zig index 9ad1b9ee..168cd376 100644 --- a/src/schema.zig +++ b/src/schema.zig @@ -17,7 +17,8 @@ const skill_docs = @import("skill_docs.zig"); // SKILL.md playbooks: the `skill` const peer_channel = @import("peer_channel.zig"); // #469: the peer_message tool's name/desc/schema live there const no_local_tools = @import("no_local_tools.zig"); // #330: the hard --no-local-tools gate (layer 1 lives here, layer 2 in exec.zig) const tool_gates = @import("tool_gates.zig"); // #352: the additive twin — optional tools that only exist when startup found their backing capability -const imagegen = @import("imagegen.zig"); // #352: name/desc/schema as plain strings, like skill_docs, so this catalog needs one entry and no import cycle +const imagegen = @import("imagegen.zig"); +const view_image = @import("view_image.zig"); const mcp_schema_gate = @import("mcp_schema_gate.zig"); // #416: which MCP tools are served schema-first vs description-only, and the `load_tool_schemas` strings const native_fold = @import("native_fold.zig"); // folded native power tools: same two-phase pattern for the harness's own catalog const render = @import("schema_render.zig"); // the comptime provider-tool renderers moved out when #352's optional-tool catalogs doubled the number held here (600-line ceiling) @@ -239,6 +240,7 @@ pub fn isMetaName(name: []const u8) bool { // several images is one subagent per image, each calling `imagegen` once. const optional_specs = [_]ToolSpec{ .{ .name = imagegen.tool_name, .desc = imagegen.tool_desc, .schema = imagegen.tool_schema }, + .{ .name = view_image.name, .desc = view_image.desc, .schema = view_image.schema }, }; // Comptime-rendered tool lists for subagents (base only, both formats). diff --git a/src/serde.zig b/src/serde.zig index 2af99a8e..8e230129 100644 --- a/src/serde.zig +++ b/src/serde.zig @@ -485,3 +485,8 @@ test "openai and responses tools give a typeless MCP root schema type object (#2 try std.testing.expectEqualStrings(composed, try renderOpenAITools(arena, composed)); try std.testing.expectEqualStrings("not json", try renderOpenAITools(arena, "not json")); } + +test "#108: a composer line that names @[path] is persisted in input history" { + try std.testing.expect(util.rememberInput("@[/tmp/shot.png] look at this")); + try std.testing.expect(util.rememberInput("go on")); +} diff --git a/src/serve_events.zig b/src/serve_events.zig index 2fb6cc40..3374d54d 100644 --- a/src/serve_events.zig +++ b/src/serve_events.zig @@ -55,8 +55,11 @@ pub const EventLog = struct { pub fn open(io: Io, dir: Io.Dir, path: []const u8) EventLog { if (std.fs.path.dirname(path)) |parent| dir.createDirPath(io, parent) catch {}; - const file = dir.createFile(io, path, .{ .truncate = false }) catch return .{ .io = io }; - const size: u64 = if (file.stat(io)) |st| st.size else |_| 0; + const file = dir.createFile(io, path, .{ .truncate = false, .read = true }) catch return .{ .io = io }; + const size: u64 = if (file.stat(io)) |st| st.size else |_| blk: { + const st = dir.statFile(io, path, .{}) catch break :blk 0; + break :blk st.size; + }; return .{ .io = io, .file = file, .offset = size }; } @@ -412,12 +415,25 @@ test "terminal_events covers every control ack mainloop can emit" { // fails; plus a floor, because the markers above could match a shorter // region and leave both counts trivially in agreement. 17 acks today, and // this only trips if acks are DELETED — adding one is fine. - try testing.expectEqual(typed, checked); try testing.expect(checked >= 17); + // checked == typed is true by construction of this scan; a handler that + // moved to its own module would still need the sibling check below. - // score is acked from its own module; answer/reattach never reach the - // streaming loop (serve.zig answers both before serveMessage sends). - const score_src = @embedFile("mainloop_score.zig"); - try testing.expect(std.mem.indexOf(u8, score_src, ".emit(.{ .type = \"score\"") != null); - try testing.expect(terminalEvent("{\"seq\":1,\"type\":\"score\"}")); + const score_src: []const u8 = @embedFile("mainloop_score.zig"); + var sib: []const u8 = score_src; + var sib_n: usize = 0; + while (std.mem.indexOf(u8, sib, typed_emit)) |at| { + const from = at + typed_emit.len; + const stop = from + (std.mem.indexOfScalar(u8, sib[from..], '"') orelse return error.ControlBlockMoved); + const name = sib[from..stop]; + sib = sib[stop..]; + var buf: [512]u8 = undefined; + const line = std.fmt.bufPrint(&buf, "{{\"seq\":1,\"type\":\"{s}\"}}", .{name}) catch return error.ControlAckNameTooLong; + if (!terminalEvent(line)) { + std.debug.print("\nsibling control ack \"{s}\" is missing from serve_events.terminal_events\n", .{name}); + return error.ControlAckNotTerminal; + } + sib_n += 1; + } + try testing.expect(sib_n >= 1); } diff --git a/src/session_run.zig b/src/session_run.zig index ef93f94d..055fbd90 100644 --- a/src/session_run.zig +++ b/src/session_run.zig @@ -64,6 +64,8 @@ const run_budget_mod = @import("run_budget.zig"); const learning_privacy = @import("learning_privacy.zig"); const commands_privacy = @import("commands_privacy.zig"); const prompts = @import("prompts.zig"); +const view_image = @import("view_image.zig"); +const vision = @import("vision.zig"); /// `graff repl`: interactive chat on the Grok-style TUI (same as `graff tui`). /// Piped/non-TTY stdin still drives the old scripted zigzag Model so CI @@ -305,6 +307,8 @@ pub fn buildRootAgent( const fresh_session_name = try std.fmt.allocPrint(arena, "session-{d}-{d}", .{ util.unixMs(io), proc_identity.selfPid() }); root.session_name = if (flags.resume_flag) |name| (if (!flags.new_session_flag and !flags.no_resume_flag) name else fresh_session_name) else fresh_session_name; try prompts.setRootSystemPrompts(&root, sys_normal, arena); // #381: same funnel + the live .graff/playbook.jsonl constraint block + // #249: advertise view_image only when this session's model can take pixels. + view_image.available = vision.visionCapable(default_provider); // Startup pays for one provider format, not all three. Other formats are // rendered on first switch with the same built-in + live MCP inputs. try root.ensureRootTools(default_provider.kind); diff --git a/src/session_start.zig b/src/session_start.zig index 7fc3f065..5491c377 100644 --- a/src/session_start.zig +++ b/src/session_start.zig @@ -51,6 +51,7 @@ const telemetry = @import("telemetry.zig"); const obs = @import("obs.zig"); const util = @import("util.zig"); const engine_sink = @import("engine_sink.zig"); // #429: startup's lines are typed events, not prints +const worktree_lease = @import("worktree_lease.zig"); const engine_events = @import("engine_events.zig"); const title_mod = @import("title.zig"); const fallback_config = @import("fallback_config.zig"); @@ -101,6 +102,7 @@ pub fn setupWorktreeAndBanner( engine_sink.enableColor(); } const sink = engine_sink.writerSink(out); + if (worktree_lease.preflight(gpa, io, arena, trace_path, util.unixMs(io))) |warn| sink.emit(io, .{ .session_notice = .{ .text = warn, .tone = .warn } }); // --worktree/-w: run this session in an isolated git worktree so parallel // agents don't collide on files. Creates .graff/worktrees/ on branch // worktree- (from HEAD) and enters it; reuses it if it already exists. diff --git a/src/snapshots.zig b/src/snapshots.zig index c9714213..c6a9a035 100644 --- a/src/snapshots.zig +++ b/src/snapshots.zig @@ -84,7 +84,10 @@ pub const Snapshots = struct { if (seen) continue; // earliest snapshot per path wins (= state before turn n) done.append(self.gpa, snap.path) catch {}; switch (snap.before) { - .content => |b| Io.Dir.cwd().writeFile(self.io, .{ .sub_path = snap.path, .data = b }) catch continue, + .content => |b| Io.Dir.cwd().writeFile(self.io, .{ .sub_path = snap.path, .data = b }) catch { + out.skipped += 1; + continue; + }, // A delete that FAILED left the file exactly as the rewound // turns wrote it, so it must not be counted as restored. // FileNotFound is the one exception: something else already diff --git a/src/snapshots_tests.zig b/src/snapshots_tests.zig index 810434b3..871ff348 100644 --- a/src/snapshots_tests.zig +++ b/src/snapshots_tests.zig @@ -162,3 +162,16 @@ test "/rewind: a file already gone when the rewind runs still counts as restored try std.testing.expectEqual(@as(usize, 1), rw.restored); try std.testing.expectEqual(@as(usize, 0), rw.skipped); } + +test "#406: a failed content write-back is skipped, not silently dropped from the tally" { + const io = std.testing.io; + const gpa = std.testing.allocator; + var snaps = snapshots.Snapshots{ .gpa = gpa, .io = io }; + defer snaps.deinit(); + snaps.turn = 1; + // A path that cannot be written (empty name) fails writeFile. + snaps.record("", .{ .content = "x" }); + const rw = snaps.restore(1); + try std.testing.expectEqual(@as(usize, 0), rw.restored); + try std.testing.expectEqual(@as(usize, 1), rw.skipped); +} diff --git a/src/tool_gates.zig b/src/tool_gates.zig index bd03d281..cab5db89 100644 --- a/src/tool_gates.zig +++ b/src/tool_gates.zig @@ -22,6 +22,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const imagegen = @import("imagegen.zig"); +const view_image = @import("view_image.zig"); /// One optional built-in and the flag that decides whether it is advertised. pub const Gate = struct { @@ -35,6 +36,7 @@ pub const gates = [_]Gate{ // only engine that actually renders an image — the hosted `image_gen` // tool is server-side and never fires outside the Codex app). .{ .name = imagegen.tool_name, .flag = &imagegen.available }, + .{ .name = view_image.name, .flag = &view_image.available }, }; /// A name test only, independent of whether the tool is currently available. @@ -97,13 +99,19 @@ test { // the served catalogs' wire-compatibility guard (an unreferenced module' test "#352: an optional tool is advertised only while its flag is set; other names are unaffected" { const saved = imagegen.available; - defer imagegen.available = saved; + const saved_view = view_image.available; + defer { + imagegen.available = saved; + view_image.available = saved_view; + } try std.testing.expect(isOptional("imagegen")); + try std.testing.expect(isOptional("view_image")); try std.testing.expect(!isOptional("bash")); try std.testing.expect(!isOptional("skill")); imagegen.available = false; + view_image.available = false; try std.testing.expect(!advertised("imagegen")); try std.testing.expect(!anyAvailable()); try std.testing.expect(blocks("imagegen")); @@ -149,11 +157,16 @@ test "#352: neither the root nor the SUBAGENT catalog mentions imagegen until it const arena = arena_state.allocator(); const saved = imagegen.available; - defer imagegen.available = saved; + const saved_view = view_image.available; + defer { + imagegen.available = saved; + view_image.available = saved_view; + } const kinds = [_]@import("provider.zig").Provider.Kind{ .anthropic, .openai, .responses }; imagegen.available = false; + view_image.available = false; const closed = try schema.effectiveRootSpecs(arena); for (closed) |spec| try std.testing.expect(!std.mem.eql(u8, spec.name, "imagegen")); // The subagent fan-out is the documented way to make several images, so a diff --git a/src/tool_handle.zig b/src/tool_handle.zig index 39919f9c..54180b84 100644 --- a/src/tool_handle.zig +++ b/src/tool_handle.zig @@ -200,6 +200,25 @@ fn refund(len: usize) ?[]const u8 { /// line count of anything else. A payload that merely starts with `{` is not /// called JSON — `jsonShape` scans it to `end_of_document` first — so the hint /// never sends the model looking for a key that is not there. +/// #452: drop handle files whose owning session is gone. Same trigger as +/// #409's session sweep. Files whose name starts with the live run id stay. +pub fn sweepOrphans(io: Io, dir: Io.Dir, arena: Allocator, current: []const u8) void { + var it_dir = dir.openDir(io, handles_dir, .{ .iterate = true }) catch return; + defer it_dir.close(io); + var it = it_dir.iterate(); + while (it.next(io) catch null) |entry| { + if (entry.kind == .directory) continue; + const name = entry.name; + if (current.len > 0 and std.mem.startsWith(u8, name, current)) continue; + const owner_end = std.mem.indexOfScalar(u8, name, '-') orelse continue; + const owner = name[0..owner_end]; + const session_file = std.fmt.allocPrint(arena, "{s}/{s}{s}", .{ @import("session_index.zig").sessions_dir, owner, @import("session_index.zig").session_ext }) catch continue; + if (dir.statFile(io, session_file, .{})) |_| continue else |_| {} + const rel = std.fmt.allocPrint(arena, "{s}/{s}", .{ handles_dir, name }) catch continue; + dir.deleteFile(io, rel) catch {}; + } +} + pub fn shapeHint(gpa: Allocator, arena: Allocator, text: []const u8) []const u8 { if (jsonShape(gpa, arena, text)) |hint| return hint; return std.fmt.allocPrint(arena, "{d} lines", .{lineCount(text)}) catch "shape unknown"; @@ -465,3 +484,25 @@ test "#440: the run byte budget bounds the handle dir, and over it the result is try std.testing.expect(second.text.len <= 1024); try std.testing.expect(tmp.dir.statFile(io, handles_dir ++ "/run-1.txt", .{}) == error.FileNotFound); } + +test "#452: sweepOrphans drops handles whose session file is gone" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + try tmp.dir.createDirPath(io, handles_dir); + try tmp.dir.createDirPath(io, @import("session_index.zig").sessions_dir); + try tmp.dir.writeFile(io, .{ .sub_path = handles_dir ++ "/dead-0.txt", .data = "gone" }); + try tmp.dir.writeFile(io, .{ .sub_path = handles_dir ++ "/kept-0.txt", .data = "keep" }); + try tmp.dir.writeFile(io, .{ .sub_path = handles_dir ++ "/now-0.txt", .data = "live" }); + try tmp.dir.writeFile(io, .{ + .sub_path = @import("session_index.zig").sessions_dir ++ "/kept" ++ @import("session_index.zig").session_ext, + .data = "{}", + }); + sweepOrphans(io, tmp.dir, a, "now"); + try std.testing.expect(tmp.dir.statFile(io, handles_dir ++ "/dead-0.txt", .{}) == error.FileNotFound); + _ = try tmp.dir.statFile(io, handles_dir ++ "/kept-0.txt", .{}); + _ = try tmp.dir.statFile(io, handles_dir ++ "/now-0.txt", .{}); +} diff --git a/src/tool_spill.zig b/src/tool_spill.zig index 692b1c46..fb1c0c41 100644 --- a/src/tool_spill.zig +++ b/src/tool_spill.zig @@ -201,6 +201,7 @@ const Leftover = struct { name: []const u8, owner: []const u8, is_dir: bool }; /// the session, and the next run collects what the rename left behind. pub fn sweepSessionsOnce(io: Io, dir: Io.Dir, arena: Allocator, current: []const u8) void { sweepOnce(.{ .io = io, .dir = dir, .base_abs = "" }, arena, current); + @import("tool_handle.zig").sweepOrphans(io, dir, arena, current); } fn sweepOnce(sink: Sink, arena: Allocator, current: []const u8) void { diff --git a/src/tools.zig b/src/tools.zig index 20593d30..dd82c438 100644 --- a/src/tools.zig +++ b/src/tools.zig @@ -102,6 +102,7 @@ pub const ToolCtx = struct { subagent_cross_provider: bool = false, registry: ?*mcp.Registry, from_sub: bool, + out: ?*Io.Writer = null, // live pane / stdout; foreground bash streams here (#472) has_eval: bool = false, // the root's --eval loop: escalation's strongest verifier approvals: ?*Approvals, tracer: ?*Tracer, diff --git a/src/view_image.zig b/src/view_image.zig new file mode 100644 index 00000000..e65e08dd --- /dev/null +++ b/src/view_image.zig @@ -0,0 +1,65 @@ +//! #249 leftover: a first-class `view_image` tool. read_file already stages +//! pixels as a side effect; this names the intent so a model does not have +//! to infer vision from a path. + +const std = @import("std"); +const Io = std.Io; + +const tools = @import("tools.zig"); +const vision = @import("vision.zig"); + +pub const name = "view_image"; +pub var available = false; // set at boot when the session model is a VLM +pub const desc = "Attach a local png/jpg/gif/webp as visual input on the next model turn."; +pub const schema = + \\{"type": "object", "properties": {"path": {"type": "string", "description": "Filesystem path of the image"}}, "required": ["path"]} +; + +pub fn exec(ctx: tools.ToolCtx, input: std.json.Value) !tools.ToolOutput { + const gpa = ctx.gpa; + const io = ctx.io; + const path = tools.strField(input, "path") orelse return tools.missingArg(gpa, "path"); + if (!@import("approvals.zig").confinedPath(path)) return tools.outsideCwd(gpa, path); + const st = Io.Dir.cwd().statFile(io, path, .{}) catch return .{ + .text = try std.fmt.allocPrint(gpa, "view_image: cannot read {s}", .{path}), + .is_error = true, + }; + if (!vision.visionCapable(ctx.provider)) return .{ + .text = try std.fmt.allocPrint(gpa, "view_image: the active model does not accept images ({s}, {d} bytes)", .{ vision.imageMediaType(path), st.size }), + .is_error = true, + }; + const reg = ctx.registry orelse return .{ + .text = try gpa.dupe(u8, "view_image: no session registry to attach to"), + .is_error = true, + }; + const data = Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(5 * 1024 * 1024)) catch return .{ + .text = try std.fmt.allocPrint(gpa, "view_image: failed to read {s}", .{path}), + .is_error = true, + }; + defer gpa.free(data); + const enc = std.base64.standard.Encoder; + const b64 = try gpa.alloc(u8, enc.calcSize(data.len)); + defer gpa.free(b64); + _ = enc.encode(b64, data); + reg.mutex.lockUncancelable(reg.io); + defer reg.mutex.unlock(reg.io); + if (reg.pending_image != null) return .{ + .text = try gpa.dupe(u8, "view_image: another image is already queued for the next turn"), + .is_error = true, + }; + const arena = reg.arena(); + const media = vision.imageMediaType(path); + reg.pending_image = .{ + .media_type = try arena.dupe(u8, media), + .b64 = try arena.dupe(u8, b64), + .label = try arena.dupe(u8, path), + }; + return .{ + .text = try std.fmt.allocPrint(gpa, "[image: {s}, {d} bytes — attached to your next turn]", .{ media, data.len }), + }; +} + +test "#249: view_image name is advertised as a first-class tool" { + try std.testing.expectEqualStrings("view_image", name); + try std.testing.expect(std.mem.indexOf(u8, schema, "path") != null); +} diff --git a/src/worktree_lease.zig b/src/worktree_lease.zig index 8cc54d5c..0af99737 100644 --- a/src/worktree_lease.zig +++ b/src/worktree_lease.zig @@ -186,6 +186,58 @@ pub fn currentIdentity(gpa: Allocator, io: Io, arena: Allocator) Identity { return canonicalIdentity(git_dir, common, cwd); } +const lease_rel = ".graff/owner.json"; + +pub fn loadOwner(io: Io, arena: Allocator) ?Owner { + const body = Io.Dir.cwd().readFileAlloc(io, lease_rel, arena, .limited(4096)) catch return null; + const v = std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{ .allocate = .alloc_always }) catch return null; + if (v != .object) return null; + const o = v.object; + const pid = o.get("pid") orelse return null; + if (pid != .integer) return null; + return .{ + .pid = @intCast(pid.integer), + .start_id = if (o.get("start_id")) |s| (if (s == .integer) @intCast(s.integer) else 0) else 0, + .session_id = if (o.get("session_id")) |s| (if (s == .string) s.string else "") else "", + .identity = if (o.get("identity")) |s| (if (s == .string) s.string else "") else "", + .goal = if (o.get("goal")) |s| (if (s == .string) s.string else "") else "", + .last_seen_ms = if (o.get("last_seen_ms")) |s| (if (s == .integer) s.integer else 0) else 0, + }; +} + +pub fn saveOwner(io: Io, arena: Allocator, rec: Owner) void { + Io.Dir.cwd().createDirPath(io, ".graff") catch {}; + var aw: Io.Writer.Allocating = .init(arena); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + s.write(.{ + .pid = rec.pid, + .start_id = rec.start_id, + .session_id = rec.session_id, + .identity = rec.identity, + .goal = rec.goal, + .last_seen_ms = rec.last_seen_ms, + }) catch return; + Io.Dir.cwd().writeFile(io, .{ .sub_path = lease_rel, .data = aw.writer.buffered(), .flags = .{ .read = true } }) catch {}; +} + +pub fn preflight(gpa: Allocator, io: Io, arena: Allocator, session_id: []const u8, now_ms: i64) ?[]const u8 { + const id = currentIdentity(gpa, io, arena); + if (id.id.len == 0) return null; + const me = selfOwner(io, id.id, session_id, now_ms); + if (loadOwner(io, arena)) |rec| { + const live = proc_identity.probe(io, rec.pid); + switch (ownerVerdict(rec, id.id, me.pid, live)) { + .live_foreign, .live_unverified => { + saveOwner(io, arena, me); + return duplicateOwnerWarning(arena, rec, now_ms - rec.last_seen_ms); + }, + else => {}, + } + } + saveOwner(io, arena, me); + return null; +} + pub fn identityLine(arena: Allocator, id: Identity) []const u8 { const kind = switch (id.kind) { .main_checkout => "main checkout", @@ -305,3 +357,20 @@ test "identityLine: says which kind of checkout, and stays honest when unknown" try std.testing.expect(std.mem.indexOf(u8, identityLine(a, .{ .id = "/repo/.git/worktrees/w", .kind = .linked_worktree }), "linked worktree") != null); try std.testing.expect(std.mem.indexOf(u8, identityLine(a, .{}), "unknown") != null); } + +test "#320: preflight records this process and is silent when we are the owner" { + const io = std.testing.io; + const gpa = std.testing.allocator; + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var orig = try Io.Dir.cwd().openDir(io, ".", .{}); + defer orig.close(io); + defer _ = std.posix.system.fchdir(orig.handle); + if (std.posix.system.fchdir(tmp.dir.handle) != 0) return error.ChdirFailed; + try std.testing.expect(preflight(gpa, io, arena, "sess-a", 1_000) == null); + try std.testing.expect(preflight(gpa, io, arena, "sess-a", 2_000) == null); + try std.testing.expect(loadOwner(io, arena) != null); +}