From 8af32e5301351d37d2c43e98de183e49d155562c Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 02:43:43 -0400 Subject: [PATCH] feat(session): resume agent sessions across relaunch On quit, capture each pane's agent (Claude Code/Codex/Gemini) session id; on relaunch, reopen each session in place. Two parts make it reliable: - Live capture: a SessionStart hook reports the agent's session id over the notify socket (new agent_session notification + runtime dispatch), so ids are recorded as soon as a session starts instead of only scraped at quit. - Robust resume: the resume command is injected as ARCHITECT_RESUME_CMD and run once by the wrapper rc after the user's shell rc loads -- never typed into the PTY, so it cannot race startup or be eaten by a shell prompt. Also: a normal quit no longer wipes a captured id (teardown only overwrites on a successful scrape), and the focused/zoomed pane is persisted and restored. --- src/app/runtime.zig | 120 +++++++++++++++++++++++++---------- src/app/terminal_history.zig | 12 ++-- src/config.zig | 12 +++- src/session/notify.zig | 38 +++++++++++ src/session/state.zig | 11 ++++ src/shell.zig | 75 +++++++++++++++++++++- 6 files changed, 227 insertions(+), 41 deletions(-) diff --git a/src/app/runtime.zig b/src/app/runtime.zig index e3adb126..8195d788 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -221,6 +221,26 @@ fn persistedAgentSessionId(session: *const SessionState, agent_type: ?[]const u8 return session.agent_session_id; } +/// Build "claude --resume " (etc.) from a restored entry and stash it on the session as +/// `resume_cmd`, to be injected as ARCHITECT_RESUME_CMD at spawn time. Must be called BEFORE +/// the session is spawned. No-op for entries without a captured agent session. +fn setResumeCommandFromEntry( + session: *SessionState, + entry: config_mod.Persistence.TerminalEntry, + allocator: std.mem.Allocator, +) void { + const agent_type_str = entry.agent_type orelse return; + const session_id = entry.agent_session_id orelse return; + if (session_id.len == 0) return; + const agent_kind = session_state.AgentKind.fromString(agent_type_str) orelse return; + const cmd = terminal_history.buildResumeCommand(allocator, agent_kind, session_id) catch |err| { + log.warn("failed to build resume command for slot {d}: {}", .{ session.slot_index, err }); + return; + }; + if (session.resume_cmd) |old| allocator.free(old); + session.resume_cmd = cmd; +} + fn seedSessionAgentMetadataFromEntry( session: *SessionState, entry: config_mod.Persistence.TerminalEntry, @@ -1431,29 +1451,18 @@ pub fn run() !void { defer if (dir_buf) |buf| allocator.free(buf); if (dir_buf) |buf| { const dir: [:0]const u8 = buf[0..entry.path.len :0]; + + // Build the resume command BEFORE spawning: it is injected as + // ARCHITECT_RESUME_CMD into the shell env and run once by the wrapper rc + // after the user's rc loads — no stdin race, can't be eaten by a startup prompt. + setResumeCommandFromEntry(sessions[new_idx], entry, allocator); + sessions[new_idx].ensureSpawnedWithDir(dir, &loop) catch |err| { std.debug.print("Failed to spawn restored terminal {d}: {}\n", .{ new_idx, err }); }; if (sessions[new_idx].spawned) { seedSessionAgentMetadataFromEntry(sessions[new_idx], entry, allocator); } - - queueResumeCommand: { - if (!sessions[new_idx].spawned) break :queueResumeCommand; - const agent_type_str = entry.agent_type orelse break :queueResumeCommand; - const session_id = entry.agent_session_id orelse break :queueResumeCommand; - if (session_id.len == 0) break :queueResumeCommand; - const agent_kind = session_state.AgentKind.fromString(agent_type_str) orelse break :queueResumeCommand; - const resume_cmd = terminal_history.buildResumeCommand(allocator, agent_kind, session_id) catch |err| { - log.warn("failed to build resume command for session {d}: {}", .{ new_idx, err }); - break :queueResumeCommand; - }; - defer allocator.free(resume_cmd); - // Write to pending_write; PTY input queues until the shell reads it after startup. - sessions[new_idx].pending_write.appendSlice(allocator, resume_cmd) catch |err| { - log.warn("failed to queue resume command for session {d}: {}", .{ new_idx, err }); - }; - } } } @@ -1475,11 +1484,21 @@ pub fn run() !void { var quit_teardown = QuitTeardownState{}; defer quit_teardown.join(); - const initial_mode = initial_view_mode; + // Restore focus + zoom from persistence (2c): clamp the focused pane to the restored + // count, and only honor zoom when there is a pane to zoom into. The renderer computes + // the Full rect from the window each frame, so setting .Full directly is sufficient. + const restored_focus: usize = if (persistence.focused_session < initial_terminal_count) + persistence.focused_session + else + 0; + const initial_mode: app_state.ViewMode = if (persistence.zoomed and initial_terminal_count >= 1) + .Full + else + initial_view_mode; var anim_state = AnimationState{ .mode = initial_mode, - .focused_session = 0, - .previous_session = 0, + .focused_session = restored_focus, + .previous_session = restored_focus, .start_time = 0, .start_rect = Rect{ .x = 0, .y = 0, .w = 0, .h = 0 }, .target_rect = Rect{ .x = 0, .y = 0, .w = 0, .h = 0 }, @@ -2356,6 +2375,13 @@ pub fn run() !void { if (terminal_entries_changed) { persistence_dirty = true; } + // Persist focus + zoom so the active/zoomed pane is restored on relaunch (2c). + const cur_zoomed = anim_state.mode == .Full; + if (persistence.focused_session != anim_state.focused_session or persistence.zoomed != cur_zoomed) { + persistence.focused_session = anim_state.focused_session; + persistence.zoomed = cur_zoomed; + persistence_dirty = true; + } savePersistenceIfDirty(&persistence, allocator, &persistence_dirty); if (quit_teardown.isFinished()) { @@ -2412,6 +2438,33 @@ pub fn run() !void { } allocator.free(s.path); }, + .agent_session => |s| { + // Live session-id capture from the agent's SessionStart hook. + // Free the heap strings on every exit path. + defer { + allocator.free(s.agent); + allocator.free(s.session_id); + } + const session_idx = findSessionIndexById(sessions, s.session) orelse continue; + const agent_kind = session_state.AgentKind.fromString(s.agent) orelse continue; + const session = sessions[session_idx]; + // Skip if nothing changed, to avoid needless persistence churn. + const unchanged = session.agent_metadata_captured and + session.agent_kind == agent_kind and + session.agent_session_id != null and + std.mem.eql(u8, session.agent_session_id.?, s.session_id); + if (unchanged) continue; + const id_dupe = allocator.dupe(u8, s.session_id) catch |err| { + log.warn("failed to store agent session id for slot {d}: {}", .{ session_idx, err }); + continue; + }; + if (session.agent_session_id) |old| allocator.free(old); + session.agent_kind = agent_kind; + session.agent_session_id = id_dupe; + session.agent_metadata_captured = true; + persistence_dirty = true; + log.info("captured agent session: slot {d} {s} {s}", .{ session_idx, s.agent, s.session_id }); + }, } } @@ -3086,26 +3139,25 @@ pub fn run() !void { for (quit_teardown.tasks[0..quit_teardown.task_count]) |task| { const session = sessions[task.session_idx]; session.stopQuitCapture(); - if (session.agent_session_id) |sid| { - allocator.free(sid); - session.agent_session_id = null; - } - session.agent_kind = null; - session.agent_metadata_captured = false; const text = session.quitCaptureBytes(); log.debug("quit teardown: session {d} extracted {d} bytes of terminal text", .{ task.session_idx, text.len }); if (terminal_history.extractLastUuid(text)) |uuid| { - log.info("quit teardown: session {d} captured session id: {s}", .{ task.session_idx, uuid }); - session.agent_kind = task.agent_kind; - session.agent_session_id = allocator.dupe(u8, uuid) catch |err| blk: { - log.warn("quit teardown: session {d} failed to allocate session id: {}", .{ task.session_idx, err }); - break :blk null; - }; - if (session.agent_session_id != null) { + // Scrape succeeded: overwrite with the freshest id from the farewell banner. + // Dup first so a failed alloc keeps the existing (hook-captured) id intact. + if (allocator.dupe(u8, uuid)) |new_id| { + if (session.agent_session_id) |sid| allocator.free(sid); + session.agent_session_id = new_id; + session.agent_kind = task.agent_kind; session.agent_metadata_captured = true; + log.info("quit teardown: session {d} captured session id: {s}", .{ task.session_idx, uuid }); + } else |err| { + log.warn("quit teardown: session {d} failed to allocate session id: {}", .{ task.session_idx, err }); } } else { - log.warn("quit teardown: session {d} agent {s} exited but no session id found in output", .{ task.session_idx, task.agent_kind.name() }); + // Scrape failed: KEEP whatever the SessionStart hook captured this run instead + // of wiping it. The live hook is the reliable source of the resume id; the quit + // scrape is only a best-effort fallback for agents without the hook. + log.warn("quit teardown: session {d} agent {s} exited; no id in farewell output, keeping live-captured id (captured={})", .{ task.session_idx, task.agent_kind.name(), session.agent_metadata_captured }); } } } diff --git a/src/app/terminal_history.zig b/src/app/terminal_history.zig index 2f1fd522..adb9331a 100644 --- a/src/app/terminal_history.zig +++ b/src/app/terminal_history.zig @@ -153,13 +153,15 @@ fn matchUuidAt(text: []const u8, start: usize) ?[]const u8 { return text[start..pos]; } -/// Builds the shell command to resume an agent session, including a trailing newline. -/// Caller owns the returned slice and must free it. +/// Builds the shell command to resume an agent session (no trailing newline). It is injected +/// as ARCHITECT_RESUME_CMD and `eval`'d by the wrapper rc after the user's shell rc loads — +/// not typed into the PTY — so it can't be eaten by a startup prompt. Caller owns the +/// returned slice and must free it. pub fn buildResumeCommand(allocator: std.mem.Allocator, agent_kind: AgentKind, session_id: []const u8) ![]u8 { return switch (agent_kind) { - .claude => std.fmt.allocPrint(allocator, "claude --resume {s}\n", .{session_id}), - .codex => std.fmt.allocPrint(allocator, "codex resume {s}\n", .{session_id}), - .gemini => std.fmt.allocPrint(allocator, "gemini --resume {s}\n", .{session_id}), + .claude => std.fmt.allocPrint(allocator, "claude --resume {s}", .{session_id}), + .codex => std.fmt.allocPrint(allocator, "codex resume {s}", .{session_id}), + .gemini => std.fmt.allocPrint(allocator, "gemini --resume {s}", .{session_id}), }; } diff --git a/src/config.zig b/src/config.zig index 2acdf28b..ef8eacd4 100644 --- a/src/config.zig +++ b/src/config.zig @@ -326,6 +326,10 @@ pub const Persistence = struct { terminal_entries: std.ArrayListUnmanaged(TerminalEntry) = .{}, recent_folders: std.ArrayListUnmanaged(RecentFolder) = .{}, visit_counter: u32 = 0, + /// Focused pane index and whether it was zoomed (Full mode) at save time, so the + /// active/zoomed pane is restored on relaunch instead of defaulting to the first. + focused_session: usize = 0, + zoomed: bool = false, const TomlPersistenceV3 = struct { window: WindowConfig = .{}, @@ -334,6 +338,8 @@ pub const Persistence = struct { terminal_agent_types: ?[]const []const u8 = null, terminal_session_ids: ?[]const []const u8 = null, recent_folders: ?toml.HashMap(u32) = null, + focused_session: usize = 0, + zoomed: bool = false, }; const TomlPersistenceV2 = struct { @@ -386,6 +392,8 @@ pub const Persistence = struct { defer result.deinit(); persistence.window = result.value.window; persistence.font_size = result.value.font_size; + persistence.focused_session = result.value.focused_session; + persistence.zoomed = result.value.zoomed; if (result.value.terminals) |paths| { for (paths, 0..) |path, idx| { @@ -477,8 +485,10 @@ pub const Persistence = struct { } pub fn serializeToWriter(self: Persistence, writer: anytype) !void { - // Write font_size first (top-level scalar) + // Write top-level scalars first (must precede any [section] in TOML). try writer.print("font_size = {d}\n", .{self.font_size}); + try writer.print("focused_session = {d}\n", .{self.focused_session}); + try writer.print("zoomed = {}\n", .{self.zoomed}); // Write terminal path and agent arrays before any sections if (self.terminal_entries.items.len > 0) { diff --git a/src/session/notify.zig b/src/session/notify.zig index 2b17e6b1..f1dc4547 100644 --- a/src/session/notify.zig +++ b/src/session/notify.zig @@ -8,6 +8,7 @@ const log = std.log.scoped(.notify); pub const Notification = union(enum) { status: StatusNotification, story: StoryNotification, + agent_session: AgentSessionNotification, }; pub const StatusNotification = struct { @@ -21,6 +22,17 @@ pub const StoryNotification = struct { path: []const u8, }; +/// Reports the live agent (e.g. Claude Code) session id for a pane, sent by the +/// agent's SessionStart hook. Lets Architect capture the resume id reliably the +/// moment a session starts — instead of scraping it during quit teardown. +pub const AgentSessionNotification = struct { + session: usize, + /// Heap-allocated agent name (e.g. "claude"); caller must free. + agent: []const u8, + /// Heap-allocated session id (UUID); caller must free. + session_id: []const u8, +}; + pub const NotificationQueue = struct { mutex: std.Thread.Mutex = .{}, items: std.ArrayListUnmanaged(Notification) = .{}, @@ -75,12 +87,17 @@ fn notificationSessionId(note: Notification) usize { return switch (note) { .status => |s| s.session, .story => |s| s.session, + .agent_session => |s| s.session, }; } fn releaseNotification(allocator: std.mem.Allocator, note: Notification) void { switch (note) { .story => |s| allocator.free(s.path), + .agent_session => |s| { + allocator.free(s.agent); + allocator.free(s.session_id); + }, .status => {}, } } @@ -150,6 +167,27 @@ pub fn startNotifyThread( .path = path_dupe, } }; } + if (tv == .string and std.mem.eql(u8, tv.string, "agent_session")) { + const agent_val = obj.get("agent") orelse return null; + const session_id_val = obj.get("session_id") orelse return null; + if (agent_val != .string or session_id_val != .string) return null; + if (session_id_val.string.len == 0) return null; + // Allocate with persistent allocator so they survive arena cleanup. + const agent_dupe = persistent_alloc.dupe(u8, agent_val.string) catch |err| { + log.err("failed to duplicate agent name for session {d}: {}", .{ session_idx, err }); + return null; + }; + const session_id_dupe = persistent_alloc.dupe(u8, session_id_val.string) catch |err| { + log.err("failed to duplicate agent session id for session {d}: {}", .{ session_idx, err }); + persistent_alloc.free(agent_dupe); + return null; + }; + return Notification{ .agent_session = .{ + .session = session_idx, + .agent = agent_dupe, + .session_id = session_id_dupe, + } }; + } } // Default: status notification diff --git a/src/session/state.zig b/src/session/state.zig index 59462c7d..efe66ad9 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -122,6 +122,11 @@ pub const SessionState = struct { /// True only when agent_kind/agent_session_id were captured during the current run's quit flow. /// Restored metadata is used for startup resume injection, but must not be re-persisted as fresh data. agent_metadata_captured: bool = false, + /// Resume command (e.g. "claude --resume ") injected as ARCHITECT_RESUME_CMD into the + /// next spawn's environment. The wrapper rc runs it ONCE after the user's shell rc finishes + /// loading — so it never races stdin and can't be eaten by a startup prompt. Owned; freed in + /// teardown. Null for normal (non-restored) panes. + resume_cmd: ?[]u8 = null, /// Raw PTY output captured after quit teardown starts. Used to avoid extracting /// stale UUIDs from earlier scrollback. quit_capture: std.ArrayListUnmanaged(u8) = .empty, @@ -207,6 +212,7 @@ pub const SessionState = struct { &self.session_id_z, self.notify_sock_z, working_dir, + self.resume_cmd, ); errdefer { var s = shell; @@ -306,6 +312,11 @@ pub const SessionState = struct { self.agent_kind = null; self.agent_metadata_captured = false; + if (self.resume_cmd) |cmd| { + allocator.free(cmd); + self.resume_cmd = null; + } + if (self.cwd_path) |path| { allocator.free(path); self.cwd_path = null; diff --git a/src/shell.zig b/src/shell.zig index e6c82c62..74c5a4b7 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -68,7 +68,9 @@ const architect_command_script = \\ \\CLAUDE_DONE = "architect notify done || true" \\CLAUDE_APPROVAL = "architect notify awaiting_approval || true" + \\CLAUDE_SESSION = "architect agent-session || true" \\CLAUDE_NEEDLES = ("architect notify", "architect_notify.py") + \\CLAUDE_SESSION_NEEDLES = ("architect agent-session",) \\ \\GEMINI_DONE = "python3 ~/.gemini/architect_hook.py done" \\GEMINI_APPROVAL = "python3 ~/.gemini/architect_hook.py awaiting_approval" @@ -96,6 +98,44 @@ const architect_command_script = \\ except Exception: \\ pass \\ + \\def send_agent_session(session_id: str) -> None: + \\ slot = os.environ.get("ARCHITECT_SESSION_ID") + \\ sock_path = os.environ.get("ARCHITECT_NOTIFY_SOCK") + \\ if not slot or not sock_path or not session_id: + \\ return + \\ try: + \\ message = json.dumps({ + \\ "session": int(slot), + \\ "type": "agent_session", + \\ "agent": "claude", + \\ "session_id": session_id, + \\ }) + "\n" + \\ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + \\ sock.connect(sock_path) + \\ sock.sendall(message.encode()) + \\ sock.close() + \\ except Exception: + \\ pass + \\ + \\def cmd_agent_session() -> int: + \\ # Invoked as a Claude Code SessionStart hook. Claude pipes the hook event + \\ # JSON (which carries "session_id") to stdin; forward that id to Architect. + \\ try: + \\ raw = sys.stdin.read() + \\ except Exception: + \\ return 0 + \\ if not raw.strip(): + \\ return 0 + \\ try: + \\ payload = json.loads(raw) + \\ except Exception: + \\ return 0 + \\ if isinstance(payload, dict): + \\ session_id = payload.get("session_id") + \\ if isinstance(session_id, str) and session_id: + \\ send_agent_session(session_id) + \\ return 0 + \\ \\def state_from_notification(raw: str) -> str | None: \\ raw = raw.strip() \\ if not raw: @@ -310,6 +350,13 @@ const architect_command_script = \\ group["hooks"].append({"type": "command", "command": CLAUDE_APPROVAL}) \\ changed = True \\ + \\ session_groups = hooks.setdefault("SessionStart", []) + \\ if not hooks_have_needles(session_groups, CLAUDE_SESSION_NEEDLES): + \\ # Append a fresh matcher-less group so it runs on every session start + \\ # (startup/resume/clear/compact), independent of any existing groups. + \\ session_groups.append({"hooks": [{"type": "command", "command": CLAUDE_SESSION}]}) + \\ changed = True + \\ \\ return changed \\ \\def ensure_gemini_hooks(data: dict) -> bool: @@ -447,6 +494,8 @@ const architect_command_script = \\ return 0 \\ notify_architect(state) \\ return 0 + \\ if cmd == "agent-session": + \\ return cmd_agent_session() \\ if cmd == "hook": \\ if len(sys.argv) < 3: \\ print_usage() @@ -607,6 +656,17 @@ const architect_zsh_rc_script = \\unset orig \\unset wrapper \\unset source_dir + \\# Architect: run the restore/resume command ONCE, after the user's rc has + \\# finished loading (so `claude`/PATH are ready) and stdin is the live tty. + \\# It is executed, never typed into the PTY, so a startup prompt (e.g. an + \\# oh-my-zsh update [Y/n]) cannot swallow it. Unset first so the launched + \\# program and any nested shells do not re-run it. + \\if [ -n "${ARCHITECT_RESUME_CMD:-}" ]; then + \\ architect__resume_cmd="$ARCHITECT_RESUME_CMD" + \\ unset ARCHITECT_RESUME_CMD + \\ eval "$architect__resume_cmd" + \\ unset architect__resume_cmd + \\fi \\ ; @@ -1020,8 +1080,9 @@ pub const Shell = struct { const name_session: [:0]const u8 = "ARCHITECT_SESSION_ID\x00"; const name_sock: [:0]const u8 = "ARCHITECT_NOTIFY_SOCK\x00"; + const name_resume: [:0]const u8 = "ARCHITECT_RESUME_CMD\x00"; - pub fn spawn(shell_path: []const u8, size: pty_mod.winsize, session_id: [:0]const u8, notify_sock: [:0]const u8, working_dir: ?[:0]const u8) SpawnError!Shell { + pub fn spawn(shell_path: []const u8, size: pty_mod.winsize, session_id: [:0]const u8, notify_sock: [:0]const u8, working_dir: ?[:0]const u8, resume_cmd: ?[]const u8) SpawnError!Shell { // Ensure terminfo is set up (parent process, before fork) ensureTerminfoSetup(); ensureArchitectCommandSetup(); @@ -1052,6 +1113,18 @@ pub const Shell = struct { if (libc.setenv(name_sock.ptr, notify_sock, 1) != 0) { std.c._exit(1); } + if (resume_cmd) |rc| { + // Null-terminate on the stack (no sentinel heap alloc) for setenv. + var rbuf: [512]u8 = undefined; + if (rc.len < rbuf.len) { + @memcpy(rbuf[0..rc.len], rc); + rbuf[rc.len] = 0; + const rc_z = rbuf[0..rc.len :0]; + if (libc.setenv(name_resume.ptr, rc_z, 1) != 0) { + std.c._exit(1); + } + } + } // Finder launches provide a nearly empty environment; seed common // terminal vars so shells behave like real terminals (color, terminfo).