Compare commits

..
4 Commits
10 changed files with 354 additions and 9 deletions

No files matched your search

+7
View File
@@ -498,6 +498,13 @@ a terminal leaf). A terminal that has since exited reopens as a fresh shell in
the recorded directory, as it would without herdr-core. Shells in the core can
also be reached from anywhere else with `hc ls` and `hc attach`.
Since the shells outlive every restart, so does anything wrong with them — an
environment variable they started with, say. `mise run fresh-shells` replaces
them all while keeping the layout: it quits playpen (saving the session), ends
every terminal playpen started in the core, and relaunches, so each pane comes
back as a new shell in its last directory. It lists what is running and asks
first, and it is safe to run from inside one of those panes.
The file is `session.json`, and it lives in the **state** directory rather than
beside `layouts.json` and `settings.json`:
+1
View File
@@ -7,6 +7,7 @@
-->
<gresources>
<gresource prefix="/dev/greyson/playpen/icons">
<file compressed="true">scalable/actions/chat-message-new-symbolic.svg</file>
<file compressed="true">scalable/actions/document-edit-symbolic.svg</file>
<file compressed="true">scalable/actions/edit-undo-symbolic.svg</file>
<file compressed="true">scalable/actions/emblem-system-symbolic.svg</file>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 3 1 c -1.644531 0 -3 1.355469 -3 3 v 6 c 0 1.644531 1.355469 3 3 3 h 1 v 3 l 3 -3 v -1 c 0 -0.550781 -0.449219 -1 -1 -1 h -3 c -0.570312 0 -1 -0.429688 -1 -1 v -6 c 0 -0.554688 0.445312 -1 1 -1 h 10 c 0.554688 0 1 0.445312 1 1 v 4 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -4 c 0 -1.644531 -1.355469 -3 -3 -3 z m 8 7 v 3 h -3 v 2 h 3 v 3 h 2 v -3 h 3 v -2 h -3 v -3 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 548 B

+9 -5
View File
@@ -180,17 +180,21 @@ pub fn build(b: *std.Build) void {
palette_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt"));
test_step.dependOn(&b.addRunArtifact(palette_tests).step);
// herdr-core's frame format is its own root: the bytes of the wire
// protocol, testable with no core to talk to. The connection on top of it
// herdr-core's client is its own root: the frame format (the bytes of the
// wire protocol) and the pure rules about what a terminal's environment
// keeps, both testable with no core to talk to. The connection itself
// wants a socket and a main loop, and is checked by running it.
const hc_frame_tests = b.addTest(.{
const hc_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/hc/frame.zig"),
.root_source_file = b.path("src/hc.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
}),
});
test_step.dependOn(&b.addRunArtifact(hc_frame_tests).step);
hc_tests.root_module.addImport("glib", gobject.module("glib2"));
hc_tests.root_module.addImport("glibunix", gobject.module("glibunix2"));
test_step.dependOn(&b.addRunArtifact(hc_tests).step);
// And a root for the settings file itself. `Settings.zig` reaches GLib for
// where the file lives, but reading and writing its *contents* is pure
+4
View File
@@ -184,6 +184,10 @@ done
rmdir "$backups" 2>/dev/null || true
'''
[tasks.fresh-shells]
description = "Replace every herdr-core shell playpen keeps with a fresh one, keeping the layout"
run = "tools/fresh-shells.sh"
[tasks.install]
description = "Install playpen into ~/.local with a desktop entry and icon"
depends = ["build"]
+15
View File
@@ -506,6 +506,13 @@ fn buildHeader(self: *Pane) void {
_ = gtk.Button.signals.clicked.connect(review_button, *Pane, &onReviewClicked, self, .{});
header.append(review_button.as(gtk.Widget));
const agent_review = gtk.Button.newFromIconName("chat-message-new-symbolic");
agent_review.as(gtk.Widget).addCssClass("flat");
agent_review.as(gtk.Widget).addCssClass("playpen-pane-button");
agent_review.as(gtk.Widget).setTooltipText("Have Claude review this tab's changes in the review pane");
_ = gtk.Button.signals.clicked.connect(agent_review, *Pane, &onAgentReviewClicked, self, .{});
header.append(agent_review.as(gtk.Widget));
// Last before close, so the destructive button stays on the end where it
// is expected and the zoom toggle sits with the other view controls.
self.zoom.as(gtk.Widget).addCssClass("flat");
@@ -750,6 +757,10 @@ fn onContentFocus(ctx: ?*anyopaque) void {
}
fn onSplitClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
// The button belongs to this pane, which need not be the focused one: a
// header click does not move focus. Focusing it first makes the new
// terminal split this pane and start in this pane's directory.
self.view.setFocused(self);
self.view.addPane(.plain(.terminal)) catch |err| {
std.log.err("failed to open terminal: {s}", .{@errorName(err)});
};
@@ -765,6 +776,10 @@ fn onReviewClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
self.view.requestReview();
}
fn onAgentReviewClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
self.view.startAgentReview(self);
}
fn onZoomClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
self.view.toggleZoom(self);
}
+2 -1
View File
@@ -678,7 +678,8 @@ fn reattach(self: *Session, address: []const u8) !Remote {
}
/// The environment a herdr-core shell is started with: this process's, as the
/// local pty would hand on, on top of the core's own.
/// local pty would hand on — and only that, since `hc.run` clears whatever the
/// core's own environment would otherwise add.
///
/// Passed whole rather than left to the core, whose environment is whatever
/// the session that first started it had — not necessarily the one this app
+66 -1
View File
@@ -302,7 +302,22 @@ pub fn addPane(self: *View, raw: Pane.Spec) !void {
if (raw.kind() == .review and self.reviewPane() != null) {
return error.ReviewAlreadyOpen;
}
const spec = self.stamp(raw);
// A terminal opened without a directory of its own starts where the pane
// it splits is working, so a new shell beside a `cd`-ed one lands in the
// same place rather than back in $HOME. Only borrowed for the length of
// `Pane.create`, which spawns the shell before returning.
var dir_buf: [std.fs.max_path_bytes]u8 = undefined;
const inherited: Pane.Spec = switch (raw) {
.terminal => |opts| if (opts.cwd.len == 0 and opts.hc.len == 0) blk: {
const from = self.focusedPane() orelse break :blk raw;
var with_dir = opts;
with_dir.cwd = self.paneDirectory(from, &dir_buf);
break :blk .{ .terminal = with_dir };
} else raw,
else => raw,
};
const spec = self.stamp(inherited);
const pane = try Pane.create(self.alloc, self, spec);
errdefer pane.destroy();
@@ -355,6 +370,56 @@ pub fn requestReview(self: *View) void {
self.on_review(self.ctx);
}
/// What the pane header's agent-review button types into the new shell.
const agent_review_command = "claude '/pr-review using /leave-review'";
/// Open a Claude Code session beside `from`, in the directory `from` is working
/// in, and have it review the tab's diff into the review pane.
///
/// `/leave-review` posts to this tab's review pane and stops if there isn't
/// one, so a missing pane is opened first rather than leaving the session to
/// fail on arrival. `from` is focused before that, so the review is bound to
/// the same directory the session starts in.
pub fn startAgentReview(self: *View, from: *Pane) void {
var buf: [std.fs.max_path_bytes]u8 = undefined;
const cwd = self.paneDirectory(from, &buf);
if (self.reviewPane() == null) {
self.setFocused(from);
self.requestReview();
}
self.addPane(.{ .terminal = .{
.cwd = cwd,
.command = agent_review_command,
} }) catch |err| {
std.log.err("failed to open the agent review: {s}", .{@errorName(err)});
};
}
/// The directory `pane` is working in, copied into `buf`, or empty to leave it
/// to the shell's default.
///
/// A terminal answers for itself, a review pane with the repository it is bound
/// to; a web pane has no directory, so it borrows the first terminal's in the
/// tab.
fn paneDirectory(self: *View, pane: *Pane, buf: []u8) []const u8 {
switch (pane.content) {
.terminal => |t| if (t.session.cwd(buf)) |dir| return dir,
.review => if (self.review_spec.repo.len > 0) {
const n = @min(buf.len, self.review_spec.repo.len);
@memcpy(buf[0..n], self.review_spec.repo[0..n]);
return buf[0..n];
},
.web => {},
}
for (self.panes.items) |other| {
const t = other.terminal() orelse continue;
if (t.session.cwd(buf)) |dir| return dir;
}
return "";
}
pub fn closePane(self: *View, pane: *Pane) void {
if (self.closing) return;
+97 -2
View File
@@ -152,7 +152,8 @@ pub const Attached = struct {
pub const RunOptions = struct {
argv: []const []const u8,
cwd: ?[]const u8 = null,
/// `NAME=value`, set on top of the core's own environment.
/// `NAME=value`: the program's whole environment. Not merely added to the
/// core's — see `run`.
env: []const []const u8 = &.{},
};
@@ -174,6 +175,9 @@ const Core = struct {
next_id: u64 = 1,
/// The core's process, asked for once per connection — see `coreEnviron`.
pid: ?i32 = null,
streams: std.AutoHashMapUnmanaged(u32, Stream) = .empty,
/// Set while `dispatch` runs, which must not nest.
@@ -651,13 +655,40 @@ fn writeAddress(address: []const u8) Error![]u8 {
/// is forgotten straight away instead of piling up in `hc ls --all`. It has
/// no parent: a pane is nobody's child, and with none the core types no notes
/// about it into anything.
///
/// The program gets exactly `opts.env`, the way a child of this process would.
/// The core only *adds* what it is given to its own environment, and its own is
/// whatever the shell that happened to start it had: start it from a Claude
/// Code session and every terminal it runs inherits that session's markers
/// (and so every `claude` in them believes it is a subagent), start it from a
/// nested compositor and they all inherit its `SWAYSOCK`. So the program is
/// started through `env -u`, unsetting each variable the core has and `opts`
/// does not — names only, so no value of the core's ends up in `hc ls`.
pub fn run(alloc: std.mem.Allocator, opts: RunOptions) Error!Started {
const self = connection() orelse return error.Unavailable;
const environ = coreEnviron(self);
defer if (environ) |e| glib.free(e.ptr);
var argv: std.ArrayListUnmanaged([]const u8) = .empty;
defer argv.deinit(gpa);
if (environ) |e| {
var it = std.mem.splitScalar(u8, e, 0);
while (it.next()) |entry| {
const eq = std.mem.indexOfScalar(u8, entry, '=') orelse continue;
const name = entry[0..eq];
if (name.len == 0 or coreOwned(name) or setIn(name, opts.env)) continue;
try argv.appendSlice(gpa, &.{ "-u", name });
}
if (argv.items.len > 0) try argv.insert(gpa, 0, envBinary());
}
try argv.appendSlice(gpa, opts.argv);
var p: Json = undefined;
p.init();
defer p.deinit();
writeRunParams(&p.j, opts) catch return error.OutOfMemory;
writeRunParams(&p.j, .{ .argv = argv.items, .cwd = opts.cwd, .env = opts.env }) catch
return error.OutOfMemory;
const reply = try call(self, "terminal.run", p.written());
defer reply.deinit();
@@ -669,6 +700,55 @@ pub fn run(alloc: std.mem.Allocator, opts: RunOptions) Error!Started {
};
}
/// The core's own environment, NUL-separated, to be freed with `glib.free`.
/// Null when it can't be read, which leaves programs with the core's leftovers
/// as before rather than failing to start them.
///
/// Read out of procfs because the API has no call for it. The core always runs
/// on this machine here — nothing in this app starts terminals `on` another.
fn coreEnviron(self: *Core) ?[]u8 {
const pid = self.pid orelse blk: {
const reply = call(self, "server.status", "{}") catch return null;
defer reply.deinit();
const pid: i32 = std.math.cast(i32, intField(result(reply), "pid") orelse return null) orelse
return null;
self.pid = pid;
break :blk pid;
};
var path_buf: [64]u8 = undefined;
const path = std.fmt.bufPrintSentinel(&path_buf, "/proc/{d}/environ", .{pid}, 0) catch return null;
var contents: [*]u8 = undefined;
var length: usize = 0;
if (glib.fileGetContents(path.ptr, &contents, &length, null) == 0) {
std.log.warn("herdr-core: could not read the core's environment; terminals inherit it", .{});
return null;
}
return contents[0..length];
}
/// Variables the core sets for each terminal itself, which must survive `env -u`
/// even when its own environment happens to hold them too.
fn coreOwned(name: []const u8) bool {
return std.mem.startsWith(u8, name, "HC_") or
std.mem.eql(u8, name, "TERM_PROGRAM") or
std.mem.eql(u8, name, "JOURNAL_STREAM");
}
/// Whether `env` (`NAME=value` entries) sets `name`.
fn setIn(name: []const u8, env: []const []const u8) bool {
for (env) |entry| {
if (entry.len > name.len and entry[name.len] == '=' and std.mem.startsWith(u8, entry, name)) return true;
}
return false;
}
/// `env` by path where it has one, so finding it doesn't depend on the PATH of
/// the very environment it is there to correct.
fn envBinary() []const u8 {
return if (c.access("/usr/bin/env", X_OK) == 0) "/usr/bin/env" else "env";
}
fn writeRunParams(j: *std.json.Stringify, opts: RunOptions) !void {
try j.beginObject();
try j.objectField("argv");
@@ -822,6 +902,21 @@ pub fn detach(channel: u32) void {
sendFrame(self, channel, .close, &.{}) catch {};
}
test "a variable counts as set only by its exact name" {
const env = [_][]const u8{ "PATH=/bin", "CLAUDE=1", "EMPTY=" };
try std.testing.expect(setIn("PATH", &env));
try std.testing.expect(setIn("EMPTY", &env));
try std.testing.expect(!setIn("PAT", &env));
try std.testing.expect(!setIn("CLAUDECODE", &env));
}
test "the core's own per-terminal variables are never unset" {
try std.testing.expect(coreOwned("HC_TERMINAL_ID"));
try std.testing.expect(coreOwned("TERM_PROGRAM"));
try std.testing.expect(!coreOwned("CLAUDE_CODE_CHILD_SESSION"));
try std.testing.expect(!coreOwned("SWAYSOCK"));
}
test {
_ = frame;
}
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# Replace every shell playpen keeps in herdr-core with a fresh one, keeping the
# layout.
#
# tools/fresh-shells.sh list what will be ended, ask, then do it
# tools/fresh-shells.sh -y don't ask
#
# Acts on the hc session in $HC_SESSION (the default one when unset), and
# relaunches with $PLAYPEN_LAUNCHER (the installed launcher when unset).
#
# A program's environment is fixed when it starts. Shells that came from a core
# with something wrong in its environment keep it for good, and since playpen
# reattaches to them on every launch, restarting playpen never clears it. And
# `hc server restart` cannot either: it is a planned restart that keeps every
# program running for the next core to adopt.
#
# So, in order:
# 1. Quit playpen with SIGTERM, which saves the session first. This has to
# come before the shells go: a pane whose shell exits closes, and closing
# the last tab deletes the session.
# 2. Kill every terminal playpen started in the core (label playpen=1).
# 3. Start playpen again. It restores the session, finds those terminals gone,
# and opens a fresh shell in each pane's last directory.
#
# Whatever is running in those shells ends with them — agents included.
set -euo pipefail
unit_prefix="playpen-fresh-shells"
launcher="${PLAYPEN_LAUNCHER:-$HOME/.local/bin/playpen}"
# By path: the detached half runs under systemd, whose PATH has no ~/.local/bin.
hc_bin=$(command -v hc || echo "$HOME/.local/bin/hc")
die() { echo "fresh-shells: $*" >&2; exit 1; }
say() { echo "fresh-shells: $*"; }
# Every hc call runs without this process's Claude Code markers. Any hc command
# starts a core when none is running, and a core started with them hands them
# to every terminal it ever runs — which is how this script came to be needed.
hc() {
local unset=()
local name
for name in $(compgen -e); do
case "$name" in CLAUDE*) unset+=(-u "$name") ;; esac
done
env "${unset[@]}" "$hc_bin" "$@"
}
[ -x "$hc_bin" ] || die "hc is not installed"
command -v jq >/dev/null || die "needs jq"
[ -x "$launcher" ] || die "no playpen launcher at $launcher (run mise run install)"
yes=false
[ "${1:-}" = "-y" ] && yes=true
terminals() {
hc ls --label playpen=1 --json | jq -r '.terminals[].address'
}
# Whether this script is running in something step 1 or 2 would end: a pane of
# playpen's own (a local shell, which quitting hangs up), or a terminal in the
# core that step 2 kills.
inside_playpen() {
if [ -n "${HC_TERMINAL_ID:-}" ] &&
[ "$(hc get "$HC_TERMINAL_ID" --json 2>/dev/null | jq -r '.labels.playpen // empty')" = 1 ]; then
return 0
fi
local pid=$$
while [ "$pid" -gt 1 ]; do
[ "$(cat "/proc/$pid/comm" 2>/dev/null)" = playpen ] && return 0
pid=$(awk '{print $4}' "/proc/$pid/stat" 2>/dev/null) || return 1
done
return 1
}
if [ -z "${PLAYPEN_FRESH_SHELLS_DETACHED:-}" ]; then
mapfile -t addrs < <(terminals)
if [ "${#addrs[@]}" -eq 0 ]; then
say "playpen has no terminals in herdr-core; nothing to do"
exit 0
fi
echo "These ${#addrs[@]} terminals will be ended, and everything running in them:"
for a in "${addrs[@]}"; do
hc get "$a" --json | jq -r '" \(.address) \((.foreground.name // "?"))\t\(.foreground.cwd // .cwd)"'
done
if ! $yes; then
read -r -p "Quit playpen, end them, and reopen with fresh shells? [y/N] " answer
case "$answer" in y | Y | yes) ;; *) say "nothing done"; exit 0 ;; esac
fi
# Hand the rest to systemd, which is not inside anything about to end.
if inside_playpen; then
unit="$unit_prefix-$$"
systemd-run --user --collect --quiet --unit="$unit" \
--setenv=PLAYPEN_FRESH_SHELLS_DETACHED=1 \
${HC_SESSION:+--setenv=HC_SESSION="$HC_SESSION"} \
${PLAYPEN_LAUNCHER:+--setenv=PLAYPEN_LAUNCHER="$PLAYPEN_LAUNCHER"} \
"$(readlink -f "$0")" -y
say "this shell is one of them, so the rest runs on its own as $unit."
say "playpen will close and come back in a few seconds."
say "if it doesn't: journalctl --user -u $unit"
exit 0
fi
fi
# 1. Quit playpen, saving the session. Only the ones on this hc session: a
# playpen talking to another core has nothing to do with these terminals.
mapfile -t pids < <(
for p in $(pgrep -u "$(id -u)" -x playpen); do
theirs=$(tr '\0' '\n' <"/proc/$p/environ" 2>/dev/null | sed -n 's/^HC_SESSION=//p')
[ "$theirs" = "${HC_SESSION:-}" ] && echo "$p"
done
)
if [ "${#pids[@]}" -gt 0 ]; then
say "quitting playpen (${pids[*]})"
kill -TERM "${pids[@]}"
for _ in $(seq 1 100); do
alive=false
for p in "${pids[@]}"; do kill -0 "$p" 2>/dev/null && alive=true; done
$alive || break
sleep 0.1
done
# Better to leave the shells alone than to end them under a playpen that is
# still up, which would close its panes and lose the layout.
$alive && die "playpen did not quit within 10s; nothing else done"
fi
# 2. End playpen's terminals.
mapfile -t addrs < <(terminals)
say "ending ${#addrs[@]} terminals"
for a in "${addrs[@]}"; do
# `rm` terminals are gone the moment they exit, so kill reports them as
# "removed" even when it worked.
hc kill "$a" >/dev/null 2>&1 || true
done
for _ in $(seq 1 100); do
[ -z "$(terminals)" ] && break
sleep 0.1
done
left=$(terminals)
[ -n "$left" ] && die "still running: $(echo "$left" | tr '\n' ' ')— not relaunching"
# 3. Start playpen again, from systemd rather than from this shell, so it gets
# the session's environment and not whatever this script was run from.
systemd-run --user --collect --quiet --unit="app-playpen-$$" \
${HC_SESSION:+--setenv=HC_SESSION="$HC_SESSION"} "$launcher"
say "playpen relaunched with fresh shells"