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
21 changes: 19 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ library plus CoreFoundation/Security.
Run from the repo root:

```bash
zig build test --summary all # unit tests (~3s, 128 at last count)
zig build test --summary all # unit tests (~3s, 289 at last count)
zig build # debug binary → zig-out/bin/lcc
zig build -Doptimize=ReleaseFast # what PATH should be serving
zig build run -- list # run without installing
Expand Down Expand Up @@ -80,7 +80,9 @@ Conventions inside a test:
`readMutation`, `unwrap` in `src/linear.zig`) — no requests, no Keychain reads.
- Never let a test touch real state under `$HOME`. Build a `std.process.Environ.Map` and set
the override the module reads: `LCC_REPOS`, `LCC_USAGE_CACHE`, `LCC_REMOTE_CACHE`,
`LCC_CLAUDE_PROJECTS`, `LCC_CLAUDE_JSON`, `LCC_DERIVED_DATA`.
`LCC_CLAUDE_PROJECTS`, `LCC_CLAUDE_JSON`, `LCC_DERIVED_DATA`, `LCC_SESSIONS`,
`LCC_WATCH_DIR`. The last one moves the socket, the lock, the hook settings and the
recovered-status files together, so it is the one the daemon and `watch_state` tests need.
- Failure messages carry what a wrong answer costs, not just the mismatch. `start_plan_test.zig`
is the reference for that shape.

Expand Down Expand Up @@ -116,6 +118,21 @@ Do not "simplify" `build.zig`'s separate `test_mod`: reusing the executable's mo
- **`src/keychain.zig` imports five narrow C headers on purpose.** The umbrella
`CoreFoundation.h` / `Security.h` do not translate on this SDK. Do not tidy them into one
import.
- **A hook event that reports no `permission_mode` must not clear the one already known.**
Only some events carry it — `Notification` does not (see the test in `watch_hooks.zig`).
`watch_session.setPlan` is guarded on `permission_mode.len > 0` for that reason, and
`watch_state.write` merges the previous record's mode in for the same one. Drop either
guard and it still compiles, still passes anything that only replays `PreToolUse`, and
quietly takes a session out of `◈ plan` the first time the agent asks for a permission —
so `plan` only ever survives until the next prompt, which reads as the mode being flaky
rather than as a bug.
- **`watch_state` recovers a status, never a session.** The rows it feeds `collect` keep
`session_id = null` on purpose: that is the only thing making `watch_table.Row.attachable`
return `false`, so enter starts the work again instead of asking the daemon for a pty that
died with the previous one. Filling the id in from the record's `lcc_session` looks like an
improvement and turns every recovered row into an `unknown_session` error. The ids collide
across daemons anyway — `next_id` restarts at 1 — which is also why the state file is named
for Claude Code's session UUID rather than for either the lcc id or the worktree path.
- **A session's hook settings file is per session, not per daemon.** `watch_paths.hooksFor`
names it `hooks-<session id>.json` and `watch_hooks.settingsJson` bakes that id into every
hook command line, so a report says which session it came from. Collapsing them back into
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,20 @@ they do not survive it: if it dies the ptys are revoked and every agent gets a
hangup, the same property tmux has. And `lcc remove` does not yet check whether a
worktree has a live session in it, so check `lcc open` before removing one.

What *does* survive is the status. Every hook writes what it reported to a small
file of its own, so when the background process is replaced the dashboard still
shows what each worktree was last doing rather than a column of `no session` —
which is what you used to get, because a fresh process rewrites the registry from
the one session it just started and erases the rest. A recovered row reads
exactly like a live one; the AGE column is what tells you the `● waiting` is four
hours old. Enter on it starts the work again with `--resume` instead of
attaching, since there is no longer a session to attach to. An agent interrupted
mid-turn reads `● waiting` rather than `◐ active`: it has no process left, and a
turn in flight is a claim only a running one can make. A session you quit
normally clears its file and goes back to `no session`, having nothing left to
say. Sessions that died before this shipped left no file behind and stay
`no session` until you start them again.

Sessions also hold the build of `lcc` they started under. After `zig build` the
sessions still running can be many commits behind the `lcc` you are typing —
older behaviour reached through the same command, and every symptom of it looks
Expand Down
141 changes: 130 additions & 11 deletions src/commands/watch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const watch_client = @import("../watch_client.zig");
const term = @import("../term.zig");
const watch_attach = @import("../watch_attach.zig");
const watch_hooks = @import("../watch_hooks.zig");
const watch_state = @import("../watch_state.zig");
const disk = @import("../disk.zig");
const claude = @import("../claude.zig");
const claude_projects = @import("../claude_projects.zig");
const linear = @import("../linear.zig");
Expand Down Expand Up @@ -379,21 +381,20 @@ fn collect(app: app_mod.App, arena: std.mem.Allocator, now: i64) ![]watch_table.
live = carried;
}

var states: ?[]const watch_state.Record = null;

if (app.repo()) |repo| {
for (try app_mod.worktreeChoices(app, repo)) |choice| {
const branch = choice.entry.branch orelse app_mod.shortHead(choice.entry.head);
const match = findSession(live, choice.entry.path);
try rows.append(arena, .{
.key = choice.entry.path,
.session_id = if (match) |m| m.id else null,
.status = if (match) |m| m.parsedStatus() else null,
.issue = if (match) |m| m.issue else issueOf(arena, branch),
.branch = branch,
.worktree = choice.entry.path,
.last_activity_at = if (match) |m| m.last_activity_at else 0,
.exit_code = if (match) |m| m.exit_code else null,
.stale = stale and match != null,
});
const recovered: ?watch_state.Resolved = if (match != null) null else recover: {
if (states == null) states = watch_state.load(arena, app.io, app.environ);
break :recover watch_state.statusFor(
states.?,
disk.realPath(arena, app.io, choice.entry.path),
);
};
try rows.append(arena, rowFor(arena, choice.entry.path, branch, match, recovered, stale));
}
} else |_| {}

Expand All @@ -419,6 +420,32 @@ fn collect(app: app_mod.App, arena: std.mem.Allocator, now: i64) ![]watch_table.
return rows.toOwnedSlice(arena);
}

pub fn rowFor(
arena: std.mem.Allocator,
path: []const u8,
branch: []const u8,
match: ?sessions.Session,
recovered: ?watch_state.Resolved,
stale: bool,
) watch_table.Row {
return .{
.key = path,
.session_id = if (match) |m| m.id else null,
.status = if (match) |m| m.parsedStatus() else if (recovered) |r| r.status else null,
.issue = if (match) |m| m.issue else issueOf(arena, branch),
.branch = branch,
.worktree = path,
.last_activity_at = if (match) |m|
m.last_activity_at
else if (recovered) |r|
r.last_activity_at
else
0,
.exit_code = if (match) |m| m.exit_code else null,
.stale = stale and match != null,
};
}

pub fn findSession(list: []const sessions.Session, worktree: []const u8) ?sessions.Session {
var fallback: ?sessions.Session = null;
for (list) |s| {
Expand Down Expand Up @@ -498,6 +525,8 @@ pub fn hook(app: app_mod.App, opts: HookOpts) !void {
const payload = watch_hooks.parsePayload(app.gpa, raw) orelse return;
if (payload.cwd.len == 0) return;

recordState(app, opts, payload, event);

watch_client.report(
app,
opts.socket,
Expand All @@ -509,6 +538,27 @@ pub fn hook(app: app_mod.App, opts: HookOpts) !void {
);
}

fn recordState(
app: app_mod.App,
opts: HookOpts,
payload: watch_hooks.Payload,
event: []const u8,
) void {
const parsed = watch_hooks.Event.parse(event) orelse return;
if (parsed == .ended) {
watch_state.clear(app.gpa, app.io, app.environ, payload.session_id);
return;
}
watch_state.write(app.gpa, app.io, app.environ, .{
.event = event,
.cwd = payload.cwd,
.claude_session = payload.session_id,
.lcc_session = opts.session orelse "",
.permission_mode = payload.permission_mode,
.at = app_mod.nowSeconds(app.io),
});
}

test "the --json keys name sessions, never the process behind them" {
const gpa = std.testing.allocator;

Expand All @@ -532,6 +582,75 @@ test "an empty snapshot still carries both flags, rather than dropping them" {
try std.testing.expect(std.mem.indexOf(u8, body, "\"outdated_build\": false") != null);
}

test "a worktree the daemon lost still wears the status its hooks last reported" {
const gpa = std.testing.allocator;
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();

const row = rowFor(
arena,
"/w/pe-290",
"feature/pe-290-relocate-chat-thread-state",
null,
.{ .status = .waiting, .last_activity_at = 1700 },
false,
);

if (row.status == null) {
std.debug.print(
"the row came back with no status even though a hook report for that worktree " ++
"was on disk: every worktree the daemon outlived reads `no session`, which " ++
"is the whole failure this recovers from.\n",
.{},
);
return error.TestExpectedEqual;
}
try std.testing.expectEqual(sessions.Status.waiting, row.status.?);
try std.testing.expectEqual(@as(i64, 1700), row.last_activity_at);

try std.testing.expect(row.session_id == null);
try std.testing.expect(!row.attachable());

try std.testing.expectEqualStrings("PE-290", row.issue.?);
}

test "a live session outranks anything left on disk for the same worktree" {
const gpa = std.testing.allocator;
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();

const live: sessions.Session = .{
.id = "s-00000004",
.worktree = "/w/pe-290",
.branch = "feature/pe-290",
.issue = "PE-290",
.status = "active",
.last_activity_at = 9000,
};

const row = rowFor(arena, "/w/pe-290", "feature/pe-290", live, null, false);
try std.testing.expectEqual(sessions.Status.active, row.status.?);
try std.testing.expectEqualStrings("s-00000004", row.session_id.?);
try std.testing.expectEqual(@as(i64, 9000), row.last_activity_at);
try std.testing.expect(row.attachable());
}

test "a worktree with neither a session nor a report still reads as having none" {
const gpa = std.testing.allocator;
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();

const row = rowFor(arena, "/w/quiet", "feature/pe-9-unrelated", null, null, true);
try std.testing.expect(row.status == null);
try std.testing.expect(row.session_id == null);
try std.testing.expectEqual(@as(i64, 0), row.last_activity_at);

try std.testing.expect(!row.stale);
}

test "a worktree row shows the session that is alive, not the first one recorded" {
const dead: sessions.Session = .{
.id = "s-00000005",
Expand Down
1 change: 1 addition & 0 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,7 @@ test {
_ = @import("watch_hooks.zig");
_ = @import("watch_paths.zig");
_ = @import("watch_session.zig");
_ = @import("watch_state.zig");
_ = @import("watch_status.zig");
_ = @import("watch_table.zig");
_ = @import("wire.zig");
Expand Down
57 changes: 57 additions & 0 deletions src/watch_paths.zig
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,26 @@ pub fn hooksFor(
return std.fs.path.join(gpa, &.{ base, name });
}

pub const state_prefix = "state-";
pub const state_suffix = ".json";

pub fn stateFor(
gpa: std.mem.Allocator,
environ: *const std.process.Environ.Map,
claude_session_id: []const u8,
) ![]const u8 {
const base = try dir(gpa, environ);
const name = try std.fmt.allocPrint(gpa, state_prefix ++ "{s}" ++ state_suffix, .{claude_session_id});
return std.fs.path.join(gpa, &.{ base, name });
}

pub fn stateName(name: []const u8) ?[]const u8 {
if (!std.mem.startsWith(u8, name, state_prefix)) return null;
if (!std.mem.endsWith(u8, name, state_suffix)) return null;
const id = name[state_prefix.len .. name.len - state_suffix.len];
return if (id.len == 0) null else id;
}

pub fn logFile(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) ![]const u8 {
if (environ.get("LCC_WATCH_DIR")) |raw| {
const override = std.mem.trim(u8, raw, " \t");
Expand Down Expand Up @@ -93,6 +113,43 @@ test "each session gets a settings file of its own, named for it" {
));
}

test "a session's recovered state is filed under Claude Code's id, not the one lcc reissues" {
const gpa = std.testing.allocator;
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();

var environ: std.process.Environ.Map = .init(arena);
try environ.put("LCC_WATCH_DIR", "/tmp/lcc-test");

const uuid = "529ae132-1fc2-4cbd-a909-585f29f46f62";
try std.testing.expectEqualStrings(
"/tmp/lcc-test/state-" ++ uuid ++ ".json",
try stateFor(arena, &environ, uuid),
);

const first = try stateFor(arena, &environ, "s-00000001");
const hooks = try hooksFor(arena, &environ, "s-00000001");
if (std.mem.eql(u8, first, hooks)) {
std.debug.print(
"the state file and the hook settings collide on one name: writing a status " ++
"report would overwrite the settings the session was launched with.\n",
.{},
);
return error.TestUnexpectedResult;
}
}

test "a state file names the session it came from, and nothing else in the directory does" {
try std.testing.expectEqualStrings("abc-123", stateName("state-abc-123.json").?);

try std.testing.expect(stateName("state-.json") == null);
try std.testing.expect(stateName("hooks-s-00000001.json") == null);
try std.testing.expect(stateName("sessions.json") == null);
try std.testing.expect(stateName("daemon.sock") == null);
try std.testing.expect(stateName("state-abc-123.json.tmp") == null);
}

test "an empty override is not an override" {
const gpa = std.testing.allocator;
var arena_state: std.heap.ArenaAllocator = .init(gpa);
Expand Down
Loading
Loading