diff --git a/bridge/main.zig b/bridge/main.zig index 6d076e6..d31a6e6 100644 --- a/bridge/main.zig +++ b/bridge/main.zig @@ -286,6 +286,7 @@ pub fn main(init: std.process.Init) !void { }; defer bridge.disconnect(); defer if (bridge.hello) |h| gpa.free(h); + defer if (bridge.client) |c| gpa.free(c); var in_buf: [1 << 16]u8 = undefined; var out_buf: [1 << 16]u8 = undefined; @@ -337,6 +338,9 @@ pub const Bridge = struct { next_id: u64 = 1, /// What fizzy said to the last `hello`: its tools and the executable it runs from. Owned. hello: ?[]u8 = null, + /// The MCP client's name from `initialize` ("claude-code"), said with `hello` so fizzy shows + /// whose steps these are. Owned. + client: ?[]u8 = null, /// A connection to the plugin, said hello on. const Link = struct { @@ -376,7 +380,7 @@ pub const Bridge = struct { link.writer = stream.writer(self.io, &link.write_buf); self.link = link; var hello: std.Io.Writer.Allocating = .init(arena); - try std.zon.stringify.serialize(.{ .token = c.token }, .{ .whitespace = false }, &hello.writer); + try std.zon.stringify.serialize(.{ .token = c.token, .client = self.client orelse "" }, .{ .whitespace = false }, &hello.writer); return switch (try self.exchange(arena, "hello", hello.written())) { .ok => |s| { self.keepHello(s); @@ -497,6 +501,12 @@ pub const Bridge = struct { } else null; if (eql(method, "initialize")) { + if (params) |p| if (p.get("clientInfo")) |info| if (info == .object) if (info.object.get("name")) |n| if (n == .string) { + if (self.client) |c| self.gpa.free(c); + self.client = self.gpa.dupe(u8, n.string) catch null; + // A hello said before the client named itself goes again, with the name. + self.disconnect(); + }; const version = if (params) |p| if (p.get("protocolVersion")) |v| switch (v) { .string => |s| s, else => protocol_version, diff --git a/src/Run.zig b/src/Run.zig index 58fb08c..0fc87ec 100644 --- a/src/Run.zig +++ b/src/Run.zig @@ -420,7 +420,7 @@ fn playTape(self: *Run, auto: *Automation, steps: []const Step) !Automation.Tick const owned = try s.finish(); var bytes: std.Io.Writer.Allocating = .init(arena); try owned.tape.write(&bytes.writer); - return switch (auto.play(bytes.written())) { + return switch (auto.playAs(bytes.written(), tools.author(self.conn))) { .started => |t| t, .busy => error.SomethingElseIsDrivingFizzy, .invalid => error.NotALiveTape, diff --git a/src/tools.zig b/src/tools.zig index 949a0ac..caca2b1 100644 --- a/src/tools.zig +++ b/src/tools.zig @@ -7,7 +7,7 @@ //! //! | tool | arguments | result | //! |---|---|---| -//! | `hello` | `.{ .token }` | the tools, what fizzy offers, and the executable it runs from | +//! | `hello` | `.{ .token, .client }` | the tools, what fizzy offers, and the executable it runs from | //! | `commands.list` | `.{ .prefix }` | no prefix: a line per command, its id and title (and "(not now)" when it cannot run); with one (`"pixi."`, or a whole id): those commands in full, parameters too (kind, description, default) | //! | `commands.call` | `.{ .id, .args = "" }` | its result, or why not | //! | `ui.snapshot` | — | what is on screen (`automation` service): fizzy's windows, then each widget's role, name, tag, rect, parent and window | @@ -71,6 +71,15 @@ const Held = struct { rec: ?Recording = null, }; +/// What each connection is called, from its client's name in `hello` ("Claude"): what the +/// person reads beside its steps (`sdk.Author.label`). A connection with no name is "An agent". +var labels: std.AutoHashMapUnmanaged(u32, []u8) = .empty; + +/// Connection `conn`, as the author of what it does. +pub fn author(conn: u32) sdk.Author { + return .{ .kind = .agent, .plugin = "agent", .session = conn, .label = labels.get(conn) orelse "An agent" }; +} + /// What a connection should hear with its next answer: a claim the person stopped. var notices: std.ArrayListUnmanaged(struct { conn: u32, text: []u8 }) = .empty; @@ -82,6 +91,7 @@ pub fn frame(host: *sdk.Host, server: *Server) void { defer closed.deinit(host.allocator); server.take(&batch, &closed); tendClaims(host, server, closed.items); + for (closed.items) |c| if (labels.fetchRemove(c)) |kv| host.allocator.free(kv.value); for (batch.items) |req| { attachNotice(server, req); handle(host, server, req); @@ -114,6 +124,9 @@ pub fn deinit(host: *sdk.Host, server: *Server) void { held.deinit(host.allocator); for (notices.items) |n| server.gpa.free(n.text); notices.deinit(host.allocator); + var it = labels.valueIterator(); + while (it.next()) |l| host.allocator.free(l.*); + labels.deinit(host.allocator); } /// End the sessions of closed connections and on closed documents, and turn claims the person @@ -179,12 +192,15 @@ pub fn agentCall(host: *sdk.Host, conn: u32, id: []const u8, args: []const u8, a .{ id, active }, ) catch "undo acts on the active document, which is not yours" }; const doc_path: ?[]const u8 = if (target(host, id, arena.dupeSentinel(u8, args, 0) catch "", arena)) |doc| blk: { - const h = session(host, conn, doc, "An agent") catch break :blk null; + const h = session(host, conn, doc, author(conn).label) catch break :blk null; break :blk arena.dupe(u8, h.path) catch null; } else null; - // What the command opens or creates lands beside the person, not over their document. + // What the command opens or creates lands beside the person, not over their document, and + // the steps it records are this connection's. host.openBeside(); + const outer = host.act(author(conn)); const out = host.callCommand(id, args, arena); + host.endAct(outer); host.endOpenBeside(); if (out == .ok) if (doc_path) |p| for (held.items) |*h| { if (h.conn != conn or !eql(h.path, p)) continue; @@ -193,6 +209,12 @@ pub fn agentCall(host: *sdk.Host, conn: u32, id: []const u8, args: []const u8, a return out; } +/// What the person reads for an MCP client's name: "Claude" for Claude's clients +/// ("claude-code", "claude-ai"), else the name as given. +fn clientLabel(client: []const u8) []const u8 { + return if (std.ascii.startsWithIgnoreCase(client, "claude")) "Claude" else client; +} + /// Undo and redo (`fizzy.undo`, `pixi.redo`, ...) take no document: they act on the active one. /// When that is not one connection `conn` works on, it is the person's, and an agent's undo would /// take back their steps: answers its path, to refuse with. Until undo takes a `path` @@ -288,6 +310,12 @@ fn handle(host: *sdk.Host, server: *Server, req: *Server.Request) void { const tool = req.tool; if (eql(tool, "hello")) { + // The client's name, as its MCP client gave it ("claude-code"): the label its steps carry. + if (parse(struct { client: []const u8 = "" }, arena, source)) |a| if (a.client.len > 0) { + if (host.allocator.dupe(u8, clientLabel(a.client))) |l| { + if (labels.fetchPut(host.allocator, req.conn, l) catch null) |old| host.allocator.free(old.value); + } else |_| {} + }; // The executable this fizzy runs from: the bridge compares it across an app build to tell // whether fizzy will restart into a new one (`fizzy_build` on an app's repo). var buf: [std.fs.max_path_bytes]u8 = undefined; @@ -349,7 +377,7 @@ fn handle(host: *sdk.Host, server: *Server, req: *Server.Request) void { return wait(host, server, req, .{ .build = b }); } if (eql(tool, "tape.play")) { - return switch (auto.play(req.args)) { + return switch (auto.playAs(req.args, author(req.conn))) { .started => |t| req.answer(server, true, std.fmt.allocPrint(arena, ".{{ .ticket = {d} }}", .{@backingInt(t)}) catch "?"), .busy => req.answer(server, false, "something else is driving fizzy: a tape or a demo"), .invalid => |why| req.answer(server, false, why),