Skip to content
Open
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
120 changes: 86 additions & 34 deletions src/app/runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,26 @@ fn persistedAgentSessionId(session: *const SessionState, agent_type: ?[]const u8
return session.agent_session_id;
}

/// Build "claude --resume <id>" (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,
Expand Down Expand Up @@ -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 });
};
}
}
}

Expand All @@ -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 },
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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 });
},
}
}

Expand Down Expand Up @@ -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 });
}
}
}
Expand Down
12 changes: 7 additions & 5 deletions src/app/terminal_history.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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}),
};
}

Expand Down
12 changes: 11 additions & 1 deletion src/config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 = .{},
Expand All @@ -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 {
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions src/session/notify.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) = .{},
Expand Down Expand Up @@ -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 => {},
}
}
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/session/state.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>") 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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading