From 5427531263dff72cea916aa00377519f0693b360 Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:26:02 -0400 Subject: [PATCH 1/2] feat(mcp): add close_session tool to close terminals and reclaim slots Symmetric to spawn_session: close_session terminates a session's shell and removes its pane via the same close/relayout path as the close-pane keybinding (despawnSessionAtIndex), so the slot is reclaimed instead of left as a dead pane. - control.zig: CloseRequest/Response/Queue/Completion, not_found error code, op:"close" wire discrimination, connectAndSendCloseRequest, failPendingClose - runtime.zig: extract DespawnSession body into shared despawnSessionAtIndex, add handleExternalCloseRequest, drain a parallel close queue in the main loop - mcp/main.zig: register close_session in tools/list + tools/call - docs: README, ARCHITECTURE, ai-integration --- README.md | 10 +- docs/ARCHITECTURE.md | 22 ++- docs/ai-integration.md | 51 ++++- src/app/control.zig | 431 +++++++++++++++++++++++++++++++++++++++- src/app/runtime.zig | 439 +++++++++++++++++++++++++++-------------- src/mcp/main.zig | 211 +++++++++++++++++++- 6 files changed, 992 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index 190ce3c..3e9e971 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Architect solves this with a grid view that keeps all your agents visible, with - **Recent folders** (⌘O) — quickly `cd` into recently visited directories with instant search filtering (start typing to narrow the list), substring highlighting, arrow key navigation, and ⌘1–⌘9 quick selection - **Diff review comments** — click diff lines in the ⌘D overlay to leave inline comments with multiline wrapping, then send them all to a running agent (or start one) with the "Send to agent" button - **Story viewer** — run `architect story ` to open a scrollable overlay that renders PR story files with prose text and diff-colored code blocks -- **MCP session spawning** — run `architect-mcp` from an MCP client to ask the running Architect app to create a terminal session in a requested working directory +- **MCP session control** — run `architect-mcp` from an MCP client to ask the running Architect app to create a terminal session in a requested working directory (`spawn_session`) or close one and reclaim its slot (`close_session`) - **Reader mode** (⌘R) — open a centered markdown reader for the selected terminal's history (works in full view and grid) with live updates, bottom pinning, incremental search (⌘F, Enter/Shift+Enter), markdown tables with inline cell styling (bold/italic/code/links/strikethrough), task checkboxes (emoji), clickable links, shared draggable scrollbar, and left-to-right gradient separators before command prompts (OSC 133 + fallback heuristics) ### Terminal Essentials @@ -129,7 +129,7 @@ architect hook gemini ## MCP -`architect-mcp` is a stdio MCP server for local clients. It exposes one tool, `spawn_session`, which forwards the request to the running Architect app. It does not launch Architect by itself. +`architect-mcp` is a stdio MCP server for local clients. It exposes two tools, `spawn_session` and `close_session`, which forward the request to the running Architect app. It does not launch Architect by itself. `spawn_session` arguments: ```json @@ -142,6 +142,12 @@ architect hook gemini `cwd` is required. `command` and `display_name` are optional. On success, the tool returns structured content with `status`, `session_id`, and `slot_index`. If Architect is not running, the grid is full, `cwd` is invalid, or spawning fails, the tool returns an MCP tool error with a stable `code` and `message`. +`close_session` is the inverse: it closes a terminal session and reclaims its grid slot (the same path as the close-pane keybinding, so the pane is removed rather than left dead). Pass exactly one identifier — the `session_id` returned by `spawn_session`, or a `slot_index`: +```json +{ "session_id": 12 } +``` +On success it returns structured content with `status: "closed"` and the `session_id`. An unknown identifier returns an MCP tool error with code `not_found`. + For release downloads, use: ```bash ./Architect.app/Contents/MacOS/architect-mcp diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bba1ca5..b07eb50 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -310,7 +310,7 @@ Story notifications -> StoryOverlay opens with file content Renderer draws attention border / story overlay ``` -### External MCP Spawn Path +### External MCP Spawn / Close Path ``` MCP client @@ -318,23 +318,25 @@ MCP client v src/mcp/main.zig | JSON-RPC initialize/tools/list/tools/call - | validates spawn_session arguments + | validates spawn_session / close_session arguments v app/control.zig | scans per-instance discovery files in XDG_RUNTIME_DIR or stable per-user runtime dir | connects to architect_control_.sock + | spawn requests omit "op"; close requests carry "op":"close" v Control socket listener thread in the running app - | parses request and queues PendingSpawn + | parses request and queues PendingSpawn or PendingClose | posts SDL wake event v app/runtime.zig main loop - | validates cwd, chooses or expands a grid slot - | calls SessionState.ensureSpawnedWithDir() - | queues optional command into pending_write + | spawn: validates cwd, chooses or expands a grid slot, ensureSpawnedWithDir() + | close: resolves the session by id/slot, then despawnSessionAtIndex() + | (same close + relayout path as the close-pane keybinding) v Control response - | status + session_id + slot_index, or stable error code + | spawn: status + session_id + slot_index, or stable error code + | close: status + session_id, or stable error code (e.g. not_found) v architect-mcp MCP tool result ``` @@ -364,7 +366,7 @@ Rotate: rename active file to architect-.log and continue in new | SDL event queue | Keyboard, mouse, window events | Primary user interaction | | PTY read | Shell process stdout/stderr | Terminal content updates | | Unix domain socket | External AI tools | Status notifications (JSON) | -| Unix domain socket | `architect-mcp` | Local `spawn_session` control requests | +| Unix domain socket | `architect-mcp` | Local `spawn_session` / `close_session` control requests | | Config files | `~/.config/architect/` | Startup configuration and persistence | ### Storage @@ -395,9 +397,9 @@ Rotate: rename active file to architect-.log and continue in new | Module | Responsibility | Public API (key functions/types) | Dependencies | |--------|---------------|----------------------------------|--------------| | `main.zig` | Thin entrypoint + global logging hook registration | `main()`, `std_options.logFn` | `app/runtime`, `logging` | -| `mcp/main.zig` | Separate `architect-mcp` stdio MCP server. Handles JSON-RPC lifecycle methods and exposes the single `spawn_session` tool. | `main()`, `run()` | `app/control` module import, std | +| `mcp/main.zig` | Separate `architect-mcp` stdio MCP server. Handles JSON-RPC lifecycle methods and exposes the `spawn_session` and `close_session` tools. | `main()`, `run()` | `app/control` module import, std | | `app/runtime.zig` | Application lifetime, frame loop, session spawning, config persistence, logging lifecycle/view-transition markers | `run()`, frame loop internals | `platform/sdl`, `session/state`, `render/renderer`, `ui/root`, `config`, `logging`, all `app/*` modules | -| `app/control.zig` | Local control channel shared by the app and `architect-mcp`: spawn request schema, discovery file, Unix socket listener, request queue, and response serialization | `SpawnRequest`, `SpawnResponse`, `SpawnQueue`, `startControlThread()`, `connectAndSendSpawnRequest()` | std (socket, thread, JSON) | +| `app/control.zig` | Local control channel shared by the app and `architect-mcp`: spawn/close request schemas, discovery file, Unix socket listener, request queues, and response serialization. Close requests are discriminated on the wire by `"op":"close"`. | `SpawnRequest`, `SpawnResponse`, `SpawnQueue`, `CloseRequest`, `CloseResponse`, `CloseQueue`, `startControlThread()`, `connectAndSendSpawnRequest()`, `connectAndSendCloseRequest()` | std (socket, thread, JSON) | | `app/terminal_history.zig` | Extract focused terminal scrollback + viewport text, strip ANSI escape sequences, convert OSC 133 prompt markers into reader-friendly prompt marker lines, and extract agent session IDs from PTY output for resumption | `extractSessionText()`, `extractTerminalText()`, `stripAnsiAlloc()`, `extractAgentSessionId()`, `buildResumeCommand()` | `session/state`, `ghostty-vt`, std | | `app/*` (app_state, layout, ui_host, grid_nav, grid_layout, input_keys, input_text, terminal_actions, worktree) | Application logic decomposed by concern: state enums, grid sizing, UI snapshot building, navigation, input encoding, clipboard, worktree commands (with configurable external directory and post-create init) | `ViewMode`, `AnimationState`, `SessionStatus`, `buildUiHost()`, `applyTerminalResize()`, `encodeKey()`, `paste()`, `clear()`, `resolveWorktreeDir()` | `geom`, `anim/easing`, `ui/types`, `ui/session_view_state`, `colors`, `input/mapper`, `session/state`, `c` | | `platform/sdl.zig` | SDL3 initialization, window management, HiDPI | `init()`, `createWindow()`, `createRenderer()` | `c` | diff --git a/docs/ai-integration.md b/docs/ai-integration.md index a07c3e0..810fa61 100644 --- a/docs/ai-integration.md +++ b/docs/ai-integration.md @@ -2,7 +2,7 @@ Architect exposes a Unix domain socket to let external tools (Claude Code, Codex, Gemini CLI, etc.) signal UI states. -Architect also ships `architect-mcp`, a separate stdio MCP helper that lets local MCP clients ask the running app to create new terminal sessions. +Architect also ships `architect-mcp`, a separate stdio MCP helper that lets local MCP clients ask the running app to create new terminal sessions and close existing ones. ## Socket Protocol @@ -17,9 +17,12 @@ Examples: {"session": 0, "state": "done"} ``` +## MCP tools + +`architect-mcp` is launched by an MCP client as a stdio server. It exposes two tools, `spawn_session` and `close_session`, and forwards each call to the running Architect app through a local Unix-domain control socket. The helper is not a daemon and does not launch the GUI app if Architect is not already running. + ## MCP `spawn_session` -`architect-mcp` is launched by an MCP client as a stdio server. It exposes exactly one tool, `spawn_session`, and forwards each call to the running Architect app through a local Unix-domain control socket. The helper is not a daemon and does not launch the GUI app if Architect is not already running. The running app writes a per-instance discovery file named `architect_control__.json` under `XDG_RUNTIME_DIR` when that is set. Otherwise it uses a stable per-user runtime directory: `~/Library/Caches/Architect/runtime` on macOS, or `~/.cache/architect/runtime` on other platforms. The app logs the full discovery file path together with the socket path. When several Architect instances are running, `architect-mcp` tries the newest reachable discovery entry. @@ -81,6 +84,50 @@ Tool errors use stable codes: - `invalid_cwd`: `cwd` is not an absolute existing directory - `spawn_failed`: Architect accepted the request but could not create or initialize the terminal session +## MCP `close_session` + +The inverse of `spawn_session`: it asks the running app to close a terminal session and reclaim its grid slot, driving the same close/relayout path as the close-pane keybinding (the shell is terminated and the pane is removed — not left as a dead, unresponsive pane). Provide exactly one identifier. + +Input schema: + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "session_id": { + "type": "integer", + "description": "The session id returned by spawn_session. Provide this or slot_index." + }, + "slot_index": { + "type": "integer", + "description": "The grid slot index to close. Provide this or session_id." + } + } +} +``` + +Example tool arguments: + +```json +{ "session_id": 12 } +``` + +Successful calls return MCP structured content like: + +```json +{ + "status": "closed", + "session_id": 12 +} +``` + +Tool errors use the same stable codes, plus: + +- `invalid_request`: the MCP arguments do not match the schema (e.g. no identifier was provided) +- `not_found`: no session matches the requested `session_id` / `slot_index` +- `app_not_running`: no running Architect app accepted the local control request + ## Built-in Command (inside Architect terminals) Architect injects a small `architect` command into each shell's `PATH`. It reads the diff --git a/src/app/control.zig b/src/app/control.zig index 578c7a9..ea4cd83 100644 --- a/src/app/control.zig +++ b/src/app/control.zig @@ -18,6 +18,7 @@ pub const SpawnErrorCode = enum { full_grid, invalid_cwd, spawn_failed, + not_found, // ponytail: shared error enum across spawn + close; only close uses this one. pub fn jsonString(self: SpawnErrorCode) []const u8 { return switch (self) { @@ -26,6 +27,7 @@ pub const SpawnErrorCode = enum { .full_grid => "full_grid", .invalid_cwd => "invalid_cwd", .spawn_failed => "spawn_failed", + .not_found => "not_found", }; } @@ -35,6 +37,7 @@ pub const SpawnErrorCode = enum { if (std.mem.eql(u8, value, "full_grid")) return .full_grid; if (std.mem.eql(u8, value, "invalid_cwd")) return .invalid_cwd; if (std.mem.eql(u8, value, "spawn_failed")) return .spawn_failed; + if (std.mem.eql(u8, value, "not_found")) return .not_found; return null; } }; @@ -142,6 +145,88 @@ pub const SpawnQueue = struct { } }; +pub const CloseRequest = struct { + session_id: ?usize = null, + slot_index: ?usize = null, +}; + +pub const CloseSuccess = struct { + session_id: usize, +}; + +pub const CloseResponse = union(enum) { + success: CloseSuccess, + failure: SpawnFailure, +}; + +pub const OwnedCloseResponse = struct { + response: CloseResponse, + owned_message: ?[]const u8 = null, + + pub fn deinit(self: *OwnedCloseResponse, allocator: std.mem.Allocator) void { + if (self.owned_message) |message| { + allocator.free(message); + self.owned_message = null; + } + } +}; + +// ponytail: deliberately parallel to SpawnCompletion/SpawnQueue rather than a +// generic Completion(T)/Queue(T) — the lint pass rejects type-returning fns. +pub const CloseCompletion = struct { + mutex: std.Thread.Mutex = .{}, + condition: std.Thread.Condition = .{}, + completed: bool = false, + response: CloseResponse = undefined, + + pub fn complete(self: *CloseCompletion, response: CloseResponse) void { + self.mutex.lock(); + defer self.mutex.unlock(); + + self.response = response; + self.completed = true; + self.condition.signal(); + } + + pub fn wait(self: *CloseCompletion) CloseResponse { + self.mutex.lock(); + defer self.mutex.unlock(); + + while (!self.completed) { + self.condition.wait(&self.mutex); + } + return self.response; + } +}; + +pub const PendingClose = struct { + request: CloseRequest, + completion: *CloseCompletion, +}; + +pub const CloseQueue = struct { + mutex: std.Thread.Mutex = .{}, + items: std.ArrayListUnmanaged(PendingClose) = .empty, + + pub fn deinit(self: *CloseQueue, allocator: std.mem.Allocator) void { + self.items.deinit(allocator); + } + + pub fn push(self: *CloseQueue, allocator: std.mem.Allocator, item: PendingClose) !void { + self.mutex.lock(); + defer self.mutex.unlock(); + try self.items.append(allocator, item); + } + + pub fn drainAll(self: *CloseQueue) std.ArrayListUnmanaged(PendingClose) { + self.mutex.lock(); + defer self.mutex.unlock(); + const items = self.items; + self.items = .empty; + return items; + } +}; + pub const ParseSpawnRequestError = error{ ExpectedObject, MissingCwd, @@ -326,7 +411,8 @@ const ControlContext = struct { allocator: std.mem.Allocator, socket_path: [:0]const u8, discovery_path: []const u8, - queue: *SpawnQueue, + spawn_queue: *SpawnQueue, + close_queue: *CloseQueue, stop: *atomic.Value(bool), runtime_wake: ?RuntimeWake, }; @@ -335,7 +421,8 @@ pub fn startControlThread( allocator: std.mem.Allocator, socket_path: [:0]const u8, discovery_path: []const u8, - queue: *SpawnQueue, + spawn_queue: *SpawnQueue, + close_queue: *CloseQueue, stop: *atomic.Value(bool), runtime_wake: ?RuntimeWake, ) std.Thread.SpawnError!std.Thread { @@ -348,7 +435,8 @@ pub fn startControlThread( .allocator = allocator, .socket_path = socket_path, .discovery_path = discovery_path, - .queue = queue, + .spawn_queue = spawn_queue, + .close_queue = close_queue, .stop = stop, .runtime_wake = runtime_wake, }; @@ -380,6 +468,19 @@ pub fn failPending( } } +pub fn failPendingClose( + queue: *CloseQueue, + allocator: std.mem.Allocator, + code: SpawnErrorCode, + message: []const u8, +) void { + var pending = queue.drainAll(); + defer pending.deinit(allocator); + for (pending.items) |*item| { + item.completion.complete(.{ .failure = .{ .code = code, .message = message } }); + } +} + fn controlThreadMain(ctx: ControlContext) !void { const addr = try std.net.Address.initUnix(ctx.socket_path); const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0); @@ -410,7 +511,7 @@ fn controlThreadMain(ctx: ControlContext) !void { }, }; setFdNonBlocking(conn_fd, "control connection"); - handleControlConnection(ctx.allocator, conn_fd, ctx.queue, ctx.runtime_wake); + handleControlConnection(ctx.allocator, conn_fd, ctx.spawn_queue, ctx.close_queue, ctx.runtime_wake); posix.close(conn_fd); } } @@ -428,10 +529,18 @@ fn setFdNonBlocking(fd: posix.fd_t, context: []const u8) void { } } +/// True when the parsed request carries `op:"close"`; spawn requests never do. +fn isCloseRequest(value: std.json.Value) bool { + if (value != .object) return false; + const op_value = value.object.get("op") orelse return false; + return op_value == .string and std.mem.eql(u8, op_value.string, "close"); +} + fn handleControlConnection( allocator: std.mem.Allocator, conn_fd: posix.fd_t, - queue: *SpawnQueue, + spawn_queue: *SpawnQueue, + close_queue: *CloseQueue, runtime_wake: ?RuntimeWake, ) void { const bytes = readLineFromFdWithTimeout(allocator, conn_fd, max_message_bytes, control_request_read_timeout_ms) catch |err| { @@ -457,6 +566,11 @@ fn handleControlConnection( }; defer parsed.deinit(); + if (isCloseRequest(parsed.value)) { + handleCloseConnection(allocator, conn_fd, parsed.value, close_queue, runtime_wake); + return; + } + var request = parseSpawnRequestFromValue(allocator, parsed.value) catch |err| { writeControlResponse(conn_fd, .{ .failure = .{ .code = .invalid_request, @@ -469,7 +583,7 @@ fn handleControlConnection( errdefer request.deinit(allocator); var completion = SpawnCompletion{}; - queue.push(allocator, .{ + spawn_queue.push(allocator, .{ .request = request, .completion = &completion, }) catch |err| { @@ -494,6 +608,48 @@ fn handleControlConnection( }; } +fn handleCloseConnection( + allocator: std.mem.Allocator, + conn_fd: posix.fd_t, + value: std.json.Value, + close_queue: *CloseQueue, + runtime_wake: ?RuntimeWake, +) void { + const request = parseCloseRequestFromValue(value) catch |err| { + writeControlCloseResponse(conn_fd, .{ .failure = .{ + .code = .invalid_request, + .message = parseCloseErrorMessage(err), + } }) catch |write_err| { + log.debug("failed to write invalid close request response: {}", .{write_err}); + }; + return; + }; + + var completion = CloseCompletion{}; + close_queue.push(allocator, .{ + .request = request, + .completion = &completion, + }) catch |err| { + log.warn("failed to queue close request: {}", .{err}); + writeControlCloseResponse(conn_fd, .{ .failure = .{ + .code = .spawn_failed, + .message = "failed to queue close request", + } }) catch |write_err| { + log.debug("failed to write close queue failure response: {}", .{write_err}); + }; + return; + }; + + if (runtime_wake) |waker| { + waker.notify(); + } + + const response = completion.wait(); + writeControlCloseResponse(conn_fd, response) catch |err| { + log.debug("failed to write close control response: {}", .{err}); + }; +} + pub fn parseErrorMessage(err: ParseSpawnRequestError) []const u8 { return switch (err) { error.ExpectedObject => "spawn_session arguments must be an object", @@ -506,6 +662,68 @@ pub fn parseErrorMessage(err: ParseSpawnRequestError) []const u8 { }; } +pub const ParseCloseRequestError = error{ + ExpectedObject, + MissingIdentifier, + InvalidSessionId, + InvalidSlotIndex, + InvalidOp, + UnknownField, +}; + +/// Parses close_session arguments. Accepts an optional `op:"close"` so the same +/// parser handles both the MCP tool arguments and the on-wire control request. +pub fn parseCloseRequestFromValue(value: std.json.Value) ParseCloseRequestError!CloseRequest { + if (value != .object) return error.ExpectedObject; + + var request = CloseRequest{}; + var have_session = false; + var have_slot = false; + + var iter = value.object.iterator(); + while (iter.next()) |entry| { + const key = entry.key_ptr.*; + const field_value = entry.value_ptr.*; + + if (std.mem.eql(u8, key, "session_id")) { + if (have_session) return error.InvalidSessionId; + if (field_value != .integer or field_value.integer < 0) return error.InvalidSessionId; + request.session_id = @intCast(field_value.integer); + have_session = true; + continue; + } + + if (std.mem.eql(u8, key, "slot_index")) { + if (have_slot) return error.InvalidSlotIndex; + if (field_value != .integer or field_value.integer < 0) return error.InvalidSlotIndex; + request.slot_index = @intCast(field_value.integer); + have_slot = true; + continue; + } + + if (std.mem.eql(u8, key, "op")) { + if (field_value != .string or !std.mem.eql(u8, field_value.string, "close")) return error.InvalidOp; + continue; + } + + return error.UnknownField; + } + + if (!have_session and !have_slot) return error.MissingIdentifier; + return request; +} + +pub fn parseCloseErrorMessage(err: ParseCloseRequestError) []const u8 { + return switch (err) { + error.ExpectedObject => "close_session arguments must be an object", + error.MissingIdentifier => "session_id or slot_index is required", + error.InvalidSessionId => "session_id must be a non-negative integer", + error.InvalidSlotIndex => "slot_index must be a non-negative integer", + error.InvalidOp => "op must be \"close\"", + error.UnknownField => "close_session contains an unsupported field", + }; +} + fn writeDiscoveryFile(allocator: std.mem.Allocator, path: []const u8, socket_path: []const u8) !void { const payload = try discoveryPayloadAlloc(allocator, socket_path); defer allocator.free(payload); @@ -632,6 +850,14 @@ fn writeControlResponse(fd: posix.fd_t, response: SpawnResponse) !void { try writeAllFd(fd, payload); } +fn writeControlCloseResponse(fd: posix.fd_t, response: CloseResponse) !void { + var buffer: [512]u8 = undefined; + var fbs = std.heap.FixedBufferAllocator.init(&buffer); + const allocator = fbs.allocator(); + const payload = try controlCloseResponseAlloc(allocator, response); + try writeAllFd(fd, payload); +} + pub fn controlRequestAlloc(allocator: std.mem.Allocator, request: SpawnRequest) ![]u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); @@ -684,6 +910,56 @@ pub fn controlResponseAlloc(allocator: std.mem.Allocator, response: SpawnRespons return try allocator.dupe(u8, out.written()); } +pub fn controlCloseRequestAlloc(allocator: std.mem.Allocator, request: CloseRequest) ![]u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + var json: std.json.Stringify = .{ .writer = &out.writer }; + + try json.beginObject(); + try json.objectField("op"); + try json.write("close"); + if (request.session_id) |session_id| { + try json.objectField("session_id"); + try json.write(session_id); + } + if (request.slot_index) |slot_index| { + try json.objectField("slot_index"); + try json.write(slot_index); + } + try json.endObject(); + try out.writer.writeByte('\n'); + + return try allocator.dupe(u8, out.written()); +} + +pub fn controlCloseResponseAlloc(allocator: std.mem.Allocator, response: CloseResponse) ![]u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + var json: std.json.Stringify = .{ .writer = &out.writer }; + + try json.beginObject(); + switch (response) { + .success => |success| { + try json.objectField("ok"); + try json.write(true); + try json.objectField("session_id"); + try json.write(success.session_id); + }, + .failure => |failure| { + try json.objectField("ok"); + try json.write(false); + try json.objectField("code"); + try json.write(failure.code.jsonString()); + try json.objectField("message"); + try json.write(failure.message); + }, + } + try json.endObject(); + try out.writer.writeByte('\n'); + + return try allocator.dupe(u8, out.written()); +} + pub fn connectAndSendSpawnRequest( allocator: std.mem.Allocator, request: SpawnRequest, @@ -704,6 +980,26 @@ pub fn connectAndSendSpawnRequest( return try parseControlResponse(allocator, response_bytes); } +pub fn connectAndSendCloseRequest( + allocator: std.mem.Allocator, + request: CloseRequest, +) !OwnedCloseResponse { + var connection = connectToNewestControlSocket(allocator) catch |err| switch (err) { + error.NoControlDiscoveryFile => return staticCloseFailure(.app_not_running, "Architect is not running"), + error.NoLiveControlSocket => return staticCloseFailure(.app_not_running, "Architect is not accepting control requests"), + else => return err, + }; + defer connection.deinit(allocator); + + const payload = try controlCloseRequestAlloc(allocator, request); + defer allocator.free(payload); + try writeAllFd(connection.fd, payload); + + const response_bytes = try readLineFromFd(allocator, connection.fd, max_message_bytes); + defer allocator.free(response_bytes); + return try parseControlCloseResponse(allocator, response_bytes); +} + const ControlConnection = struct { fd: posix.fd_t, socket_path: []const u8, @@ -829,6 +1125,12 @@ fn staticFailure(code: SpawnErrorCode, message: []const u8) OwnedSpawnResponse { }; } +fn staticCloseFailure(code: SpawnErrorCode, message: []const u8) OwnedCloseResponse { + return .{ + .response = .{ .failure = .{ .code = code, .message = message } }, + }; +} + fn parseDiscoverySocketPath(allocator: std.mem.Allocator, bytes: []const u8) ![]const u8 { var parsed = try std.json.parseFromSlice(std.json.Value, allocator, bytes, .{}); defer parsed.deinit(); @@ -870,6 +1172,34 @@ fn parseControlResponse(allocator: std.mem.Allocator, bytes: []const u8) !OwnedS }; } +fn parseControlCloseResponse(allocator: std.mem.Allocator, bytes: []const u8) !OwnedCloseResponse { + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, bytes, .{}); + defer parsed.deinit(); + + if (parsed.value != .object) return error.InvalidControlResponse; + const object = parsed.value.object; + const ok_value = object.get("ok") orelse return error.InvalidControlResponse; + if (ok_value != .bool) return error.InvalidControlResponse; + + if (ok_value.bool) { + const session_id_value = object.get("session_id") orelse return error.InvalidControlResponse; + if (session_id_value != .integer or session_id_value.integer < 0) return error.InvalidControlResponse; + return .{ .response = .{ .success = .{ + .session_id = @intCast(session_id_value.integer), + } } }; + } + + const code_value = object.get("code") orelse return error.InvalidControlResponse; + const message_value = object.get("message") orelse return error.InvalidControlResponse; + if (code_value != .string or message_value != .string) return error.InvalidControlResponse; + const code = SpawnErrorCode.fromString(code_value.string) orelse return error.InvalidControlResponse; + const message = try allocator.dupe(u8, message_value.string); + return .{ + .response = .{ .failure = .{ .code = code, .message = message } }, + .owned_message = message, + }; +} + test "parseSpawnRequestFromValue accepts cwd with optional metadata" { const allocator = std.testing.allocator; var parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"cwd\":\"/tmp\",\"command\":\"pwd\",\"display_name\":\"Task\"}", .{}); @@ -994,3 +1324,92 @@ test "SpawnQueue drains queued requests" { defer empty.deinit(allocator); try std.testing.expectEqual(@as(usize, 0), empty.items.len); } + +test "parseCloseRequestFromValue accepts session_id, slot_index, and op" { + const allocator = std.testing.allocator; + + { + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"session_id\":7}", .{}); + defer parsed.deinit(); + const request = try parseCloseRequestFromValue(parsed.value); + try std.testing.expectEqual(@as(?usize, 7), request.session_id); + try std.testing.expectEqual(@as(?usize, null), request.slot_index); + } + { + // Wire form carries op:"close" alongside the identifier. + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"op\":\"close\",\"slot_index\":2}", .{}); + defer parsed.deinit(); + const request = try parseCloseRequestFromValue(parsed.value); + try std.testing.expectEqual(@as(?usize, null), request.session_id); + try std.testing.expectEqual(@as(?usize, 2), request.slot_index); + } +} + +test "parseCloseRequestFromValue rejects invalid shapes" { + const allocator = std.testing.allocator; + + const cases = [_][]const u8{ + "{}", // no identifier + "{\"op\":\"close\"}", // op alone is not an identifier + "{\"session_id\":-1}", + "{\"session_id\":\"3\"}", + "{\"slot_index\":1.5}", + "{\"op\":\"spawn\",\"session_id\":1}", + "{\"session_id\":1,\"extra\":true}", + }; + + for (cases) |case| { + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, case, .{}); + defer parsed.deinit(); + if (parseCloseRequestFromValue(parsed.value)) |_| { + try std.testing.expect(false); + } else |_| {} + } +} + +test "close control response round-trips success and failure" { + const allocator = std.testing.allocator; + + const success_payload = try controlCloseResponseAlloc(allocator, .{ .success = .{ .session_id = 12 } }); + defer allocator.free(success_payload); + + var success = try parseControlCloseResponse(allocator, success_payload); + defer success.deinit(allocator); + switch (success.response) { + .success => |result| try std.testing.expectEqual(@as(usize, 12), result.session_id), + .failure => try std.testing.expect(false), + } + + const failure_payload = try controlCloseResponseAlloc(allocator, .{ .failure = .{ + .code = .not_found, + .message = "no session matches that id", + } }); + defer allocator.free(failure_payload); + + var failure = try parseControlCloseResponse(allocator, failure_payload); + defer failure.deinit(allocator); + switch (failure.response) { + .failure => |result| { + try std.testing.expectEqual(SpawnErrorCode.not_found, result.code); + try std.testing.expectEqualStrings("no session matches that id", result.message); + }, + .success => try std.testing.expect(false), + } +} + +test "controlCloseRequestAlloc emits op close and identifier" { + const allocator = std.testing.allocator; + + const payload = try controlCloseRequestAlloc(allocator, .{ .session_id = 5 }); + defer allocator.free(payload); + + try std.testing.expect(std.mem.indexOf(u8, payload, "\"op\":\"close\"") != null); + try std.testing.expect(std.mem.indexOf(u8, payload, "\"session_id\":5") != null); + + // Round-trips back through the parser the control thread uses. + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, std.mem.trim(u8, payload, "\n"), .{}); + defer parsed.deinit(); + try std.testing.expect(isCloseRequest(parsed.value)); + const request = try parseCloseRequestFromValue(parsed.value); + try std.testing.expectEqual(@as(?usize, 5), request.session_id); +} diff --git a/src/app/runtime.zig b/src/app/runtime.zig index e3adb12..50eacd9 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -754,6 +754,245 @@ fn handleExternalSpawnRequest( } }); } +/// Close the terminal in `idx`, reclaim its grid slot, and relayout/animate the +/// remaining sessions exactly as the close-pane keybinding does. Shared by the +/// `.DespawnSession` UI action and the external `close_session` control request +/// so a programmatic close goes through the same battle-tested path (rather than +/// signal-killing the child, which would leave a dead, unresponsive pane). +fn despawnSessionAtIndex( + idx: usize, + allocator: std.mem.Allocator, + sessions: []*SessionState, + grid: *GridLayout, + anim_state: *AnimationState, + session_interaction_component: *ui_mod.SessionInteractionComponent, + render_cache: *renderer_mod.RenderCache, + loop: *xev.Loop, + animations_enabled: bool, + now: i64, + render_width: c_int, + render_height: c_int, + ui_scale: f32, + font: *font_mod.Font, + grid_font_scale: f32, + full_cols: *u16, + full_rows: *u16, + cell_width_pixels: *c_int, + cell_height_pixels: *c_int, +) void { + if (idx >= sessions.len) return; + + if (anim_state.mode == .Full and anim_state.focused_session == idx) { + if (animations_enabled) { + grid_nav.startCollapseToGrid(anim_state, now, cell_width_pixels.*, cell_height_pixels.*, render_width, render_height, grid.cols); + } else { + anim_state.mode = .Grid; + } + } + log.info("despawn idx={d} mode={s} spawned_count={d}", .{ + idx, + @tagName(anim_state.mode), + countSpawnedSessions(sessions), + }); + var old_positions: ?std.ArrayList(SessionIndexSnapshot) = null; + defer if (old_positions) |*snapshots| { + snapshots.deinit(allocator); + }; + if (animations_enabled and anim_state.mode == .Grid) { + old_positions = collectSessionIndexSnapshots(sessions, allocator) catch |err| blk: { + std.debug.print("Failed to snapshot session positions: {}\n", .{err}); + break :blk null; + }; + } + sessions[idx].despawn(allocator); + session_interaction_component.resetView(idx); + sessions[idx].markDirty(); + compactSessions(sessions, session_interaction_component.viewSlice(), render_cache, anim_state); + + // Handle grid contraction + const remaining_count = countSpawnedSessions(sessions); + const max_spawned_idx = highestSpawnedIndex(sessions); + const required_slots = if (max_spawned_idx) |max_idx| max_idx + 1 else 0; + + if (remaining_count == 0) { + // Re-spawn a fresh terminal in slot 0 + sessions[0].ensureSpawnedWithLoop(loop) catch |err| { + std.debug.print("Failed to respawn terminal: {}\n", .{err}); + }; + anim_state.focused_session = 0; + grid.cols = 1; + grid.rows = 1; + cell_width_pixels.* = render_width; + cell_height_pixels.* = render_height; + anim_state.mode = .Full; + applyTerminalLayout(sessions, allocator, font, render_width, render_height, ui_scale, anim_state, grid.cols, grid.rows, grid_font_scale, full_cols, full_rows); + } else if (remaining_count == 1) { + // Only 1 terminal remains - go directly to Full mode, no resize animation + grid.cols = 1; + grid.rows = 1; + cell_width_pixels.* = render_width; + cell_height_pixels.* = render_height; + if (!sessions[anim_state.focused_session].spawned) { + for (sessions, 0..) |s, i| { + if (s.spawned) { + anim_state.focused_session = i; + break; + } + } + } + anim_state.mode = .Full; + applyTerminalLayout(sessions, allocator, font, render_width, render_height, ui_scale, anim_state, grid.cols, grid.rows, grid_font_scale, full_cols, full_rows); + } else { + const new_dims = GridLayout.calculateDimensions(required_slots); + const should_shrink = new_dims.cols < grid.cols or new_dims.rows < grid.rows; + if (should_shrink) { + const can_animate_reflow = animations_enabled and anim_state.mode == .Grid; + const grid_will_resize = new_dims.cols != grid.cols or new_dims.rows != grid.rows; + if (can_animate_reflow) { + if (old_positions) |snapshots| { + var move_result: ?SessionMoves = collectSessionMovesFromSnapshots(sessions, snapshots.items, allocator) catch |err| blk: { + std.debug.print("Failed to collect session moves: {}\n", .{err}); + break :blk null; + }; + if (move_result) |*moves| { + defer moves.list.deinit(allocator); + if (grid_will_resize or moves.moved) { + grid.startResize(new_dims.cols, new_dims.rows, now, render_width, render_height, moves.list.items) catch |err| { + std.debug.print("Failed to start grid resize animation: {}\n", .{err}); + }; + anim_state.mode = .GridResizing; + } else { + grid.cols = new_dims.cols; + grid.rows = new_dims.rows; + } + } else { + grid.cols = new_dims.cols; + grid.rows = new_dims.rows; + } + } else { + grid.cols = new_dims.cols; + grid.rows = new_dims.rows; + } + } else { + grid.cols = new_dims.cols; + grid.rows = new_dims.rows; + } + + cell_width_pixels.* = @divFloor(render_width, @as(c_int, @intCast(grid.cols))); + cell_height_pixels.* = @divFloor(render_height, @as(c_int, @intCast(grid.rows))); + applyTerminalLayout(sessions, allocator, font, render_width, render_height, ui_scale, anim_state, grid.cols, grid.rows, grid_font_scale, full_cols, full_rows); + + if (!sessions[anim_state.focused_session].spawned) { + var new_focus: usize = 0; + for (sessions, 0..) |s, i| { + if (s.spawned) { + new_focus = i; + break; + } + } + anim_state.focused_session = new_focus; + } + std.debug.print("Grid shrunk to {d}x{d} with {d} terminals\n", .{ grid.cols, grid.rows, remaining_count }); + } else { + const can_animate_reflow = animations_enabled and anim_state.mode == .Grid; + if (can_animate_reflow) { + if (old_positions) |snapshots| { + var move_result: ?SessionMoves = collectSessionMovesFromSnapshots(sessions, snapshots.items, allocator) catch |err| blk: { + std.debug.print("Failed to collect session moves: {}\n", .{err}); + break :blk null; + }; + if (move_result) |*moves| { + defer moves.list.deinit(allocator); + if (moves.moved) { + grid.startResize(grid.cols, grid.rows, now, render_width, render_height, moves.list.items) catch |err| { + std.debug.print("Failed to start grid reflow animation: {}\n", .{err}); + }; + anim_state.mode = .GridResizing; + } + } + } + } + if (!sessions[anim_state.focused_session].spawned) { + var new_focus: usize = 0; + for (sessions, 0..) |s, i| { + if (s.spawned) { + new_focus = i; + break; + } + } + anim_state.focused_session = new_focus; + } + } + } +} + +/// Resolve which spawned slot an external close request targets: by session id +/// if given, else by explicit slot_index. Returns null when nothing matches. +fn resolveCloseTargetIndex(sessions: []const *SessionState, request: control.CloseRequest) ?usize { + if (request.session_id) |session_id| { + return findSessionIndexById(sessions, session_id); + } + if (request.slot_index) |slot_index| { + if (slot_index < sessions.len and sessions[slot_index].spawned) return slot_index; + } + return null; +} + +fn handleExternalCloseRequest( + allocator: std.mem.Allocator, + pending: *control.PendingClose, + sessions: []*SessionState, + grid: *GridLayout, + anim_state: *AnimationState, + session_interaction_component: *ui_mod.SessionInteractionComponent, + render_cache: *renderer_mod.RenderCache, + loop: *xev.Loop, + animations_enabled: bool, + now: i64, + render_width: c_int, + render_height: c_int, + ui_scale: f32, + font: *font_mod.Font, + grid_font_scale: f32, + full_cols: *u16, + full_rows: *u16, + cell_width_pixels: *c_int, + cell_height_pixels: *c_int, +) void { + const idx = resolveCloseTargetIndex(sessions, pending.request) orelse { + pending.completion.complete(.{ .failure = .{ + .code = .not_found, + .message = "no Architect session matches the requested identifier", + } }); + return; + }; + + const session_id = sessions[idx].id; + despawnSessionAtIndex( + idx, + allocator, + sessions, + grid, + anim_state, + session_interaction_component, + render_cache, + loop, + animations_enabled, + now, + render_width, + render_height, + ui_scale, + font, + grid_font_scale, + full_cols, + full_rows, + cell_width_pixels, + cell_height_pixels, + ); + + pending.completion.complete(.{ .success = .{ .session_id = session_id } }); +} + fn initSharedFont( allocator: std.mem.Allocator, renderer: *c.SDL_Renderer, @@ -1164,6 +1403,9 @@ pub fn run() !void { var control_queue = ControlQueue{}; defer control_queue.deinit(allocator); + var close_queue = control.CloseQueue{}; + defer close_queue.deinit(allocator); + const notify_sock = try notify.getNotifySocketPath(allocator); defer allocator.free(notify_sock); @@ -1288,6 +1530,7 @@ pub fn run() !void { control_sock, control_discovery_path, &control_queue, + &close_queue, &control_stop, .{ .context = &sdl, @@ -1297,6 +1540,7 @@ pub fn run() !void { defer { control_stop.store(true, .seq_cst); control.failPending(&control_queue, allocator, .app_not_running, "Architect is shutting down"); + control.failPendingClose(&close_queue, allocator, .app_not_running, "Architect is shutting down"); control_thread.join(); control.cleanupControlFiles(control_sock, control_discovery_path); } @@ -2365,7 +2609,7 @@ pub fn run() !void { var control_requests = control_queue.drainAll(); defer control_requests.deinit(allocator); - const had_control_requests = control_requests.items.len > 0; + var had_control_requests = control_requests.items.len > 0; for (control_requests.items) |*request| { handleExternalSpawnRequest( allocator, @@ -2390,6 +2634,33 @@ pub fn run() !void { request.request.deinit(allocator); } + var close_requests = close_queue.drainAll(); + defer close_requests.deinit(allocator); + if (close_requests.items.len > 0) had_control_requests = true; + for (close_requests.items) |*request| { + handleExternalCloseRequest( + allocator, + request, + sessions, + &grid, + &anim_state, + session_interaction_component, + &render_cache, + &loop, + animations_enabled, + now, + render_width, + render_height, + ui_scale, + &font, + config.grid.font_scale, + &full_cols, + &full_rows, + &cell_width_pixels, + &cell_height_pixels, + ); + } + var notifications = notify_queue.drainAll(); defer notifications.deinit(allocator); const had_notifications = notifications.items.len > 0; @@ -2493,151 +2764,27 @@ pub fn run() !void { std.debug.print("Expanding session: {d}\n", .{idx}); }, .DespawnSession => |idx| { - if (idx < sessions.len) { - if (anim_state.mode == .Full and anim_state.focused_session == idx) { - if (animations_enabled) { - grid_nav.startCollapseToGrid(&anim_state, now, cell_width_pixels, cell_height_pixels, render_width, render_height, grid.cols); - } else { - anim_state.mode = .Grid; - } - } - log.info("ui despawn requested idx={d} mode={s} spawned_count={d}", .{ - idx, - @tagName(anim_state.mode), - countSpawnedSessions(sessions), - }); - var old_positions: ?std.ArrayList(SessionIndexSnapshot) = null; - defer if (old_positions) |*snapshots| { - snapshots.deinit(allocator); - }; - if (animations_enabled and anim_state.mode == .Grid) { - old_positions = collectSessionIndexSnapshots(sessions, allocator) catch |err| blk: { - std.debug.print("Failed to snapshot session positions: {}\n", .{err}); - break :blk null; - }; - } - sessions[idx].despawn(allocator); - session_interaction_component.resetView(idx); - sessions[idx].markDirty(); - compactSessions(sessions, session_interaction_component.viewSlice(), &render_cache, &anim_state); - std.debug.print("UI requested despawn: {d}\n", .{idx}); - - // Handle grid contraction - const remaining_count = countSpawnedSessions(sessions); - const max_spawned_idx = highestSpawnedIndex(sessions); - const required_slots = if (max_spawned_idx) |max_idx| max_idx + 1 else 0; - - if (remaining_count == 0) { - // Re-spawn a fresh terminal in slot 0 - sessions[0].ensureSpawnedWithLoop(&loop) catch |err| { - std.debug.print("Failed to respawn terminal: {}\n", .{err}); - }; - anim_state.focused_session = 0; - grid.cols = 1; - grid.rows = 1; - cell_width_pixels = render_width; - cell_height_pixels = render_height; - anim_state.mode = .Full; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); - } else if (remaining_count == 1) { - // Only 1 terminal remains - go directly to Full mode, no resize animation - grid.cols = 1; - grid.rows = 1; - cell_width_pixels = render_width; - cell_height_pixels = render_height; - if (!sessions[anim_state.focused_session].spawned) { - for (sessions, 0..) |s, i| { - if (s.spawned) { - anim_state.focused_session = i; - break; - } - } - } - anim_state.mode = .Full; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); - } else { - const new_dims = GridLayout.calculateDimensions(required_slots); - const should_shrink = new_dims.cols < grid.cols or new_dims.rows < grid.rows; - if (should_shrink) { - const can_animate_reflow = animations_enabled and anim_state.mode == .Grid; - const grid_will_resize = new_dims.cols != grid.cols or new_dims.rows != grid.rows; - if (can_animate_reflow) { - if (old_positions) |snapshots| { - var move_result: ?SessionMoves = collectSessionMovesFromSnapshots(sessions, snapshots.items, allocator) catch |err| blk: { - std.debug.print("Failed to collect session moves: {}\n", .{err}); - break :blk null; - }; - if (move_result) |*moves| { - defer moves.list.deinit(allocator); - if (grid_will_resize or moves.moved) { - grid.startResize(new_dims.cols, new_dims.rows, now, render_width, render_height, moves.list.items) catch |err| { - std.debug.print("Failed to start grid resize animation: {}\n", .{err}); - }; - anim_state.mode = .GridResizing; - } else { - grid.cols = new_dims.cols; - grid.rows = new_dims.rows; - } - } else { - grid.cols = new_dims.cols; - grid.rows = new_dims.rows; - } - } else { - grid.cols = new_dims.cols; - grid.rows = new_dims.rows; - } - } else { - grid.cols = new_dims.cols; - grid.rows = new_dims.rows; - } - - cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid.cols))); - cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid.rows))); - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); - - if (!sessions[anim_state.focused_session].spawned) { - var new_focus: usize = 0; - for (sessions, 0..) |s, i| { - if (s.spawned) { - new_focus = i; - break; - } - } - anim_state.focused_session = new_focus; - } - std.debug.print("Grid shrunk to {d}x{d} with {d} terminals\n", .{ grid.cols, grid.rows, remaining_count }); - } else { - const can_animate_reflow = animations_enabled and anim_state.mode == .Grid; - if (can_animate_reflow) { - if (old_positions) |snapshots| { - var move_result: ?SessionMoves = collectSessionMovesFromSnapshots(sessions, snapshots.items, allocator) catch |err| blk: { - std.debug.print("Failed to collect session moves: {}\n", .{err}); - break :blk null; - }; - if (move_result) |*moves| { - defer moves.list.deinit(allocator); - if (moves.moved) { - grid.startResize(grid.cols, grid.rows, now, render_width, render_height, moves.list.items) catch |err| { - std.debug.print("Failed to start grid reflow animation: {}\n", .{err}); - }; - anim_state.mode = .GridResizing; - } - } - } - } - if (!sessions[anim_state.focused_session].spawned) { - var new_focus: usize = 0; - for (sessions, 0..) |s, i| { - if (s.spawned) { - new_focus = i; - break; - } - } - anim_state.focused_session = new_focus; - } - } - } - } + despawnSessionAtIndex( + idx, + allocator, + sessions, + &grid, + &anim_state, + session_interaction_component, + &render_cache, + &loop, + animations_enabled, + now, + render_width, + render_height, + ui_scale, + &font, + config.grid.font_scale, + &full_cols, + &full_rows, + &cell_width_pixels, + &cell_height_pixels, + ); }, .RequestCollapseFocused => { if (anim_state.mode == .Full) { diff --git a/src/mcp/main.zig b/src/mcp/main.zig index ba35efc..6b6ca89 100644 --- a/src/mcp/main.zig +++ b/src/mcp/main.zig @@ -6,6 +6,7 @@ const protocol_version = "2025-11-25"; const server_name = "architect-mcp"; const server_version = "0.1.0"; const tool_name = "spawn_session"; +const close_tool_name = "close_session"; const JsonRpcErrorCode = enum(i32) { parse_error = -32700, @@ -128,12 +129,27 @@ fn handleToolsCall( try writeJsonRpcError(allocator, stdout_file, id_value, .invalid_params, "tool name is required"); return; }; - if (name_value != .string or !std.mem.eql(u8, name_value.string, tool_name)) { + if (name_value != .string) { try writeJsonRpcError(allocator, stdout_file, id_value, .invalid_params, "unknown tool"); return; } - const arguments = params.object.get("arguments") orelse { + if (std.mem.eql(u8, name_value.string, tool_name)) { + try handleSpawnToolCall(allocator, stdout_file, id_value, params.object.get("arguments")); + } else if (std.mem.eql(u8, name_value.string, close_tool_name)) { + try handleCloseToolCall(allocator, stdout_file, id_value, params.object.get("arguments")); + } else { + try writeJsonRpcError(allocator, stdout_file, id_value, .invalid_params, "unknown tool"); + } +} + +fn handleSpawnToolCall( + allocator: std.mem.Allocator, + stdout_file: std.fs.File, + id_value: ?std.json.Value, + arguments_value: ?std.json.Value, +) !void { + const arguments = arguments_value orelse { try writeToolFailure(allocator, stdout_file, id_value, .invalid_request, "cwd is required"); return; }; @@ -166,6 +182,44 @@ fn handleToolsCall( } } +fn handleCloseToolCall( + allocator: std.mem.Allocator, + stdout_file: std.fs.File, + id_value: ?std.json.Value, + arguments_value: ?std.json.Value, +) !void { + const arguments = arguments_value orelse { + try writeToolFailure(allocator, stdout_file, id_value, .invalid_request, "session_id or slot_index is required"); + return; + }; + const request = control.parseCloseRequestFromValue(arguments) catch |err| { + try writeToolFailure( + allocator, + stdout_file, + id_value, + .invalid_request, + control.parseCloseErrorMessage(err), + ); + return; + }; + + var response = control.connectAndSendCloseRequest(allocator, request) catch |err| { + var message_buf: [160]u8 = undefined; + const message = std.fmt.bufPrint(&message_buf, "failed to contact Architect: {}", .{err}) catch |fmt_err| blk: { + log.debug("failed to format Architect contact error: {}", .{fmt_err}); + break :blk "failed to contact Architect"; + }; + try writeToolFailure(allocator, stdout_file, id_value, .app_not_running, message); + return; + }; + defer response.deinit(allocator); + + switch (response.response) { + .success => |success| try writeCloseToolSuccess(allocator, stdout_file, id_value, success), + .failure => |failure| try writeToolFailure(allocator, stdout_file, id_value, failure.code, failure.message), + } +} + fn writeInitializeResult( allocator: std.mem.Allocator, stdout_file: std.fs.File, @@ -224,6 +278,7 @@ fn writeToolsListResult( try json.objectField("tools"); try json.beginArray(); try writeSpawnSessionTool(&json); + try writeCloseSessionTool(&json); try json.endArray(); try endRpcResult(&json); @@ -329,6 +384,87 @@ fn writeSpawnOutputSchema(json: *std.json.Stringify) !void { try json.endObject(); } +fn writeCloseSessionTool(json: *std.json.Stringify) !void { + try json.beginObject(); + try json.objectField("name"); + try json.write(close_tool_name); + try json.objectField("description"); + try json.write("Ask the running Architect app to close a terminal session and reclaim its grid slot."); + try json.objectField("inputSchema"); + try writeCloseInputSchema(json); + try json.objectField("outputSchema"); + try writeCloseOutputSchema(json); + try json.endObject(); +} + +fn writeCloseInputSchema(json: *std.json.Stringify) !void { + try json.beginObject(); + try json.objectField("type"); + try json.write("object"); + try json.objectField("additionalProperties"); + try json.write(false); + try json.objectField("properties"); + try json.beginObject(); + + try json.objectField("session_id"); + try json.beginObject(); + try json.objectField("type"); + try json.write("integer"); + try json.objectField("description"); + try json.write("The session id returned by spawn_session. Provide this or slot_index."); + try json.endObject(); + + try json.objectField("slot_index"); + try json.beginObject(); + try json.objectField("type"); + try json.write("integer"); + try json.objectField("description"); + try json.write("The grid slot index to close. Provide this or session_id."); + try json.endObject(); + + try json.endObject(); + try json.endObject(); +} + +fn writeCloseOutputSchema(json: *std.json.Stringify) !void { + try json.beginObject(); + try json.objectField("type"); + try json.write("object"); + try json.objectField("required"); + try json.beginArray(); + try json.write("status"); + try json.endArray(); + try json.objectField("properties"); + try json.beginObject(); + + try json.objectField("status"); + try json.beginObject(); + try json.objectField("type"); + try json.write("string"); + try json.endObject(); + + try json.objectField("session_id"); + try json.beginObject(); + try json.objectField("type"); + try json.write("integer"); + try json.endObject(); + + try json.objectField("code"); + try json.beginObject(); + try json.objectField("type"); + try json.write("string"); + try json.endObject(); + + try json.objectField("message"); + try json.beginObject(); + try json.objectField("type"); + try json.write("string"); + try json.endObject(); + + try json.endObject(); + try json.endObject(); +} + fn writeToolSuccess( allocator: std.mem.Allocator, stdout_file: std.fs.File, @@ -365,6 +501,40 @@ fn writeToolSuccess( try writeJsonLine(stdout_file, out.written()); } +fn writeCloseToolSuccess( + allocator: std.mem.Allocator, + stdout_file: std.fs.File, + id_value: ?std.json.Value, + success: control.CloseSuccess, +) !void { + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + var json: std.json.Stringify = .{ .writer = &out.writer }; + + try beginRpcResult(&json, id_value); + try json.objectField("content"); + try json.beginArray(); + try json.beginObject(); + try json.objectField("type"); + try json.write("text"); + try json.objectField("text"); + try json.print("\"Closed Architect session {d}.\"", .{success.session_id}); + try json.endObject(); + try json.endArray(); + try json.objectField("structuredContent"); + try json.beginObject(); + try json.objectField("status"); + try json.write("closed"); + try json.objectField("session_id"); + try json.write(success.session_id); + try json.endObject(); + try json.objectField("isError"); + try json.write(false); + try endRpcResult(&json); + + try writeJsonLine(stdout_file, out.written()); +} + fn writeToolFailure( allocator: std.mem.Allocator, stdout_file: std.fs.File, @@ -458,7 +628,7 @@ fn writeJsonLine(stdout_file: std.fs.File, bytes: []const u8) !void { try stdout_file.writeAll("\n"); } -test "tools/list exposes exactly spawn_session" { +test "tools/list exposes spawn_session and close_session" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); @@ -478,9 +648,38 @@ test "tools/list exposes exactly spawn_session" { const result_value = parsed.value.object.get("result") orelse return error.TestUnexpectedResult; const tools_value = result_value.object.get("tools") orelse return error.TestUnexpectedResult; const tools = tools_value.array; - try std.testing.expectEqual(@as(usize, 1), tools.items.len); - const name_value = tools.items[0].object.get("name") orelse return error.TestUnexpectedResult; - try std.testing.expectEqualStrings(tool_name, name_value.string); + try std.testing.expectEqual(@as(usize, 2), tools.items.len); + const first_name = tools.items[0].object.get("name") orelse return error.TestUnexpectedResult; + const second_name = tools.items[1].object.get("name") orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings(tool_name, first_name.string); + try std.testing.expectEqualStrings(close_tool_name, second_name.string); +} + +test "close tool success response carries status closed and session_id" { + const allocator = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var out = try tmp.dir.createFile("close-success.jsonl", .{ .truncate = true }); + + const id = std.json.Value{ .integer = 3 }; + try writeCloseToolSuccess(allocator, out, id, .{ .session_id = 8 }); + out.close(); + + const input = try tmp.dir.readFileAlloc(allocator, "close-success.jsonl", 16 * 1024); + defer allocator.free(input); + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, std.mem.trim(u8, input, "\n"), .{}); + defer parsed.deinit(); + + const result = (parsed.value.object.get("result") orelse return error.TestUnexpectedResult).object; + const is_error = result.get("isError") orelse return error.TestUnexpectedResult; + try std.testing.expect(!is_error.bool); + const structured = (result.get("structuredContent") orelse return error.TestUnexpectedResult).object; + const status = structured.get("status") orelse return error.TestUnexpectedResult; + const session_id = structured.get("session_id") orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("closed", status.string); + try std.testing.expectEqual(@as(i64, 8), session_id.integer); } test "tool failure response is an MCP tool error result" { From c468a0faca2e70ce7864a543da2ddb9df56c6805 Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:48:47 -0400 Subject: [PATCH 2/2] test(mcp): cover close_session not_found error response --- src/mcp/main.zig | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/mcp/main.zig b/src/mcp/main.zig index 6b6ca89..ac9b041 100644 --- a/src/mcp/main.zig +++ b/src/mcp/main.zig @@ -710,6 +710,34 @@ test "tool failure response is an MCP tool error result" { try std.testing.expectEqualStrings("invalid_cwd", code.string); } +test "close not_found failure surfaces the not_found code" { + const allocator = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var out = try tmp.dir.createFile("close-not-found.jsonl", .{ .truncate = true }); + + const id = std.json.Value{ .integer = 12 }; + // not_found is the close-only error code; a missing session_id/slot_index maps here. + try writeToolFailure(allocator, out, id, .not_found, "no Architect session matches the requested identifier"); + out.close(); + + const input = try tmp.dir.readFileAlloc(allocator, "close-not-found.jsonl", 16 * 1024); + defer allocator.free(input); + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, std.mem.trim(u8, input, "\n"), .{}); + defer parsed.deinit(); + + const result = (parsed.value.object.get("result") orelse return error.TestUnexpectedResult).object; + const is_error = result.get("isError") orelse return error.TestUnexpectedResult; + try std.testing.expect(is_error.bool); + const structured = (result.get("structuredContent") orelse return error.TestUnexpectedResult).object; + const status = structured.get("status") orelse return error.TestUnexpectedResult; + const code = structured.get("code") orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("error", status.string); + try std.testing.expectEqualStrings("not_found", code.string); +} + test "run discards the rest of an oversized line" { const allocator = std.testing.allocator;