Skip to content
Merged
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
37 changes: 22 additions & 15 deletions bridge/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ fn takeLine(r: *Io.Reader, arena: std.mem.Allocator) ![]u8 {
fn connectionPath(arena: std.mem.Allocator, env: *const std.process.Environ.Map, profile: ?[]const u8) ![]const u8 {
const tail = [_][]const u8{ "plugins", "agent", "connection.zon" };
if (profile) |p| return std.fs.path.join(arena, &.{ p, tail[0], tail[1], tail[2] });
const os = @import("builtin").os.tag;
const os = @import("builtin").target.os.tag;
const base: []const u8 = switch (os) {
.macos => try std.fs.path.join(arena, &.{ env.get("HOME") orelse return error.NoHome, "Library", "Application Support" }),
.windows => env.get("LOCALAPPDATA") orelse return error.NoLocalAppData,
Expand Down Expand Up @@ -365,7 +365,8 @@ pub const Bridge = struct {
const Connection = struct { socket: []const u8, token: []const u8 };
const source = Io.Dir.cwd().readFileAllocOptions(self.io, self.connection_path, arena, .limited(64 * 1024), .of(u8), 0) catch
return error.FizzyNotRunning;
const c = std.zon.parse.fromSliceAlloc(Connection, arena, source, null, .{ .ignore_unknown_fields = true }) catch
var diag: std.zon.parse.Diagnostics = undefined;
const c = std.zon.parse.fromSlice(Connection, .{ .gpa = arena, .arena = arena, .source = source, .diagnostics = &diag, .ignore_unknown_fields = true }) catch
return error.BadConnectionFile;
const addr = try Io.net.UnixAddress.init(c.socket);
const stream = addr.connect(self.io) catch return error.FizzyNotRunning;
Expand Down Expand Up @@ -421,16 +422,18 @@ pub const Bridge = struct {
if (!self.probe(arena)) {
if (!self.reconnect(arena)) return null;
}
const source = arena.dupeZ(u8, self.hello orelse return null) catch return null;
const source = arena.dupeSentinel(u8, self.hello orelse return null, 0) catch return null;
const Hello = struct { executable: []const u8 = "" };
const h = std.zon.parse.fromSliceAlloc(Hello, arena, source, null, .{ .ignore_unknown_fields = true }) catch return null;
var diag: std.zon.parse.Diagnostics = undefined;
const h = std.zon.parse.fromSlice(Hello, .{ .gpa = arena, .arena = arena, .source = source, .diagnostics = &diag, .ignore_unknown_fields = true }) catch return null;
return if (h.executable.len > 0) h.executable else null;
}

/// The token `connection.zon` holds now: a new one is a new fizzy.
pub fn connectionToken(self: *Bridge, arena: std.mem.Allocator) ?[]const u8 {
const source = Io.Dir.cwd().readFileAllocOptions(self.io, self.connection_path, arena, .limited(64 * 1024), .of(u8), 0) catch return null;
const c = std.zon.parse.fromSliceAlloc(struct { token: []const u8 }, arena, source, null, .{ .ignore_unknown_fields = true }) catch return null;
var diag: std.zon.parse.Diagnostics = undefined;
const c = std.zon.parse.fromSlice(struct { token: []const u8 }, .{ .gpa = arena, .arena = arena, .source = source, .diagnostics = &diag, .ignore_unknown_fields = true }) catch return null;
return c.token;
}

Expand All @@ -449,8 +452,9 @@ pub const Bridge = struct {
try w.writeByte('\n');
try w.flush();
const line = try takeLine(&link.reader.interface, arena);
const source = try arena.dupeZ(u8, std.mem.trimEnd(u8, line, "\r"));
const r = try std.zon.parse.fromSliceAlloc(Wire.Response, arena, source, null, .{ .ignore_unknown_fields = true });
const source = try arena.dupeSentinel(u8, std.mem.trimEnd(u8, line, "\r"), 0);
var diag: std.zon.parse.Diagnostics = undefined;
const r = try std.zon.parse.fromSlice(Wire.Response, .{ .gpa = arena, .arena = arena, .source = source, .diagnostics = &diag, .ignore_unknown_fields = true });
if (r.err) |e| return .{ .err = e };
return .{ .ok = r.ok orelse "" };
}
Expand Down Expand Up @@ -590,7 +594,7 @@ pub const Bridge = struct {
const b64 = try arena.alloc(u8, std.base64.standard.Encoder.calcSize(png.len));
_ = std.base64.standard.Encoder.encode(b64, png);
const note = try std.fmt.allocPrint(arena, "{s}\n{d}x{d} (from {d}x{d}); fizzy {d} ms, here {d} ms, {d} KiB", .{
path, size.w, size.h, cw, ch,
path, size.w, size.h, cw, ch,
(t1 - t0) / std.time.ns_per_ms, (t2 - t1) / std.time.ns_per_ms, png.len / 1024,
});
return reply(out, id, .{ .result = .{
Expand All @@ -616,7 +620,8 @@ pub const Bridge = struct {
rect: struct { x: f64 = 0, y: f64 = 0, w: f64 = 0, h: f64 = 0 } = .{},
window: ?u32 = null,
};
const nodes = try std.zon.parse.fromSliceAlloc([]const Node, arena, try arena.dupeZ(u8, text), null, .{ .ignore_unknown_fields = true });
var diag: std.zon.parse.Diagnostics = undefined;
const nodes = try std.zon.parse.fromSlice([]const Node, .{ .gpa = arena, .arena = arena, .source = try arena.dupeSentinel(u8, text, 0), .diagnostics = &diag, .ignore_unknown_fields = true });
const node = for (nodes) |n| {
if (std.mem.eql(u8, n.tag, tag)) break n;
} else return error.NoSuchTag;
Expand Down Expand Up @@ -710,8 +715,9 @@ pub const Bridge = struct {
.ok => |s| s,
.err => return &.{},
};
const source = arena.dupeZ(u8, list) catch return &.{};
const commands = std.zon.parse.fromSliceAlloc([]const Listed, arena, source, null, .{ .ignore_unknown_fields = true }) catch return &.{};
const source = arena.dupeSentinel(u8, list, 0) catch return &.{};
var diag: std.zon.parse.Diagnostics = undefined;
const commands = std.zon.parse.fromSlice([]const Listed, .{ .gpa = arena, .arena = arena, .source = source, .diagnostics = &diag, .ignore_unknown_fields = true }) catch return &.{};
for (commands) |c| {
if (!eql(c.id, id)) continue;
var keys: std.ArrayListUnmanaged([]const u8) = .empty;
Expand Down Expand Up @@ -790,9 +796,9 @@ fn reply(out: *Io.Writer, id: json.Value, body: anytype) !void {
const B = @TypeOf(body);
try out.writeAll("{\"jsonrpc\":\"2.0\",\"id\":");
try json.Stringify.value(id, .{}, out);
inline for (@typeInfo(B).@"struct".fields) |f| {
try out.writeAll(",\"" ++ f.name ++ "\":");
try json.Stringify.value(@field(body, f.name), .{}, out);
inline for (@typeInfo(B).@"struct".field_names, @typeInfo(B).@"struct".field_types, @typeInfo(B).@"struct".field_attrs) |f_name, _, _| {
try out.writeAll(",\"" ++ f_name ++ "\":");
try json.Stringify.value(@field(body, f_name), .{}, out);
}
try out.writeAll("}\n");
}
Expand Down Expand Up @@ -871,7 +877,8 @@ test "JSON arguments read as ZON" {
// And parse back as the plugin's types would.
const Step = union(enum) { click: struct { tag: []const u8 }, type: []const u8, key: []const u8 };
const Args = struct { steps: []const Step, snapshot: bool = true };
const parsed = try std.zon.parse.fromSliceAlloc(Args, a, try a.dupeZ(u8, w.written()), null, .{});
var diag: std.zon.parse.Diagnostics = undefined;
const parsed = try std.zon.parse.fromSlice(Args, .{ .gpa = a, .arena = a, .source = try a.dupeSentinel(u8, w.written(), 0), .diagnostics = &diag });
try std.testing.expectEqualStrings("hi \"there\"\n", parsed.steps[1].type);
try std.testing.expect(!parsed.snapshot);
}
Expand Down
56 changes: 2 additions & 54 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pub fn build(b: *std.Build) void {
// std-only: it relays between an MCP client on stdio and the plugin's socket, and makes the
// pictures (scaling and encoding a frame fizzy hands over raw), so it is always built
// optimized: a tool, not code anyone steps through, and the encode is 10x slower in Debug.
const bridge_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseFast else optimize;
const bridge_optimize: std.builtin.OptimizeMode = if (optimize == .debug) .fast else optimize;
// The compiler's diagnostics, read the same way by the plugin's build loop and the bridge's.
const diagnostics_module = b.createModule(.{
.target = target,
Expand All @@ -38,15 +38,7 @@ pub fn build(b: *std.Build) void {
// Beside the plugin's library too, `<plugins>/agent/fizzy-mcp`, where the chat plugin looks
// for it to hand an agent (ACP `session/new`'s `mcpServers`). Not on the web, which has no
// plugins directory.
if (target.result.cpu.arch != .wasm32) {
const beside = b.allocator.create(BesidePlugin) catch @panic("OOM");
beside.* = .{
.step = std.Build.Step.init(.{ .id = .custom, .name = "install fizzy-mcp beside the plugin", .owner = b, .makeFn = BesidePlugin.make }),
.exe = bridge,
};
beside.step.dependOn(&bridge.step);
b.getInstallStep().dependOn(&beside.step);
}
if (target.result.cpu.arch != .wasm32) fizzy.plugin.installBeside(b, bridge, .{ .plugin = "agent" });

const tests = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
Expand All @@ -63,47 +55,3 @@ pub fn build(b: *std.Build) void {
test_step.dependOn(&b.addRunArtifact(tests).step);
test_step.dependOn(&b.addRunArtifact(diagnostics).step);
}

/// Copies `fizzy-mcp` into `<plugins>/agent/`, the directory `fizzy.plugin.install` puts the
/// library in: `$FIZZY_PROFILE/plugins` when that is set, else fizzy's own plugins directory.
/// Skipped, with a warning, where neither resolves (a bare CI runner).
const BesidePlugin = struct {
step: std.Build.Step,
exe: *std.Build.Step.Compile,

fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) anyerror!void {
const self: *BesidePlugin = @fieldParentPtr("step", step);
const b = step.owner;
const io = b.graph.io;
const plugins = pluginsDir(b) orelse {
std.log.warn("agent: no plugins directory here; fizzy-mcp stays in zig-out/bin", .{});
return;
};
const dir = try std.fs.path.join(b.allocator, &.{ plugins, "agent" });
std.Io.Dir.cwd().createDirPath(io, dir) catch {};
const name = if (b.graph.host.result.os.tag == .windows) "fizzy-mcp.exe" else "fizzy-mcp";
const dest = try std.fs.path.join(b.allocator, &.{ dir, name });
const src = self.exe.getEmittedBin().getPath2(b, step);
const data = try std.Io.Dir.cwd().readFileAlloc(io, src, b.allocator, .limited(64 * 1024 * 1024));
const tmp = try std.fmt.allocPrint(b.allocator, "{s}.part", .{dest});
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = tmp, .data = data, .flags = .{ .permissions = .fromMode(0o755) } });
try std.Io.Dir.renameAbsolute(tmp, dest, io);
std.log.info("agent: installed fizzy-mcp → {s}", .{dest});
}

/// As `fizzy.plugin.install` resolves it (`sdk/plugin_sdk.zig`'s `fizzyPluginsDir`).
fn pluginsDir(b: *std.Build) ?[]const u8 {
const env = &b.graph.environ_map;
if (env.get("FIZZY_PROFILE")) |profile| if (profile.len > 0)
return std.fs.path.resolve(b.allocator, &.{ b.build_root.path orelse ".", profile, "plugins" }) catch null;
const join = std.fs.path.join;
return switch (b.graph.host.result.os.tag) {
.macos => join(b.allocator, &.{ env.get("HOME") orelse return null, "Library", "Application Support", "fizzy", "plugins" }) catch null,
.windows => join(b.allocator, &.{ env.get("LOCALAPPDATA") orelse return null, "fizzy", "plugins" }) catch null,
else => if (env.get("XDG_CONFIG_HOME")) |x|
join(b.allocator, &.{ x, "fizzy", "plugins" }) catch null
else
join(b.allocator, &.{ env.get("HOME") orelse return null, ".config", "fizzy", "plugins" }) catch null,
};
}
};
6 changes: 3 additions & 3 deletions build.zig.zon
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.{
.name = .agent,
.version = "0.1.0",
.minimum_zig_version = "0.16.0",
.minimum_zig_version = "0.17.0",
.paths = .{ "build.zig", "build.zig.zon", "plugin.zig", "plugin.zig.zon", "src", "bridge", "README.md" },
.fingerprint = 0x268b9c9dc0289c64,
.dependencies = .{
Expand All @@ -10,8 +10,8 @@
// clean clone, and the release asset (never the tag's git archive) is the SDK
// package alone. To build against a local fizzy instead:
// `zig build --fork=<fizzy checkout>/sdk`.
.url = "https://github.com/fizzyedit/fizzy/releases/download/sdk-v0.2.28/fizzy-sdk-v0.2.28.tar.gz",
.hash = "fizzy-0.2.28-SGBwY_PuJQCcliz1OqCE9VlJoS204IsAExXIg75ejXhx",
.url = "https://github.com/fizzyedit/fizzy/releases/download/sdk-v0.2.30/fizzy-sdk-v0.2.30.tar.gz",
.hash = "fizzy-0.2.30-SGBwY1QnJwDznUMVnSLATxXgH2e8_Vz7eMwLj9UQ0sVU",
},
},
}
4 changes: 2 additions & 2 deletions plugin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ fn initPlugin(_: *anyopaque) anyerror!void {
const socket_path = if (beside.len < 100) beside else blk: {
gpa.free(beside);
// Windows has no `/tmp`, and its process ids are not `pid_t`s.
if (comptime builtin.os.tag == .windows) {
if (comptime builtin.target.os.tag == .windows) {
const temp = if (std.c.getenv("TEMP")) |t| std.mem.span(t) else return error.NoTempDir;
break :blk try std.fmt.allocPrint(gpa, "{s}\\fizzy-agent-{d}.sock", .{ temp, std.os.windows.GetCurrentProcessId() });
}
Expand All @@ -83,7 +83,7 @@ fn initPlugin(_: *anyopaque) anyerror!void {
fn writeConnection(dir: []const u8, socket_path: []const u8, token: []const u8) !void {
const path = try std.fmt.allocPrint(host.allocator, "{s}/connection.zon", .{dir});
defer host.allocator.free(path);
const permissions: std.Io.File.Permissions = if (comptime builtin.os.tag == .windows) .default_file else .fromMode(0o600);
const permissions: std.Io.File.Permissions = if (comptime builtin.target.os.tag == .windows) .default_file else .fromMode(0o600);
const file = try std.Io.Dir.cwd().createFile(dvui.io, path, .{ .permissions = permissions });
defer file.close(dvui.io);
var buf: [1024]u8 = undefined;
Expand Down
4 changes: 2 additions & 2 deletions plugin.zig.zon
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.{
.id = "agent",
.name = "Agent",
.version = "0.1.0",
.min_sdk_version = "0.2.28",
.version = "0.1.1",
.min_sdk_version = "0.2.30",
.description = "Lets an AI agent drive fizzy as a person does: its commands as tools, the screen as text, input through live tapes, the profiler on request. Pair it with fizzy-mcp.",
}
11 changes: 6 additions & 5 deletions src/Build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,20 @@ pub fn start(gpa: std.mem.Allocator, host: *sdk.Host, plugins: *Plugins, conn: u
var arena: std.heap.ArenaAllocator = .init(gpa);
errdefer arena.deinit();
const a = arena.allocator();
const args = std.zon.parse.fromSliceAlloc(Args, a, source, null, .{ .ignore_unknown_fields = true }) catch return error.BadArguments;
var diag: std.zon.parse.Diagnostics = undefined;
const args = std.zon.parse.fromSlice(Args, .{ .gpa = a, .arena = a, .source = source, .diagnostics = &diag, .ignore_unknown_fields = true }) catch return error.BadArguments;
if (!std.fs.path.isAbsolute(args.path)) return error.PathNotAbsolute;

// Which plugin: its identity file, as the build helper reads it.
const zon_path = try std.fs.path.join(a, &.{ args.path, "plugin.zig.zon" });
const zon = std.Io.Dir.cwd().readFileAllocOptions(dvui.io, zon_path, a, .limited(64 * 1024), .of(u8), 0) catch return error.NoPluginZon;
const Identity = struct { id: []const u8 };
const id = (std.zon.parse.fromSliceAlloc(Identity, a, zon, null, .{ .ignore_unknown_fields = true }) catch return error.NoPluginZon).id;
const id = (std.zon.parse.fromSlice(Identity, .{ .gpa = a, .arena = a, .source = zon, .diagnostics = &diag, .ignore_unknown_fields = true }) catch return error.NoPluginZon).id;
if (std.mem.eql(u8, id, "agent")) return error.CannotBuildItself;

const plugins_dir = host.plugins_dir orelse return error.NoPluginsDir;
const profile = std.fs.path.dirname(plugins_dir) orelse plugins_dir;
const ext = switch (builtin.os.tag) {
const ext = switch (builtin.target.os.tag) {
.macos => "dylib",
.windows => "dll",
else => "so",
Expand Down Expand Up @@ -153,7 +154,7 @@ fn build(self: *Build) void {
}

fn spawn(gpa: std.mem.Allocator, argv: []const []const u8, cwd: []const u8) !std.process.Child {
if (comptime builtin.os.tag == .macos) {
if (comptime builtin.target.os.tag == .macos) {
return core.darwin_spawn.spawn(gpa, .{ .argv = argv, .cwd = cwd, .stdin = .discard, .stdout = .discard, .stderr = .pipe }, core.shell_env.path(gpa, dvui.io));
}
return std.process.spawn(dvui.io, .{ .argv = argv, .cwd = .{ .path = cwd }, .stdin = .ignore, .stdout = .ignore, .stderr = .pipe, .create_no_window = true });
Expand Down Expand Up @@ -250,7 +251,7 @@ fn checkOrFinish(self: *Build, host: *sdk.Host, auto: ?*Automation) ?[]const u8
var src: std.Io.Writer.Allocating = .init(self.arena.allocator());
// `Run.Step` nests (a `compare` holds steps), so the serializer is given a depth to stop at.
std.zon.stringify.serializeMaxDepth(Run.Args{ .steps = self.args.check }, .{ .emit_default_optional_fields = false }, &src.writer, 32) catch return self.fail("could not write the check");
const zon = self.arena.allocator().dupeZ(u8, src.written()) catch return self.fail("out of memory");
const zon = self.arena.allocator().dupeSentinel(u8, src.written(), 0) catch return self.fail("out of memory");
const r = Run.init(self.gpa, self.conn, zon) catch return self.fail("the check is not a script ui.run takes");
self.phase = .{ .checking = r };
const report = r.advance(host, auto.?) orelse return null;
Expand Down
3 changes: 2 additions & 1 deletion src/Run.zig
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ pub const Args = struct {
pub fn init(gpa: std.mem.Allocator, conn: u32, source: [:0]const u8) error{ BadScript, OutOfMemory }!*Run {
var arena: std.heap.ArenaAllocator = .init(gpa);
errdefer arena.deinit();
const args = std.zon.parse.fromSliceAlloc(Args, arena.allocator(), source, null, .{ .ignore_unknown_fields = true }) catch
var diag: std.zon.parse.Diagnostics = undefined;
const args = std.zon.parse.fromSlice(Args, .{ .gpa = arena.allocator(), .arena = arena.allocator(), .source = source, .diagnostics = &diag, .ignore_unknown_fields = true }) catch
return error.BadScript;
const self = try gpa.create(Run);
self.* = .{ .arena = arena, .conn = conn, .steps = args.steps, .timeout_ms = args.timeout_ms, .report = .init(gpa) };
Expand Down
7 changes: 4 additions & 3 deletions src/Server.zig
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const std = @import("std");
const builtin = @import("builtin");
const Io = std.Io;

const has_wake_pipe = builtin.os.tag != .windows;
const has_wake_pipe = builtin.target.os.tag != .windows;

gpa: std.mem.Allocator,
io: Io,
Expand Down Expand Up @@ -241,8 +241,9 @@ fn serve(self: *Server, stream: Io.net.Stream, conn_id: u32) void {
defer arena.deinit();
const a = arena.allocator();
const line = takeLine(&reader.interface, a) catch return;
const source = a.dupeZ(u8, std.mem.trimEnd(u8, line, "\r")) catch return;
const wire = std.zon.parse.fromSliceAlloc(Wire.Request, a, source, null, .{ .ignore_unknown_fields = true }) catch {
const source = a.dupeSentinel(u8, std.mem.trimEnd(u8, line, "\r"), 0) catch return;
var diag: std.zon.parse.Diagnostics = undefined;
const wire = std.zon.parse.fromSlice(Wire.Request, .{ .gpa = a, .arena = a, .source = source, .diagnostics = &diag, .ignore_unknown_fields = true }) catch {
respond(&writer.interface, .{ .id = 0, .err = "not a request: one ZON value per line, .{ .id, .tool, .args }" }) catch return;
continue;
};
Expand Down
2 changes: 1 addition & 1 deletion src/measure.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const core = @import("core");

/// The timer that wakes fizzy when a measurement ends. One is enough: one measurement waits at a
/// time per frame, and the latest deadline wins.
const timer_id: dvui.Id = @enumFromInt(0x6167_6e74_6d65_6173); // "agntmeas"
const timer_id: dvui.Id = @fromBackingInt(@intCast(0x6167_6e74_6d65_6173)); // "agntmeas"

/// Ask for a frame at `until` (boot-clock nanoseconds), however idle fizzy is meanwhile. Called each
/// frame while waiting, which also keeps the timer alive (dvui drops one nobody asks after).
Expand Down
Loading