Skip to content
Merged
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ already there, starting one if not.
ISSUE STATUS BRANCH AGE
❯ PE-256 ● waiting feature/pe-256-app-hangs-on-launch 4s
PE-270 ◐ active feature/pe-270-crash-in-mapview 12s
PE-301 ◈ plan feature/pe-301-widget-refresh 31s
PE-9 · no session feature/pe-9-unrelated —

↑↓ move · enter opens · n new issue · x kill · q quit
Expand All @@ -127,6 +128,13 @@ prompt or a question. `◐ active` is a turn in flight, `○ idle` is finished.
Those come from Claude Code's own hooks rather than from reading its screen, so
a new Claude Code release cannot quietly make them wrong.

`◈ plan` is a turn in flight too, in Claude Code's plan mode — it is researching
and writing a plan, and has not been approved to touch any files yet. Since
`planMode` defaults to on, every session starts there and leaves when you
approve the plan, so the marker going away is the useful half of it. A session
that is *blocked* on that approval reads `● waiting`, not `◈ plan`: the prompt
in front of you outranks the mode behind it.

Enter attaches, and from there the terminal is entirely Claude Code's — lcc
writes nothing of its own over it. **`^\` returns to the dashboard** without
touching the session. `^C` still reaches the agent, which is the point of not
Expand Down
2 changes: 1 addition & 1 deletion src/commands/watch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ pub fn hook(app: app_mod.App, opts: HookOpts) !void {
// `opts.socket`, not the environment: the daemon baked its own socket into
// this command line, and the environment here is the session's — whichever
// shell started it. See `watch_client.connectAt`.
watch_client.report(app, opts.socket, payload.cwd, payload.session_id, event);
watch_client.report(app, opts.socket, payload.cwd, payload.session_id, event, payload.permission_mode);
}

test "the --json keys name sessions, never the process behind them" {
Expand Down
30 changes: 26 additions & 4 deletions src/daemon.zig
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ fn handleFrame(loop: *Loop, client: *Client, frame: wire.Frame, at: i64) void {
// session it did not start — ignored rather than an error, so a
// stale hook config cannot make anything fail.
const session = loop.findByWorktree(body.cwd) orelse return;
if (session.note(event, at)) loop.dirty = true;
if (session.note(event, body.permission_mode, at)) loop.dirty = true;
},
.attach => attachClient(loop, client, frame),
.detach => detachClient(loop, client),
Expand Down Expand Up @@ -702,7 +702,10 @@ fn registerSession(loop: *Loop, client: *Client, frame: wire.Frame, at: i64) voi
loop.sendControl(client, .registered, wire.Registered{
.session_id = id,
.pid = @intCast(session.pid),
.status = @tagName(session.status),
// `shown`, like every other status that leaves this process. It is
// always `starting` here, but a second spelling of "what a reader is
// told" is how the two answers drift apart later.
.status = @tagName(session.shown()),
.started_at = session.started_at,
});
}
Expand Down Expand Up @@ -1251,6 +1254,25 @@ test "a registered session runs, echoes, and its output survives a reconnect" {
const after = try conn.recv(.snapshot, &b);
const after_view = try wire.parse(wire.Snapshot, arena, after);
try testing.expectEqualStrings("waiting", after_view.sessions[0].status);

// And the mode rides the same frame. The unit tests prove the payload
// parses and prove the projection; only this proves the field survives
// the socket and reaches the session the cwd names.
try conn.send(arena, .hook, wire.Hook{
.cwd = base,
.session_id = "irrelevant",
.event = "active",
.permission_mode = "plan",
});
waited = 5_000;
while (waited > 0) : (waited -= 100) {
try conn.send(arena, .list, .{});
const snap = try conn.recv(.snapshot, &b);
const view = try wire.parse(wire.Snapshot, arena, snap);
if (std.mem.eql(u8, view.sessions[0].status, "plan")) break;
io.sleep(.fromMilliseconds(100), .awake) catch {};
}
try testing.expect(waited > 0);
}

// Everything above is gone — both connections closed, as if the terminal
Expand Down Expand Up @@ -1392,13 +1414,13 @@ test "a hook reports to the socket it was handed, not to the one its environment
// Without a socket there is nothing to fall back to but that environment,
// and the report is lost. Asserted so the fallback stays a fallback: if this
// ever starts landing, the test above it has stopped proving anything.
watch_client.report(hook_app, null, base, "s", "waiting");
watch_client.report(hook_app, null, base, "s", "waiting", "");
io.sleep(.fromMilliseconds(300), .awake) catch {};
try testing.expectEqualStrings("starting", try statusNow(&conn, arena, &b));

// Handed the daemon's own socket, exactly as `watch_hooks.settingsJson`
// writes it, the same report lands.
watch_client.report(hook_app, socket_path, base, "s", "waiting");
watch_client.report(hook_app, socket_path, base, "s", "waiting", "");
var waited: i32 = 5_000;
while (waited > 0) : (waited -= 100) {
if (std.mem.eql(u8, try statusNow(&conn, arena, &b), "waiting")) break;
Expand Down
25 changes: 25 additions & 0 deletions src/sessions.zig
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ pub const Status = enum {
active,
waiting,
idle,
/// Working, but still in Claude Code's plan mode — it has not been approved
/// to touch files yet.
///
/// A *mode* rather than a point in the lifecycle, and it sits in this enum
/// anyway because the column has one slot and this is the more useful thing
/// to put in it: `lcc start` launches every session in plan mode, so the
/// question a row has to answer is not "is a turn in flight" — it nearly
/// always is — but "has this one been let loose yet". It displaces `active`
/// and `idle` only. See `watch_status.present` for what it must never
/// displace and why.
plan,
exited,
/// The worktree is gone from disk but the agent is still running in it.
orphan,
Expand Down Expand Up @@ -461,13 +472,27 @@ test "a worktree that is gone reads as orphan, and the row stays" {
// An exited session's worktree being gone is ordinary cleanup, not
// an orphan — there is no agent left to be stranded.
.{ .id = "s-done", .worktree = try std.fs.path.join(arena, &.{ base, "removed" }), .status = "exited" },
// Still planning, and the directory it was planning in is gone. The
// reader's verdict has to win over the daemon's here as much as it
// does for `active` — an agent stranded in a deleted worktree is
// stranded whatever mode it is in.
.{ .id = "s-plan", .worktree = try std.fs.path.join(arena, &.{ base, "removed" }), .status = "plan" },
},
};

const rows = try resolved(arena, io, state, 1001);
try testing.expectEqual(Status.active, rows[0].status);
try testing.expectEqual(Status.orphan, rows[1].status);
try testing.expectEqual(Status.exited, rows[2].status);
try testing.expectEqual(Status.orphan, rows[3].status);
}

test "plan round-trips as text, like every other status" {
// Stored as TEXT, so the tag order stays an implementation detail — adding
// `plan` in the middle of the enum must not repaint rows already on disk.
const s: Session = .{ .status = "plan" };
try testing.expectEqual(Status.plan, s.parsedStatus());
try testing.expectEqualStrings("plan", Status.plan.label());
}

test "owning matches the worktree and what is inside it, never a sibling prefix" {
Expand Down
2 changes: 2 additions & 0 deletions src/watch_client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ pub fn report(
cwd: []const u8,
session_id: []const u8,
event: []const u8,
permission_mode: []const u8,
) void {
const opened = if (socket) |path|
connectAt(app, .control, path)
Expand All @@ -256,6 +257,7 @@ pub fn report(
.cwd = cwd,
.session_id = session_id,
.event = event,
.permission_mode = permission_mode,
}) catch {};
}

Expand Down
116 changes: 109 additions & 7 deletions src/watch_hooks.zig
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@
//! notification's payload to work out which kind it was. A field lcc never
//! reads is a field that cannot be renamed out from under it.
//!
//! `permission_mode` is the one exception, and it is one because Claude Code
//! offers no way to make it the rule: matchers select on a notification's type
//! and on tool names, never on the mode, so plan mode cannot be baked into a
//! command line the way every other state above is. It is read out of the
//! payload instead — from `PreToolUse`, `UserPromptSubmit` and `Stop`, the
//! three of these events that carry it.
//!
//! What keeps that from becoming the fragility the rule exists to avoid: an
//! absent or empty value is a **no-op**, never a clear. A renamed or dropped
//! field freezes the last mode reported rather than silently deciding every
//! session has left plan mode, and a value this build has never heard of reads
//! as "not plan" — the direction `Session.parsedStatus` already fails in.
//!
//! Installed through `claude --settings <file>`, which loads *additional*
//! settings and merges hook entries rather than replacing them. So lcc writes
//! nothing to `~/.claude/settings.json`, nothing into the repo, and the hooks
Expand Down Expand Up @@ -163,8 +176,26 @@ pub const Payload = struct {
session_id: []const u8 = "",
hook_event_name: []const u8 = "",
transcript_path: []const u8 = "",
/// Claude Code's own permission mode. Empty on the events that do not carry
/// it — `Notification`, `SubagentStart`, `SessionEnd` — which is why the
/// daemon holds the last one rather than re-deriving it per event.
permission_mode: []const u8 = "",
};

/// The one mode lcc distinguishes.
///
/// The others (`default`, `acceptEdits`, `bypassPermissions`, `dontAsk`,
/// `auto`) all present as the lifecycle status. Plan mode is worth a row of its
/// own because it ends: `lcc start` launches in it and approving the plan
/// leaves it, so the marker appearing and going away is the signal. A badge
/// that every session wore for its whole life would not be.
pub const plan_mode = "plan";

/// Whether a reported mode means the session is still planning.
pub fn isPlan(permission_mode: []const u8) bool {
return std.mem.eql(u8, permission_mode, plan_mode);
}

pub fn parsePayload(gpa: std.mem.Allocator, raw: []const u8) ?Payload {
return std.json.parseFromSliceLeaky(Payload, gpa, raw, .{
.ignore_unknown_fields = true,
Expand All @@ -177,6 +208,7 @@ pub const Report = struct {
cwd: []const u8,
session_id: []const u8,
event: []const u8,
permission_mode: []const u8 = "",
};

const testing = std.testing;
Expand Down Expand Up @@ -274,20 +306,30 @@ test "a hook payload yields the worktree, and a broken one yields nothing" {
defer arena_state.deinit();
const arena = arena_state.allocator();

// Captured verbatim from Claude Code 2.1.223, only the paths shortened.
// Written out rather than reduced to the four fields lcc reads, because
// what this has to prove is that the *real* shape parses — including
// `effort` and `tool_input`, which are nested objects rather than the
// scalar unknowns a hand-written fixture would have contained.
const raw =
\\{"session_id":"abc123","transcript_path":"/h/.claude/projects/x/t.jsonl",
\\ "cwd":"/r/.lcc/worktrees/pe-256","permission_mode":"plan",
\\ "hook_event_name":"Notification","message":"Claude needs your permission"}
\\{"session_id":"669f68ae","transcript_path":"/h/.claude/projects/x/t.jsonl",
\\ "cwd":"/r/.lcc/worktrees/pe-256","prompt_id":"551136fb",
\\ "permission_mode":"plan","effort":{"level":"xhigh"},
\\ "hook_event_name":"PreToolUse","tool_name":"Bash",
\\ "tool_input":{"command":"echo hi","description":"Print hi"},
\\ "tool_use_id":"toolu_016wh833eFmoMGNzHJyvZ3Ay"}
;
const payload = parsePayload(arena, raw).?;
// cwd is the worktree, which is the key the daemon already files sessions
// under — no correlation table, no session-id mapping to keep in sync.
try testing.expectEqualStrings("/r/.lcc/worktrees/pe-256", payload.cwd);
try testing.expectEqualStrings("abc123", payload.session_id);
try testing.expectEqualStrings("669f68ae", payload.session_id);
try testing.expectEqualStrings("PreToolUse", payload.hook_event_name);

// Fields lcc does not read must not be able to break it: `permission_mode`
// and `message` are ignored rather than rejected.
try testing.expectEqualStrings("Notification", payload.hook_event_name);
// The one field read out of the payload rather than baked into a matcher,
// because Claude Code offers no matcher that selects on it. See the header.
try testing.expectEqualStrings("plan", payload.permission_mode);
try testing.expect(isPlan(payload.permission_mode));

// Malformed input is a dropped update, never a crash in a hook that runs on
// every turn of every session.
Expand All @@ -298,6 +340,66 @@ test "a hook payload yields the worktree, and a broken one yields nothing" {
// Claude Code that drops a key costs one update rather than all of them.
const sparse = parsePayload(arena, "{}").?;
try testing.expectEqualStrings("", sparse.cwd);
// And an absent mode reads as absent, not as "left plan mode". The daemon
// holds the last one it was told; a renamed field must cost the update
// rather than silently clearing every session's plan marker.
try testing.expectEqualStrings("", sparse.permission_mode);
try testing.expect(!isPlan(sparse.permission_mode));
}

test "the events that report no mode really report none" {
const gpa = testing.allocator;
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();

// Half the events lcc registers carry no `permission_mode` — measured, and
// the reason `watch_session.Session` holds the last one rather than reading
// it per event. This payload is a `Notification`, which is *also* the one
// event whose absent mode would do the most damage if read as a clear: it
// fires on the permission prompt at the end of a plan, so a session would
// drop its plan marker at the exact moment the marker was earned.
const raw =
\\{"session_id":"abc123","transcript_path":"/h/.claude/projects/x/t.jsonl",
\\ "cwd":"/r/.lcc/worktrees/pe-256","hook_event_name":"Notification",
\\ "notification_type":"permission_prompt",
\\ "message":"Claude needs your permission"}
;
const payload = parsePayload(arena, raw).?;
try testing.expectEqualStrings("/r/.lcc/worktrees/pe-256", payload.cwd);
try testing.expectEqualStrings("", payload.permission_mode);
}

test "plan is the only mode lcc distinguishes" {
try testing.expect(isPlan("plan"));
// The other five Claude Code reports all mean the same thing to a row: the
// lifecycle status, unchanged. Plan mode earns a marker because it *ends* —
// one that every session wore for its whole life would say nothing.
for ([_][]const u8{ "default", "acceptEdits", "bypassPermissions", "dontAsk", "auto" }) |mode| {
try testing.expect(!isPlan(mode));
}
// Case matters, and a near miss is not a match: reading `Plan` as plan mode
// would be a guess, and guessing is what the hook design exists to avoid.
try testing.expect(!isPlan("Plan"));
try testing.expect(!isPlan(""));
}

test "plan mode needs no hook of its own" {
const gpa = testing.allocator;
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();

// The mode rides on events lcc already registers, so the settings file is
// byte-for-byte what it was. Pinned because the obvious "fix" for a future
// bug here is to add a hook — and there is no hook event that reports a
// mode change, so one would fire on something else and mean nothing.
const body = try settingsJson(arena, "/opt/homebrew/bin/lcc", "/s.sock");
try testing.expect(std.mem.indexOf(u8, body, "permission_mode") == null);
try testing.expect(std.mem.indexOf(u8, body, "--event plan") == null);
inline for (@typeInfo(Events).@"struct".fields) |field| {
try testing.expect(std.mem.indexOf(u8, body, "\"" ++ field.name ++ "\"") != null);
}
}

test "event names round-trip through the command line" {
Expand Down
Loading
Loading